@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.
- package/dist/audio-analyzer/index.js +1 -1
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +83 -22
- package/dist/detection-pipeline/index.mjs +83 -22
- package/dist/{dist-WW3gH8hF.js → dist-BxxVKcnw.js} +56 -5
- package/dist/{dist-D6tHM9GA.mjs → dist-DkGHtAaK.mjs} +56 -5
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +88 -22
- package/dist/pipeline-runner/index.mjs +88 -22
- package/dist/recorder/index.js +141 -1
- package/dist/recorder/index.mjs +141 -1
- package/dist/session-decode/decode-worker-child.js +185 -14
- package/dist/session-decode/decode-worker-child.mjs +185 -14
- package/dist/{step-definitions-CXZpwwT0.js → step-definitions-CAs0RD2N.js} +127 -8
- package/dist/{step-definitions-Dbsm64_I.mjs → step-definitions-Delhq28w.mjs} +127 -8
- package/dist/stream-broker/_stub.js +2 -2
- 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
- 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
- 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
- package/dist/stream-broker/{hostInit-QaDEOY0Z.mjs → hostInit-D-S5fCDx.mjs} +2 -2
- 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-C7H0kVAH.js → MaskShapeCanvas-DI4BY7W2-CRvzj7Of.js} +1 -1
- package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CHfEFlEF.js → MotionZonesSettings-NcxxQN8r-CBgvPUB-.js} +1 -1
- package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Bc-trVxX.js → PrivacyMaskSettings-APgPLF7p-DH07fe1k.js} +1 -1
- package/embed-dist/assets/index-BkoPcGtQ.js +114 -0
- package/embed-dist/assets/index-D2pF9Z3W.css +2 -0
- package/embed-dist/index.html +2 -2
- package/package.json +1 -1
- package/python/inference_pool.py +392 -45
- package/python/postprocessors/softmax.py +17 -5
- package/python/postprocessors/test_softmax.py +52 -0
- package/python/test_inference_pool_ov_ppp.py +117 -0
- package/python/test_inference_pool_preprocess.py +93 -1
- 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
- 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
- package/embed-dist/assets/index-Bm9LaQPH.css +0 -2
- package/embed-dist/assets/index-eZGm_itM.js +0 -81
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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"
|