@camstack/addon-pipeline 1.1.65 → 1.1.67

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 (40) 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 +83 -22
  4. package/dist/detection-pipeline/index.mjs +83 -22
  5. package/dist/{dist-WW3gH8hF.js → dist-BxxVKcnw.js} +56 -5
  6. package/dist/{dist-D6tHM9GA.mjs → dist-DkGHtAaK.mjs} +56 -5
  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 +88 -22
  10. package/dist/pipeline-runner/index.mjs +88 -22
  11. package/dist/recorder/index.js +141 -1
  12. package/dist/recorder/index.mjs +141 -1
  13. package/dist/session-decode/decode-worker-child.js +185 -14
  14. package/dist/session-decode/decode-worker-child.mjs +185 -14
  15. package/dist/{step-definitions-CXZpwwT0.js → step-definitions-CAs0RD2N.js} +127 -8
  16. package/dist/{step-definitions-Dbsm64_I.mjs → step-definitions-Delhq28w.mjs} +127 -8
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CbU_HuOC.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CfFsU700.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D68Lj69v.mjs +26 -0
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DlALQ_wp.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-QaDEOY0Z.mjs → hostInit-D-S5fCDx.mjs} +2 -2
  22. package/dist/stream-broker/index.js +1 -1
  23. package/dist/stream-broker/index.mjs +1 -1
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C7H0kVAH.js → MaskShapeCanvas-DI4BY7W2-CRvzj7Of.js} +1 -1
  26. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CHfEFlEF.js → MotionZonesSettings-NcxxQN8r-CBgvPUB-.js} +1 -1
  27. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Bc-trVxX.js → PrivacyMaskSettings-APgPLF7p-DH07fe1k.js} +1 -1
  28. package/embed-dist/assets/index-BkoPcGtQ.js +114 -0
  29. package/embed-dist/assets/index-D2pF9Z3W.css +2 -0
  30. package/embed-dist/index.html +2 -2
  31. package/package.json +1 -1
  32. package/python/inference_pool.py +392 -45
  33. package/python/postprocessors/softmax.py +17 -5
  34. package/python/postprocessors/test_softmax.py +52 -0
  35. package/python/test_inference_pool_ov_ppp.py +117 -0
  36. package/python/test_inference_pool_preprocess.py +93 -1
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CxZvj8X1.mjs +0 -26
  38. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BzNJyeNE.mjs +0 -26
  39. package/embed-dist/assets/index-Bm9LaQPH.css +0 -2
  40. package/embed-dist/assets/index-eZGm_itM.js +0 -81
@@ -180,6 +180,27 @@ OV_DEFAULT_CONCURRENCY: int = 4
180
180
  # live on the N100 iGPU). Compiling ONE explicit device removes the handover
181
181
  # entirely. We resolve an ordered candidate list (NPU > GPU > CPU) and compile the
182
182
  # first that succeeds, with CPU as the guaranteed floor.
183
+ #
184
+ # OpenVINO async execution (CAMSTACK_OV_ASYNC, default OFF / "0"). When ON, the
185
+ # OpenVINO runtime drives an ov.AsyncInferQueue sized to
186
+ # OPTIMAL_NUMBER_OF_INFER_REQUESTS instead of the synchronous InferRequest pool +
187
+ # req.infer(). This mirrors how the reference Intel-iGPU async pipeline saturates
188
+ # the GPU: keep N inferences in flight, collect each in the queue's callback.
189
+ # start_async() BLOCKS once all N jobs are busy, so in-flight work is bounded to N
190
+ # with NO extra buffer (respects the "never buffer frames at frame-rate" rule).
191
+ # OFF is byte-for-byte the historical synchronous path — the ship-dark default.
192
+ _OV_ASYNC_ENABLED: bool = os.environ.get("CAMSTACK_OV_ASYNC", "0") not in (
193
+ "0", "false", "no",
194
+ )
195
+ # OpenVINO PERFORMANCE_HINT (CAMSTACK_OV_HINT, default "LATENCY"). THROUGHPUT
196
+ # raises OPTIMAL_NUMBER_OF_INFER_REQUESTS (a larger async queue → more device
197
+ # saturation) but spins up multiple internal streams that multiply memory and
198
+ # lengthen tail latency — a known N100 OOM-freeze factor (see the ov_config note
199
+ # in _load_model). Kept at LATENCY by default; only flip to THROUGHPUT on a node
200
+ # with headroom. Any unrecognised value falls back to LATENCY.
201
+ _OV_PERF_HINT: str = os.environ.get("CAMSTACK_OV_HINT", "LATENCY").upper()
202
+ if _OV_PERF_HINT not in ("LATENCY", "THROUGHPUT"):
203
+ _OV_PERF_HINT = "LATENCY"
183
204
 
184
205
 
185
206
  def _resolve_ov_devices(available: list[str], full_names: dict[str, str]) -> list[str]:
@@ -556,12 +577,16 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
556
577
  candidates = [ov_device_req] # operator pin — compiled directly
557
578
  sys.stderr.write(f"OpenVINO: operator-pinned device {ov_device_req}\n")
558
579
  sys.stderr.flush()
559
- # LATENCY (not THROUGHPUT): one deterministic execution stream instead of
560
- # N. THROUGHPUT spun up multiple internal streams whose lazy request
561
- # creation collided with the AUTO handover AND multiplied memory (a factor
562
- # in the N100 OOM-freeze). LATENCY keeps a single stream; camera-level
563
- # concurrency comes from the fixed InferRequest pool built below.
564
- ov_config = {"PERFORMANCE_HINT": "LATENCY"}
580
+ # PERFORMANCE_HINT defaults to LATENCY (CAMSTACK_OV_HINT): one
581
+ # deterministic execution stream instead of N. THROUGHPUT spins up
582
+ # multiple internal streams whose lazy request creation historically
583
+ # collided with the AUTO handover AND multiplied memory (a factor in the
584
+ # N100 OOM-freeze); it also RAISES OPTIMAL_NUMBER_OF_INFER_REQUESTS, which
585
+ # enlarges the async queue built below at a memory / tail-latency cost.
586
+ # LATENCY keeps a single stream; camera-level concurrency comes from the
587
+ # fixed InferRequest pool / async queue built below. Operators can opt into
588
+ # THROUGHPUT on a node with headroom via CAMSTACK_OV_HINT=THROUGHPUT.
589
+ ov_config = {"PERFORMANCE_HINT": _OV_PERF_HINT}
565
590
  # Persist compiled GPU/NPU kernels so the multi-second JIT compile is paid
566
591
  # once (first boot) instead of on every pool spawn. The cold compile storm
567
592
  # (~25s for the full model set on an N100 iGPU) is where frames pile up;
@@ -572,6 +597,31 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
572
597
  ov_config["CACHE_DIR"] = _cache_dir
573
598
  except OSError:
574
599
  pass
600
+ # Fix 2 — fold uint8→float/scale/layout preprocessing INTO the compiled
601
+ # graph via PrePostProcessor. When it succeeds we compile the PPP model
602
+ # object (fed uint8 NHWC at inference time); on ANY failure we fall back
603
+ # to compiling the plain `path` (byte-identical to the historical float
604
+ # preprocess). Default on for OpenVINO — CAMSTACK_OV_PPP=0 disables.
605
+ ppp_meta: Optional[dict] = None
606
+ compile_source: Any = path
607
+ if _OV_PPP_ENABLED:
608
+ try:
609
+ ppp_model, ppp_meta = _build_ov_ppp_model(core, path, config)
610
+ if ppp_model is not None:
611
+ compile_source = ppp_model
612
+ sys.stderr.write(
613
+ f"OpenVINO: PrePostProcessor folded for {os.path.basename(path)} "
614
+ f"(uint8 NHWC in, {ppp_meta}); Python preprocess stays uint8\n"
615
+ )
616
+ sys.stderr.flush()
617
+ except Exception as exc:
618
+ ppp_meta = None
619
+ compile_source = path
620
+ sys.stderr.write(
621
+ f"OpenVINO: PrePostProcessor setup failed for {os.path.basename(path)} "
622
+ f"({exc}); falling back to Python float preprocess\n"
623
+ )
624
+ sys.stderr.flush()
575
625
  # Compile-time failover over the explicit candidate list (NO AUTO): try
576
626
  # each device in order (NPU>GPU>CPU) and stop at the first that compiles.
577
627
  # CPU is the guaranteed floor. If every candidate fails, re-raise — the
@@ -581,7 +631,9 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
581
631
  last_exc: Optional[BaseException] = None
582
632
  for candidate in candidates:
583
633
  try:
584
- compiled = core.compile_model(path, device_name=candidate, config=ov_config)
634
+ compiled = core.compile_model(
635
+ compile_source, device_name=candidate, config=ov_config,
636
+ )
585
637
  ov_device = candidate
586
638
  break
587
639
  except Exception as exc:
@@ -606,7 +658,18 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
606
658
  f"EXECUTION_DEVICES={_exec_devices}\n"
607
659
  )
608
660
  sys.stderr.flush()
609
- output_names = [o.get_any_name() for o in compiled.outputs]
661
+ # An OpenVINO IR converted from ONNX can carry an UNNAMED output tensor
662
+ # (e.g. `yolov8n-package`): `get_any_name()` then raises
663
+ # "Attempt to get a name for a Tensor without names", which previously
664
+ # failed the whole model load on OpenVINO nodes (the package-detection
665
+ # step could never run on OpenVINO). Outputs are fetched POSITIONALLY in
666
+ # `predict` below, so a synthetic key per unnamed port is sufficient.
667
+ output_names = []
668
+ for _i, _o in enumerate(compiled.outputs):
669
+ try:
670
+ output_names.append(_o.get_any_name())
671
+ except Exception:
672
+ output_names.append(f"output{_i}")
610
673
 
611
674
  # Record the device's optimal infer-request count so the dispatcher
612
675
  # can size the predict pool to actually feed the streams.
@@ -618,14 +681,10 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
618
681
  except Exception:
619
682
  pass
620
683
 
621
- # Create ALL InferRequests ONCE, now, post-compile. With AUTO gone there
622
- # is no CPU->GPU handover, so no request can be born mid-handover and bind
623
- # to stale ports the score_8 race is structurally removed. Requests are
624
- # handed out through a thread-safe queue: an InferRequest is never touched
625
- # by two threads at once (checkout/return), so sharing a fixed pool is safe
626
- # and avoids the old thread-local lazy creation. Size the pool to the
627
- # device's optimal count (>= OV_DEFAULT_CONCURRENCY) so it matches the
628
- # predict-pool worker count sized in _run().
684
+ # Size N ONCE from the device's optimal infer-request count (fallback to
685
+ # OV_DEFAULT_CONCURRENCY when the property is missing/zero) so it matches
686
+ # the predict-pool worker count sized in _run(). Both the sync request-pool
687
+ # and the async queue below are sized to this same N.
629
688
  import queue as _queue
630
689
 
631
690
  try:
@@ -633,32 +692,112 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
633
692
  except Exception:
634
693
  _n_req = 0
635
694
  _n_req = max(_n_req, OV_DEFAULT_CONCURRENCY)
636
- _req_pool: "_queue.Queue" = _queue.Queue()
637
- for _ in range(_n_req):
638
- _req_pool.put(compiled.create_infer_request())
639
- sys.stderr.write(f"OpenVINO: created {_n_req} InferRequest(s) up-front on {ov_device}\n")
640
- sys.stderr.flush()
641
695
 
642
- def predict(inp_dict: dict, _names=output_names, _pool=_req_pool) -> dict:
643
- inp = list(inp_dict.values())[0]
644
- req = _pool.get()
696
+ if _OV_ASYNC_ENABLED:
697
+ # Async pipelining path (CAMSTACK_OV_ASYNC=1). An ov.AsyncInferQueue of
698
+ # N jobs keeps up to N inferences in flight on the device at once — the
699
+ # way the Intel-iGPU reference path saturates the GPU. Each completion
700
+ # fires the queue callback; start_async() BLOCKS when all N jobs are
701
+ # busy, so in-flight submissions are bounded to N with NO extra buffer
702
+ # (repo rule: never buffer frames at frame-rate). The RuntimeDispatcher
703
+ # still calls a BLOCKING predict_fn(input)->dict, so we bridge async->
704
+ # sync per call with a threading.Event + a 1-slot result holder passed
705
+ # as the submission's userdata; the callback fills it and wakes us.
706
+ import threading as _threading
645
707
  try:
646
- result = req.infer(inp)
647
- finally:
648
- _pool.put(req)
649
- # Output fetch by positional index — stable and harmless now that the
650
- # ports no longer diverge (single explicit device, no handover).
651
- return {name: result[i] for i, name in enumerate(_names)}
652
-
653
- slot.model = compiled
654
- slot.predict_fn = predict
655
- # Capture the model's declared input layout so `_preprocess` feeds
656
- # NHWC vs NCHW correctly (ArcFace/CLIP are NHWC). Best-effort a
657
- # failure leaves `_input_shape` unset NCHW fallback (unchanged).
658
- try:
659
- slot.config["_input_shape"] = list(compiled.inputs[0].get_partial_shape())
660
- except Exception:
661
- pass
708
+ from openvino import AsyncInferQueue as _AsyncInferQueue
709
+ except Exception:
710
+ from openvino.runtime import AsyncInferQueue as _AsyncInferQueue
711
+
712
+ _infer_queue = _AsyncInferQueue(compiled, _n_req)
713
+
714
+ # Callback runs on an OpenVINO worker thread when a job finishes. Fetch
715
+ # outputs POSITIONALLY (get_output_tensor(i)) so an UNNAMED IR output
716
+ # tensor is tolerated (same reason the sync path indexes by position —
717
+ # see the output_names note above), and .copy() detaches the result
718
+ # from the request's reusable output buffer before the job is freed
719
+ # back into the queue and possibly overwritten by the next inference.
720
+ def _on_done(_request, _userdata, _names=output_names) -> None:
721
+ _event, _holder = _userdata
722
+ try:
723
+ _holder[0] = {
724
+ name: _request.get_output_tensor(i).data.copy()
725
+ for i, name in enumerate(_names)
726
+ }
727
+ except BaseException as _exc: # surface to the waiting caller
728
+ _holder[1] = _exc
729
+ finally:
730
+ _event.set()
731
+
732
+ _infer_queue.set_callback(_on_done)
733
+ # Serialise only the (cheap) hand-off. start_async internally waits for
734
+ # an idle request; guarding it means two dispatcher threads can never
735
+ # claim the same idle slot. The inference itself stays fully async —
736
+ # N requests keep running on the device concurrently — so this lock
737
+ # does not reduce pipelining, it only makes concurrent submit safe.
738
+ _submit_lock = _threading.Lock()
739
+ sys.stderr.write(
740
+ f"OpenVINO: AsyncInferQueue with {_n_req} job(s) on {ov_device} "
741
+ f"(CAMSTACK_OV_ASYNC=1, hint={_OV_PERF_HINT})\n"
742
+ )
743
+ sys.stderr.flush()
744
+
745
+ def predict(inp_dict: dict, _q=_infer_queue, _lock=_submit_lock) -> dict:
746
+ inp = list(inp_dict.values())[0]
747
+ done = _threading.Event()
748
+ holder: list = [None, None] # [result_dict, exception]
749
+ with _lock:
750
+ # Blocks here when all N jobs are busy -> bounded in-flight.
751
+ _q.start_async(inp, (done, holder))
752
+ done.wait()
753
+ if holder[1] is not None:
754
+ raise holder[1]
755
+ return holder[0]
756
+
757
+ slot.model = compiled
758
+ slot.predict_fn = predict
759
+ else:
760
+ # Synchronous path (default, CAMSTACK_OV_ASYNC=0) — byte-for-byte the
761
+ # historical behaviour. Create ALL InferRequests ONCE, now, post-
762
+ # compile. With AUTO gone there is no CPU->GPU handover, so no request
763
+ # can be born mid-handover and bind to stale ports — the score_8 race
764
+ # is structurally removed. Requests are handed out through a thread-
765
+ # safe queue: an InferRequest is never touched by two threads at once
766
+ # (checkout/return), so sharing a fixed pool is safe and avoids the old
767
+ # thread-local lazy creation.
768
+ _req_pool: "_queue.Queue" = _queue.Queue()
769
+ for _ in range(_n_req):
770
+ _req_pool.put(compiled.create_infer_request())
771
+ sys.stderr.write(f"OpenVINO: created {_n_req} InferRequest(s) up-front on {ov_device}\n")
772
+ sys.stderr.flush()
773
+
774
+ def predict(inp_dict: dict, _names=output_names, _pool=_req_pool) -> dict:
775
+ inp = list(inp_dict.values())[0]
776
+ req = _pool.get()
777
+ try:
778
+ result = req.infer(inp)
779
+ finally:
780
+ _pool.put(req)
781
+ # Output fetch by positional index — stable and harmless now that
782
+ # the ports no longer diverge (single explicit device, no handover).
783
+ return {name: result[i] for i, name in enumerate(_names)}
784
+
785
+ slot.model = compiled
786
+ slot.predict_fn = predict
787
+ # When the PPP preprocessor is folded in, `_preprocess` produces uint8
788
+ # NHWC and short-circuits BEFORE the NHWC/NCHW float logic — so the
789
+ # `_input_shape` heuristic below is irrelevant for PPP models. Record
790
+ # the plan so `_preprocess` takes the uint8 fast path.
791
+ if ppp_meta is not None:
792
+ slot.config["_ov_ppp"] = ppp_meta
793
+ else:
794
+ # Capture the model's declared input layout so `_preprocess` feeds
795
+ # NHWC vs NCHW correctly (ArcFace/CLIP are NHWC). Best-effort — a
796
+ # failure leaves `_input_shape` unset → NCHW fallback (unchanged).
797
+ try:
798
+ slot.config["_input_shape"] = list(compiled.inputs[0].get_partial_shape())
799
+ except Exception:
800
+ pass
662
801
 
663
802
  elif _runtime == "onnxruntime":
664
803
  ort = _runtime_lib
@@ -759,6 +898,22 @@ def _unload_model(slot: ModelSlot) -> None:
759
898
  # Avoids the CPython id() reuse problem that caused stale detections.
760
899
  _bench_preprocess_cache: dict[tuple[int, int], tuple[dict, float, tuple[int, int]]] = {}
761
900
 
901
+ # Verifiable proof that the bench preprocess cache is actually HIT on the
902
+ # sustained-throughput BATCH path. A tagged frame that misses runs the FULL
903
+ # decode-independent preprocess (letterbox + float/255 + transpose); a hit
904
+ # returns the pre-built input tensor untouched — pure inference, no CPU
905
+ # preprocess. The historical bug was that MSG_INFER_BATCH wrapped each item
906
+ # fresh WITHOUT the tag, so the cache was NEVER hit on the batch path and every
907
+ # inference re-ran the full preprocess. The sustained benchmark now asserts
908
+ # hits ≫ misses (surfaced in the `status` command) so that regression is caught.
909
+ _bench_cache_hits: int = 0
910
+ _bench_cache_misses: int = 0
911
+
912
+
913
+ def _bench_cache_stats() -> dict:
914
+ """(hits, misses) for the bench preprocess cache — surfaced in `status`."""
915
+ return {"hits": _bench_cache_hits, "misses": _bench_cache_misses}
916
+
762
917
  def _is_channels_last(input_shape) -> bool:
763
918
  """True when a 4-D model input is NHWC ([N,H,W,C]) rather than NCHW
764
919
  ([N,C,H,W]). Heuristic: the LAST dim is a channel count (1 or 3) and the
@@ -819,6 +974,30 @@ def _to_model_tensor(arr: "np.ndarray", channels_last: bool) -> "np.ndarray":
819
974
  return arr.transpose(2, 0, 1)[np.newaxis].astype(np.float32)
820
975
 
821
976
 
977
+ _IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
978
+ _IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
979
+
980
+
981
+ def _apply_normalization(arr: "np.ndarray", config: dict) -> "np.ndarray":
982
+ """Apply per-model input normalization to an HWC RGB float array in [0,1].
983
+
984
+ Only ``inputNormalization == 'imagenet'`` changes anything: it subtracts the
985
+ ImageNet per-channel mean and divides by the std (RGB), matching the
986
+ EfficientNet-Lite0 / MobileNetV3 animal + vehicle classifiers whose
987
+ ``labels.json`` declares ``normalize: imagenet``. Absent / ``'none'`` /
988
+ ``'zero-one'`` keep the plain ``/255`` array byte-identical to the historical
989
+ unconditional path (detectors, CLIP/ArcFace, and the AIY bird classifier
990
+ which bakes its own scale). Runs on the HWC array BEFORE the optional
991
+ NHWC/NCHW transpose in :func:`_to_model_tensor`, so it is layout-agnostic
992
+ (channel axis is always last here). Pure numpy.
993
+ """
994
+ if config.get("inputNormalization") != "imagenet":
995
+ return arr
996
+ if arr.ndim != 3 or arr.shape[-1] != 3:
997
+ return arr
998
+ return (arr - _IMAGENET_MEAN) / _IMAGENET_STD
999
+
1000
+
822
1001
  def _preprocess_ctc_gray(img: "Image.Image", input_w: int, input_h: int) -> "np.ndarray":
823
1002
  """EasyOCR-accurate preprocess for a fixed-width grayscale CTC recognizer.
824
1003
 
@@ -849,16 +1028,152 @@ def _preprocess_ctc_gray(img: "Image.Image", input_w: int, input_h: int) -> "np.
849
1028
  return arr[np.newaxis, np.newaxis].astype(np.float32) # (1, 1, H, W)
850
1029
 
851
1030
 
1031
+ # ---------------------------------------------------------------------------
1032
+ # Fix 2 — OpenVINO PrePostProcessor (preprocess folded into the compiled graph)
1033
+ # ---------------------------------------------------------------------------
1034
+
1035
+ # Default ON for OpenVINO; set CAMSTACK_OV_PPP=0 to fall back to the pure-Python
1036
+ # float preprocess (the historical path) if a model/postprocessor misbehaves.
1037
+ _OV_PPP_ENABLED: bool = os.environ.get("CAMSTACK_OV_PPP", "1") not in ("0", "false", "no")
1038
+
1039
+
1040
+ def _ov_dim_to_int(dim: Any) -> "int | None":
1041
+ """Best-effort static-length extraction from an OpenVINO Dimension (or a
1042
+ plain int). Dynamic / unknown dims return None. Pure — no numpy."""
1043
+ try:
1044
+ return int(dim)
1045
+ except (TypeError, ValueError):
1046
+ pass
1047
+ try:
1048
+ if getattr(dim, "is_static", False):
1049
+ return int(dim.get_length())
1050
+ except (TypeError, ValueError, AttributeError):
1051
+ pass
1052
+ return None
1053
+
1054
+
1055
+ def _ov_ppp_plan(
1056
+ dims: "list[int | None] | None",
1057
+ input_channels: int,
1058
+ preprocess_mode: str,
1059
+ normalization: "str | None",
1060
+ enabled: bool,
1061
+ ) -> "dict | None":
1062
+ """Pure decision: should preprocessing be folded into the OpenVINO graph?
1063
+
1064
+ `dims` is the model's declared input partial-shape as a list of int|None in
1065
+ NCHW order. We only fold for standard 3-channel NCHW detectors/classifiers
1066
+ with a STATIC spatial size — grayscale CTC recognizers (channels==1) and
1067
+ NHWC-native embedders (ArcFace/CLIP, channel dim last) keep the pure-Python
1068
+ path. Returns a `ppp_meta` dict describing the Python-side uint8 producer,
1069
+ or None when PPP is not applicable. No OpenVINO / numpy imports —
1070
+ unit-testable in isolation."""
1071
+ if not enabled:
1072
+ return None
1073
+ if input_channels == 1:
1074
+ return None
1075
+ if dims is None or len(dims) != 4:
1076
+ return None
1077
+ _n, c, h, w = dims
1078
+ # NCHW with a concrete 3-channel dim. A last-dim of 3 (NHWC-native) leaves
1079
+ # c as a spatial value → rejected here, so embedders stay on the float path.
1080
+ if c != 3:
1081
+ return None
1082
+ if h is None or w is None or h <= 0 or w <= 0:
1083
+ return None
1084
+ return {
1085
+ "inputW": int(w),
1086
+ "inputH": int(h),
1087
+ "size": int(max(w, h)),
1088
+ "letterbox": preprocess_mode == "letterbox",
1089
+ "normalization": normalization or "none",
1090
+ }
1091
+
1092
+
1093
+ def _preprocess_ov_uint8(
1094
+ img: "Image.Image", plan: dict,
1095
+ ) -> tuple["np.ndarray", float, tuple[int, int]]:
1096
+ """Produce the uint8 NHWC ``[1,H,W,3]`` input for a PPP-folded OV model.
1097
+
1098
+ Letterbox (aspect-preserving pad) or plain resize stays in cheap uint8 PIL
1099
+ space; the compiled OV graph does the float convert + /255 + optional
1100
+ mean/std + NHWC→NCHW transpose on the inference device. Returns
1101
+ ``(tensor, scale, pad)`` — the scale/pad still drive the postprocessor's box
1102
+ mapping exactly like the letterbox float path. Pure numpy/PIL."""
1103
+ if plan["letterbox"]:
1104
+ canvas, scale_val, pad = letterbox_image(img, plan["size"])
1105
+ else:
1106
+ w, h = plan["inputW"], plan["inputH"]
1107
+ if img.size == (w, h):
1108
+ canvas, scale_val, pad = img, 1.0, (0, 0)
1109
+ else:
1110
+ canvas, scale_val, pad = img.resize((w, h), Image.BILINEAR), 1.0, (0, 0)
1111
+ pix = np.asarray(canvas, dtype=np.uint8)
1112
+ if pix.ndim == 2:
1113
+ pix = np.stack([pix] * 3, axis=-1)
1114
+ return pix[np.newaxis], scale_val, pad
1115
+
1116
+
1117
+ def _build_ov_ppp_model(core: Any, path: str, config: dict) -> "tuple[Any, dict] | tuple[None, None]":
1118
+ """Read the OpenVINO IR and, when eligible, attach a PrePostProcessor that
1119
+ accepts a uint8 NHWC tensor and folds convert→scale(→mean/std)→layout into
1120
+ the compiled graph. Returns ``(model, ppp_meta)`` to compile, or
1121
+ ``(None, None)`` when PPP is not applied (caller compiles the path directly,
1122
+ byte-identical to the historical float path). Raises on hard OV errors so
1123
+ the caller's try/except falls back safely."""
1124
+ from openvino import Layout, Type
1125
+ from openvino.preprocess import PrePostProcessor
1126
+
1127
+ model = core.read_model(path)
1128
+ inp = model.input()
1129
+ try:
1130
+ pshape = inp.get_partial_shape()
1131
+ dims = [_ov_dim_to_int(pshape[i]) for i in range(len(pshape))]
1132
+ except Exception:
1133
+ dims = None
1134
+
1135
+ _iw, _ih, input_channels = _resolve_input_dims(config)
1136
+ plan = _ov_ppp_plan(
1137
+ dims,
1138
+ input_channels,
1139
+ config.get("preprocessMode", "letterbox"),
1140
+ config.get("inputNormalization"),
1141
+ _OV_PPP_ENABLED,
1142
+ )
1143
+ if plan is None:
1144
+ return None, None
1145
+
1146
+ ppp = PrePostProcessor(model)
1147
+ # Tensor as the caller feeds it: uint8, NHWC, already at the model's spatial
1148
+ # size (Python letterboxes/resizes to size in uint8) → OV needs no resize.
1149
+ ppp.input().tensor().set_element_type(Type.u8).set_layout(Layout("NHWC"))
1150
+ ppp.input().model().set_layout(Layout("NCHW"))
1151
+ steps = ppp.input().preprocess().convert_element_type(Type.f32)
1152
+ if plan["normalization"] == "imagenet":
1153
+ # ((x/255) - mean) / std, applied in add-order (scale divides, mean
1154
+ # subtracts). RGB channel order matches the fed PIL tensor.
1155
+ steps.scale([255.0, 255.0, 255.0])
1156
+ steps.mean([float(v) for v in _IMAGENET_MEAN])
1157
+ steps.scale([float(v) for v in _IMAGENET_STD])
1158
+ else:
1159
+ steps.scale([255.0, 255.0, 255.0])
1160
+ built = ppp.build()
1161
+ return built, plan
1162
+
1163
+
852
1164
  def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int, int]]:
853
1165
  input_size = config.get("inputSize", 640)
854
1166
  input_w, input_h, input_channels = _resolve_input_dims(config)
855
1167
  # Bench frames have _bench_frame_id tag → use preprocess cache
856
1168
  bench_fid = getattr(img, '_bench_frame_id', None)
857
1169
  if bench_fid is not None:
1170
+ global _bench_cache_hits, _bench_cache_misses
858
1171
  cache_key = (bench_fid, input_size)
859
1172
  cached = _bench_preprocess_cache.get(cache_key)
860
1173
  if cached is not None:
1174
+ _bench_cache_hits += 1
861
1175
  return cached
1176
+ _bench_cache_misses += 1
862
1177
  preprocess_mode = config.get("preprocessMode", "letterbox")
863
1178
  input_dict: dict = {}
864
1179
  if _runtime == "coreml":
@@ -894,6 +1209,9 @@ def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int,
894
1209
  arr = resize_image(img, input_w, input_h)
895
1210
  scale_val = 1.0
896
1211
  pad = (0, 0)
1212
+ # Per-model input normalization (imagenet mean/std for the
1213
+ # animal + vehicle classifiers; no-op for everything else).
1214
+ arr = _apply_normalization(arr, config)
897
1215
  # NHWC vs NCHW from the model's declared last dim (1/3 ⇒
898
1216
  # channels-last), exactly as before for 3-channel models.
899
1217
  _, _, _, w_or_c = input_shape
@@ -931,6 +1249,21 @@ def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int,
931
1249
  else:
932
1250
  # OpenVINO / ONNX — always need a float tensor.
933
1251
  input_name = config.get("_input_name", "images")
1252
+ # Fix 2 — OpenVINO PrePostProcessor fast path. When the model was
1253
+ # compiled with a folded uint8→float/scale/layout preprocessor
1254
+ # (`_ov_ppp` plan present), skip ALL numpy float work here: produce a
1255
+ # uint8 NHWC tensor (aspect-preserving letterbox stays in cheap PIL
1256
+ # space) and let the compiled OV graph do convert/scale/transpose on
1257
+ # the inference device. Removes the per-frame /255 + transpose that
1258
+ # dominated the non-inference CPU cost in production.
1259
+ ov_ppp = config.get("_ov_ppp") if _runtime == "openvino" else None
1260
+ if ov_ppp is not None and input_channels != 1:
1261
+ input_arr, scale_val, pad = _preprocess_ov_uint8(img, ov_ppp)
1262
+ input_dict[input_name] = input_arr
1263
+ result = (input_dict, scale_val, pad)
1264
+ if bench_fid is not None:
1265
+ _bench_preprocess_cache[(bench_fid, input_size)] = result
1266
+ return result
934
1267
  if input_channels == 1:
935
1268
  # Grayscale CTC recognizer (EasyOCR plate-OCR): aspect-preserving
936
1269
  # resize + edge-pad + [-1,1] norm → [1,1,H,W]. NOT the squared
@@ -946,6 +1279,9 @@ def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int,
946
1279
  arr = resize_image(img, input_w, input_h)
947
1280
  scale_val = 1.0
948
1281
  pad = (0, 0)
1282
+ # Per-model input normalization (imagenet mean/std for the animal +
1283
+ # vehicle classifiers; no-op for detectors, CLIP, bird, etc.).
1284
+ arr = _apply_normalization(arr, config)
949
1285
  # Feed the layout the model actually declares. NHWC models (ArcFace
950
1286
  # face-embedding [N,112,112,3], CLIP) must NOT be NCHW-transposed —
951
1287
  # a strict backend (NPU) throws on the wrong shape and the embedding
@@ -1394,7 +1730,7 @@ def _handle_command(models: list[ModelSlot], cmd: dict) -> dict:
1394
1730
  "loaded": slot.loaded,
1395
1731
  "postprocessor": slot.config.get("postprocessor") if slot.loaded else None,
1396
1732
  })
1397
- return {"cmd": "status", "models": status}
1733
+ return {"cmd": "status", "models": status, "benchCache": _bench_cache_stats()}
1398
1734
 
1399
1735
  return {"cmd": action or "unknown", "status": "error", "error": f"Unknown command: {action}"}
1400
1736
 
@@ -1742,13 +2078,21 @@ async def _run() -> None:
1742
2078
  _dispatch_inference(req_id, img, model_idx)
1743
2079
 
1744
2080
  elif msg_type == MSG_INFER_BATCH:
1745
- # Header: [1B model_idx][1B count]
1746
- if len(payload) < 2:
2081
+ # Header: [1B model_idx][1B count][4B frame_id]
2082
+ # frame_id (0 = untagged, the live/default case). A NONZERO frame_id
2083
+ # tags every wrapped item with `_bench_frame_id` so `_preprocess`
2084
+ # uses the bench cache — the whole batch is the SAME pinned frame in
2085
+ # the sustained-throughput benchmark, so decode+preprocess runs ONCE
2086
+ # (first miss) and every later inference is a pure-inference cache
2087
+ # hit. Historically the batch path shipped no frame_id and re-ran the
2088
+ # full preprocess on every inference (the ~78fps ceiling).
2089
+ if len(payload) < 6:
1747
2090
  await writer.send(req_id, {"error": "truncated infer_batch header"})
1748
2091
  continue
1749
2092
  model_idx = payload[0]
1750
2093
  count = payload[1]
1751
- offset = 2
2094
+ batch_frame_id = struct.unpack("<I", payload[2:6])[0]
2095
+ offset = 6
1752
2096
  items: list[Image.Image] = []
1753
2097
  parse_err: Optional[str] = None
1754
2098
  for _ in range(count):
@@ -1766,7 +2110,10 @@ async def _run() -> None:
1766
2110
  raw = payload[offset:offset + size]
1767
2111
  offset += size
1768
2112
  try:
1769
- items.append(wrap_raw(raw, width, height, fmt))
2113
+ item_img = wrap_raw(raw, width, height, fmt)
2114
+ if batch_frame_id != 0:
2115
+ item_img._bench_frame_id = batch_frame_id
2116
+ items.append(item_img)
1770
2117
  except Exception as exc:
1771
2118
  parse_err = f"raw wrap failed: {exc}"
1772
2119
  break
@@ -16,16 +16,28 @@ def postprocess_softmax(
16
16
  scale: float,
17
17
  pad: tuple[int, int],
18
18
  ) -> dict:
19
- """Softmax + argmax + label lookup."""
19
+ """Softmax + argmax + label lookup.
20
+
21
+ When ``config['outputProbabilities']`` is set the model already applies
22
+ softmax IN-GRAPH (e.g. the Google AIY Birds classifier) — its raw output is
23
+ a probability distribution, so we consume it directly. Re-softmaxing an
24
+ already-normalised probability vector is NOT a no-op: it collapses the
25
+ distribution toward uniform (the top-1 score craters far below its true
26
+ value), which silently defeats every downstream confidence gate.
27
+ """
20
28
  labels = config.get("labels", [])
21
29
 
22
30
  # Get first output tensor
23
31
  raw = np.array(list(predictions.values())[0]).flatten().astype(np.float32)
24
32
 
25
- # Stable softmax
26
- shifted = raw - np.max(raw)
27
- exps = np.exp(shifted)
28
- probs = exps / np.sum(exps)
33
+ if config.get("outputProbabilities"):
34
+ # Model output is already a softmax distribution — do NOT re-softmax.
35
+ probs = raw
36
+ else:
37
+ # Stable softmax over raw logits.
38
+ shifted = raw - np.max(raw)
39
+ exps = np.exp(shifted)
40
+ probs = exps / np.sum(exps)
29
41
 
30
42
  # Return top-K classifications — normalization (top-1 + alternates) happens in TypeScript
31
43
  top_k = min(5, len(probs))
@@ -0,0 +1,52 @@
1
+ """Tests for the softmax classifier postprocessor.
2
+
3
+ Covers the raw-logits (default) path and the `outputProbabilities` path used by
4
+ the Google AIY bird classifier, whose output is ALREADY a softmax distribution
5
+ and must NOT be re-softmaxed (re-softmaxing flattens the scores toward uniform
6
+ and silently defeats every downstream confidence gate).
7
+ """
8
+ import math
9
+
10
+ import numpy as np
11
+
12
+ from softmax import postprocess_softmax
13
+
14
+
15
+ def _run(raw, config):
16
+ predictions = {"logits": np.array(raw, dtype=np.float32)}
17
+ return postprocess_softmax(predictions, config, 100, 100, 1.0, (0, 0))
18
+
19
+
20
+ def test_raw_logits_are_softmaxed():
21
+ # Strongly-peaked logits → a near-1.0 top-1 probability after softmax.
22
+ out = _run([10.0, 0.0, 0.0], {"labels": ["a", "b", "c"]})
23
+ top = out["classifications"][0]
24
+ assert top["class"] == "a"
25
+ assert top["score"] > 0.99
26
+
27
+
28
+ def test_output_probabilities_are_consumed_verbatim():
29
+ # Already a probability distribution — consumed directly, NOT re-softmaxed.
30
+ probs = [0.9, 0.07, 0.03]
31
+ out = _run(probs, {"labels": ["a", "b", "c"], "outputProbabilities": True})
32
+ top = out["classifications"][0]
33
+ assert top["class"] == "a"
34
+ # The true 0.9 survives verbatim. Re-softmaxing this vector would collapse
35
+ # the top-1 to ~0.49 — well below a 0.6 gate — which is the bug we prevent.
36
+ assert math.isclose(top["score"], 0.9, abs_tol=1e-3)
37
+
38
+
39
+ def test_re_softmaxing_probabilities_would_crater_the_score():
40
+ # Guard the rationale: prove the WRONG path (default softmax on an already-
41
+ # softmaxed vector) really does destroy the top-1 confidence.
42
+ probs = [0.9, 0.07, 0.03]
43
+ wrong = _run(probs, {"labels": ["a", "b", "c"]}) # no outputProbabilities
44
+ assert wrong["classifications"][0]["score"] < 0.6
45
+
46
+
47
+ def test_argmax_is_preserved_either_way():
48
+ probs = [0.05, 0.8, 0.15]
49
+ a = _run(probs, {"labels": ["a", "b", "c"], "outputProbabilities": True})
50
+ b = _run(probs, {"labels": ["a", "b", "c"]})
51
+ assert a["classifications"][0]["class"] == "b"
52
+ assert b["classifications"][0]["class"] == "b"