@camstack/addon-pipeline 1.2.103 → 1.2.104

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 (52) hide show
  1. package/dist/{addon-utils-CLc6yHCN.js → addon-utils-DRbRzrDy.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +844 -110
  5. package/dist/detection-pipeline/index.mjs +842 -108
  6. package/dist/{dist-Ccmt3fGJ.mjs → dist-D9sltFoR.mjs} +230 -6
  7. package/dist/{dist-C11WuNUP.js → dist-gLAVhbvO.js} +247 -5
  8. package/dist/{event-loop-stall-monitor-OJrOMeuu.mjs → event-loop-stall-monitor-BS_lPX6H.mjs} +82 -88
  9. package/dist/{event-loop-stall-monitor-Cq_NeC4o.js → event-loop-stall-monitor-BUhYY39J.js} +82 -88
  10. package/dist/{lazy-sharp-RxUs6on_.js → lazy-sharp-OiAUva0g.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +8 -6
  14. package/dist/pipeline-runner/index.mjs +6 -4
  15. package/dist/{process-memory-DOjQ3MgC.js → process-memory-DY4RHcTj.js} +1 -1
  16. package/dist/{process-memory-D0zDmXLI.mjs → process-memory-Dw7-9jCI.mjs} +1 -1
  17. package/dist/recorder/index.js +660 -130
  18. package/dist/recorder/index.mjs +659 -129
  19. package/dist/remote-restream-BeHi78PZ.mjs +25 -0
  20. package/dist/remote-restream-CO36Sr30.js +36 -0
  21. package/dist/restream-intent-B4BXZra7.mjs +72 -0
  22. package/dist/{remote-restream-BYbAsgUf.js → restream-intent-Cv9x3jmu.js} +30 -30
  23. package/dist/session-decode/decode-worker-child.js +2 -2
  24. package/dist/session-decode/decode-worker-child.mjs +1 -1
  25. package/dist/stream-broker/_stub.js +1 -1
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BdgcF1lL.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZaqZ17-x.mjs} +3 -3
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BnhMnnKV.mjs +26 -0
  28. package/dist/stream-broker/{hostInit-Da9wVA2r.mjs → hostInit-aZY81RnZ.mjs} +3 -3
  29. package/dist/stream-broker/index.js +598 -82
  30. package/dist/stream-broker/index.mjs +597 -81
  31. package/dist/stream-broker/remoteEntry.js +1 -1
  32. package/dist/{worker-protocol-DDpliBIW.mjs → worker-protocol-Bz7-sMZC.mjs} +1 -1
  33. package/dist/{worker-protocol-BePduZVV.js → worker-protocol-D_0q_4Le.js} +1 -1
  34. package/package.json +1 -1
  35. package/python/inference_pool.py +193 -1
  36. package/python/postprocessors/__init__.py +4 -0
  37. package/python/postprocessors/ctc.py +52 -0
  38. package/python/postprocessors/plate_slots.py +180 -0
  39. package/python/postprocessors/test_ctc.py +39 -0
  40. package/python/postprocessors/test_plate_slots.py +217 -0
  41. package/python/postprocessors/test_yolonas.py +83 -0
  42. package/python/postprocessors/yolonas.py +67 -0
  43. package/python/test_inference_pool_backpressure.py +6 -2
  44. package/python/test_inference_pool_coreml_cache.py +12 -4
  45. package/python/test_inference_pool_device_selection.py +12 -4
  46. package/python/test_inference_pool_layout.py +16 -5
  47. package/python/test_inference_pool_memstats.py +6 -2
  48. package/python/test_inference_pool_ov_ppp.py +12 -9
  49. package/python/test_inference_pool_preprocess.py +57 -0
  50. package/python/test_inference_pool_static_batch.py +426 -0
  51. package/dist/remote-restream-Ci7RXNGb.mjs +0 -66
  52. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DY31bUCj.mjs +0 -26
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BdgcF1lL.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZaqZ17-x.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
@@ -1,4 +1,4 @@
1
- import { _ as NativeLeaseSettingsSchema, g as NativeLeaseAdmissionSchema, u as DEFAULT_NATIVE_LEASE_SETTINGS } from "./dist-Ccmt3fGJ.mjs";
1
+ import { _ as NativeLeaseAdmissionSchema, d as DEFAULT_NATIVE_LEASE_SETTINGS, v as NativeLeaseSettingsSchema } from "./dist-D9sltFoR.mjs";
2
2
  //#region src/session-decode/native-lease-config.ts
3
3
  /**
4
4
  * Resolution of the native-frame hold / tile knobs for one decode worker.
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-C11WuNUP.js");
1
+ const require_dist = require("./dist-gLAVhbvO.js");
2
2
  //#region src/session-decode/native-lease-config.ts
3
3
  /**
4
4
  * Resolution of the native-frame hold / tile knobs for one decode worker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.103",
3
+ "version": "1.2.104",
4
4
  "description": "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",
@@ -522,6 +522,154 @@ def _init_runtime(runtime: str, device: str = "") -> None:
522
522
  raise ValueError(f"Unknown runtime: {runtime}")
523
523
 
524
524
 
525
+ # ---------------------------------------------------------------------------
526
+ # Batch capability is a HARDWARE FACT, not a setting (D202)
527
+ # ---------------------------------------------------------------------------
528
+ # Whether a compiled graph may keep a DYNAMIC batch axis is decided here, from
529
+ # the (engine, device) pair, at the moment of load — never per model, never by
530
+ # an operator, never in a conversion script. The conversion scripts and the
531
+ # model-studio convert path both emit dynamic-batch graphs (`ov.convert_model`
532
+ # preserves the ONNX dynamic axis), and the same artifact is loaded on CPU, iGPU,
533
+ # NPU and ANE nodes — so the artifact cannot know, and only the loader does.
534
+ #
535
+ # The one target that refuses: the Intel NPU. Its Level-Zero compiler rejects a
536
+ # dynamic dimension outright ("Got negative shape dim bound: '-1'", then
537
+ # ZE_RESULT_ERROR_INVALID_ARGUMENT). Found live 2026-08-20 on the hub, and the
538
+ # symptom is the trap — a pool load failure is a RETRY, not an error the caller
539
+ # sees, so an NPU-pinned node repeated "Loading step variant" forever and looked
540
+ # busy rather than broken. Under AUTO it is quieter and just as wrong: the NPU
541
+ # candidate silently loses every dynamic-batch model to the GPU fallback.
542
+ #
543
+ # This is COMPILE shape only. The pool's runtime request grouping (`batch_mode`
544
+ # / `max_batch_size`, and the CoreML stacked-tensor fast path gated on the
545
+ # export's own RangeDim) is a separate, untouched optimisation.
546
+ STATIC_BATCH_PIN = 1
547
+
548
+
549
+ def _static_batch_required(runtime: str, device: str) -> bool:
550
+ """THE authority: does this (engine, device) pair refuse a dynamic shape?
551
+
552
+ Pure — no OpenVINO import, so it is unit-testable in a bare sandbox.
553
+
554
+ openvino + NPU -> True (Level-Zero rejects any dynamic dim)
555
+ openvino + CPU/GPU -> False
556
+ onnxruntime -> False (every EP we ship handles dynamic axes)
557
+ coreml -> False (a RangeDim export is exactly what the batched
558
+ ANE dispatch path reads; pinning would delete it)
559
+ edgetpu -> False (a tflite graph is static by construction)
560
+ """
561
+ if runtime != "openvino":
562
+ return False
563
+ return device.strip().upper().startswith("NPU")
564
+
565
+
566
+ def _static_batch_plan(
567
+ shapes: "list[list[int | None] | None]",
568
+ ) -> "tuple[dict[int, list[int]], list[int]]":
569
+ """Pure. Given each input's dims (``None`` = dynamic axis, ``None`` for the
570
+ whole entry = unreadable), return ``(plan, unpinnable)``:
571
+
572
+ plan {input_index: static_dims} for inputs whose BATCH axis is
573
+ dynamic and whose every other axis is already static.
574
+ unpinnable [input_index] for inputs carrying a dynamic axis that is NOT
575
+ the batch — there is no value we are allowed to invent for a
576
+ spatial dim, so the caller compiles as-is and SAYS SO rather
577
+ than letting the failure be silent.
578
+ """
579
+ plan: "dict[int, list[int]]" = {}
580
+ unpinnable: "list[int]" = []
581
+ for index, dims in enumerate(shapes):
582
+ if dims is None:
583
+ unpinnable.append(index)
584
+ continue
585
+ if len(dims) == 0:
586
+ continue
587
+ if any(d is None for d in dims[1:]):
588
+ unpinnable.append(index)
589
+ continue
590
+ if dims[0] is not None:
591
+ continue # already a static batch — the rule pins dynamic axes only
592
+ plan[index] = [STATIC_BATCH_PIN] + [int(d) for d in dims[1:] if d is not None]
593
+ return plan, unpinnable
594
+
595
+
596
+ def _pin_batch_for_device(core: Any, source: Any, path: str, device: str) -> Any:
597
+ """OpenVINO applicator of {@link _static_batch_required}.
598
+
599
+ Returns what `compile_model` should be handed for `device`: the untouched
600
+ `source` when the target accepts a dynamic shape (or the graph is already
601
+ static), otherwise a model RESHAPED to a static batch of
602
+ {@link STATIC_BATCH_PIN}. `source` is either the IR path (the plain load
603
+ path) or an already-built model object (the PrePostProcessor path); the pin
604
+ is applied to whichever, and it only ever touches axis 0, so a PPP model's
605
+ rewritten uint8 NHWC input survives it. Every outcome is logged — the
606
+ derivation must be readable in the pool stderr.
607
+
608
+ (`_ov_dim_to_int` is defined further down with the other OV shape helpers.)
609
+ """
610
+ name = os.path.basename(path)
611
+ if not _static_batch_required("openvino", device):
612
+ return source
613
+ try:
614
+ model = core.read_model(path) if isinstance(source, str) else source
615
+ except Exception as exc:
616
+ sys.stderr.write(
617
+ f"OpenVINO: {device} needs a static shape but re-reading {name} failed "
618
+ f"({exc}); compiling the source as-is\n"
619
+ )
620
+ sys.stderr.flush()
621
+ return source
622
+
623
+ shapes: "list[list[int | None] | None]" = []
624
+ for inp in model.inputs:
625
+ try:
626
+ pshape = inp.get_partial_shape()
627
+ shapes.append([_ov_dim_to_int(pshape[i]) for i in range(len(pshape))])
628
+ except Exception:
629
+ shapes.append(None)
630
+
631
+ plan, unpinnable = _static_batch_plan(shapes)
632
+ if unpinnable:
633
+ sys.stderr.write(
634
+ f"OpenVINO: {name} has a DYNAMIC NON-BATCH axis on input(s) {unpinnable} "
635
+ f"and {device} refuses dynamic dims — no value may be invented for a "
636
+ f"spatial axis, so this compile is expected to fail\n"
637
+ )
638
+ sys.stderr.flush()
639
+ if not plan:
640
+ sys.stderr.write(
641
+ f"OpenVINO: batch shape for {name} on {device} — already static, no "
642
+ f"reshape (derived from engine+device)\n"
643
+ )
644
+ sys.stderr.flush()
645
+ return source
646
+ try:
647
+ from openvino import PartialShape
648
+
649
+ # `Model.reshape` mutates IN PLACE, and on the PrePostProcessor path
650
+ # `source` IS the model object the compile loop reuses for the NEXT
651
+ # candidate. Pinning it directly would leak this NPU-only decision into
652
+ # the GPU/CPU fallback — a candidate that accepts a dynamic shape would
653
+ # silently get the pinned graph. So reshape a CLONE and leave `source`
654
+ # exactly as the caller built it.
655
+ model = model.clone() if not isinstance(source, str) else model
656
+ model.reshape({index: PartialShape(dims) for index, dims in plan.items()})
657
+ except Exception as exc:
658
+ sys.stderr.write(
659
+ f"OpenVINO: static-batch reshape of {name} for {device} failed ({exc}); "
660
+ f"compiling the source as-is\n"
661
+ )
662
+ sys.stderr.flush()
663
+ return source
664
+ sys.stderr.write(
665
+ f"OpenVINO: batch shape for {name} on {device} — PINNED to "
666
+ f"{STATIC_BATCH_PIN} on input(s) {sorted(plan)} because the target refuses "
667
+ f"a dynamic dim (derived from engine+device, not configured)\n"
668
+ )
669
+ sys.stderr.flush()
670
+ return model
671
+
672
+
525
673
  def _load_model(slot: ModelSlot, config: dict) -> None:
526
674
  """Load a model into a slot using the active runtime. Thin adapter per backend."""
527
675
  slot.config = dict(config)
@@ -667,8 +815,16 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
667
815
  last_exc: Optional[BaseException] = None
668
816
  for candidate in candidates:
669
817
  try:
818
+ # Batch capability is derived from THIS candidate, not from the
819
+ # artifact and not from a setting (D202): a graph with a dynamic
820
+ # batch axis is reshaped static before it can reach a compiler
821
+ # that refuses dynamic dims (the Intel NPU), and is handed over
822
+ # untouched to every other target.
823
+ candidate_source = _pin_batch_for_device(
824
+ core, compile_source, path, candidate,
825
+ )
670
826
  compiled = core.compile_model(
671
- compile_source, device_name=candidate, config=ov_config,
827
+ candidate_source, device_name=candidate, config=ov_config,
672
828
  )
673
829
  ov_device = candidate
674
830
  break
@@ -867,6 +1023,15 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
867
1023
  slot.config["_input_shape"] = session.get_inputs()[0].shape
868
1024
  except Exception:
869
1025
  pass
1026
+ # Declared input DTYPE. super-gradients / Frigate YOLO-NAS exports bake
1027
+ # their preprocessing into the graph and take `tensor(uint8)` — the
1028
+ # preprocess must feed raw uint8 pixels, not the float32 [0,1] tensor
1029
+ # (a hard onnxruntime type error otherwise).
1030
+ try:
1031
+ if session.get_inputs()[0].type == "tensor(uint8)":
1032
+ slot.config["_input_dtype"] = "uint8"
1033
+ except Exception:
1034
+ pass
870
1035
 
871
1036
  def predict(inp_dict: dict) -> dict:
872
1037
  outputs = session.run(output_names, inp_dict)
@@ -1300,6 +1465,33 @@ def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int,
1300
1465
  if bench_fid is not None:
1301
1466
  _bench_preprocess_cache[(bench_fid, input_size)] = result
1302
1467
  return result
1468
+ # uint8-input ONNX models: super-gradients / Frigate YOLO-NAS exports
1469
+ # bake the /255 preprocessing INTO the graph and declare a uint8 input
1470
+ # tensor. Feeding the historical float32 [0,1] tensor is a hard
1471
+ # onnxruntime type error, so feed raw uint8 pixels in the declared
1472
+ # layout instead (`_input_dtype` is captured at session load).
1473
+ if config.get("_input_dtype") == "uint8" and input_channels == 3:
1474
+ in_side = max(input_w, input_h)
1475
+ if preprocess_mode == "letterbox":
1476
+ canvas, scale_val, pad = letterbox_image(img, in_side)
1477
+ else:
1478
+ canvas = (
1479
+ img
1480
+ if img.size == (input_w, input_h)
1481
+ else img.resize((input_w, input_h), Image.BILINEAR)
1482
+ )
1483
+ scale_val, pad = 1.0, (0, 0)
1484
+ pix = np.asarray(canvas, dtype=np.uint8)
1485
+ if pix.ndim == 2:
1486
+ pix = np.stack([pix] * 3, axis=-1)
1487
+ if _is_channels_last(config.get("_input_shape")):
1488
+ input_dict[input_name] = pix[np.newaxis]
1489
+ else:
1490
+ input_dict[input_name] = pix.transpose(2, 0, 1)[np.newaxis]
1491
+ result = (input_dict, scale_val, pad)
1492
+ if bench_fid is not None:
1493
+ _bench_preprocess_cache[(bench_fid, input_size)] = result
1494
+ return result
1303
1495
  if input_channels == 1:
1304
1496
  # Grayscale CTC recognizer (EasyOCR plate-OCR): aspect-preserving
1305
1497
  # resize + edge-pad + [-1,1] norm → [1,1,H,W]. NOT the squared
@@ -10,10 +10,12 @@ from .arcface import postprocess_arcface
10
10
  from .clip import postprocess_clip
11
11
  from .softmax import postprocess_softmax
12
12
  from .ctc import postprocess_ctc
13
+ from .plate_slots import postprocess_plate_slots
13
14
  from .saliency import postprocess_saliency
14
15
  from .yamnet import postprocess_yamnet
15
16
  from .ssd import postprocess_ssd
16
17
  from .rfdetr import postprocess_rfdetr
18
+ from .yolonas import postprocess_yolonas
17
19
 
18
20
  POSTPROCESSORS = {
19
21
  "yolo": postprocess_yolo,
@@ -23,8 +25,10 @@ POSTPROCESSORS = {
23
25
  "clip": postprocess_clip,
24
26
  "softmax": postprocess_softmax,
25
27
  "ctc": postprocess_ctc,
28
+ "plate-slots": postprocess_plate_slots,
26
29
  "saliency": postprocess_saliency,
27
30
  "yamnet": postprocess_yamnet,
28
31
  "ssd": postprocess_ssd,
29
32
  "rfdetr": postprocess_rfdetr,
33
+ "yolonas": postprocess_yolonas,
30
34
  }
@@ -246,6 +246,37 @@ def _rescore_for_grammar(
246
246
  return text, False
247
247
 
248
248
 
249
+ # Glyphs the recognizer emits for a plate's physical registration seal.
250
+ SEAL_GLYPHS = frozenset(".:;,*-'`\"·")
251
+
252
+
253
+ def _strip_seal_glyph(text: str, region: str) -> "str | None":
254
+ """Return the normalised plate when `text` is a conforming read carrying ONE
255
+ interior seal glyph; ``None`` otherwise (caller applies the normal gate).
256
+
257
+ Narrow by construction: exactly one seal-like glyph, never at either end,
258
+ nothing else non-alphanumeric left over, and the normalised remainder must
259
+ match the region grammar. A read that does not become a legal plate is not
260
+ rescued.
261
+ """
262
+ pattern_src = PLATE_GRAMMARS.get(region)
263
+ if pattern_src is None:
264
+ return None
265
+ seals = [i for i, c in enumerate(text) if c in SEAL_GLYPHS]
266
+ if len(seals) != 1:
267
+ return None
268
+ pos = seals[0]
269
+ if pos == 0 or pos == len(text) - 1:
270
+ return None
271
+ stripped = text[:pos] + text[pos + 1 :]
272
+ if any(not (c.isalnum() or c.isspace()) for c in stripped):
273
+ return None
274
+ normalised = "".join(c for c in stripped.upper() if c.isalnum())
275
+ if re.match(pattern_src, normalised) is None:
276
+ return None
277
+ return normalised
278
+
279
+
249
280
  def postprocess_ctc(
250
281
  predictions: dict,
251
282
  config: dict,
@@ -318,6 +349,27 @@ def postprocess_ctc(
318
349
  has_symbol_glyph = region != "off" and any(
319
350
  not (c.isalnum() or c.isspace()) for c in text
320
351
  )
352
+ # …except the SEAL. A German plate physically carries a registration seal
353
+ # between the district code and the letter group, and the recognizer
354
+ # transcribes that badge as a punctuation glyph ('.', ':', '-', …). Once the
355
+ # quad warp (2026-08-20) started delivering legible strips, the correct
356
+ # decodes arrived as "DN.D7578" / "DN-P 1091" / "DN:HF 974" and this gate
357
+ # threw away every one of them — the warp scored 1/21 instead of 4/21 on the
358
+ # cam-617 ground-truth set purely because of this line.
359
+ #
360
+ # The exemption is deliberately narrow: EXACTLY ONE seal-like glyph, strictly
361
+ # INTERIOR, and the remainder must CONFORM to the region grammar. Noise
362
+ # cannot be laundered by it — the 2026-07-22 junk ("6ou.2x", "[miv4o)") has
363
+ # either the wrong count, the wrong position, or no conforming remainder.
364
+ if has_symbol_glyph:
365
+ repaired = _strip_seal_glyph(text, region)
366
+ if repaired is not None:
367
+ return {
368
+ "kind": "text",
369
+ "text": repaired,
370
+ "confidence": round(confidence, 4),
371
+ "formatValid": True,
372
+ }
321
373
  if alnum_count < min_len or confidence < min_conf or has_symbol_glyph:
322
374
  return {
323
375
  "kind": "text",
@@ -0,0 +1,180 @@
1
+ """Fixed-slot license-plate recognition postprocessor.
2
+
3
+ Used for: `cct-s-v2-global` (fast-plate-ocr), the plate-OCR default since
4
+ 2026-08-20.
5
+
6
+ Input: the `plate` head, ``[1, PLATE_SLOTS, VOCAB]`` (or the flattened
7
+ ``[1, PLATE_SLOTS * VOCAB]``) — an INDEPENDENT softmax per character position,
8
+ already normalised inside the graph.
9
+ Output: ``{"kind": "text", "text": "DNRO309", "confidence": 0.99,
10
+ "formatValid": true}`` — the same StepOutput shape the CTC path produces, so
11
+ nothing downstream (the label writer, the D186 per-track vote) changes.
12
+
13
+ Why this is not `ctc.py`
14
+ ------------------------
15
+ The two readers fail in opposite directions and that dictates opposite gates.
16
+
17
+ A CTC recognizer fed noise collapses toward the blank: the decode comes out one
18
+ or two characters long, which is why "at least `minTextLength` alphanumerics"
19
+ was a sound plausibility gate for `vgg_english_g2`. A fixed-slot reader has no
20
+ blank. It runs `PLATE_SLOTS` classification heads and every one of them picks a
21
+ winner, on a plate, on a bumper, on an OSD weather band. Length carries no
22
+ information here — `minTextLength` would admit literally every ROI the detector
23
+ ever hands over.
24
+
25
+ What DOES separate them is calibration, and only at the weakest position. On the
26
+ 39-crop cam-617 ground-truth set (21 plates, 9 vehicles, 18 junk ROIs) every
27
+ correct read has a minimum per-character probability of 0.80 or better, while no
28
+ junk ROI reaches it; the misreads that do get close are wrong in exactly one
29
+ slot, and that slot is the one that dips. The MEAN hides this — six confident
30
+ glyphs and one guess still average ~0.9 — so the gate reads the MINIMUM.
31
+
32
+ Second gate: the region grammar (`plateRegion`, shared with `ctc.py` so there is
33
+ one German plate pattern in this codebase, not two). Here a non-conforming read
34
+ is DROPPED rather than kept-and-annotated. That differs from the CTC path on
35
+ purpose: this alphabet is 36 alphanumerics plus a pad, so it cannot emit the
36
+ "obviously not a plate" shapes (`[miv4o)`, `6ou.2`) that made an annotated read
37
+ worth storing — a non-conforming read here is a confident misread, which is the
38
+ one thing that must never reach a label.
39
+
40
+ No confusable rescoring
41
+ -----------------------
42
+ `ctc.py` repairs a grammar failure by substituting 0↔O, 1↔I, 5↔S… That machinery
43
+ exists because `vgg_english_g2` decodes from a 97-glyph English charset where
44
+ case and punctuation are live classes. This alphabet has neither, so the only
45
+ surviving confusion is 0↔O — and, run over the 39-crop set at the shipped
46
+ operating point, the rescorer fires on zero crops. An unfired repair path is a
47
+ path nothing verifies, so it is not here. If a future set shows it firing, take
48
+ it from `ctc.py`: `_rescore_for_grammar` is already position/probability
49
+ indexed, and a slot index is exactly the "aligned timestep" it wants.
50
+ """
51
+ import re
52
+
53
+ import numpy as np
54
+
55
+ from .ctc import ABSENT_PLATE_REGION, PLATE_GRAMMARS
56
+
57
+ # The `cct_s_v2_global_plate_config.yaml` contract, published beside the weights.
58
+ # Class indices are positions in this string; a drift re-maps every glyph.
59
+ PLATE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_"
60
+ PLATE_PAD_CHAR = "_"
61
+ PLATE_SLOTS = 10
62
+
63
+ # Minimum probability the WEAKEST emitted character must carry.
64
+ #
65
+ # 0.75 is not a round number picked for taste — it is the centre of the band
66
+ # that separates the two populations on the cam-617 set WITH THE INFERENCE
67
+ # PRECISION SPREAD INCLUDED. Taking, per crop, the worst case across ONNX fp32,
68
+ # OpenVINO fp16 and CoreML: the highest-scoring junk ROI that also conforms to
69
+ # the German grammar reaches 0.728, and the lowest-scoring correct plate that
70
+ # still has to be read sits at 0.779. Anything inside (0.728, 0.779) stores
71
+ # every one of those plates and none of that junk on every engine.
72
+ #
73
+ # The band matters because a plate recognizer does not get to choose its
74
+ # arithmetic: OpenVINO's CPU default inference precision is fp16, an Intel NPU
75
+ # has no other option, and a marginal crop's weakest character moves by as much
76
+ # as 0.16 between engines. A floor chosen from one engine's numbers would be a
77
+ # different filter on the next node.
78
+ #
79
+ # The two sides of the band are not symmetric, which is why 0.75 leans up. Too
80
+ # LOW stores a wrong plate, and nothing downstream can undo that. Too HIGH
81
+ # silences a marginal read — and the step runs up to `maxPerTrack` times per
82
+ # track with the reads voted (D186), so the next frame gets another go.
83
+ #
84
+ # Config-overridable (`minCharProbability`).
85
+ DEFAULT_MIN_CHAR_PROBABILITY = 0.75
86
+
87
+ # Name of the plate head in the published graph. The region head (`region`,
88
+ # [1,66]) rides along; it is NOT consulted — at this floor every junk ROI it
89
+ # would have caught is already below the confidence gate, and a second gate
90
+ # nothing removes is a second thing to keep true.
91
+ PLATE_OUTPUT_NAME = "plate"
92
+
93
+
94
+ def _select_plate_logits(predictions: dict) -> np.ndarray:
95
+ """Return the plate head as ``[PLATE_SLOTS, VOCAB]``.
96
+
97
+ By NAME first — the graph has two outputs and dict ordering is not a
98
+ contract. By SHAPE as the fallback, so an engine whose exporter dropped the
99
+ tensor names still decodes the right head rather than the region logits.
100
+ """
101
+ vocab = len(PLATE_ALPHABET)
102
+ expected = PLATE_SLOTS * vocab
103
+ candidate = predictions.get(PLATE_OUTPUT_NAME)
104
+ if candidate is None:
105
+ for value in predictions.values():
106
+ array = np.asarray(value)
107
+ if array.size == expected:
108
+ candidate = array
109
+ break
110
+ if candidate is None:
111
+ raise ValueError(
112
+ f"no plate head in predictions {sorted(predictions)}: "
113
+ f"expected an output of {expected} values"
114
+ )
115
+ array = np.asarray(candidate, dtype=np.float32)
116
+ if array.size != expected:
117
+ raise ValueError(
118
+ f"plate head has {array.size} values, expected "
119
+ f"{PLATE_SLOTS}x{vocab}={expected}"
120
+ )
121
+ return array.reshape(PLATE_SLOTS, vocab)
122
+
123
+
124
+ def postprocess_plate_slots(
125
+ predictions: dict,
126
+ config: dict,
127
+ orig_w: int,
128
+ orig_h: int,
129
+ scale: float,
130
+ pad: tuple[int, int],
131
+ ) -> dict:
132
+ """Per-slot argmax decode + confidence floor + region grammar."""
133
+ probs = _select_plate_logits(predictions)
134
+ alphabet = str(config.get("plateAlphabet") or PLATE_ALPHABET)
135
+ pad_char = str(config.get("platePadChar") or PLATE_PAD_CHAR)
136
+ min_char_prob = float(
137
+ config.get("minCharProbability", DEFAULT_MIN_CHAR_PROBABILITY)
138
+ )
139
+ region = str(config.get("plateRegion", ABSENT_PLATE_REGION))
140
+
141
+ winners = np.argmax(probs, axis=-1)
142
+ scores = probs[np.arange(len(winners)), winners]
143
+ decoded = "".join(alphabet[int(i)] for i in winners)
144
+
145
+ # Trailing pad is the model's length signal; strip it and keep the scores of
146
+ # the glyphs that survive aligned with them.
147
+ text = decoded.rstrip(pad_char)
148
+ kept = [float(s) for s in scores[: len(text)]]
149
+ confidence = round(float(np.mean(kept)), 4) if kept else 0.0
150
+
151
+ def silent() -> dict:
152
+ return {"kind": "text", "text": "", "confidence": confidence}
153
+
154
+ if not text:
155
+ return silent()
156
+ # A pad the model placed BETWEEN glyphs is a refusal at a position it
157
+ # believes exists — never a plate, and never something to paper over.
158
+ if pad_char in text:
159
+ return silent()
160
+ if min(kept) < min_char_prob:
161
+ return silent()
162
+
163
+ pattern_src = PLATE_GRAMMARS.get(region) if region != "off" else None
164
+ if pattern_src is None:
165
+ # No grammar for this region (or rescoring disabled): the read stands on
166
+ # the confidence gate alone, and says so.
167
+ return {
168
+ "kind": "text",
169
+ "text": text,
170
+ "confidence": confidence,
171
+ "formatValid": False,
172
+ }
173
+ if re.match(pattern_src, text) is None:
174
+ return silent()
175
+ return {
176
+ "kind": "text",
177
+ "text": text,
178
+ "confidence": confidence,
179
+ "formatValid": True,
180
+ }
@@ -382,3 +382,42 @@ def test_region_off_still_returns_the_raw_read():
382
382
  out = _read("dn Ro 3o9", {"plateRegion": "off"})
383
383
  assert out["text"] == "dn Ro 3o9"
384
384
  assert "formatValid" not in out
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Seal tolerance (2026-08-20)
389
+ # ---------------------------------------------------------------------------
390
+ def test_seal_glyph_between_district_and_letters_is_not_junk():
391
+ """A German plate carries a physical SEAL between the district code and the
392
+ letter group, and the recognizer transcribes it as a punctuation glyph.
393
+
394
+ Measured on the 2026-08-20 cam-617 ground-truth set (21 crops, 9 vehicles):
395
+ with the quad warp in front of the reader, the correct decodes are
396
+ ``DN.D7578``, ``DN-P 1091``, ``DN:HF 974`` — the plate, exactly, plus the
397
+ seal. The blanket symbol gate returned '' for every one of them, which is
398
+ why the warp alone scored 1/21 instead of 4/21. ONE interior seal glyph is
399
+ dropped, and the read is accepted only if what remains CONFORMS to the
400
+ region grammar — so it can never launder noise.
401
+ """
402
+ for raw, expected in (("DN.D7578", "DND7578"), ("DN-P1091", "DNP1091"), ("DN:HF974", "DNHF974")):
403
+ out = _read(raw)
404
+ assert out["text"] == expected, raw
405
+ assert out["formatValid"] is True, raw
406
+
407
+
408
+ def test_seal_tolerance_does_not_rescue_non_conforming_noise():
409
+ """The rescue is grammar-gated: the live junk of 2026-07-22 stays rejected."""
410
+ for junk in ("6OU.2X", "[MIV4O)", "A.B", "12.34"):
411
+ assert _read(junk)["text"] == "", junk
412
+
413
+
414
+ def test_seal_tolerance_needs_exactly_one_interior_symbol():
415
+ # Two symbols, or a symbol at either end, is hallucination — not a seal.
416
+ for junk in ("DN.D.7578", ".DND7578", "DND7578."):
417
+ assert _read(junk)["text"] == "", junk
418
+
419
+
420
+ def test_seal_tolerance_is_off_when_region_is_off():
421
+ # 'off' preserves the historical decode byte-for-byte.
422
+ idx = _indices("DN.D7578")
423
+ assert _decode(idx, {"plateRegion": "off"})["text"] == "DN.D7578"