@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.
- package/dist/{addon-utils-CLc6yHCN.js → addon-utils-DRbRzrDy.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +844 -110
- package/dist/detection-pipeline/index.mjs +842 -108
- package/dist/{dist-Ccmt3fGJ.mjs → dist-D9sltFoR.mjs} +230 -6
- package/dist/{dist-C11WuNUP.js → dist-gLAVhbvO.js} +247 -5
- package/dist/{event-loop-stall-monitor-OJrOMeuu.mjs → event-loop-stall-monitor-BS_lPX6H.mjs} +82 -88
- package/dist/{event-loop-stall-monitor-Cq_NeC4o.js → event-loop-stall-monitor-BUhYY39J.js} +82 -88
- package/dist/{lazy-sharp-RxUs6on_.js → lazy-sharp-OiAUva0g.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +8 -6
- package/dist/pipeline-runner/index.mjs +6 -4
- package/dist/{process-memory-DOjQ3MgC.js → process-memory-DY4RHcTj.js} +1 -1
- package/dist/{process-memory-D0zDmXLI.mjs → process-memory-Dw7-9jCI.mjs} +1 -1
- package/dist/recorder/index.js +660 -130
- package/dist/recorder/index.mjs +659 -129
- package/dist/remote-restream-BeHi78PZ.mjs +25 -0
- package/dist/remote-restream-CO36Sr30.js +36 -0
- package/dist/restream-intent-B4BXZra7.mjs +72 -0
- package/dist/{remote-restream-BYbAsgUf.js → restream-intent-Cv9x3jmu.js} +30 -30
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +1 -1
- 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
- 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
- package/dist/stream-broker/{hostInit-Da9wVA2r.mjs → hostInit-aZY81RnZ.mjs} +3 -3
- package/dist/stream-broker/index.js +598 -82
- package/dist/stream-broker/index.mjs +597 -81
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DDpliBIW.mjs → worker-protocol-Bz7-sMZC.mjs} +1 -1
- package/dist/{worker-protocol-BePduZVV.js → worker-protocol-D_0q_4Le.js} +1 -1
- package/package.json +1 -1
- package/python/inference_pool.py +193 -1
- package/python/postprocessors/__init__.py +4 -0
- package/python/postprocessors/ctc.py +52 -0
- package/python/postprocessors/plate_slots.py +180 -0
- package/python/postprocessors/test_ctc.py +39 -0
- package/python/postprocessors/test_plate_slots.py +217 -0
- package/python/postprocessors/test_yolonas.py +83 -0
- package/python/postprocessors/yolonas.py +67 -0
- package/python/test_inference_pool_backpressure.py +6 -2
- package/python/test_inference_pool_coreml_cache.py +12 -4
- package/python/test_inference_pool_device_selection.py +12 -4
- package/python/test_inference_pool_layout.py +16 -5
- package/python/test_inference_pool_memstats.py +6 -2
- package/python/test_inference_pool_ov_ppp.py +12 -9
- package/python/test_inference_pool_preprocess.py +57 -0
- package/python/test_inference_pool_static_batch.py +426 -0
- package/dist/remote-restream-Ci7RXNGb.mjs +0 -66
- 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-
|
|
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
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
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",
|
package/python/inference_pool.py
CHANGED
|
@@ -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
|
-
|
|
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"
|