@camstack/addon-pipeline 1.1.66 → 1.1.68
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/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +52 -13
- package/dist/detection-pipeline/index.mjs +52 -13
- package/dist/{dist-D5o9fGxq.js → dist-BxxVKcnw.js} +43 -5
- package/dist/{dist-BpWot5-w.mjs → dist-DkGHtAaK.mjs} +43 -5
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +2 -2
- package/dist/pipeline-runner/index.mjs +2 -2
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/session-decode/decode-worker-child.js +176 -11
- package/dist/session-decode/decode-worker-child.mjs +176 -11
- package/dist/{step-definitions-81eYyJbW.js → step-definitions-CAs0RD2N.js} +1 -1
- package/dist/{step-definitions-D6R9X0V7.mjs → step-definitions-Delhq28w.mjs} +1 -1
- package/dist/stream-broker/_stub.js +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CIIIVPzB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BUXIIogo.mjs} +1 -1
- 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
- package/dist/stream-broker/{hostInit-CeBNudbY.mjs → hostInit-D7_95hNV.mjs} +1 -1
- package/dist/stream-broker/index.js +1 -1
- package/dist/stream-broker/index.mjs +1 -1
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CbBbcdZb.js → MaskShapeCanvas-DI4BY7W2-CRvzj7Of.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-aA6zvms_.js → MotionZonesSettings-NcxxQN8r-CBgvPUB-.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-CcGB7iAE.js → PrivacyMaskSettings-APgPLF7p-DH07fe1k.js} +1 -1
- package/embed-dist/assets/{index-BRG_Fqb5.js → index-BkoPcGtQ.js} +13 -13
- package/embed-dist/index.html +1 -1
- package/package.json +1 -1
- package/python/inference_pool.py +362 -45
- package/python/test_inference_pool_ov_ppp.py +117 -0
- package/python/test_inference_pool_preprocess.py +93 -1
package/embed-dist/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
3
|
+
"version": "1.1.68",
|
|
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",
|
package/python/inference_pool.py
CHANGED
|
@@ -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 (
|
|
560
|
-
#
|
|
561
|
-
#
|
|
562
|
-
#
|
|
563
|
-
#
|
|
564
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
622
|
-
#
|
|
623
|
-
#
|
|
624
|
-
#
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|