@camstack/addon-pipeline 1.2.5 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +499 -30
  4. package/dist/detection-pipeline/index.mjs +493 -24
  5. package/dist/{dist-CtQsET1Q.js → dist-Bh0H8jM6.js} +37 -2
  6. package/dist/{dist-ZB-B8UrH.mjs → dist-Bw_V5L0r.mjs} +37 -2
  7. package/dist/motion-wasm/index.js +1 -1
  8. package/dist/motion-wasm/index.mjs +1 -1
  9. package/dist/pipeline-runner/index.js +19 -9
  10. package/dist/pipeline-runner/index.mjs +19 -9
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/{step-definitions-BKJI5CXI.mjs → step-definitions-aY4D9Loy.mjs} +70 -2
  14. package/dist/{step-definitions-DrRnEQPM.js → step-definitions-xxdpJX9-.js} +70 -2
  15. package/dist/stream-broker/_stub.js +1 -1
  16. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DPvS4Ex7.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DX-Ef8CP.mjs} +1 -1
  17. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-gchR4WlX.mjs +26 -0
  18. package/dist/stream-broker/{hostInit-CG0IeE_e.mjs → hostInit-DUL71Vjq.mjs} +1 -1
  19. package/dist/stream-broker/index.js +1 -1
  20. package/dist/stream-broker/index.mjs +1 -1
  21. package/dist/stream-broker/remoteEntry.js +1 -1
  22. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C-rkQ2rg.js → MaskShapeCanvas-DI4BY7W2-CGWEeRkz.js} +1 -1
  23. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BtbCLveu.js → MotionZonesSettings-NcxxQN8r-4YbTa3Wp.js} +1 -1
  24. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-C9CEgSlA.js → PrivacyMaskSettings-APgPLF7p-DL_tAs9-.js} +1 -1
  25. package/embed-dist/assets/{index-CgJ1frQY.js → index-CvBwcYl_.js} +13 -13
  26. package/embed-dist/index.html +1 -1
  27. package/package.json +1 -1
  28. package/python/postprocessors/ctc.py +228 -4
  29. package/python/postprocessors/test_ctc.py +312 -0
  30. package/python/postprocessors/testdata/ctc_degenerate_lowres_logits.npy.gz.b64 +1 -0
  31. package/python/postprocessors/testdata/ctc_dwro309_logits.npy.gz.b64 +1 -0
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CQC_f4Yv.mjs +0 -26
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>CamStack Embed</title>
7
- <script type="module" crossorigin src="./assets/index-CgJ1frQY.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-CvBwcYl_.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-Dv5H-32C.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.5",
3
+ "version": "1.2.6",
4
4
  "description": "CamStack Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -3,12 +3,203 @@
3
3
  Used for: vgg_english_g2 (plate OCR).
4
4
 
5
5
  Input: [1, seqLen, numChars] logits
6
- Output: {"kind": "text", "text": "ABC123", "confidence": 0.95}
6
+ Output: {"kind": "text", "text": "ABC123", "confidence": 0.95, "formatValid": true}
7
7
 
8
8
  Algorithm: argmax per timestep → collapse consecutive duplicates → remove blank (index 0) → join
9
+
10
+ Plausibility gate
11
+ -----------------
12
+ When the recognizer is fed a too-low-resolution / heavily-blurred plate crop
13
+ its logit map collapses toward the CTC blank and greedy decode emits a spurious
14
+ 1–2 character read (e.g. a lone "A") at whatever confidence the single surviving
15
+ timestep carries. That noise was being stored verbatim as a "plate" label. The
16
+ charset + greedy decode themselves are correct — this only rejects a result too
17
+ short to be a real plate (`minTextLength`, non-space chars) or below a confidence
18
+ floor (`minTextConfidence`), returning empty text so no garbage label is stored.
19
+ Real plates read at 5+ chars / conf ~0.86–1.0, so the conservative defaults never
20
+ suppress a genuine read; both are config-overridable for per-camera tuning.
21
+
22
+ Format-aware rescoring (post-hoc)
23
+ ---------------------------------
24
+ After the plausibility gate, the greedy read is validated against the active
25
+ region's plate grammar (`plateRegion`, default 'DE'; 'off' disables). If the raw
26
+ letter/digit sequence already matches, it is accepted as-is (`formatValid: true`).
27
+ If it does NOT match, candidate reads are generated by substituting classic OCR
28
+ confusables (0↔O, 1↔I, 8↔B, 5↔S, 2↔Z, 6↔G, 4↔A) position-by-position; each
29
+ candidate is scored by the mean per-character probability and the highest-scoring
30
+ GRAMMAR-CONFORMING candidate replaces the read (`formatValid: true`). If no
31
+ candidate matches, the original greedy read is kept verbatim (`formatValid:
32
+ false`) — validation NEVER drops a plausible read, it only fixes or annotates it.
33
+
34
+ Per-character probability approximation
35
+ ---------------------------------------
36
+ The greedy CTC path collapses consecutive-duplicate argmax timesteps; for each
37
+ emitted output character we retain the single timestep `t` at which its run
38
+ began. A candidate's per-character probability is then approximated by the
39
+ softmax at that ONE aligned timestep — for an unchanged position the original
40
+ argmax probability, and for a substituted position `probs[t, altIndex]` (the
41
+ real softmax mass the confusable already carried at that timestep). This is an
42
+ approximation: it does not re-run a full CTC forward alignment for the candidate
43
+ string (which could re-collapse differently). For single-character confusable
44
+ swaps it is a faithful, cheap proxy — the timestep that produced the original
45
+ glyph is exactly where its confusable competes for probability.
9
46
  """
47
+ import itertools
48
+ import re
49
+
10
50
  import numpy as np
11
51
 
52
+ # Shortest plausible plate (non-space chars). No real plate is 1–2 chars, so
53
+ # this only rejects the degenerate near-all-blank collapse; kept conservative to
54
+ # never drop a genuine short/vanity plate.
55
+ DEFAULT_MIN_TEXT_LENGTH = 3
56
+ # Confidence floor (0.0 = disabled by default — character count is the reliable
57
+ # discriminator for the degenerate collapse; the "A"@1.0 case is caught by
58
+ # length, not confidence). Available for per-camera tuning via config.
59
+ DEFAULT_MIN_TEXT_CONFIDENCE = 0.0
60
+ # Product-level default plate grammar region — supplied to this postprocessor by
61
+ # the TS step config (`plateRegion`, see step-definitions.ts). When the key is
62
+ # ABSENT from `config` the postprocessor falls back to 'off' (no rescoring) so a
63
+ # bare / pre-plumbing call is byte-identical to the historical decode behaviour;
64
+ # the DE default only takes effect once the config plumbing supplies it.
65
+ DEFAULT_PLATE_REGION = "DE"
66
+ # Fallback when `plateRegion` is absent from config (backward-compatible: no
67
+ # rescoring, no formatValid annotation).
68
+ ABSENT_PLATE_REGION = "off"
69
+ # Cap on the number of positions that may simultaneously carry a confusable
70
+ # alternative. Each substitutable position doubles the candidate count, so the
71
+ # worst case is 2**MAX_SUBST_POSITIONS candidates. Plates are ≤8 glyphs so this
72
+ # is rarely binding, but the cap guarantees a bounded search (≤256 candidates)
73
+ # even on a pathological long read: when more positions than this have a
74
+ # confusable, only the LOWEST-confidence positions (the most likely misreads)
75
+ # stay substitutable; the rest are locked to their greedy glyph.
76
+ MAX_SUBST_POSITIONS = 8
77
+
78
+ # Classic OCR confusable pairs, bidirectional. Maps a glyph to the alternates it
79
+ # is commonly mistaken for/against. Used ONLY to repair a read that fails the
80
+ # grammar — never to alter a read that already conforms.
81
+ CONFUSABLES: dict[str, tuple[str, ...]] = {
82
+ "0": ("O",),
83
+ "O": ("0",),
84
+ "1": ("I",),
85
+ "I": ("1",),
86
+ "8": ("B",),
87
+ "B": ("8",),
88
+ "5": ("S",),
89
+ "S": ("5",),
90
+ "2": ("Z",),
91
+ "Z": ("2",),
92
+ "6": ("G",),
93
+ "G": ("6",),
94
+ "4": ("A",),
95
+ "A": ("4",),
96
+ }
97
+
98
+ # Region plate grammars, keyed by ISO-3166 alpha-2 code. Each pattern validates
99
+ # the RAW letter/digit sequence the OCR emits (no separators — the recognizer
100
+ # never outputs the dash/space of the physical plate). Ready for future
101
+ # countries ('IT', 'FR', …); 'off' (handled before lookup) disables rescoring.
102
+ #
103
+ # GERMANY ('DE'): district code (1–3 letters) + 1–2 letters + 1–4 digits +
104
+ # optional E (electric) / H (historic) suffix; ≤8 glyphs excluding separators.
105
+ # Separators are optional in the pattern for robustness, though the OCR emits
106
+ # none. The letter-block split (district vs. series) is inherently ambiguous on
107
+ # the raw string, but that does not matter — the regex validates the whole
108
+ # sequence, not the split.
109
+ PLATE_GRAMMARS: dict[str, str] = {
110
+ "DE": r"^[A-ZÄÖÜ]{1,3}[- ]?[A-Z]{1,2}[- ]?[0-9]{1,4}[EH]?$",
111
+ }
112
+
113
+
114
+ def _matches_grammar(text: str, pattern) -> bool:
115
+ return pattern.match(text) is not None
116
+
117
+
118
+ def _rescore_for_grammar(
119
+ text: str,
120
+ char_scores: list[float],
121
+ char_timesteps: list[int],
122
+ probs: np.ndarray,
123
+ charset: list[str],
124
+ pattern,
125
+ ) -> tuple[str, bool]:
126
+ """Return (repaired_or_original_text, format_valid).
127
+
128
+ If `text` already matches `pattern`, return it unchanged with True.
129
+ Otherwise enumerate confusable substitutions (bounded), score each candidate
130
+ by mean per-character probability, and return the highest-scoring candidate
131
+ that matches the grammar. If none match, return the original text with False.
132
+ """
133
+ if _matches_grammar(text, pattern):
134
+ return text, True
135
+
136
+ chars = list(text)
137
+ n = len(chars)
138
+
139
+ # Reverse charset lookup (glyph → first index) so a substituted glyph's
140
+ # softmax mass can be read from the aligned timestep. Built once per call.
141
+ char_to_index: dict[str, int] = {}
142
+ for idx, glyph in enumerate(charset):
143
+ if glyph and glyph not in char_to_index:
144
+ char_to_index[glyph] = idx
145
+
146
+ # Which positions carry a usable confusable alternative (both the glyph has a
147
+ # mapping AND the alternate is representable in this charset so its timestep
148
+ # probability can be looked up).
149
+ subst_positions: list[int] = []
150
+ for i, ch in enumerate(chars):
151
+ alts = CONFUSABLES.get(ch)
152
+ if not alts:
153
+ continue
154
+ if any(alt in char_to_index for alt in alts):
155
+ subst_positions.append(i)
156
+
157
+ if not subst_positions:
158
+ return text, False
159
+
160
+ # Bound the search: keep only the MAX_SUBST_POSITIONS lowest-confidence
161
+ # positions substitutable (most likely misreads); lock the rest.
162
+ if len(subst_positions) > MAX_SUBST_POSITIONS:
163
+ subst_positions.sort(key=lambda i: char_scores[i])
164
+ subst_positions = sorted(subst_positions[:MAX_SUBST_POSITIONS])
165
+
166
+ subst_set = set(subst_positions)
167
+
168
+ # Per-position option list: original glyph, plus any representable confusables
169
+ # (only for substitutable positions). itertools.product enumerates the grid.
170
+ options: list[list[str]] = []
171
+ for i, ch in enumerate(chars):
172
+ if i in subst_set:
173
+ alts = [a for a in CONFUSABLES.get(ch, ()) if a in char_to_index]
174
+ options.append([ch, *alts])
175
+ else:
176
+ options.append([ch])
177
+
178
+ def _prob_at(i: int, glyph: str) -> float:
179
+ if glyph == chars[i]:
180
+ return char_scores[i]
181
+ idx = char_to_index.get(glyph)
182
+ if idx is None:
183
+ return 0.0
184
+ return float(probs[char_timesteps[i], idx])
185
+
186
+ best_text: str | None = None
187
+ best_score = -1.0
188
+ for combo in itertools.product(*options):
189
+ candidate = "".join(combo)
190
+ if candidate == text:
191
+ continue # already known non-matching (checked above)
192
+ if not _matches_grammar(candidate, pattern):
193
+ continue
194
+ score = sum(_prob_at(i, combo[i]) for i in range(n)) / n if n else 0.0
195
+ if score > best_score:
196
+ best_score = score
197
+ best_text = candidate
198
+
199
+ if best_text is not None:
200
+ return best_text, True
201
+ return text, False
202
+
12
203
 
13
204
  def postprocess_ctc(
14
205
  predictions: dict,
@@ -39,30 +230,63 @@ def postprocess_ctc(
39
230
  best_indices = np.argmax(probs, axis=1) # [T]
40
231
  best_scores = probs[np.arange(seq_len), best_indices] # [T]
41
232
 
42
- # Collapse consecutive duplicates
233
+ # Collapse consecutive duplicates (retain the timestep each run began at so
234
+ # the rescorer can read a confusable's softmax mass at that aligned step).
43
235
  collapsed = []
44
236
  collapsed_scores = []
237
+ collapsed_timesteps = []
45
238
  for i, idx in enumerate(best_indices):
46
239
  if i == 0 or idx != best_indices[i - 1]:
47
240
  collapsed.append(int(idx))
48
241
  collapsed_scores.append(float(best_scores[i]))
242
+ collapsed_timesteps.append(i)
49
243
 
50
244
  # Remove blank (index 0) and join
51
245
  chars = []
52
246
  scores = []
53
- for idx, score in zip(collapsed, collapsed_scores):
247
+ char_timesteps = []
248
+ for idx, score, t in zip(collapsed, collapsed_scores, collapsed_timesteps):
54
249
  if idx != 0:
55
250
  if idx < len(charset):
56
251
  chars.append(charset[idx])
57
252
  else:
58
253
  chars.append(chr(idx))
59
254
  scores.append(score)
255
+ char_timesteps.append(t)
60
256
 
61
257
  text = "".join(chars)
62
258
  confidence = float(np.mean(scores)) if scores else 0.0
63
259
 
64
- return {
260
+ # Plausibility gate — suppress the degenerate near-all-blank collapse so
261
+ # low-resolution noise is never stored as a plate label. Count non-space
262
+ # characters ("DW RO 309" → 7) so word-gaps don't inflate the length.
263
+ min_len = int(config.get("minTextLength", DEFAULT_MIN_TEXT_LENGTH))
264
+ min_conf = float(config.get("minTextConfidence", DEFAULT_MIN_TEXT_CONFIDENCE))
265
+ alnum_count = sum(1 for c in text if not c.isspace())
266
+ if alnum_count < min_len or confidence < min_conf:
267
+ return {
268
+ "kind": "text",
269
+ "text": "",
270
+ "confidence": round(confidence, 4),
271
+ }
272
+
273
+ result = {
65
274
  "kind": "text",
66
275
  "text": text,
67
276
  "confidence": round(confidence, 4),
68
277
  }
278
+
279
+ # Post-hoc format validation + confusable rescoring. 'off' disables it
280
+ # entirely (no grammar lookup, no formatValid annotation).
281
+ region = str(config.get("plateRegion", ABSENT_PLATE_REGION))
282
+ if region != "off":
283
+ pattern_src = PLATE_GRAMMARS.get(region)
284
+ if pattern_src is not None:
285
+ pattern = re.compile(pattern_src)
286
+ fixed_text, format_valid = _rescore_for_grammar(
287
+ text, scores, char_timesteps, probs, list(charset), pattern
288
+ )
289
+ result["text"] = fixed_text
290
+ result["formatValid"] = format_valid
291
+
292
+ return result
@@ -0,0 +1,312 @@
1
+ """Tests for the CTC (plate-OCR) postprocessor.
2
+
3
+ Locks in the two properties that keep license-plate recognition correct:
4
+
5
+ 1. **Decode + charset calibration** — greedy argmax → collapse consecutive
6
+ duplicates → drop the CTC blank → map through the `vgg_english_g2`
7
+ ``VGG_G2_CHARSET`` (index 0 blank, digits 1–10, symbols 11–43, one extra
8
+ symbol slot at 44, letters A–Z/a–z at 45–96). This charset alignment is the
9
+ empirically-calibrated off-by-one fix (commit fe5f34b4c). A regression here
10
+ silently returns systematically wrong letters (e.g. "DWRO" → "EXSP").
11
+
12
+ 2. **Plausibility gate** — a too-low-resolution / blurred plate collapses the
13
+ logit map toward the blank and greedy decode emits a spurious 1–2 char read;
14
+ the gate returns empty text so noise is never stored as a plate label. The
15
+ charset/decode are proven correct via captured REAL logits from the ONNX
16
+ model on crisp synthetic text (no model needed at test time).
17
+ """
18
+ import base64
19
+ import gzip
20
+ import io
21
+ import os
22
+
23
+ import numpy as np
24
+
25
+ from ctc import (
26
+ DEFAULT_MIN_TEXT_LENGTH,
27
+ MAX_SUBST_POSITIONS,
28
+ postprocess_ctc,
29
+ )
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # VGG_G2_CHARSET — mirrors packages/.../registry/step-definitions.ts. The TS
33
+ # copy is the runtime source of truth (passed in via config); this local build
34
+ # lets the decode be tested hermetically. `test_charset_structure` locks the
35
+ # exact index→glyph contract so the two can't silently drift.
36
+ # ---------------------------------------------------------------------------
37
+ VGG_G2_CHARSET = (
38
+ [""]
39
+ + list("0123456789")
40
+ + list("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ ")
41
+ + [" "]
42
+ + list("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
43
+ )
44
+
45
+ TESTDATA = os.path.join(os.path.dirname(__file__), "testdata")
46
+
47
+
48
+ def _one_hot_logits(indices, num_chars=97, peak=10.0):
49
+ """Build a [T, C] logit map whose per-timestep argmax is `indices`."""
50
+ num_chars = max(num_chars, max(indices) + 1)
51
+ seq = np.full((len(indices), num_chars), -peak, dtype=np.float32)
52
+ for t, idx in enumerate(indices):
53
+ seq[t, idx] = peak
54
+ return seq[np.newaxis] # [1, T, C]
55
+
56
+
57
+ def _decode(indices, config=None):
58
+ cfg = {"charset": VGG_G2_CHARSET}
59
+ if config:
60
+ cfg.update(config)
61
+ return postprocess_ctc({"o": _one_hot_logits(indices)}, cfg, 110, 70, 1.0, (0, 0))
62
+
63
+
64
+ def _indices(text):
65
+ """Map a plate string to its VGG_G2_CHARSET argmax index sequence.
66
+
67
+ A CTC blank (index 0) is interleaved between consecutive identical glyphs so
68
+ a true repeat (the BB in "DNBB722", the 22 in "…722") survives the
69
+ duplicate-collapse instead of merging into one character.
70
+ """
71
+ out = []
72
+ prev = None
73
+ for c in text:
74
+ idx = VGG_G2_CHARSET.index(c)
75
+ if idx == prev:
76
+ out.append(0) # blank separates a genuine repeat
77
+ out.append(idx)
78
+ prev = idx
79
+ return out
80
+
81
+
82
+ def _read(text, config=None):
83
+ """Decode the one-hot logits for `text` with the DE grammar active.
84
+
85
+ (`_decode` leaves `plateRegion` absent → 'off'; `_read` opts the DE grammar
86
+ in so the rescoring tests exercise the product default.)
87
+ """
88
+ cfg = {"plateRegion": "DE"}
89
+ if config:
90
+ cfg.update(config)
91
+ return _decode(_indices(text), cfg)
92
+
93
+
94
+ def _load_logits(name):
95
+ raw = gzip.decompress(base64.b64decode(open(os.path.join(TESTDATA, name)).read()))
96
+ return np.load(io.BytesIO(raw)).astype(np.float32) # [T, C] float16→float32
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Charset structure (the off-by-one calibration)
101
+ # ---------------------------------------------------------------------------
102
+ def test_charset_structure():
103
+ assert len(VGG_G2_CHARSET) == 97 # 1 blank + 10 digits + 33 symbols + 1 + 52
104
+ assert VGG_G2_CHARSET[0] == "" # CTC blank
105
+ assert "".join(VGG_G2_CHARSET[1:11]) == "0123456789"
106
+ assert VGG_G2_CHARSET[44] == " " # the extra symbol slot that realigns letters
107
+ # Letters start at 45 — this is the calibration that fixes "DWRO"→"EXSP".
108
+ assert VGG_G2_CHARSET[45] == "A"
109
+ assert VGG_G2_CHARSET[48] == "D"
110
+ assert VGG_G2_CHARSET[70] == "Z"
111
+ assert VGG_G2_CHARSET[71] == "a"
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Greedy CTC decode mechanics
116
+ # ---------------------------------------------------------------------------
117
+ def test_collapses_consecutive_duplicates():
118
+ # D D W R R O -> DWRO (D=48, W=67, R=62, O=59).
119
+ assert _decode([48, 48, 67, 62, 62, 59])["text"] == "DWRO"
120
+
121
+
122
+ def test_removes_blank_token():
123
+ # blanks (index 0) interleaved must be dropped AND allow a true repeat:
124
+ # A blank A B C → the blank breaks the collapse so both A's survive → "AABC".
125
+ assert _decode([45, 0, 45, 46, 47])["text"] == "AABC"
126
+
127
+
128
+ def test_dwro309_index_sequence_decodes_exactly():
129
+ # D W R O 3 0 9 → the calibration acceptance string, mechanically.
130
+ idx = [48, 45 + 22, 45 + 17, 45 + 14, 1 + 3, 1, 1 + 9]
131
+ assert _decode(idx)["text"] == "DWRO309"
132
+
133
+
134
+ def test_alphabet_maps_one_to_one():
135
+ idx = [45 + i for i in range(26)] # A..Z
136
+ assert _decode(idx)["text"] == "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
137
+
138
+
139
+ def test_out_of_range_index_falls_back_to_chr():
140
+ # Defensive path: an index beyond the charset uses chr(idx) rather than crash.
141
+ # (The real model never emits this; guards against a charset/model mismatch.)
142
+ out = _decode([200, 201, 202])
143
+ assert out["kind"] == "text"
144
+ assert out["text"] == "".join(chr(i) for i in (200, 201, 202))
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Captured REAL logits (ONNX vgg_english_g2 on crisp synthetic text)
149
+ # ---------------------------------------------------------------------------
150
+ def test_real_model_logits_read_dwro309():
151
+ logits = _load_logits("ctc_dwro309_logits.npy.gz.b64")
152
+ out = postprocess_ctc({"o": logits}, {"charset": VGG_G2_CHARSET}, 110, 70, 1.0, (0, 0))
153
+ # The model reads 0 as O on this font; letters D W R O and the digits are
154
+ # correct with NO off-by-one — that is the calibration guarantee.
155
+ assert out["text"] == "DWRO3O9"
156
+ assert out["confidence"] > 0.9
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Plausibility gate
161
+ # ---------------------------------------------------------------------------
162
+ def test_gate_suppresses_single_char_noise():
163
+ # A lone high-confidence "A" (the reported degenerate) is NOT a plate.
164
+ assert _decode([45])["text"] == ""
165
+
166
+
167
+ def test_gate_suppresses_real_lowres_collapse():
168
+ # Real ONNX logits from a too-low-resolution plate crop → spurious short read.
169
+ logits = _load_logits("ctc_degenerate_lowres_logits.npy.gz.b64")
170
+ out = postprocess_ctc({"o": logits}, {"charset": VGG_G2_CHARSET}, 110, 70, 1.0, (0, 0))
171
+ assert out["text"] == ""
172
+
173
+
174
+ def test_gate_allows_plausible_plate():
175
+ # Exactly the default minimum length passes.
176
+ assert len("ABC") == DEFAULT_MIN_TEXT_LENGTH
177
+ assert _decode([45, 46, 47])["text"] == "ABC"
178
+
179
+
180
+ def test_gate_length_is_configurable():
181
+ # Lowering the floor lets a 1-char read through (per-camera override).
182
+ assert _decode([45], {"minTextLength": 1})["text"] == "A"
183
+
184
+
185
+ def test_gate_confidence_floor():
186
+ # Weak (low-peak) logits: mean confidence below the floor → suppressed.
187
+ seq = _one_hot_logits([45, 46, 47], peak=0.05) # near-uniform → low softmax conf
188
+ out = postprocess_ctc(
189
+ {"o": seq}, {"charset": VGG_G2_CHARSET, "minTextConfidence": 0.5}, 110, 70, 1.0, (0, 0)
190
+ )
191
+ assert out["text"] == ""
192
+
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Format-aware rescoring — GERMANY ('DE') grammar
196
+ # ---------------------------------------------------------------------------
197
+ def test_de_grammar_accepts_conforming_reads():
198
+ # Well-formed German plates: district(1-3) + letters(1-2) + digits(1-4) [+E/H].
199
+ for plate in ("DNBB722", "DNSJ162", "BHKH2294"):
200
+ out = _read(plate)
201
+ assert out["text"] == plate, plate
202
+ assert out["formatValid"] is True, plate
203
+
204
+
205
+ def test_de_grammar_accepts_ev_and_historic_suffix():
206
+ # Optional E (electric) / H (historic) suffix glyph.
207
+ assert _read("MAB1234E")["formatValid"] is True
208
+ assert _read("MAB1234H")["formatValid"] is True
209
+
210
+
211
+ def test_de_rescores_digit_in_letter_block_via_confusable():
212
+ # OCR misread the letter B as digit 8 ("DN8B722"). The raw read fails the
213
+ # grammar (a digit sits in the trailing-letter block); confusable rescoring
214
+ # 8↔B yields "DNBB722", the unique grammar-conforming candidate.
215
+ out = _read("DN8B722")
216
+ assert out["text"] == "DNBB722"
217
+ assert out["formatValid"] is True
218
+
219
+
220
+ def test_de_rescores_zero_for_o_in_letter_block():
221
+ # "B0DE123": the 0 in the second glyph is a misread O → "BODE123".
222
+ out = _read("B0DE123")
223
+ assert out["text"] == "BODE123"
224
+ assert out["formatValid"] is True
225
+
226
+
227
+ def test_de_keeps_original_when_no_candidate_matches():
228
+ # A read with no confusable path to a valid plate is kept verbatim and
229
+ # annotated formatValid=false — validation never DROPS a plausible read.
230
+ out = _read("XYZW") # all letters, no digits → cannot become a valid plate
231
+ assert out["text"] == "XYZW"
232
+ assert out["formatValid"] is False
233
+
234
+
235
+ def test_de_conforming_read_is_never_altered():
236
+ # A grammar-conforming read containing confusable glyphs (0, 2) must NOT be
237
+ # "corrected" — rescoring only runs on a NON-matching read.
238
+ out = _read("BB2034") # BB(letters) 2034(digits) — valid, has 0/2
239
+ assert out["text"] == "BB2034"
240
+ assert out["formatValid"] is True
241
+
242
+
243
+ def test_rescoring_prefers_higher_probability_candidate():
244
+ # TWO grammar-valid repairs exist for "8B123" (digit-first → invalid):
245
+ # - "BB123" (one swap 8→B; letters BB + digits 123)
246
+ # - "BBI23" (two swaps 8→B AND 1→I; letters BBI + digits 23)
247
+ # With one-hot logits every swapped glyph carries ~0 softmax mass, so the
248
+ # FEWER-substitution candidate has the higher mean per-char probability and
249
+ # must win. Verifies the probability-weighted selection, not just "a match".
250
+ out = _read("8B123")
251
+ assert out["text"] == "BB123"
252
+ assert out["formatValid"] is True
253
+
254
+
255
+ def test_probability_weighted_selection_uses_aligned_timestep_mass():
256
+ # Hand-built logits: read "8B12" is digit-first → invalid. The ONLY valid
257
+ # repair is "BB12"; the substituted 'B' at pos0 draws its score from the
258
+ # softmax mass at that aligned timestep. A confident runner-up on 'B' should
259
+ # yield a high formatValid repair regardless of the exact prob value.
260
+ charset = VGG_G2_CHARSET
261
+ b_idx = charset.index("B")
262
+ eight_idx = charset.index("8")
263
+ one_idx = charset.index("1")
264
+ two_idx = charset.index("2")
265
+ seq = np.full((4, 97), -10.0, dtype=np.float32)
266
+ seq[0, eight_idx] = 3.0
267
+ seq[0, b_idx] = 2.9 # strong runner-up → B is a confident repair
268
+ seq[1, b_idx] = 10.0
269
+ seq[2, one_idx] = 10.0
270
+ seq[3, two_idx] = 10.0
271
+ out = postprocess_ctc(
272
+ {"o": seq[np.newaxis]},
273
+ {"charset": charset, "plateRegion": "DE"},
274
+ 110,
275
+ 70,
276
+ 1.0,
277
+ (0, 0),
278
+ )
279
+ assert out["text"] == "BB12"
280
+ assert out["formatValid"] is True
281
+
282
+
283
+ def test_off_region_bypasses_rescoring():
284
+ # region 'off' → no grammar lookup, no formatValid, raw read returned as-is.
285
+ out = _read("DN8B722", {"plateRegion": "off"})
286
+ assert out["text"] == "DN8B722"
287
+ assert "formatValid" not in out
288
+
289
+
290
+ def test_unknown_region_bypasses_rescoring():
291
+ # A region with no grammar entry leaves the read untouched (no annotation).
292
+ out = _read("DN8B722", {"plateRegion": "IT"})
293
+ assert out["text"] == "DN8B722"
294
+ assert "formatValid" not in out
295
+
296
+
297
+ def test_gate_suppression_skips_rescoring():
298
+ # A gate-suppressed (empty) read carries no formatValid annotation.
299
+ out = _decode([45]) # single 'A' → suppressed by length gate
300
+ assert out["text"] == ""
301
+ assert "formatValid" not in out
302
+
303
+
304
+ def test_substitution_position_cap_is_bounded():
305
+ # The candidate search is bounded even when many positions are confusable.
306
+ # A long all-confusable read must not explode; it returns (kept or repaired)
307
+ # within the MAX_SUBST_POSITIONS bound without error.
308
+ assert MAX_SUBST_POSITIONS == 8
309
+ out = _read("0000000000000") # 13 confusable digits, all substitutable
310
+ # No grammar-valid repair exists (would need a letter block) → kept verbatim.
311
+ assert out["text"] == "0000000000000"
312
+ assert out["formatValid"] is False
@@ -0,0 +1 @@
1
+ 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