@camstack/addon-pipeline 1.1.66 → 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 (32) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +52 -13
  4. package/dist/detection-pipeline/index.mjs +52 -13
  5. package/dist/{dist-D5o9fGxq.js → dist-BxxVKcnw.js} +43 -5
  6. package/dist/{dist-BpWot5-w.mjs → dist-DkGHtAaK.mjs} +43 -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 +2 -2
  10. package/dist/pipeline-runner/index.mjs +2 -2
  11. package/dist/recorder/index.js +1 -1
  12. package/dist/recorder/index.mjs +1 -1
  13. package/dist/session-decode/decode-worker-child.js +176 -11
  14. package/dist/session-decode/decode-worker-child.mjs +176 -11
  15. package/dist/{step-definitions-81eYyJbW.js → step-definitions-CAs0RD2N.js} +1 -1
  16. package/dist/{step-definitions-D6R9X0V7.mjs → step-definitions-Delhq28w.mjs} +1 -1
  17. package/dist/stream-broker/_stub.js +1 -1
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CIIIVPzB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CfFsU700.mjs} +1 -1
  19. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CPbLcza9.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D68Lj69v.mjs} +1 -1
  20. package/dist/stream-broker/{hostInit-CeBNudbY.mjs → hostInit-D-S5fCDx.mjs} +1 -1
  21. package/dist/stream-broker/index.js +1 -1
  22. package/dist/stream-broker/index.mjs +1 -1
  23. package/dist/stream-broker/remoteEntry.js +1 -1
  24. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CbBbcdZb.js → MaskShapeCanvas-DI4BY7W2-CRvzj7Of.js} +1 -1
  25. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-aA6zvms_.js → MotionZonesSettings-NcxxQN8r-CBgvPUB-.js} +1 -1
  26. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-CcGB7iAE.js → PrivacyMaskSettings-APgPLF7p-DH07fe1k.js} +1 -1
  27. package/embed-dist/assets/{index-BRG_Fqb5.js → index-BkoPcGtQ.js} +13 -13
  28. package/embed-dist/index.html +1 -1
  29. package/package.json +1 -1
  30. package/python/inference_pool.py +362 -45
  31. package/python/test_inference_pool_ov_ppp.py +117 -0
  32. package/python/test_inference_pool_preprocess.py +93 -1
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>CamStack Embed</title>
7
- <script type="module" crossorigin src="./assets/index-BRG_Fqb5.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-BkoPcGtQ.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-D2pF9Z3W.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.1.66",
3
+ "version": "1.1.67",
4
4
  "description": "CamStack Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -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
@@ -873,16 +1028,152 @@ def _preprocess_ctc_gray(img: "Image.Image", input_w: int, input_h: int) -> "np.
873
1028
  return arr[np.newaxis, np.newaxis].astype(np.float32) # (1, 1, H, W)
874
1029
 
875
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
+
876
1164
  def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int, int]]:
877
1165
  input_size = config.get("inputSize", 640)
878
1166
  input_w, input_h, input_channels = _resolve_input_dims(config)
879
1167
  # Bench frames have _bench_frame_id tag → use preprocess cache
880
1168
  bench_fid = getattr(img, '_bench_frame_id', None)
881
1169
  if bench_fid is not None:
1170
+ global _bench_cache_hits, _bench_cache_misses
882
1171
  cache_key = (bench_fid, input_size)
883
1172
  cached = _bench_preprocess_cache.get(cache_key)
884
1173
  if cached is not None:
1174
+ _bench_cache_hits += 1
885
1175
  return cached
1176
+ _bench_cache_misses += 1
886
1177
  preprocess_mode = config.get("preprocessMode", "letterbox")
887
1178
  input_dict: dict = {}
888
1179
  if _runtime == "coreml":
@@ -958,6 +1249,21 @@ def _preprocess(img: Image.Image, config: dict) -> tuple[dict, float, tuple[int,
958
1249
  else:
959
1250
  # OpenVINO / ONNX — always need a float tensor.
960
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
961
1267
  if input_channels == 1:
962
1268
  # Grayscale CTC recognizer (EasyOCR plate-OCR): aspect-preserving
963
1269
  # resize + edge-pad + [-1,1] norm → [1,1,H,W]. NOT the squared
@@ -1424,7 +1730,7 @@ def _handle_command(models: list[ModelSlot], cmd: dict) -> dict:
1424
1730
  "loaded": slot.loaded,
1425
1731
  "postprocessor": slot.config.get("postprocessor") if slot.loaded else None,
1426
1732
  })
1427
- return {"cmd": "status", "models": status}
1733
+ return {"cmd": "status", "models": status, "benchCache": _bench_cache_stats()}
1428
1734
 
1429
1735
  return {"cmd": action or "unknown", "status": "error", "error": f"Unknown command: {action}"}
1430
1736
 
@@ -1772,13 +2078,21 @@ async def _run() -> None:
1772
2078
  _dispatch_inference(req_id, img, model_idx)
1773
2079
 
1774
2080
  elif msg_type == MSG_INFER_BATCH:
1775
- # Header: [1B model_idx][1B count]
1776
- 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:
1777
2090
  await writer.send(req_id, {"error": "truncated infer_batch header"})
1778
2091
  continue
1779
2092
  model_idx = payload[0]
1780
2093
  count = payload[1]
1781
- offset = 2
2094
+ batch_frame_id = struct.unpack("<I", payload[2:6])[0]
2095
+ offset = 6
1782
2096
  items: list[Image.Image] = []
1783
2097
  parse_err: Optional[str] = None
1784
2098
  for _ in range(count):
@@ -1796,7 +2110,10 @@ async def _run() -> None:
1796
2110
  raw = payload[offset:offset + size]
1797
2111
  offset += size
1798
2112
  try:
1799
- 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)
1800
2117
  except Exception as exc:
1801
2118
  parse_err = f"raw wrap failed: {exc}"
1802
2119
  break
@@ -0,0 +1,117 @@
1
+ """Fix 2 — OpenVINO PrePostProcessor eligibility (`_ov_ppp_plan`) + the uint8
2
+ dimension helper (`_ov_dim_to_int`).
3
+
4
+ `_ov_ppp_plan` is the PURE decision (no OpenVINO / numpy) of WHETHER to fold
5
+ preprocessing into the compiled graph: only standard 3-channel NCHW
6
+ detectors/classifiers with a static spatial size qualify; grayscale CTC
7
+ recognizers and NHWC-native embedders (ArcFace/CLIP) keep the pure-Python float
8
+ path. Heavy deps are stubbed so the module imports in a minimal sandbox.
9
+
10
+ Run: python3 -m unittest test_inference_pool_ov_ppp -v
11
+ """
12
+ import sys
13
+ import types
14
+ import unittest
15
+
16
+ if "numpy" not in sys.modules:
17
+ _np = types.ModuleType("numpy")
18
+ # inference_pool builds `_IMAGENET_MEAN = np.array(...)` at import time; the
19
+ # PPP plan decision under test needs no real numpy, so a passthrough stub
20
+ # keeps this pure test runnable in a numpy-less sandbox.
21
+ _np.array = lambda seq, dtype=None: list(seq) # noqa: E731
22
+ _np.float32 = "float32"
23
+ sys.modules["numpy"] = _np
24
+ for _mod in ("PIL", "PIL.Image"):
25
+ if _mod not in sys.modules:
26
+ sys.modules[_mod] = types.ModuleType(_mod)
27
+ if not hasattr(sys.modules["PIL"], "Image"):
28
+ sys.modules["PIL"].Image = types.ModuleType("PIL.Image")
29
+ if "postprocessors" not in sys.modules:
30
+ _pp = types.ModuleType("postprocessors")
31
+ _pp.POSTPROCESSORS = {}
32
+ sys.modules["postprocessors"] = _pp
33
+
34
+ from inference_pool import _ov_dim_to_int, _ov_ppp_plan # noqa: E402
35
+
36
+
37
+ class _StaticDim:
38
+ """Mimics an OpenVINO static Dimension: int() raises, get_length() works."""
39
+
40
+ def __init__(self, length: int) -> None:
41
+ self._length = length
42
+ self.is_static = True
43
+
44
+ def __int__(self) -> int:
45
+ raise TypeError("Dimension is not directly int()-able")
46
+
47
+ def get_length(self) -> int:
48
+ return self._length
49
+
50
+
51
+ class _DynamicDim:
52
+ is_static = False
53
+
54
+ def __int__(self) -> int:
55
+ raise TypeError("dynamic dimension")
56
+
57
+
58
+ class OvDimToIntTest(unittest.TestCase):
59
+ def test_plain_int_passthrough(self) -> None:
60
+ self.assertEqual(_ov_dim_to_int(640), 640)
61
+
62
+ def test_static_dimension_uses_get_length(self) -> None:
63
+ self.assertEqual(_ov_dim_to_int(_StaticDim(416)), 416)
64
+
65
+ def test_dynamic_dimension_is_none(self) -> None:
66
+ self.assertIsNone(_ov_dim_to_int(_DynamicDim()))
67
+
68
+ def test_garbage_is_none(self) -> None:
69
+ self.assertIsNone(_ov_dim_to_int(object()))
70
+
71
+
72
+ class OvPppPlanTest(unittest.TestCase):
73
+ def test_standard_square_detector_folds_with_letterbox(self) -> None:
74
+ plan = _ov_ppp_plan([1, 3, 640, 640], 3, "letterbox", None, True)
75
+ self.assertIsNotNone(plan)
76
+ assert plan is not None
77
+ self.assertEqual((plan["inputW"], plan["inputH"], plan["size"]), (640, 640, 640))
78
+ self.assertTrue(plan["letterbox"])
79
+ self.assertEqual(plan["normalization"], "none")
80
+
81
+ def test_non_letterbox_classifier_plan_is_plain_resize(self) -> None:
82
+ plan = _ov_ppp_plan([1, 3, 224, 224], 3, "resize", "imagenet", True)
83
+ assert plan is not None
84
+ self.assertFalse(plan["letterbox"])
85
+ self.assertEqual(plan["normalization"], "imagenet")
86
+
87
+ def test_non_square_detector_uses_max_side_for_letterbox(self) -> None:
88
+ # `_ov_ppp_plan` takes ALREADY-resolved int|None dims (the caller runs
89
+ # `_ov_dim_to_int` over the OV Dimensions first — see _ov_dim_to_int
90
+ # tests above). Non-square 384×640 → letterbox square uses the max side.
91
+ plan = _ov_ppp_plan([1, 3, 384, 640], 3, "letterbox", None, True)
92
+ assert plan is not None
93
+ self.assertEqual((plan["inputW"], plan["inputH"]), (640, 384))
94
+ self.assertEqual(plan["size"], 640)
95
+
96
+ def test_disabled_returns_none(self) -> None:
97
+ self.assertIsNone(_ov_ppp_plan([1, 3, 640, 640], 3, "letterbox", None, False))
98
+
99
+ def test_grayscale_ctc_is_rejected(self) -> None:
100
+ # Plate-OCR: channels==1 → keep the pure-Python EasyOCR path.
101
+ self.assertIsNone(_ov_ppp_plan([1, 1, 64, 384], 1, "resize", None, True))
102
+
103
+ def test_nhwc_native_embedder_is_rejected(self) -> None:
104
+ # ArcFace/CLIP declare [N,H,W,C] → the NCHW channel slot holds a spatial
105
+ # value (112), not 3 → rejected, embedder stays on the float NHWC path.
106
+ self.assertIsNone(_ov_ppp_plan([1, 112, 112, 3], 3, "resize", None, True))
107
+
108
+ def test_dynamic_spatial_is_rejected(self) -> None:
109
+ self.assertIsNone(_ov_ppp_plan([1, 3, None, None], 3, "letterbox", None, True))
110
+
111
+ def test_wrong_rank_is_rejected(self) -> None:
112
+ self.assertIsNone(_ov_ppp_plan([3, 640, 640], 3, "letterbox", None, True))
113
+ self.assertIsNone(_ov_ppp_plan(None, 3, "letterbox", None, True))
114
+
115
+
116
+ if __name__ == "__main__":
117
+ unittest.main()