File size: 17,720 Bytes
ebeed67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
"""CTC text recognition postprocessor.

Used for: vgg_english_g2 (plate OCR).

Input: [1, seqLen, numChars] logits
Output: {"kind": "text", "text": "ABC123", "confidence": 0.95, "formatValid": true}

Algorithm: argmax per timestep β†’ collapse consecutive duplicates β†’ remove blank (index 0) β†’ join

Plausibility gate
-----------------
When the recognizer is fed a too-low-resolution / heavily-blurred plate crop
its logit map collapses toward the CTC blank and greedy decode emits a spurious
1–2 character read (e.g. a lone "A") at whatever confidence the single surviving
timestep carries. That noise was being stored verbatim as a "plate" label. The
charset + greedy decode themselves are correct β€” this only rejects a result too
short to be a real plate (`minTextLength`, non-space chars) or below a confidence
floor (`minTextConfidence`), returning empty text so no garbage label is stored.
Real plates read at 5+ chars / conf ~0.86–1.0, so the conservative defaults never
suppress a genuine read; both are config-overridable for per-camera tuning.

Normalisation before the grammar
--------------------------------
The recognizer's charset carries lowercase letters and a space, and live reads
use them freely ("dn Ro 3o9" is the same plate as "DNRO309"). The grammar and
the confusable table are both defined over UPPERCASE alphanumerics, so a raw
read is upper-cased and stripped of non-alphanumerics BEFORE either runs β€”
otherwise a perfectly readable plate fails the grammar for its case, and the
0↔O repair cannot even be attempted (lowercase 'o' is not a CONFUSABLES key).
The per-character scores and aligned timesteps are filtered alongside the
glyphs, so the rescorer keeps reading the softmax mass at the right timestep.

The RAW text still feeds the symbol gate below: normalisation must never be
able to launder hallucinated punctuation into a conforming plate. A read that
carries a symbol glyph is rejected before the grammar is ever consulted, so
repair only ever runs on a read that already passed the plausibility gate.

Format-aware rescoring (post-hoc)
---------------------------------
After the plausibility gate, the NORMALISED read is validated against the active
region's plate grammar (`plateRegion`, default 'DE'; 'off' disables). If the
letter/digit sequence already matches, it is accepted as-is (`formatValid: true`).
If it does NOT match, candidate reads are generated by substituting classic OCR
confusables (0↔O, 1↔I, 8↔B, 5↔S, 2↔Z, 6↔G, 4↔A) position-by-position; each
candidate is scored by the mean per-character probability and the highest-scoring
GRAMMAR-CONFORMING candidate replaces the read (`formatValid: true`). If no
candidate matches, the original greedy read is kept verbatim (`formatValid:
false`) β€” validation NEVER drops a plausible read, it only fixes or annotates it.

Per-character probability approximation
---------------------------------------
The greedy CTC path collapses consecutive-duplicate argmax timesteps; for each
emitted output character we retain the single timestep `t` at which its run
began. A candidate's per-character probability is then approximated by the
softmax at that ONE aligned timestep β€” for an unchanged position the original
argmax probability, and for a substituted position `probs[t, altIndex]` (the
real softmax mass the confusable already carried at that timestep). This is an
approximation: it does not re-run a full CTC forward alignment for the candidate
string (which could re-collapse differently). For single-character confusable
swaps it is a faithful, cheap proxy β€” the timestep that produced the original
glyph is exactly where its confusable competes for probability.
"""
import itertools
import re

import numpy as np

# Shortest plausible plate (non-space chars). No real plate is 1–2 chars, so
# this only rejects the degenerate near-all-blank collapse; kept conservative to
# never drop a genuine short/vanity plate.
DEFAULT_MIN_TEXT_LENGTH = 3
# Confidence floor (0.0 = disabled by default β€” character count is the reliable
# discriminator for the degenerate collapse; the "A"@1.0 case is caught by
# length, not confidence). Available for per-camera tuning via config.
DEFAULT_MIN_TEXT_CONFIDENCE = 0.0
# Product-level default plate grammar region β€” supplied to this postprocessor by
# the TS step config (`plateRegion`, see step-definitions.ts). When the key is
# ABSENT from `config` the postprocessor falls back to 'off' (no rescoring) so a
# bare / pre-plumbing call is byte-identical to the historical decode behaviour;
# the DE default only takes effect once the config plumbing supplies it.
DEFAULT_PLATE_REGION = "DE"
# Fallback when `plateRegion` is absent from config (backward-compatible: no
# rescoring, no formatValid annotation).
ABSENT_PLATE_REGION = "off"
# Cap on the number of positions that may simultaneously carry a confusable
# alternative. Each substitutable position doubles the candidate count, so the
# worst case is 2**MAX_SUBST_POSITIONS candidates. Plates are ≀8 glyphs so this
# is rarely binding, but the cap guarantees a bounded search (≀256 candidates)
# even on a pathological long read: when more positions than this have a
# confusable, only the LOWEST-confidence positions (the most likely misreads)
# stay substitutable; the rest are locked to their greedy glyph.
MAX_SUBST_POSITIONS = 8

# Classic OCR confusable pairs, bidirectional. Maps a glyph to the alternates it
# is commonly mistaken for/against. Used ONLY to repair a read that fails the
# grammar β€” never to alter a read that already conforms.
CONFUSABLES: dict[str, tuple[str, ...]] = {
    "0": ("O",),
    "O": ("0",),
    "1": ("I",),
    "I": ("1",),
    "8": ("B",),
    "B": ("8",),
    "5": ("S",),
    "S": ("5",),
    "2": ("Z",),
    "Z": ("2",),
    "6": ("G",),
    "G": ("6",),
    "4": ("A",),
    "A": ("4",),
}

# Region plate grammars, keyed by ISO-3166 alpha-2 code. Each pattern validates
# the RAW letter/digit sequence the OCR emits (no separators β€” the recognizer
# never outputs the dash/space of the physical plate). Ready for future
# countries ('IT', 'FR', …); 'off' (handled before lookup) disables rescoring.
#
# GERMANY ('DE'): district code (1–3 letters) + 1–2 letters + 1–4 digits +
# optional E (electric) / H (historic) suffix; ≀8 glyphs excluding separators.
# Separators are optional in the pattern for robustness, though the OCR emits
# none. The letter-block split (district vs. series) is inherently ambiguous on
# the raw string, but that does not matter β€” the regex validates the whole
# sequence, not the split.
PLATE_GRAMMARS: dict[str, str] = {
    "DE": r"^[A-ZΓ„Γ–Γœ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$",
}


def _matches_grammar(text: str, pattern) -> bool:
    return pattern.match(text) is not None


def _normalize_read(
    chars: list[str],
    char_scores: list[float],
    char_timesteps: list[int],
) -> tuple[str, list[float], list[int]]:
    """Upper-case + drop non-alphanumeric glyphs, keeping scores/timesteps aligned.

    The grammar patterns and CONFUSABLES are defined over uppercase alnums; the
    charset the recognizer decodes from is not. Returning the filtered score and
    timestep lists alongside the text is what lets the rescorer keep reading a
    candidate glyph's softmax mass at the timestep that produced its position.

    A glyph whose upper-case form is not a single character (no such glyph in
    this charset, but the fold is locale-dependent in general) is kept as-is
    rather than silently changing the string length.
    """
    out_chars: list[str] = []
    out_scores: list[float] = []
    out_timesteps: list[int] = []
    for ch, score, timestep in zip(chars, char_scores, char_timesteps):
        if not ch.isalnum():
            continue
        upper = ch.upper()
        out_chars.append(upper if len(upper) == 1 else ch)
        out_scores.append(score)
        out_timesteps.append(timestep)
    return "".join(out_chars), out_scores, out_timesteps


def _rescore_for_grammar(
    text: str,
    char_scores: list[float],
    char_timesteps: list[int],
    probs: np.ndarray,
    charset: list[str],
    pattern,
) -> tuple[str, bool]:
    """Return (repaired_or_original_text, format_valid).

    If `text` already matches `pattern`, return it unchanged with True.
    Otherwise enumerate confusable substitutions (bounded), score each candidate
    by mean per-character probability, and return the highest-scoring candidate
    that matches the grammar. If none match, return the original text with False.
    """
    if _matches_grammar(text, pattern):
        return text, True

    chars = list(text)
    n = len(chars)

    # Reverse charset lookup (glyph β†’ first index) so a substituted glyph's
    # softmax mass can be read from the aligned timestep. Built once per call.
    char_to_index: dict[str, int] = {}
    for idx, glyph in enumerate(charset):
        if glyph and glyph not in char_to_index:
            char_to_index[glyph] = idx

    # Which positions carry a usable confusable alternative (both the glyph has a
    # mapping AND the alternate is representable in this charset so its timestep
    # probability can be looked up).
    subst_positions: list[int] = []
    for i, ch in enumerate(chars):
        alts = CONFUSABLES.get(ch)
        if not alts:
            continue
        if any(alt in char_to_index for alt in alts):
            subst_positions.append(i)

    if not subst_positions:
        return text, False

    # Bound the search: keep only the MAX_SUBST_POSITIONS lowest-confidence
    # positions substitutable (most likely misreads); lock the rest.
    if len(subst_positions) > MAX_SUBST_POSITIONS:
        subst_positions.sort(key=lambda i: char_scores[i])
        subst_positions = sorted(subst_positions[:MAX_SUBST_POSITIONS])

    subst_set = set(subst_positions)

    # Per-position option list: original glyph, plus any representable confusables
    # (only for substitutable positions). itertools.product enumerates the grid.
    options: list[list[str]] = []
    for i, ch in enumerate(chars):
        if i in subst_set:
            alts = [a for a in CONFUSABLES.get(ch, ()) if a in char_to_index]
            options.append([ch, *alts])
        else:
            options.append([ch])

    def _prob_at(i: int, glyph: str) -> float:
        if glyph == chars[i]:
            return char_scores[i]
        idx = char_to_index.get(glyph)
        if idx is None:
            return 0.0
        return float(probs[char_timesteps[i], idx])

    best_text: str | None = None
    best_score = -1.0
    for combo in itertools.product(*options):
        candidate = "".join(combo)
        if candidate == text:
            continue  # already known non-matching (checked above)
        if not _matches_grammar(candidate, pattern):
            continue
        score = sum(_prob_at(i, combo[i]) for i in range(n)) / n if n else 0.0
        if score > best_score:
            best_score = score
            best_text = candidate

    if best_text is not None:
        return best_text, True
    return text, False


# Glyphs the recognizer emits for a plate's physical registration seal.
SEAL_GLYPHS = frozenset(".:;,*-'`\"Β·")


def _strip_seal_glyph(text: str, region: str) -> "str | None":
    """Return the normalised plate when `text` is a conforming read carrying ONE
    interior seal glyph; ``None`` otherwise (caller applies the normal gate).

    Narrow by construction: exactly one seal-like glyph, never at either end,
    nothing else non-alphanumeric left over, and the normalised remainder must
    match the region grammar. A read that does not become a legal plate is not
    rescued.
    """
    pattern_src = PLATE_GRAMMARS.get(region)
    if pattern_src is None:
        return None
    seals = [i for i, c in enumerate(text) if c in SEAL_GLYPHS]
    if len(seals) != 1:
        return None
    pos = seals[0]
    if pos == 0 or pos == len(text) - 1:
        return None
    stripped = text[:pos] + text[pos + 1 :]
    if any(not (c.isalnum() or c.isspace()) for c in stripped):
        return None
    normalised = "".join(c for c in stripped.upper() if c.isalnum())
    if re.match(pattern_src, normalised) is None:
        return None
    return normalised


def postprocess_ctc(
    predictions: dict,
    config: dict,
    orig_w: int,
    orig_h: int,
    scale: float,
    pad: tuple[int, int],
) -> dict:
    """CTC greedy decode."""
    charset = config.get("charset", [])

    # Get first output tensor
    raw = np.array(list(predictions.values())[0]).astype(np.float32)

    # Handle shapes: [1, T, C] or [T, C]
    if raw.ndim == 3:
        raw = raw[0]  # remove batch
    # raw is now [T, C] β€” logits (not probabilities)
    seq_len, num_chars = raw.shape

    # Apply softmax to get probabilities
    raw_max = np.max(raw, axis=1, keepdims=True)
    exp_raw = np.exp(raw - raw_max)
    probs = exp_raw / np.sum(exp_raw, axis=1, keepdims=True)

    # Argmax per timestep
    best_indices = np.argmax(probs, axis=1)  # [T]
    best_scores = probs[np.arange(seq_len), best_indices]  # [T]

    # Collapse consecutive duplicates (retain the timestep each run began at so
    # the rescorer can read a confusable's softmax mass at that aligned step).
    collapsed = []
    collapsed_scores = []
    collapsed_timesteps = []
    for i, idx in enumerate(best_indices):
        if i == 0 or idx != best_indices[i - 1]:
            collapsed.append(int(idx))
            collapsed_scores.append(float(best_scores[i]))
            collapsed_timesteps.append(i)

    # Remove blank (index 0) and join
    chars = []
    scores = []
    char_timesteps = []
    for idx, score, t in zip(collapsed, collapsed_scores, collapsed_timesteps):
        if idx != 0:
            if idx < len(charset):
                chars.append(charset[idx])
            else:
                chars.append(chr(idx))
            scores.append(score)
            char_timesteps.append(t)

    text = "".join(chars)
    confidence = float(np.mean(scores)) if scores else 0.0

    # Plausibility gate β€” suppress the degenerate near-all-blank collapse so
    # low-resolution noise is never stored as a plate label. Count ALPHANUMERIC
    # characters only ("DW RO 309" β†’ 7): the charset carries symbol glyphs
    # ('[', ')', '.', …) that a blurred crop hallucinates, and they must not
    # count toward "long enough to be a plate".
    min_len = int(config.get("minTextLength", DEFAULT_MIN_TEXT_LENGTH))
    min_conf = float(config.get("minTextConfidence", DEFAULT_MIN_TEXT_CONFIDENCE))
    region = str(config.get("plateRegion", ABSENT_PLATE_REGION))
    alnum_count = sum(1 for c in text if c.isalnum())
    # Symbol-blind gate: when a plate grammar/region is ACTIVE, no real plate
    # contains a non-alphanumeric glyph (spaces allowed β€” word gaps are real).
    # Observed live labels "[miv4o)" / "6ou.2" (2026-07-22) sailed through the
    # length-only gate; reject them exactly like the other implausible reads.
    has_symbol_glyph = region != "off" and any(
        not (c.isalnum() or c.isspace()) for c in text
    )
    # …except the SEAL. A German plate physically carries a registration seal
    # between the district code and the letter group, and the recognizer
    # transcribes that badge as a punctuation glyph ('.', ':', '-', …). Once the
    # quad warp (2026-08-20) started delivering legible strips, the correct
    # decodes arrived as "DN.D7578" / "DN-P 1091" / "DN:HF 974" and this gate
    # threw away every one of them β€” the warp scored 1/21 instead of 4/21 on the
    # cam-617 ground-truth set purely because of this line.
    #
    # The exemption is deliberately narrow: EXACTLY ONE seal-like glyph, strictly
    # INTERIOR, and the remainder must CONFORM to the region grammar. Noise
    # cannot be laundered by it β€” the 2026-07-22 junk ("6ou.2x", "[miv4o)") has
    # either the wrong count, the wrong position, or no conforming remainder.
    if has_symbol_glyph:
        repaired = _strip_seal_glyph(text, region)
        if repaired is not None:
            return {
                "kind": "text",
                "text": repaired,
                "confidence": round(confidence, 4),
                "formatValid": True,
            }
    if alnum_count < min_len or confidence < min_conf or has_symbol_glyph:
        return {
            "kind": "text",
            "text": "",
            "confidence": round(confidence, 4),
        }

    result = {
        "kind": "text",
        "text": text,
        "confidence": round(confidence, 4),
    }

    # Post-hoc format validation + confusable rescoring. 'off' disables it
    # entirely (no grammar lookup, no formatValid annotation).
    if region != "off":
        pattern_src = PLATE_GRAMMARS.get(region)
        if pattern_src is not None:
            pattern = re.compile(pattern_src)
            # Grammar + confusables run on the NORMALISED read (upper, alnum
            # only). Reached only past the plausibility gate above, so a repair
            # can never turn sub-legible noise into a more credible string: the
            # symbol gate already rejected the reads that would qualify, and the
            # normalised length equals the alnum count the length gate cleared.
            norm_text, norm_scores, norm_timesteps = _normalize_read(
                chars, scores, char_timesteps
            )
            fixed_text, format_valid = _rescore_for_grammar(
                norm_text, norm_scores, norm_timesteps, probs, list(charset), pattern
            )
            result["text"] = fixed_text
            result["formatValid"] = format_valid

    return result