@camstack/addon-pipeline 1.1.25 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/audio-codec-ffmpeg/index.js +1 -1
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  5. package/dist/decoder-ffmpeg/index.js +1 -1
  6. package/dist/decoder-ffmpeg/index.mjs +1 -1
  7. package/dist/detection-pipeline/index.js +148 -26
  8. package/dist/detection-pipeline/index.mjs +148 -26
  9. package/dist/{dist-BecXbIzJ.mjs → dist-CgEP_0OL.mjs} +730 -14
  10. package/dist/{dist-wzWBZ26C.js → dist-DAIlCdAx.js} +730 -14
  11. package/dist/{frame-handle-plane-DGg0Aevs.mjs → frame-handle-plane-Dq20KtKL.mjs} +1 -1
  12. package/dist/{frame-handle-plane-BAKiW6t4.js → frame-handle-plane-DtTRX_0n.js} +1 -1
  13. package/dist/motion-wasm/index.js +1 -1
  14. package/dist/motion-wasm/index.mjs +1 -1
  15. package/dist/pipeline-runner/index.js +38 -9
  16. package/dist/pipeline-runner/index.mjs +38 -9
  17. package/dist/recorder/index.js +1 -1
  18. package/dist/recorder/index.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CWPU9tbs.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BJK0-svt.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-B7Fx3R_6.mjs → hostInit-DyLqyJaS.mjs} +2 -2
  24. package/dist/stream-broker/index.js +2 -2
  25. package/dist/stream-broker/index.mjs +2 -2
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CPkQfKnu.js → MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DlLMQBag.js → MotionZonesSettings-NcxxQN8r-CoLjNiUN.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-kNcauIAN.js → PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js} +1 -1
  30. package/embed-dist/assets/index-C-pL8ETk.js +81 -0
  31. package/embed-dist/index.html +1 -1
  32. package/package.json +1 -1
  33. package/python/inference_pool.py +522 -27
  34. package/python/test_inference_pool_backpressure.py +121 -0
  35. package/python/test_inference_pool_coreml_cache.py +416 -0
  36. package/python/test_inference_pool_device_selection.py +256 -0
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-wfWYFiTT.mjs +0 -26
  38. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-lpFwIH9e.mjs +0 -26
  39. package/embed-dist/assets/index-xpPLFfsT.js +0 -80
@@ -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-xpPLFfsT.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-C-pL8ETk.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-DrJ0ee3f.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.25",
3
+ "version": "1.1.26",
4
4
  "description": "CamStack Pipeline bundle — runner, detection, motion, decoders, audio + stream broker. Multi-entry npm package shipping 7 addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -44,10 +44,13 @@ import concurrent.futures
44
44
  import io
45
45
  import json
46
46
  import os
47
+ import platform
48
+ import shutil
47
49
  import struct
48
50
  import sys
49
51
  import threading
50
52
  import time
53
+ from collections import deque
51
54
  from dataclasses import dataclass, field
52
55
  from typing import Any, Awaitable, Callable, Optional
53
56
 
@@ -169,6 +172,274 @@ _ov_optimal_reqs: int = 0
169
172
  # query the device's optimal request count). ≈ measured optimal on Intel
170
173
  # CPU/iGPU/NPU (4-5). Threads idle-block on infer, so over-provisioning is cheap.
171
174
  OV_DEFAULT_CONCURRENCY: int = 4
175
+ # OpenVINO device-mode decision — EXACT port of Scrypted's OpenVINO plugin
176
+ # device switch (scrypted-ov-init.py:109-194). Scrypted's rationale, kept
177
+ # verbatim: "AUTO mode can cause conflicts or hide errors with NPU and GPU
178
+ # so try to be explicit and fall back accordingly." The intel-iGPU-only case
179
+ # (the N100) compiles explicit "GPU" — the exact host where the AUTO plugin's
180
+ # mid-flight CPU-helper -> GPU handover invalidated in-flight InferRequest
181
+ # output ports ("Cannot find tensor for port opset1::Result score_8").
182
+
183
+
184
+ def _resolve_ov_mode(available: list[str], full_names: dict[str, str]) -> str:
185
+ """Scrypted's exact mode string for an AUTO/default device request.
186
+
187
+ `available` is `core.available_devices`; `full_names` maps each device to
188
+ its FULL_DEVICE_NAME property (entries whose property query failed are
189
+ absent — mirrors Scrypted's per-device `except: pass`, so such a device
190
+ is not classified). The branch matrix, byte-faithful to Scrypted:
191
+
192
+ npu & gpu -> "AUTO:NPU,GPU,CPU"
193
+ npu & !gpu -> "AUTO:NPU,CPU"
194
+ NVIDIA dGPU(s) -> "AUTO:<dgpu[,dgpu...]>,CPU"
195
+ gpu (intel only) -> "GPU" (explicit — the N100 case)
196
+ else -> "AUTO"
197
+ """
198
+ npu = False
199
+ gpu = False
200
+ dgpus: list[str] = []
201
+ for device in available:
202
+ full_device_name = full_names.get(device)
203
+ if full_device_name is None:
204
+ continue
205
+ if "NVIDIA" in full_device_name and "dGPU" in full_device_name:
206
+ dgpus.append(device)
207
+ if "NPU" in device:
208
+ npu = True
209
+ if "GPU" in device:
210
+ gpu = True
211
+
212
+ mode = "AUTO"
213
+ if npu:
214
+ if gpu:
215
+ mode = "AUTO:NPU,GPU,CPU"
216
+ else:
217
+ mode = "AUTO:NPU,CPU"
218
+ elif len(dgpus):
219
+ mode = f"AUTO:{','.join(dgpus)},CPU"
220
+ # forcing GPU can cause crashes on older GPU. (Scrypted's comment — the
221
+ # GPU-mode compile failure falls back to AUTO in the compile loop below.)
222
+ elif gpu:
223
+ mode = "GPU"
224
+ return mode
225
+
226
+
227
+ # ONNX Runtime execution-provider names used by _resolve_onnx_providers.
228
+ ONNX_CUDA_EP = "CUDAExecutionProvider"
229
+ ONNX_COREML_EP = "CoreMLExecutionProvider"
230
+ ONNX_CPU_EP = "CPUExecutionProvider"
231
+
232
+
233
+ def _resolve_onnx_providers(
234
+ device: str,
235
+ plat: str,
236
+ machine: str,
237
+ available: list[str],
238
+ cuda_device_id: int = 0,
239
+ ) -> list:
240
+ """Ordered ONNX Runtime providers — hardware first, CPU always last.
241
+
242
+ Mirrors Scrypted's ONNX plugin ordering (scrypted-onnx-init.py:95-109)
243
+ for the AUTO/default case:
244
+
245
+ darwin -> CoreMLExecutionProvider
246
+ linux/win on x86_64/AMD64 -> ("CUDAExecutionProvider", {"device_id": N})
247
+ always -> CPUExecutionProvider appended LAST
248
+
249
+ Deliberate adaptations for camstack:
250
+ - an operator device pin is honored: "cuda"/"coreml" put that EP first
251
+ (CPU still last as fallback); "cpu" compiles CPU only.
252
+ - hardware entries not present in `available`
253
+ (ort.get_available_providers()) are dropped — camstack ships the
254
+ plain `onnxruntime` wheel on most nodes, and requesting an EP the
255
+ build lacks raises at session creation instead of falling back.
256
+ - platform match uses startswith so "darwin" never matches "win"
257
+ (Scrypted's `"win" in sys.platform` would).
258
+ """
259
+ dev = (device or "").strip().lower()
260
+ ordered: list = []
261
+ if dev == "cuda":
262
+ ordered.append((ONNX_CUDA_EP, {"device_id": cuda_device_id}))
263
+ elif dev == "coreml":
264
+ ordered.append(ONNX_COREML_EP)
265
+ elif dev == "cpu":
266
+ pass # explicit CPU pin — no hardware EPs
267
+ else:
268
+ # AUTO/default — Scrypted's platform-driven ordering.
269
+ if plat == "darwin":
270
+ ordered.append(ONNX_COREML_EP)
271
+ if plat.startswith(("linux", "win")) and machine in ("x86_64", "AMD64"):
272
+ ordered.append((ONNX_CUDA_EP, {"device_id": cuda_device_id}))
273
+ providers: list = [
274
+ p for p in ordered
275
+ if (p[0] if isinstance(p, tuple) else p) in available
276
+ ]
277
+ providers.append(ONNX_CPU_EP)
278
+ return providers
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # CoreML persistent compiled-model cache
283
+ # ---------------------------------------------------------------------------
284
+ # macOS bug (documented by Scrypted, coreml-init comment): the OS-level
285
+ # compiled-model cache is NOT reused across process restarts — every
286
+ # `ct.models.MLModel(.mlpackage)` load RECOMPILES the model, and the stale OS
287
+ # cache is only cleared on reboot. camstack loads the .mlpackage on every pool
288
+ # spawn, so each spawn paid the full recompile. Fix: after the first compile,
289
+ # copy the compiled `.mlmodelc` (get_compiled_model_path() returns a TEMP dir
290
+ # that dies with the MLModel object) into a persistent `.coreml-cache` dir next
291
+ # to the model (mirrors the OpenVINO `.ov-cache` convention); subsequent spawns
292
+ # load it via `ct.models.CompiledMLModel(...)` — no recompile. A stamp file
293
+ # fingerprinting the source model invalidates the cache when the model changes.
294
+ # HARD RULE: every failure in this machinery falls back to the plain
295
+ # `ct.models.MLModel(path)` load — exactly the pre-cache behavior.
296
+
297
+ COREML_CACHE_DIR_NAME = ".coreml-cache"
298
+ COREML_CACHE_STAMP_VERSION = 1
299
+
300
+
301
+ def _coreml_cache_paths(model_path: str) -> tuple[str, str]:
302
+ """(compiled .mlmodelc cache dir, stamp file) for a source model path.
303
+
304
+ The cache lives in a dot-dir next to the model file — same convention as
305
+ the OpenVINO `.ov-cache` — so it ships/wipes with the models dir.
306
+ """
307
+ models_dir = os.path.dirname(os.path.abspath(model_path))
308
+ stem = os.path.splitext(os.path.basename(model_path))[0]
309
+ cache_root = os.path.join(models_dir, COREML_CACHE_DIR_NAME)
310
+ return (
311
+ os.path.join(cache_root, stem + ".mlmodelc"),
312
+ os.path.join(cache_root, stem + ".stamp.json"),
313
+ )
314
+
315
+
316
+ def _coreml_model_fingerprint(model_path: str) -> dict:
317
+ """Cheap source-model fingerprint — file count + total size + max mtime.
318
+
319
+ Works for both a plain file (.mlmodel) and a .mlpackage DIRECTORY (the
320
+ normal case), whose top-level mtime alone would not reflect content edits.
321
+ JSON-serializable so it round-trips through the stamp file unchanged.
322
+ """
323
+ if not os.path.isdir(model_path):
324
+ st = os.stat(model_path)
325
+ return {"kind": "file", "files": 1, "size": st.st_size, "mtimeNs": st.st_mtime_ns}
326
+ total_size = 0
327
+ latest_mtime = 0
328
+ file_count = 0
329
+ for root, _dirs, files in os.walk(model_path):
330
+ for name in files:
331
+ st = os.stat(os.path.join(root, name))
332
+ total_size += st.st_size
333
+ if st.st_mtime_ns > latest_mtime:
334
+ latest_mtime = st.st_mtime_ns
335
+ file_count += 1
336
+ return {"kind": "dir", "files": file_count, "size": total_size, "mtimeNs": latest_mtime}
337
+
338
+
339
+ def _coreml_stamp_matches(stamp: Any, fingerprint: dict) -> bool:
340
+ """Pure decision: does a parsed stamp validate the cached compile?"""
341
+ return (
342
+ isinstance(stamp, dict)
343
+ and stamp.get("version") == COREML_CACHE_STAMP_VERSION
344
+ and stamp.get("fingerprint") == fingerprint
345
+ )
346
+
347
+
348
+ def _persist_coreml_compiled(
349
+ compiled_src: str, cache_path: str, stamp_path: str, fingerprint: dict,
350
+ ) -> None:
351
+ """Copy coremltools' TEMP compiled dir into the persistent cache path.
352
+
353
+ Crash-safe ordering: copy into a tmp sibling on the SAME filesystem, then
354
+ `os.replace` into place, and write the stamp LAST — a stamp file only ever
355
+ exists for a fully-moved `.mlmodelc`, so a crash mid-copy can never
356
+ produce a half-written cache that validates. Raises on failure — the
357
+ caller logs and continues WITHOUT a cache (model already loaded fine).
358
+ """
359
+ cache_root = os.path.dirname(cache_path)
360
+ os.makedirs(cache_root, exist_ok=True)
361
+ tmp_dir = f"{cache_path}.tmp-{os.getpid()}"
362
+ if os.path.exists(tmp_dir):
363
+ shutil.rmtree(tmp_dir)
364
+ try:
365
+ shutil.copytree(compiled_src, tmp_dir)
366
+ # Drop any stale previous cache: its stamp first (so nothing validates
367
+ # the old dir), then the dir itself — POSIX rename cannot replace a
368
+ # non-empty directory.
369
+ if os.path.exists(stamp_path):
370
+ os.remove(stamp_path)
371
+ if os.path.exists(cache_path):
372
+ shutil.rmtree(cache_path)
373
+ os.replace(tmp_dir, cache_path)
374
+ except BaseException:
375
+ shutil.rmtree(tmp_dir, ignore_errors=True)
376
+ raise
377
+ stamp_tmp = f"{stamp_path}.tmp-{os.getpid()}"
378
+ try:
379
+ with open(stamp_tmp, "w", encoding="utf-8") as fh:
380
+ json.dump({"version": COREML_CACHE_STAMP_VERSION, "fingerprint": fingerprint}, fh)
381
+ os.replace(stamp_tmp, stamp_path)
382
+ except BaseException:
383
+ try:
384
+ os.remove(stamp_tmp)
385
+ except OSError:
386
+ pass
387
+ raise
388
+
389
+
390
+ def _acquire_coreml_model(ct: Any, path: str, compute_units: Any) -> tuple[Any, Any]:
391
+ """Return (model, spec) for the CoreML branch via the persistent cache.
392
+
393
+ Cache HIT → `CompiledMLModel(cache)` — loads the already-compiled
394
+ `.mlmodelc`, no recompile. It has NO `get_spec()`, so the
395
+ spec is read straight from the source .mlpackage with
396
+ `ct.utils.load_spec` (cheap — parses the protobuf, does not
397
+ compile). Spec-derived metadata is therefore identical to
398
+ what `MLModel.get_spec()` returned before.
399
+ Cache MISS → plain `MLModel(path)` (compiles — pre-cache behavior), then
400
+ best-effort persist of the compiled dir for the next spawn.
401
+ ANY failure in the cache machinery → the plain MLModel path, exactly as
402
+ before this cache existed. Never raises differently from pre-cache code.
403
+ """
404
+ try:
405
+ cache_path, stamp_path = _coreml_cache_paths(path)
406
+ fingerprint = _coreml_model_fingerprint(path)
407
+ stamp: Any = None
408
+ if os.path.isfile(stamp_path):
409
+ with open(stamp_path, "r", encoding="utf-8") as fh:
410
+ stamp = json.load(fh)
411
+ if _coreml_stamp_matches(stamp, fingerprint) and os.path.isdir(cache_path):
412
+ spec = ct.utils.load_spec(path)
413
+ model = ct.models.CompiledMLModel(cache_path, compute_units)
414
+ sys.stderr.write(f"CoreML: compiled-model cache HIT {cache_path}\n")
415
+ sys.stderr.flush()
416
+ return model, spec
417
+ except Exception as exc:
418
+ sys.stderr.write(
419
+ f"CoreML: compiled-model cache lookup failed ({exc}); "
420
+ f"falling back to plain compile of {os.path.basename(path)}\n"
421
+ )
422
+ sys.stderr.flush()
423
+
424
+ # Cache MISS / invalid / lookup failure — identical to pre-cache behavior.
425
+ model = ct.models.MLModel(path, compute_units=compute_units)
426
+ spec = model.get_spec()
427
+ try:
428
+ cache_path, stamp_path = _coreml_cache_paths(path)
429
+ fingerprint = _coreml_model_fingerprint(path)
430
+ compiled_src = model.get_compiled_model_path()
431
+ _persist_coreml_compiled(compiled_src, cache_path, stamp_path, fingerprint)
432
+ sys.stderr.write(
433
+ f"CoreML: compiled-model cache MISS — compiled and persisted {cache_path}\n"
434
+ )
435
+ sys.stderr.flush()
436
+ except Exception as exc:
437
+ sys.stderr.write(
438
+ f"CoreML: could not persist compiled-model cache ({exc}); "
439
+ f"continuing without cache\n"
440
+ )
441
+ sys.stderr.flush()
442
+ return model, spec
172
443
 
173
444
 
174
445
  def _init_runtime(runtime: str) -> None:
@@ -201,8 +472,28 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
201
472
  "ane": ct.ComputeUnit.CPU_AND_NE,
202
473
  "all": ct.ComputeUnit.ALL,
203
474
  }
204
- model = ct.models.MLModel(path, compute_units=compute_map.get(device, ct.ComputeUnit.ALL))
205
- spec = model.get_spec()
475
+ # NOTE (Scrypted caveat, coreml-init comment): a macOS bug can cause
476
+ # the .mlpackage to be RECOMPILED on every load with the compiled
477
+ # cache not reused until reboot. camstack loads the .mlpackage on
478
+ # every pool spawn, so it was exposed to the same trap.
479
+ # _acquire_coreml_model routes through a persistent `.coreml-cache`
480
+ # of the compiled `.mlmodelc` (CompiledMLModel on hit, plain MLModel
481
+ # compile + persist on miss) and returns the spec read via
482
+ # ct.utils.load_spec on hits — falling back to the plain
483
+ # MLModel/get_spec() path on ANY cache failure.
484
+ model, spec = _acquire_coreml_model(
485
+ ct, path, compute_map.get(device, ct.ComputeUnit.ALL),
486
+ )
487
+ # Report the compute unit that was ACTUALLY applied so a silent
488
+ # CPU-only placement is visible in the pool stderr (mirrors the
489
+ # OpenVINO EXECUTION_DEVICES / ONNX provider reports). Both MLModel
490
+ # and CompiledMLModel expose `compute_unit`.
491
+ _actual_cu = getattr(model, "compute_unit", None)
492
+ sys.stderr.write(
493
+ f"CoreML: loaded {os.path.basename(path)} device={device} "
494
+ f"compute_unit={_actual_cu}\n"
495
+ )
496
+ sys.stderr.flush()
206
497
  input_spec = spec.description.input[0]
207
498
  slot.config["_input_name"] = input_spec.name
208
499
  slot.config["_input_type"] = input_spec.type.WhichOneof("Type")
@@ -234,16 +525,29 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
234
525
 
235
526
  elif _runtime == "openvino":
236
527
  core = _runtime_lib
237
- ov_device = config.get("device", "AUTO").upper()
238
- if ov_device == "AUTO":
239
- # Scrypted-style device priority, built from the devices OpenVINO
240
- # actually enumerates (more accurate than a hardware probe): NPU >
241
- # GPU > CPU. The AUTO plugin handles runtime selection + failover.
242
- # On a CPU-only image this is just AUTO:CPU; once the Intel GPU/NPU
243
- # runtime is present it becomes AUTO:NPU,GPU,CPU automatically.
244
- order = [d for d in ("NPU", "GPU", "CPU") if d in core.available_devices]
245
- if order:
246
- ov_device = "AUTO:" + ",".join(order)
528
+ ov_device = str(config.get("device", "AUTO")).upper()
529
+ # Scrypted's exact device switch (see _resolve_ov_mode above): an
530
+ # AUTO/default request classifies the enumerated devices by their
531
+ # FULL_DEVICE_NAME and builds the same mode string Scrypted's
532
+ # OpenVINO plugin does. An operator-pinned device (config "device" =
533
+ # gpu/cpu/npu) is honored unchanged compiled directly.
534
+ if ov_device in ("AUTO", "DEFAULT", ""):
535
+ available = list(core.available_devices)
536
+ full_names: dict[str, str] = {}
537
+ for dev in available:
538
+ try:
539
+ full_names[dev] = str(core.get_property(dev, "FULL_DEVICE_NAME"))
540
+ except Exception:
541
+ pass
542
+ ov_device = _resolve_ov_mode(available, full_names)
543
+ sys.stderr.write(
544
+ f"OpenVINO: AUTO resolved to mode {ov_device} "
545
+ f"(available: {available}, names: {full_names})\n"
546
+ )
547
+ sys.stderr.flush()
548
+ else:
549
+ sys.stderr.write(f"OpenVINO: operator-pinned device {ov_device}\n")
550
+ sys.stderr.flush()
247
551
  # THROUGHPUT hint lets OpenVINO spin up multiple internal execution
248
552
  # streams. Combined with one InferRequest per predict-pool thread
249
553
  # (below), concurrent frames from N cameras run in parallel — the
@@ -251,8 +555,53 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
251
555
  # every camera serialised through one stream regardless of how many
252
556
  # predict workers existed. Measured ~1.5–2.4x throughput on CPU/GPU/NPU.
253
557
  ov_config = {"PERFORMANCE_HINT": "THROUGHPUT"}
254
- compiled = core.compile_model(path, device_name=ov_device, config=ov_config)
255
- output_layers = [compiled.output(i) for i in range(len(compiled.outputs))]
558
+ # Persist compiled GPU/NPU kernels so the multi-second JIT compile is paid
559
+ # once (first boot) instead of on every pool spawn. The cold compile storm
560
+ # (~25s for the full model set on an N100 iGPU) is the window where the AUTO
561
+ # CPU->GPU handover races (see the index-fetch note below) and frames pile up.
562
+ try:
563
+ _cache_dir = os.path.join(os.path.dirname(os.path.abspath(path)), ".ov-cache")
564
+ os.makedirs(_cache_dir, exist_ok=True)
565
+ ov_config["CACHE_DIR"] = _cache_dir
566
+ except OSError:
567
+ pass
568
+ # Compile the resolved mode. Failure fallback is Scrypted's, exactly:
569
+ # any mode containing "GPU" reverts to plain "AUTO" and recompiles
570
+ # once; if THAT also fails (or the mode had no GPU), re-raise —
571
+ # camstack has no reset-and-restart like Scrypted, so the existing
572
+ # load/replace error handler reports the failure.
573
+ candidates = [ov_device]
574
+ if "GPU" in ov_device:
575
+ candidates.append("AUTO")
576
+ compiled = None
577
+ last_exc: Optional[BaseException] = None
578
+ for candidate in candidates:
579
+ try:
580
+ compiled = core.compile_model(path, device_name=candidate, config=ov_config)
581
+ ov_device = candidate
582
+ break
583
+ except Exception as exc:
584
+ last_exc = exc
585
+ sys.stderr.write(f"OpenVINO: compile with mode {candidate} failed: {exc}\n")
586
+ if candidate != candidates[-1]:
587
+ sys.stderr.write(f"OpenVINO: {candidate} mode failed, reverting to AUTO.\n")
588
+ sys.stderr.flush()
589
+ if compiled is None:
590
+ if last_exc is not None:
591
+ raise last_exc
592
+ raise RuntimeError("OpenVINO: no device available to compile the model")
593
+ # Name the resolved mode + what OpenVINO actually placed the model on
594
+ # (mirrors Scrypted's EXECUTION_DEVICES print) so a silent CPU
595
+ # placement is visible in the pool stderr.
596
+ try:
597
+ _exec_devices = compiled.get_property("EXECUTION_DEVICES")
598
+ except Exception:
599
+ _exec_devices = "unknown"
600
+ sys.stderr.write(
601
+ f"OpenVINO: compiled {os.path.basename(path)} mode={ov_device} "
602
+ f"EXECUTION_DEVICES={_exec_devices}\n"
603
+ )
604
+ sys.stderr.flush()
256
605
  output_names = [o.get_any_name() for o in compiled.outputs]
257
606
 
258
607
  # Record the device's optimal infer-request count so the dispatcher
@@ -270,27 +619,55 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
270
619
  # what lets the THROUGHPUT streams run concurrently.
271
620
  _ov_tls = threading.local()
272
621
 
273
- def predict(inp_dict: dict, _c=compiled, _layers=output_layers, _names=output_names, _tls=_ov_tls) -> dict:
622
+ def predict(inp_dict: dict, _c=compiled, _names=output_names, _tls=_ov_tls) -> dict:
274
623
  req = getattr(_tls, "req", None)
275
624
  if req is None:
276
625
  req = _tls.req = _c.create_infer_request()
277
626
  inp = list(inp_dict.values())[0]
278
- result = req.infer(inp)
279
- return {name: result[layer] for name, layer in zip(_names, _layers)}
627
+ try:
628
+ result = req.infer(inp)
629
+ except RuntimeError:
630
+ # AUTO CPU->GPU handover race: a request created during the
631
+ # CPU-helper phase (while the GPU JIT-compiles) cannot map the
632
+ # served model's output ports and raises — from INSIDE infer() —
633
+ # "Cannot find tensor for port ... score_8". (Reproduced live on
634
+ # the N100 iGPU under concurrent THROUGHPUT threads; the raise is
635
+ # in req.infer(), not the output fetch below.) Recreate the
636
+ # thread-local request post-handover and retry once.
637
+ req = _tls.req = _c.create_infer_request()
638
+ result = req.infer(inp)
639
+ # Fetch outputs by positional index (stable across the handover)
640
+ # rather than ConstOutput port identity — belt-and-braces.
641
+ return {name: result[i] for i, name in enumerate(_names)}
280
642
 
281
643
  slot.model = compiled
282
644
  slot.predict_fn = predict
283
645
 
284
646
  elif _runtime == "onnxruntime":
285
647
  ort = _runtime_lib
286
- ort_device = config.get("device", "cpu")
287
- if ort_device == "cuda":
288
- providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
289
- elif ort_device == "coreml":
290
- providers = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
291
- else:
292
- providers = ["CPUExecutionProvider"]
648
+ ort_device = str(config.get("device", "") or "")
649
+ # Scrypted-style ordered providers — hardware EP first, CPU LAST as
650
+ # fallback (see _resolve_onnx_providers): a "cuda"/"coreml" pin puts
651
+ # that EP first, a "cpu" pin compiles CPU only, and the AUTO/default
652
+ # case derives the hardware EP from the platform.
653
+ try:
654
+ available_eps = list(ort.get_available_providers())
655
+ except Exception:
656
+ available_eps = [ONNX_CPU_EP]
657
+ providers = _resolve_onnx_providers(
658
+ ort_device, sys.platform, platform.machine(), available_eps,
659
+ )
293
660
  session = ort.InferenceSession(path, providers=providers)
661
+ # Report the EP that ACTUALLY initialized — Scrypted strips CPU and
662
+ # logs the remainder — so a silent CPU fallback is visible.
663
+ _active_eps = [p for p in session.get_providers() if p != ONNX_CPU_EP]
664
+ _requested = [p[0] if isinstance(p, tuple) else p for p in providers]
665
+ sys.stderr.write(
666
+ f"ONNX: execution provider {', '.join(_active_eps) or ONNX_CPU_EP} "
667
+ f"(device={ort_device or 'auto'}, requested={_requested}, "
668
+ f"available={available_eps})\n"
669
+ )
670
+ sys.stderr.flush()
294
671
  output_names = [o.name for o in session.get_outputs()]
295
672
  slot.config["_input_name"] = session.get_inputs()[0].name
296
673
 
@@ -650,6 +1027,85 @@ class RuntimeDispatcher:
650
1027
  self._predict_pool.shutdown(wait=False)
651
1028
 
652
1029
 
1030
+ # ---------------------------------------------------------------------------
1031
+ # Per-model backpressure — bound in-flight inference, drop-oldest shedding
1032
+ # ---------------------------------------------------------------------------
1033
+
1034
+ # Frames a single model may hold WAITING behind its running set before the
1035
+ # OLDEST waiting frame is shed. Running slots (= predict-pool size) keep the
1036
+ # device fed; a couple of pending frames cover dispatch gaps. Anything beyond
1037
+ # that only sits in an invisible executor queue inflating per-frame
1038
+ # "inference" time — on an overloaded node (N100 iGPU) that queue wait
1039
+ # ballooned to 9-22s per frame. Shedding it instead makes overload read as
1040
+ # `dropped` frames (fast `"dropped": true` responses) rather than latency.
1041
+ MAX_PENDING_PER_MODEL: int = 2
1042
+
1043
+
1044
+ @dataclass
1045
+ class _ModelQueueState:
1046
+ """Mutable per-model counters for ModelBackpressure."""
1047
+ running: int = 0
1048
+ pending: "deque[Any]" = field(default_factory=deque)
1049
+
1050
+
1051
+ class ModelBackpressure:
1052
+ """Per-model in-flight bound with drop-oldest shedding.
1053
+
1054
+ Pure decision core — no asyncio, no I/O — so it is unit-testable
1055
+ (see test_inference_pool_backpressure.py). The event loop calls:
1056
+
1057
+ admit(model_idx, item) on every single-frame inference arrival.
1058
+ Returns (to_run, to_drop): items to dispatch NOW and items shed
1059
+ (oldest queued first). At most one of the lists is non-empty.
1060
+ complete(model_idx) when a dispatched inference finishes.
1061
+ Returns the next queued items to dispatch (0 or 1).
1062
+
1063
+ Why per-model and why these bounds: the predict pool has `workers`
1064
+ threads shared by all models; a model gets at most `max_running`
1065
+ concurrently-dispatched inferences (sized to the predict pool), plus
1066
+ a `max_pending` waiting buffer. The previous GLOBAL ceiling
1067
+ (`workers * 4`, min 16) was unreachable from the live pipeline — the
1068
+ TS runner's semaphore keeps at most `maxConcurrentInferences` frames
1069
+ in flight per node, well below 16 — so overload never shed frames; it
1070
+ just queued inside the executor as multi-second "inference" time.
1071
+ """
1072
+
1073
+ def __init__(self, max_running: int, max_pending: int) -> None:
1074
+ self._max_running = max(1, max_running)
1075
+ self._max_pending = max(0, max_pending)
1076
+ self._states: dict[int, _ModelQueueState] = {}
1077
+
1078
+ def _state(self, model_idx: int) -> _ModelQueueState:
1079
+ state = self._states.get(model_idx)
1080
+ if state is None:
1081
+ state = _ModelQueueState()
1082
+ self._states[model_idx] = state
1083
+ return state
1084
+
1085
+ def admit(self, model_idx: int, item: Any) -> tuple[list, list]:
1086
+ """Admit one inference request; returns (to_run, to_drop)."""
1087
+ state = self._state(model_idx)
1088
+ if state.running < self._max_running:
1089
+ state.running += 1
1090
+ return [item], []
1091
+ state.pending.append(item)
1092
+ dropped: list = []
1093
+ while len(state.pending) > self._max_pending:
1094
+ # DROP-OLDEST: the newest frame is the most valuable one for a
1095
+ # live detection pipeline — shed the stalest queued frame(s).
1096
+ dropped.append(state.pending.popleft())
1097
+ return [], dropped
1098
+
1099
+ def complete(self, model_idx: int) -> list:
1100
+ """One running inference finished; returns queued items to start."""
1101
+ state = self._state(model_idx)
1102
+ state.running = max(0, state.running - 1)
1103
+ if state.pending and state.running < self._max_running:
1104
+ state.running += 1
1105
+ return [state.pending.popleft()]
1106
+ return []
1107
+
1108
+
653
1109
  # ---------------------------------------------------------------------------
654
1110
  # IPC — binary framing with request_id multiplexing
655
1111
  # ---------------------------------------------------------------------------
@@ -832,6 +1288,17 @@ async def _run() -> None:
832
1288
  concurrency = target
833
1289
 
834
1290
  dispatcher = RuntimeDispatcher(runtime, concurrency)
1291
+ # Backpressure: bound in-flight single-frame inference PER MODEL, shedding
1292
+ # the OLDEST queued frame when full (see ModelBackpressure). max_running is
1293
+ # sized to the predict pool — dispatching more than `workers` concurrent
1294
+ # inferences for one model can't run any faster, it only queues invisibly
1295
+ # inside the executor and balloons per-frame "inference" time (9-22s
1296
+ # observed on the N100 iGPU). Healthy nodes never fill the pending buffer,
1297
+ # so this is a no-op except under genuine overload.
1298
+ backpressure = ModelBackpressure(
1299
+ max_running=dispatcher.workers,
1300
+ max_pending=MAX_PENDING_PER_MODEL,
1301
+ )
835
1302
  startup_ms = round((time.perf_counter() - t_start) * 1000)
836
1303
  loaded_count = sum(1 for s in models if s.loaded)
837
1304
  sys.stderr.write(
@@ -963,6 +1430,34 @@ async def _run() -> None:
963
1430
  "detections": [],
964
1431
  "inferenceMs": 0,
965
1432
  })
1433
+ finally:
1434
+ # Release this model's running slot; promote the next queued
1435
+ # frame (if any) into the freed slot immediately.
1436
+ for next_req_id, next_img in backpressure.complete(model_idx):
1437
+ asyncio.create_task(handle_inference(next_req_id, next_img, model_idx))
1438
+
1439
+ def _send_dropped(req_id: int) -> None:
1440
+ # Fast shed response — returned in microseconds instead of queuing the
1441
+ # frame for seconds. The TS side (shared-inference-pool.ts) recognises
1442
+ # `dropped: true` and counts it, so overload is measurable, not silent.
1443
+ asyncio.create_task(writer.send(req_id, {
1444
+ "kind": "detections",
1445
+ "detections": [],
1446
+ "inferenceMs": 0,
1447
+ "dropped": True,
1448
+ }))
1449
+
1450
+ def _dispatch_inference(req_id: int, img: Image.Image, model_idx: int) -> None:
1451
+ # Gate every single-frame inference through the per-model in-flight
1452
+ # bound. Slot accounting is synchronous (admit here, complete in the
1453
+ # handle_inference `finally`), so a slot can never leak past a task's
1454
+ # lifetime. Under overload the OLDEST queued frame is shed and answered
1455
+ # immediately so the caller never hangs on a frame we chose not to run.
1456
+ to_run, to_drop = backpressure.admit(model_idx, (req_id, img))
1457
+ for dropped_req_id, _dropped_img in to_drop:
1458
+ _send_dropped(dropped_req_id)
1459
+ for run_req_id, run_img in to_run:
1460
+ asyncio.create_task(handle_inference(run_req_id, run_img, model_idx))
966
1461
 
967
1462
  async def handle_batch(req_id: int, model_idx: int, items: list[Image.Image]) -> None:
968
1463
  if model_idx >= len(models) or not models[model_idx].loaded:
@@ -1045,7 +1540,7 @@ async def _run() -> None:
1045
1540
  except Exception as exc:
1046
1541
  await writer.send(req_id, {"error": f"jpeg decode failed: {exc}"})
1047
1542
  continue
1048
- asyncio.create_task(handle_inference(req_id, img, model_idx))
1543
+ _dispatch_inference(req_id, img, model_idx)
1049
1544
 
1050
1545
  elif msg_type == MSG_INFER_RAW:
1051
1546
  if len(payload) < 10:
@@ -1060,7 +1555,7 @@ async def _run() -> None:
1060
1555
  except Exception as exc:
1061
1556
  await writer.send(req_id, {"error": f"raw wrap failed: {exc}"})
1062
1557
  continue
1063
- asyncio.create_task(handle_inference(req_id, img, model_idx))
1558
+ _dispatch_inference(req_id, img, model_idx)
1064
1559
 
1065
1560
  elif msg_type == MSG_INFER_BATCH:
1066
1561
  # Header: [1B model_idx][1B count]
@@ -1130,7 +1625,7 @@ async def _run() -> None:
1130
1625
  if model_idx >= len(models) or not models[model_idx].loaded:
1131
1626
  await writer.send(req_id, {"error": f"model {model_idx} not loaded"})
1132
1627
  continue
1133
- asyncio.create_task(handle_inference(req_id, img, model_idx))
1628
+ _dispatch_inference(req_id, img, model_idx)
1134
1629
 
1135
1630
  else:
1136
1631
  await writer.send(req_id, {"error": f"unknown msg_type: {msg_type}"})