@camstack/addon-pipeline 1.2.103 → 1.2.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{addon-utils-CLc6yHCN.js → addon-utils-UzUMfVj1.js} +1 -1
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/detection-pipeline/index.js +1188 -127
- package/dist/detection-pipeline/index.mjs +1186 -125
- package/dist/{dist-Ccmt3fGJ.mjs → dist-BSWr9Qg8.mjs} +296 -11
- package/dist/{dist-C11WuNUP.js → dist-DVMPCqaj.js} +313 -10
- package/dist/{event-loop-stall-monitor-Cq_NeC4o.js → event-loop-stall-monitor-BazdZ0g0.js} +82 -88
- package/dist/{event-loop-stall-monitor-OJrOMeuu.mjs → event-loop-stall-monitor-BeQHivuY.mjs} +82 -88
- package/dist/{lazy-sharp-RxUs6on_.js → lazy-sharp-Du1-XaNP.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +8 -6
- package/dist/pipeline-runner/index.mjs +6 -4
- package/dist/{process-memory-DOjQ3MgC.js → process-memory-CiNyC-wv.js} +1 -1
- package/dist/{process-memory-D0zDmXLI.mjs → process-memory-DFL51gCx.mjs} +1 -1
- package/dist/recorder/index.js +660 -130
- package/dist/recorder/index.mjs +659 -129
- package/dist/remote-restream-BeHi78PZ.mjs +25 -0
- package/dist/remote-restream-CO36Sr30.js +36 -0
- package/dist/restream-intent-B4BXZra7.mjs +72 -0
- package/dist/{remote-restream-BYbAsgUf.js → restream-intent-Cv9x3jmu.js} +30 -30
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +1 -1
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BdgcF1lL.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-pkzutnza.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DvZWLWk1.mjs +26 -0
- package/dist/stream-broker/{hostInit-Da9wVA2r.mjs → hostInit-B1i1rtBB.mjs} +3 -3
- package/dist/stream-broker/index.js +598 -82
- package/dist/stream-broker/index.mjs +597 -81
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-DDpliBIW.mjs → worker-protocol-Clcy8PvY.mjs} +1 -1
- package/dist/{worker-protocol-BePduZVV.js → worker-protocol-pUUt-yhh.js} +1 -1
- package/package.json +1 -1
- package/python/inference_pool.py +238 -2
- package/python/postprocessors/__init__.py +4 -0
- package/python/postprocessors/ctc.py +52 -0
- package/python/postprocessors/plate_slots.py +180 -0
- package/python/postprocessors/test_ctc.py +39 -0
- package/python/postprocessors/test_plate_slots.py +217 -0
- package/python/postprocessors/test_yolonas.py +83 -0
- package/python/postprocessors/yolonas.py +67 -0
- package/python/test_inference_pool_backpressure.py +6 -2
- package/python/test_inference_pool_coreml_cache.py +12 -4
- package/python/test_inference_pool_device_selection.py +12 -4
- package/python/test_inference_pool_layout.py +16 -5
- package/python/test_inference_pool_memstats.py +6 -2
- package/python/test_inference_pool_ov_ppp.py +12 -9
- package/python/test_inference_pool_preprocess.py +57 -0
- package/python/test_inference_pool_reconfigure.py +122 -0
- package/python/test_inference_pool_static_batch.py +426 -0
- package/dist/remote-restream-Ci7RXNGb.mjs +0 -66
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DY31bUCj.mjs +0 -26
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The `reconfigure` command retunes a LOADED slot without reloading it.
|
|
2
|
+
|
|
3
|
+
D214 follow-up. `nmsIouThreshold` was a dead knob end to end; now the operator's
|
|
4
|
+
value reaches `slot.config`, and a CHANGE to it must not cost a session reload —
|
|
5
|
+
`replace` is unload+load (~1.4s on coreml, an OpenVINO recompile) and stalls
|
|
6
|
+
every live camera routed to the pool.
|
|
7
|
+
|
|
8
|
+
Two properties are load-bearing and pinned here:
|
|
9
|
+
1. the patch lands on the named index ONLY, leaving the model object and every
|
|
10
|
+
other slot untouched (the postprocessor re-reads `slot.config` per frame,
|
|
11
|
+
which is what makes an in-place update take effect);
|
|
12
|
+
2. a key outside the allow-list is REFUSED. A `path` accepted here would be
|
|
13
|
+
written into the config while the loaded session kept serving the old
|
|
14
|
+
model — an undetectable lie. That caller must use `replace`.
|
|
15
|
+
|
|
16
|
+
Heavy runtime deps are stubbed exactly as the sibling pool tests do, so the
|
|
17
|
+
module imports on a bare interpreter.
|
|
18
|
+
"""
|
|
19
|
+
import sys
|
|
20
|
+
import types
|
|
21
|
+
import unittest
|
|
22
|
+
|
|
23
|
+
try: # real numpy when the sandbox has it — a blind stub POISONS the whole
|
|
24
|
+
import numpy # noqa: F401 # pytest session for every sibling that needs it
|
|
25
|
+
except ImportError:
|
|
26
|
+
_np = types.ModuleType("numpy")
|
|
27
|
+
_np.array = lambda seq, dtype=None: list(seq) # noqa: E731
|
|
28
|
+
_np.float32 = "float32"
|
|
29
|
+
sys.modules["numpy"] = _np
|
|
30
|
+
try: # real PIL when present, for the same reason
|
|
31
|
+
import PIL.Image # noqa: F401
|
|
32
|
+
except ImportError:
|
|
33
|
+
_pil = types.ModuleType("PIL")
|
|
34
|
+
_pil_image = types.ModuleType("PIL.Image")
|
|
35
|
+
_pil.Image = _pil_image
|
|
36
|
+
sys.modules["PIL"] = _pil
|
|
37
|
+
sys.modules["PIL.Image"] = _pil_image
|
|
38
|
+
|
|
39
|
+
from inference_pool import ( # noqa: E402
|
|
40
|
+
RECONFIGURABLE_CONFIG_KEYS,
|
|
41
|
+
ModelSlot,
|
|
42
|
+
_handle_command,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _loaded_slot(nms: float = 0.45) -> ModelSlot:
|
|
47
|
+
slot = ModelSlot()
|
|
48
|
+
slot.model = object()
|
|
49
|
+
slot.loaded = True
|
|
50
|
+
slot.config = {
|
|
51
|
+
"path": "/models/camstack-yolo26n.onnx",
|
|
52
|
+
"postprocessor": "yolo",
|
|
53
|
+
"confidence": 0.05,
|
|
54
|
+
"nmsIouThreshold": nms,
|
|
55
|
+
}
|
|
56
|
+
return slot
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ReconfigureCommand(unittest.TestCase):
|
|
60
|
+
def test_applies_the_patch_to_the_named_slot_only(self) -> None:
|
|
61
|
+
models = [_loaded_slot(0.45), _loaded_slot(0.45)]
|
|
62
|
+
model_object = models[0].model
|
|
63
|
+
|
|
64
|
+
resp = _handle_command(
|
|
65
|
+
models, {"cmd": "reconfigure", "index": 0, "config": {"nmsIouThreshold": 0.7}}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self.assertEqual(resp["status"], "ok")
|
|
69
|
+
self.assertEqual(models[0].config["nmsIouThreshold"], 0.7)
|
|
70
|
+
# The session is NOT re-instantiated: same object, still loaded.
|
|
71
|
+
self.assertIs(models[0].model, model_object)
|
|
72
|
+
self.assertTrue(models[0].loaded)
|
|
73
|
+
# Every other slot is untouched — the whole point of a per-pair retune.
|
|
74
|
+
self.assertEqual(models[1].config["nmsIouThreshold"], 0.45)
|
|
75
|
+
|
|
76
|
+
def test_leaves_the_rest_of_the_config_alone(self) -> None:
|
|
77
|
+
models = [_loaded_slot(0.45)]
|
|
78
|
+
_handle_command(
|
|
79
|
+
models, {"cmd": "reconfigure", "index": 0, "config": {"nmsIouThreshold": 0.2}}
|
|
80
|
+
)
|
|
81
|
+
self.assertEqual(models[0].config["path"], "/models/camstack-yolo26n.onnx")
|
|
82
|
+
self.assertEqual(models[0].config["postprocessor"], "yolo")
|
|
83
|
+
self.assertEqual(models[0].config["confidence"], 0.05)
|
|
84
|
+
|
|
85
|
+
def test_refuses_a_key_that_needs_a_reload(self) -> None:
|
|
86
|
+
models = [_loaded_slot(0.45)]
|
|
87
|
+
resp = _handle_command(
|
|
88
|
+
models,
|
|
89
|
+
{"cmd": "reconfigure", "index": 0, "config": {"path": "/models/other.onnx"}},
|
|
90
|
+
)
|
|
91
|
+
self.assertEqual(resp["status"], "error")
|
|
92
|
+
self.assertIn("path", resp["error"])
|
|
93
|
+
# And it did NOT half-apply.
|
|
94
|
+
self.assertEqual(models[0].config["path"], "/models/camstack-yolo26n.onnx")
|
|
95
|
+
|
|
96
|
+
def test_refuses_an_unloaded_slot_instead_of_writing_a_ghost_config(self) -> None:
|
|
97
|
+
models = [ModelSlot()]
|
|
98
|
+
resp = _handle_command(
|
|
99
|
+
models, {"cmd": "reconfigure", "index": 0, "config": {"nmsIouThreshold": 0.7}}
|
|
100
|
+
)
|
|
101
|
+
self.assertEqual(resp["status"], "error")
|
|
102
|
+
self.assertIn("not loaded", resp["error"])
|
|
103
|
+
|
|
104
|
+
def test_refuses_an_index_past_the_end(self) -> None:
|
|
105
|
+
models = [_loaded_slot()]
|
|
106
|
+
resp = _handle_command(
|
|
107
|
+
models, {"cmd": "reconfigure", "index": 9, "config": {"nmsIouThreshold": 0.7}}
|
|
108
|
+
)
|
|
109
|
+
self.assertEqual(resp["status"], "error")
|
|
110
|
+
# A `load`/`replace` grows the slot list; `reconfigure` must not — it
|
|
111
|
+
# would leave a config with no model behind it.
|
|
112
|
+
self.assertEqual(len(models), 1)
|
|
113
|
+
|
|
114
|
+
def test_the_allow_list_mirrors_the_typescript_PoolDecodeSettings(self) -> None:
|
|
115
|
+
# Adding a key on one side only reintroduces the D214 dead-knob class:
|
|
116
|
+
# TS would record the value as applied while Python refused it (or
|
|
117
|
+
# accepted a key the decode never reads).
|
|
118
|
+
self.assertEqual(RECONFIGURABLE_CONFIG_KEYS, frozenset({"nmsIouThreshold"}))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
unittest.main()
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Batch capability is a HARDWARE FACT, decided at load — never a setting.
|
|
2
|
+
|
|
3
|
+
Two layers are covered:
|
|
4
|
+
|
|
5
|
+
1. The PURE derivation — `_ov_static_batch_required` (engine+device -> may the
|
|
6
|
+
graph keep a dynamic batch axis?) and `_static_batch_plan` (which inputs
|
|
7
|
+
have a pinnable dynamic BATCH axis, and which carry a dynamic axis that is
|
|
8
|
+
NOT the batch and therefore cannot be pinned). No OpenVINO import: these
|
|
9
|
+
run in a bare sandbox.
|
|
10
|
+
|
|
11
|
+
2. The REAL reshape — when OpenVINO is installed the last class builds a tiny
|
|
12
|
+
ONNX graph with a dynamic first dimension, reads it as an OV model and runs
|
|
13
|
+
`_pin_batch_for_device` against an NPU-shaped target and a CPU one. NPU
|
|
14
|
+
must come back STATIC 1 (this is the compile that hung the hub live on
|
|
15
|
+
2026-08-20: "Got negative shape dim bound: '-1'", then a load that never
|
|
16
|
+
completed), CPU must come back UNTOUCHED and still dynamic.
|
|
17
|
+
|
|
18
|
+
Run: /tmp/conv-venv/bin/python -m pytest test_inference_pool_static_batch.py -v
|
|
19
|
+
(or `python3 -m unittest test_inference_pool_static_batch -v` — the pure
|
|
20
|
+
classes run without OpenVINO; the OV class self-skips.)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
import types
|
|
26
|
+
import unittest
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Stub the heavy third-party imports ONLY when they are genuinely absent, and
|
|
31
|
+
# BEFORE importing inference_pool. A blind stub would shadow the REAL numpy the
|
|
32
|
+
# OpenVINO class below needs, so every stub is an ImportError fallback.
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
try: # pragma: no cover - environment-dependent
|
|
36
|
+
import numpy # noqa: F401
|
|
37
|
+
except ImportError: # pragma: no cover
|
|
38
|
+
_np = types.ModuleType("numpy")
|
|
39
|
+
_np.array = lambda seq, dtype=None: list(seq) # noqa: E731
|
|
40
|
+
_np.float32 = "float32"
|
|
41
|
+
sys.modules["numpy"] = _np
|
|
42
|
+
|
|
43
|
+
try: # pragma: no cover - environment-dependent
|
|
44
|
+
import PIL.Image # noqa: F401
|
|
45
|
+
except ImportError: # pragma: no cover
|
|
46
|
+
_pil = types.ModuleType("PIL")
|
|
47
|
+
_pil_image = types.ModuleType("PIL.Image")
|
|
48
|
+
_pil.Image = _pil_image
|
|
49
|
+
sys.modules["PIL"] = _pil
|
|
50
|
+
sys.modules["PIL.Image"] = _pil_image
|
|
51
|
+
|
|
52
|
+
try: # pragma: no cover - environment-dependent
|
|
53
|
+
import postprocessors # noqa: F401
|
|
54
|
+
except ImportError: # pragma: no cover
|
|
55
|
+
_pp = types.ModuleType("postprocessors")
|
|
56
|
+
_pp.POSTPROCESSORS = {}
|
|
57
|
+
sys.modules["postprocessors"] = _pp
|
|
58
|
+
|
|
59
|
+
from inference_pool import ( # noqa: E402
|
|
60
|
+
STATIC_BATCH_PIN,
|
|
61
|
+
_pin_batch_for_device,
|
|
62
|
+
_static_batch_plan,
|
|
63
|
+
_static_batch_required,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class StaticBatchRequiredTest(unittest.TestCase):
|
|
68
|
+
"""The rule itself: which (engine, device) pairs refuse a dynamic shape.
|
|
69
|
+
|
|
70
|
+
ONE authority for every engine. Only the Intel NPU refuses, and it is not a
|
|
71
|
+
preference — the Level-Zero compiler rejects the graph outright, and because
|
|
72
|
+
a pool load failure is a retry loop rather than an error the caller sees,
|
|
73
|
+
getting this wrong looks like a busy pool, not a broken one.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def test_openvino_npu_requires_static_batch(self) -> None:
|
|
77
|
+
self.assertTrue(_static_batch_required("openvino", "NPU"))
|
|
78
|
+
|
|
79
|
+
def test_npu_with_index_suffix_requires_static_batch(self) -> None:
|
|
80
|
+
# OpenVINO enumerates multiple units as NPU.0 / NPU.1.
|
|
81
|
+
self.assertTrue(_static_batch_required("openvino", "NPU.0"))
|
|
82
|
+
|
|
83
|
+
def test_lowercase_and_padded_pin_is_still_the_npu(self) -> None:
|
|
84
|
+
self.assertTrue(_static_batch_required("openvino", " npu "))
|
|
85
|
+
|
|
86
|
+
def test_openvino_cpu_keeps_dynamic(self) -> None:
|
|
87
|
+
self.assertFalse(_static_batch_required("openvino", "CPU"))
|
|
88
|
+
|
|
89
|
+
def test_openvino_gpu_keeps_dynamic(self) -> None:
|
|
90
|
+
self.assertFalse(_static_batch_required("openvino", "GPU"))
|
|
91
|
+
|
|
92
|
+
def test_openvino_gpu_with_index_suffix_keeps_dynamic(self) -> None:
|
|
93
|
+
self.assertFalse(_static_batch_required("openvino", "GPU.1"))
|
|
94
|
+
|
|
95
|
+
def test_empty_device_keeps_dynamic(self) -> None:
|
|
96
|
+
# A missing device name must never silently pin a graph.
|
|
97
|
+
self.assertFalse(_static_batch_required("openvino", ""))
|
|
98
|
+
|
|
99
|
+
def test_onnxruntime_keeps_dynamic(self) -> None:
|
|
100
|
+
self.assertFalse(_static_batch_required("onnxruntime", "cuda"))
|
|
101
|
+
self.assertFalse(_static_batch_required("onnxruntime", "cpu"))
|
|
102
|
+
|
|
103
|
+
def test_coreml_keeps_dynamic(self) -> None:
|
|
104
|
+
# A RangeDim export is what the batched ANE dispatch path reads; the
|
|
105
|
+
# loader must never pin it away.
|
|
106
|
+
self.assertFalse(_static_batch_required("coreml", "ane"))
|
|
107
|
+
self.assertFalse(_static_batch_required("coreml", "all"))
|
|
108
|
+
|
|
109
|
+
def test_edgetpu_keeps_dynamic(self) -> None:
|
|
110
|
+
# A tflite/edgetpu graph is static by construction — nothing to pin.
|
|
111
|
+
self.assertFalse(_static_batch_required("edgetpu", "usb:0"))
|
|
112
|
+
|
|
113
|
+
def test_an_npu_named_device_on_another_engine_is_not_the_intel_npu(self) -> None:
|
|
114
|
+
# The rule is a PAIR. `coreml`'s Neural Engine is not Level Zero.
|
|
115
|
+
self.assertFalse(_static_batch_required("coreml", "npu"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class StaticBatchPlanTest(unittest.TestCase):
|
|
119
|
+
"""Which inputs get pinned, and which are honestly reported as unpinnable."""
|
|
120
|
+
|
|
121
|
+
def test_dynamic_batch_is_pinned_to_one(self) -> None:
|
|
122
|
+
plan, unpinnable = _static_batch_plan([[None, 64, 128, 3]])
|
|
123
|
+
self.assertEqual(plan, {0: [STATIC_BATCH_PIN, 64, 128, 3]})
|
|
124
|
+
self.assertEqual(unpinnable, [])
|
|
125
|
+
|
|
126
|
+
def test_already_static_graph_needs_no_reshape(self) -> None:
|
|
127
|
+
plan, unpinnable = _static_batch_plan([[1, 3, 640, 640]])
|
|
128
|
+
self.assertEqual(plan, {})
|
|
129
|
+
self.assertEqual(unpinnable, [])
|
|
130
|
+
|
|
131
|
+
def test_static_batch_greater_than_one_is_left_alone(self) -> None:
|
|
132
|
+
# An export that deliberately declares batch 4 is a static shape; the
|
|
133
|
+
# rule pins DYNAMIC axes, it does not renegotiate static ones.
|
|
134
|
+
plan, unpinnable = _static_batch_plan([[4, 3, 640, 640]])
|
|
135
|
+
self.assertEqual(plan, {})
|
|
136
|
+
self.assertEqual(unpinnable, [])
|
|
137
|
+
|
|
138
|
+
def test_dynamic_spatial_axis_cannot_be_pinned(self) -> None:
|
|
139
|
+
# A dynamic H/W has no value we are allowed to invent — report it so the
|
|
140
|
+
# compile failure below is explained rather than silent.
|
|
141
|
+
plan, unpinnable = _static_batch_plan([[None, 3, None, 640]])
|
|
142
|
+
self.assertEqual(plan, {})
|
|
143
|
+
self.assertEqual(unpinnable, [0])
|
|
144
|
+
|
|
145
|
+
def test_multi_input_pins_only_the_dynamic_ones(self) -> None:
|
|
146
|
+
plan, unpinnable = _static_batch_plan([
|
|
147
|
+
[1, 3, 640, 640],
|
|
148
|
+
[None, 3, 112, 112],
|
|
149
|
+
])
|
|
150
|
+
self.assertEqual(plan, {1: [STATIC_BATCH_PIN, 3, 112, 112]})
|
|
151
|
+
self.assertEqual(unpinnable, [])
|
|
152
|
+
|
|
153
|
+
def test_unreadable_shape_is_not_guessed(self) -> None:
|
|
154
|
+
plan, unpinnable = _static_batch_plan([None])
|
|
155
|
+
self.assertEqual(plan, {})
|
|
156
|
+
self.assertEqual(unpinnable, [0])
|
|
157
|
+
|
|
158
|
+
def test_rank_one_dynamic_is_pinnable(self) -> None:
|
|
159
|
+
plan, unpinnable = _static_batch_plan([[None]])
|
|
160
|
+
self.assertEqual(plan, {0: [STATIC_BATCH_PIN]})
|
|
161
|
+
self.assertEqual(unpinnable, [])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _dynamic_batch_ov_model(core, tmpdir: str):
|
|
165
|
+
"""Build the smallest ONNX with a DYNAMIC first dim and read it as an OV
|
|
166
|
+
model. Mirrors what `ov.convert_model` produces from every dynamic-batch
|
|
167
|
+
export in this repo (`scripts/build-camstack-models.py`, and the
|
|
168
|
+
model-studio convert path)."""
|
|
169
|
+
import os
|
|
170
|
+
|
|
171
|
+
import onnx
|
|
172
|
+
from onnx import TensorProto, helper
|
|
173
|
+
|
|
174
|
+
inp = helper.make_tensor_value_info(
|
|
175
|
+
"input", TensorProto.FLOAT, ["batch", 4],
|
|
176
|
+
)
|
|
177
|
+
out = helper.make_tensor_value_info(
|
|
178
|
+
"output", TensorProto.FLOAT, ["batch", 4],
|
|
179
|
+
)
|
|
180
|
+
node = helper.make_node("Relu", ["input"], ["output"])
|
|
181
|
+
graph = helper.make_graph([node], "dyn", [inp], [out])
|
|
182
|
+
model = helper.make_model(
|
|
183
|
+
graph, opset_imports=[helper.make_operatorsetid("", 17)],
|
|
184
|
+
)
|
|
185
|
+
onnx.checker.check_model(model)
|
|
186
|
+
path = os.path.join(tmpdir, "dyn.onnx")
|
|
187
|
+
onnx.save(model, path)
|
|
188
|
+
return core.read_model(path), path
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class OpenVinoReshapeTest(unittest.TestCase):
|
|
192
|
+
"""The real thing: a dynamic-batch IR compiled for an NPU target.
|
|
193
|
+
|
|
194
|
+
This is the case that hung the hub. Without the load-time reshape the graph
|
|
195
|
+
reaches the Level-Zero compiler with a `-1` dim; with it, the model handed to
|
|
196
|
+
`compile_model` is static and the compile is a normal one.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def setUpClass(cls) -> None:
|
|
201
|
+
try:
|
|
202
|
+
import onnx # noqa: F401
|
|
203
|
+
import openvino # noqa: F401
|
|
204
|
+
except ImportError: # pragma: no cover - sandbox without the runtime
|
|
205
|
+
raise unittest.SkipTest("openvino/onnx not installed")
|
|
206
|
+
|
|
207
|
+
def test_dynamic_batch_is_reshaped_static_for_the_npu(self) -> None:
|
|
208
|
+
import tempfile
|
|
209
|
+
|
|
210
|
+
import openvino as ov
|
|
211
|
+
|
|
212
|
+
core = ov.Core()
|
|
213
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
214
|
+
model, path = _dynamic_batch_ov_model(core, tmp)
|
|
215
|
+
self.assertTrue(
|
|
216
|
+
model.input(0).get_partial_shape()[0].is_dynamic,
|
|
217
|
+
"fixture must start dynamic or the test proves nothing",
|
|
218
|
+
)
|
|
219
|
+
pinned = _pin_batch_for_device(core, model, path, "NPU")
|
|
220
|
+
shape = pinned.input(0).get_partial_shape()
|
|
221
|
+
self.assertTrue(shape[0].is_static)
|
|
222
|
+
self.assertEqual(shape[0].get_length(), STATIC_BATCH_PIN)
|
|
223
|
+
# Shape inference must have carried the pin to the OUTPUT too — the
|
|
224
|
+
# plate model carried the `-1` on both ends.
|
|
225
|
+
out_shape = pinned.output(0).get_partial_shape()
|
|
226
|
+
self.assertTrue(out_shape[0].is_static)
|
|
227
|
+
self.assertEqual(out_shape[0].get_length(), STATIC_BATCH_PIN)
|
|
228
|
+
|
|
229
|
+
def test_the_source_model_object_is_never_mutated(self) -> None:
|
|
230
|
+
# `Model.reshape` mutates in place, and on the PrePostProcessor path the
|
|
231
|
+
# compile loop REUSES the same object for the next candidate. If the pin
|
|
232
|
+
# leaked, a GPU fallback after a failed NPU attempt would silently
|
|
233
|
+
# compile the pinned graph.
|
|
234
|
+
import tempfile
|
|
235
|
+
|
|
236
|
+
import openvino as ov
|
|
237
|
+
|
|
238
|
+
core = ov.Core()
|
|
239
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
240
|
+
model, path = _dynamic_batch_ov_model(core, tmp)
|
|
241
|
+
pinned = _pin_batch_for_device(core, model, path, "NPU")
|
|
242
|
+
self.assertIsNot(pinned, model)
|
|
243
|
+
self.assertTrue(
|
|
244
|
+
model.input(0).get_partial_shape()[0].is_dynamic,
|
|
245
|
+
"the source the caller holds must still be dynamic",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def test_dynamic_batch_survives_on_cpu(self) -> None:
|
|
249
|
+
import tempfile
|
|
250
|
+
|
|
251
|
+
import openvino as ov
|
|
252
|
+
|
|
253
|
+
core = ov.Core()
|
|
254
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
255
|
+
model, path = _dynamic_batch_ov_model(core, tmp)
|
|
256
|
+
same = _pin_batch_for_device(core, model, path, "CPU")
|
|
257
|
+
self.assertIs(same, model, "CPU must get the source object untouched")
|
|
258
|
+
self.assertTrue(same.input(0).get_partial_shape()[0].is_dynamic)
|
|
259
|
+
|
|
260
|
+
def test_dynamic_batch_survives_on_gpu(self) -> None:
|
|
261
|
+
import tempfile
|
|
262
|
+
|
|
263
|
+
import openvino as ov
|
|
264
|
+
|
|
265
|
+
core = ov.Core()
|
|
266
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
267
|
+
model, path = _dynamic_batch_ov_model(core, tmp)
|
|
268
|
+
same = _pin_batch_for_device(core, model, path, "GPU")
|
|
269
|
+
self.assertTrue(same.input(0).get_partial_shape()[0].is_dynamic)
|
|
270
|
+
|
|
271
|
+
def test_a_path_source_is_read_and_pinned_for_the_npu(self) -> None:
|
|
272
|
+
# The non-PPP load path hands `compile_model` the IR PATH, not a model
|
|
273
|
+
# object. The pin has to work from either.
|
|
274
|
+
import tempfile
|
|
275
|
+
|
|
276
|
+
import openvino as ov
|
|
277
|
+
|
|
278
|
+
core = ov.Core()
|
|
279
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
280
|
+
_model, path = _dynamic_batch_ov_model(core, tmp)
|
|
281
|
+
pinned = _pin_batch_for_device(core, path, path, "NPU")
|
|
282
|
+
self.assertNotIsInstance(pinned, str)
|
|
283
|
+
shape = pinned.input(0).get_partial_shape()
|
|
284
|
+
self.assertTrue(shape[0].is_static)
|
|
285
|
+
self.assertEqual(shape[0].get_length(), STATIC_BATCH_PIN)
|
|
286
|
+
|
|
287
|
+
def test_a_path_source_is_left_a_path_on_cpu(self) -> None:
|
|
288
|
+
# No reshape needed => no wasted read_model; compile still takes the path.
|
|
289
|
+
import tempfile
|
|
290
|
+
|
|
291
|
+
import openvino as ov
|
|
292
|
+
|
|
293
|
+
core = ov.Core()
|
|
294
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
295
|
+
_model, path = _dynamic_batch_ov_model(core, tmp)
|
|
296
|
+
self.assertEqual(_pin_batch_for_device(core, path, path, "CPU"), path)
|
|
297
|
+
|
|
298
|
+
def test_an_already_static_graph_is_not_re_read_for_the_npu(self) -> None:
|
|
299
|
+
import os
|
|
300
|
+
import tempfile
|
|
301
|
+
|
|
302
|
+
import onnx
|
|
303
|
+
import openvino as ov
|
|
304
|
+
from onnx import TensorProto, helper
|
|
305
|
+
|
|
306
|
+
core = ov.Core()
|
|
307
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
308
|
+
inp = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 4])
|
|
309
|
+
out = helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 4])
|
|
310
|
+
graph = helper.make_graph(
|
|
311
|
+
[helper.make_node("Relu", ["input"], ["output"])],
|
|
312
|
+
"static", [inp], [out],
|
|
313
|
+
)
|
|
314
|
+
model = helper.make_model(
|
|
315
|
+
graph, opset_imports=[helper.make_operatorsetid("", 17)],
|
|
316
|
+
)
|
|
317
|
+
path = os.path.join(tmp, "static.onnx")
|
|
318
|
+
onnx.save(model, path)
|
|
319
|
+
ov_model = core.read_model(path)
|
|
320
|
+
self.assertIs(_pin_batch_for_device(core, ov_model, path, "NPU"), ov_model)
|
|
321
|
+
|
|
322
|
+
def test_the_pinned_model_actually_compiles_on_cpu(self) -> None:
|
|
323
|
+
# End of the chain: a reshaped model is still a compilable model. (The
|
|
324
|
+
# NPU itself is not present on a dev machine — CPU proves the graph
|
|
325
|
+
# survived the reshape, the shape assertions above prove it is static.)
|
|
326
|
+
import tempfile
|
|
327
|
+
|
|
328
|
+
import numpy as np
|
|
329
|
+
import openvino as ov
|
|
330
|
+
|
|
331
|
+
core = ov.Core()
|
|
332
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
333
|
+
model, path = _dynamic_batch_ov_model(core, tmp)
|
|
334
|
+
pinned = _pin_batch_for_device(core, model, path, "NPU")
|
|
335
|
+
compiled = core.compile_model(pinned, device_name="CPU")
|
|
336
|
+
result = compiled(np.zeros((1, 4), dtype=np.float32))
|
|
337
|
+
self.assertEqual(list(result[compiled.output(0)].shape), [1, 4])
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class _RecordingCore:
|
|
341
|
+
"""A real `ov.Core` with `compile_model` intercepted: it records EXACTLY what
|
|
342
|
+
the loader handed the compiler, then compiles it for CPU so the rest of the
|
|
343
|
+
load path (outputs, properties, infer requests) runs for real.
|
|
344
|
+
|
|
345
|
+
This is the fake that cannot lie in the dangerous direction — it supplies
|
|
346
|
+
nothing, it only observes production's own argument.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def __init__(self, real: Any) -> None: # noqa: ANN401 - test double
|
|
350
|
+
self._real = real
|
|
351
|
+
self.compiled: list = []
|
|
352
|
+
|
|
353
|
+
def read_model(self, path: str) -> Any: # noqa: ANN401 - test double
|
|
354
|
+
return self._real.read_model(path)
|
|
355
|
+
|
|
356
|
+
def compile_model(self, source: Any, device_name: str, config: Any = None) -> Any: # noqa: ANN401
|
|
357
|
+
self.compiled.append((source, device_name))
|
|
358
|
+
return self._real.compile_model(source, device_name="CPU")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class LoadPathAppliesTheRuleTest(unittest.TestCase):
|
|
362
|
+
"""The derivation has to happen AT THE LOAD, not merely be available.
|
|
363
|
+
|
|
364
|
+
Drives `_load_model`'s OpenVINO branch with an operator-pinned NPU device
|
|
365
|
+
and asserts the object that reached `compile_model` was static. Remove the
|
|
366
|
+
`_pin_batch_for_device` call from the compile loop and this goes red —
|
|
367
|
+
the helper tests above would stay green.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
@classmethod
|
|
371
|
+
def setUpClass(cls) -> None:
|
|
372
|
+
try:
|
|
373
|
+
import onnx # noqa: F401
|
|
374
|
+
import openvino # noqa: F401
|
|
375
|
+
except ImportError: # pragma: no cover - sandbox without the runtime
|
|
376
|
+
raise unittest.SkipTest("openvino/onnx not installed")
|
|
377
|
+
|
|
378
|
+
def _load_with_pinned_device(self, device: str) -> tuple:
|
|
379
|
+
import tempfile
|
|
380
|
+
|
|
381
|
+
import openvino as ov
|
|
382
|
+
|
|
383
|
+
import inference_pool as ip
|
|
384
|
+
|
|
385
|
+
core = ov.Core()
|
|
386
|
+
saved = (ip._runtime, ip._runtime_lib, ip._OV_PPP_ENABLED, ip._OV_ASYNC_ENABLED)
|
|
387
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
388
|
+
_model, path = _dynamic_batch_ov_model(core, tmp)
|
|
389
|
+
recording = _RecordingCore(core)
|
|
390
|
+
ip._runtime = "openvino"
|
|
391
|
+
ip._runtime_lib = recording
|
|
392
|
+
# PPP folds a uint8 NHWC preprocessor into image graphs; this 2-D
|
|
393
|
+
# fixture is not one, and disabling it keeps the test on the plain
|
|
394
|
+
# path where `compile_source` is the PATH — the harder case.
|
|
395
|
+
ip._OV_PPP_ENABLED = False
|
|
396
|
+
ip._OV_ASYNC_ENABLED = False
|
|
397
|
+
try:
|
|
398
|
+
slot = ip.ModelSlot()
|
|
399
|
+
ip._load_model(slot, {"path": path, "device": device})
|
|
400
|
+
finally:
|
|
401
|
+
(ip._runtime, ip._runtime_lib,
|
|
402
|
+
ip._OV_PPP_ENABLED, ip._OV_ASYNC_ENABLED) = saved
|
|
403
|
+
self.assertTrue(slot.loaded)
|
|
404
|
+
self.assertEqual(len(recording.compiled), 1)
|
|
405
|
+
return recording.compiled[0]
|
|
406
|
+
|
|
407
|
+
def test_npu_pin_reaches_the_compiler_static(self) -> None:
|
|
408
|
+
source, device = self._load_with_pinned_device("NPU")
|
|
409
|
+
self.assertEqual(device, "NPU")
|
|
410
|
+
self.assertNotIsInstance(
|
|
411
|
+
source, str, "the NPU must be handed a reshaped model, not the raw IR path",
|
|
412
|
+
)
|
|
413
|
+
shape = source.input(0).get_partial_shape()
|
|
414
|
+
self.assertTrue(shape[0].is_static)
|
|
415
|
+
self.assertEqual(shape[0].get_length(), STATIC_BATCH_PIN)
|
|
416
|
+
|
|
417
|
+
def test_gpu_pin_reaches_the_compiler_untouched(self) -> None:
|
|
418
|
+
source, device = self._load_with_pinned_device("GPU")
|
|
419
|
+
self.assertEqual(device, "GPU")
|
|
420
|
+
self.assertIsInstance(
|
|
421
|
+
source, str, "a target that accepts dynamic dims gets the path as before",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
unittest.main()
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Path suffix for the audio-free variant dialed by a DETECTION decode session.
|
|
3
|
-
* Muted in every respect (no audio, still `isMuted()`), plus exempt from the
|
|
4
|
-
* live-edge join withhold.
|
|
5
|
-
*/
|
|
6
|
-
var DETECTION_MUTED_PATH_SUFFIX = "/muted-detection";
|
|
7
|
-
/**
|
|
8
|
-
* Split a restream request path (already stripped of `rtsp://host:port/` and of
|
|
9
|
-
* any `/trackID=N` control suffix) into its token, mute bit and intent.
|
|
10
|
-
*/
|
|
11
|
-
function parseRestreamPath(streamPath) {
|
|
12
|
-
if (streamPath.endsWith("/muted-detection")) return {
|
|
13
|
-
lookupPath: streamPath.slice(0, -16),
|
|
14
|
-
muted: true,
|
|
15
|
-
intent: "detection"
|
|
16
|
-
};
|
|
17
|
-
if (streamPath.endsWith("/muted")) return {
|
|
18
|
-
lookupPath: streamPath.slice(0, -6),
|
|
19
|
-
muted: true,
|
|
20
|
-
intent: null
|
|
21
|
-
};
|
|
22
|
-
return {
|
|
23
|
-
lookupPath: streamPath,
|
|
24
|
-
muted: false,
|
|
25
|
-
intent: null
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Rewrite an acquired restream URL to declare the detection intent.
|
|
30
|
-
*
|
|
31
|
-
* Only a MUTED url is rewritten. `resolveSourceUrl` falls back to the
|
|
32
|
-
* audio-bearing url when the broker has no muted variant; that session is not
|
|
33
|
-
* muted, the live-edge withhold never applied to it, and appending the suffix
|
|
34
|
-
* would only break the token lookup. Idempotent.
|
|
35
|
-
*/
|
|
36
|
-
function withDetectionIntent(restreamUrl) {
|
|
37
|
-
if (restreamUrl.endsWith("/muted-detection")) return restreamUrl;
|
|
38
|
-
if (!restreamUrl.endsWith("/muted")) return restreamUrl;
|
|
39
|
-
return `${restreamUrl.slice(0, -6)}${DETECTION_MUTED_PATH_SUFFIX}`;
|
|
40
|
-
}
|
|
41
|
-
//#endregion
|
|
42
|
-
//#region src/pipeline-runner/remote-restream.ts
|
|
43
|
-
/**
|
|
44
|
-
* Runner-side mode selection (pure): whether an attach payload routes this
|
|
45
|
-
* camera through the remote-source leg. Absent `frameSource` (a pre-P2c
|
|
46
|
-
* payload) and `local-broker` both take the co-located broker path.
|
|
47
|
-
*/
|
|
48
|
-
function isRemoteRestream(frameSource) {
|
|
49
|
-
return frameSource !== void 0 && frameSource.kind === "remote-restream";
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Resolve WHICH broker profile a camStream id feeds for a device (pure).
|
|
53
|
-
*
|
|
54
|
-
* `getStreamWithCodec` targets a PROFILE (its assigned camStream), while the
|
|
55
|
-
* runner is dispatched with camStream ids (`motionStreamId` /
|
|
56
|
-
* `detectionStreamId`). The broker's profile slots carry the mapping
|
|
57
|
-
* (`sourceCamStreamId`); a stream no slot feeds returns `null` — the acquire
|
|
58
|
-
* fails fast and the caller's backoff retries (the assignment may still be
|
|
59
|
-
* propagating).
|
|
60
|
-
*/
|
|
61
|
-
function profileForStreamId(slots, deviceId, streamId) {
|
|
62
|
-
for (const slot of slots) if (slot.deviceId === deviceId && slot.sourceCamStreamId === streamId) return slot.profile;
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
//#endregion
|
|
66
|
-
export { withDetectionIntent as i, profileForStreamId as n, parseRestreamPath as r, isRemoteRestream as t };
|