@camstack/addon-pipeline 1.2.621 → 1.2.623

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 (49) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/{custom-model-catalog-Il8UY4D4.js → custom-model-catalog-BTBdgN2p.js} +29 -16
  4. package/dist/{custom-model-catalog-0_SnSXzN.mjs → custom-model-catalog-D5zTw1LU.mjs} +29 -10
  5. package/dist/detection-pipeline/index.js +67 -416
  6. package/dist/detection-pipeline/index.mjs +91 -440
  7. package/dist/{dist-ClkumZgv.js → dist-BVo3wa7s.js} +711 -302
  8. package/dist/{dist-DHqkQhTQ.mjs → dist-opSx6v5i.mjs} +651 -296
  9. package/dist/{model-catalogs-BagalaYp.js → model-catalogs-CQytR2AN.js} +1 -1
  10. package/dist/{model-catalogs-Z1GZAhSb.mjs → model-catalogs-rIvLUVlw.mjs} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-CkJLxlWI.mjs → node-CGIYuJMN.mjs} +1 -1
  14. package/dist/{node-0O_61WVW.js → node-DqLsuN_A.js} +1 -1
  15. package/dist/pipeline-runner/index.js +115 -460
  16. package/dist/pipeline-runner/index.mjs +108 -453
  17. package/dist/{process-memory-Dj3UgL1e.mjs → process-memory-Bd-TuWfM.mjs} +1 -1
  18. package/dist/{process-memory-DdIOhjYB.js → process-memory-DyxG-0mG.js} +1 -1
  19. package/dist/recorder/index.js +2 -2
  20. package/dist/recorder/index.mjs +2 -2
  21. package/dist/{segment-demux-js-BePzacki.js → segment-demux-js-B4bfTQpK.js} +1 -1
  22. package/dist/{segment-demux-js-Bv0t0-tQ.mjs → segment-demux-js-CdVRzwSd.mjs} +1 -1
  23. package/dist/session-decode/decode-worker-main.js +12 -21
  24. package/dist/session-decode/decode-worker-main.mjs +12 -21
  25. package/dist/stream-broker/_stub.js +2 -2
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-dNk74ahz.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DF3FGCR8.mjs} +2 -2
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-oUZNn2Kr.mjs +26 -0
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Bb-ETNGC.mjs +26 -0
  29. package/dist/stream-broker/demux-worker-child.js +1 -1
  30. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  31. package/dist/stream-broker/{hostInit-CH5g78CJ.mjs → hostInit-BiyFzSqq.mjs} +2 -2
  32. package/dist/stream-broker/index.js +3 -3
  33. package/dist/stream-broker/index.mjs +3 -3
  34. package/dist/stream-broker/remoteEntry.js +1 -1
  35. package/dist/{worker-protocol-D_NzPcnh.mjs → worker-protocol-DngGEAT2.js} +102 -2
  36. package/dist/{worker-protocol-C-G8qmye.js → worker-protocol-_avq7y8O.mjs} +67 -25
  37. package/package.json +1 -1
  38. package/python/accelerator_facts.py +275 -0
  39. package/python/coreml_load_probe.py +105 -15
  40. package/python/inference_pool.py +19 -2
  41. package/python/openvino_compile_probe.py +72 -18
  42. package/python/postprocessors/ssd.py +12 -4
  43. package/python/postprocessors/test_ssd.py +64 -0
  44. package/python/test_accelerator_facts.py +105 -0
  45. package/python/test_coreml_load_probe.py +83 -0
  46. package/python/test_openvino_compile_probe.py +114 -0
  47. package/python/webgpu_isolated.py +19 -0
  48. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-2KkGoQ55.mjs +0 -26
  49. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dx-WrMx0.mjs +0 -26
@@ -12,8 +12,11 @@ happens in a throwaway interpreter. The verdict is remembered next to the
12
12
  compiled-model cache:
13
13
 
14
14
  ok -> load in-process, never probe this package again
15
- crashed -> refuse with `CoremlLoadCrashed` (reason `native-load-crashed`),
16
- now and on every later load, without re-probing
15
+ crashed -> refuse with `CoremlLoadCrashed` (reason `native-load-crashed`)
16
+ now. ONE crash is only `suspect` (D926): refused without
17
+ probing for a cool-off, then probed again. A SECOND crash
18
+ at least a cool-off after the first caches `crashed`, refused
19
+ on every later load without re-probing; an `ok` resets.
17
20
  inconclusive -> (a Python exception in the child, a probe past its bound, a
18
21
  child that could not start) remembered NOWHERE; the
19
22
  in-process load runs exactly as it did before this gate
@@ -33,6 +36,7 @@ import platform
33
36
  import signal
34
37
  import subprocess
35
38
  import sys
39
+ import time
36
40
  from typing import Callable, Optional
37
41
 
38
42
  # One-way: inference_pool imports this module lazily, inside its CoreML branch.
@@ -43,6 +47,63 @@ VERDICT_CRASHED = "crashed"
43
47
  VERDICT_INCONCLUSIVE = "inconclusive"
44
48
 
45
49
  PROBE_VERDICT_VERSION = 1
50
+
51
+ # The two-crash rule, shared with `openvino_compile_probe` (D926, D49): one
52
+ # native crash is `suspect`; this many crashes, the last at least a cool-off
53
+ # after the first, is `crashed` and cached for good. A v1 `crashed` record
54
+ # (no `crashes` list) reads as ONE crash of unknown age, so it is re-probed.
55
+ VERDICT_SUSPECT = "suspect"
56
+ CRASHES_TO_CONFIRM = 2
57
+ CRASH_COOL_OFF_SEC = 600.0
58
+
59
+ Clock = Callable[[], float]
60
+ LoadAvg = Callable[[], "tuple[float, float, float]"]
61
+
62
+
63
+ def host_loadavg() -> "tuple[float, float, float]":
64
+ try:
65
+ return os.getloadavg()
66
+ except (OSError, AttributeError): # Windows has no load average
67
+ return (-1.0, -1.0, -1.0)
68
+
69
+
70
+ def crash_at(crash: object) -> float:
71
+ if isinstance(crash, dict):
72
+ at = crash.get("at")
73
+ if isinstance(at, (int, float)):
74
+ return float(at)
75
+ return 0.0
76
+
77
+
78
+ def crashes_confirmed(crashes: list) -> bool:
79
+ """Two crashes (or more), the last at least a cool-off after the first."""
80
+ if len(crashes) < CRASHES_TO_CONFIRM:
81
+ return False
82
+ return crash_at(crashes[-1]) - crash_at(crashes[0]) >= CRASH_COOL_OFF_SEC
83
+
84
+
85
+ def normalise_crash_record(stored: dict) -> Optional[dict]:
86
+ """A stored verdict with its crash list filled in; an unconfirmed `crashed` reads `suspect`."""
87
+ verdict = stored.get("verdict")
88
+ if verdict not in (VERDICT_OK, VERDICT_CRASHED, VERDICT_SUSPECT):
89
+ return None
90
+ if verdict == VERDICT_OK:
91
+ return stored
92
+ crashes = stored.get("crashes")
93
+ if not isinstance(crashes, list) or not crashes:
94
+ crashes = [{"at": 0.0, "loadavg": None, "detail": str(stored.get("detail") or "")}]
95
+ out = {**stored, "crashes": crashes}
96
+ if verdict == VERDICT_CRASHED and not crashes_confirmed(crashes):
97
+ out = {**out, "verdict": VERDICT_SUSPECT}
98
+ return out
99
+
100
+
101
+ def record_crash(
102
+ prior: list, detail: str, now: float, load: "tuple[float, float, float]",
103
+ ) -> "tuple[str, list]":
104
+ """Append one crash; return the verdict to store (`suspect` or `crashed`) and the list."""
105
+ crashes = [*prior, {"at": now, "loadavg": [round(x, 2) for x in load], "detail": detail}]
106
+ return (VERDICT_CRASHED if crashes_confirmed(crashes) else VERDICT_SUSPECT), crashes
46
107
  PROBE_MARKER = "@@CAMSTACK_COREML_PROBE_OK@@"
47
108
  # Long enough for the slowest cold compile measured on a Mac (rfdetr-package,
48
109
  # 58 s); the pool's own load bound reports a slow load to the host meanwhile.
@@ -169,18 +230,21 @@ def _read_verdict(path: str, key: dict) -> Optional[dict]:
169
230
  return None
170
231
  if not isinstance(stored, dict) or stored.get("key") != key:
171
232
  return None
172
- if stored.get("verdict") not in (VERDICT_OK, VERDICT_CRASHED):
173
- return None
174
- return stored
233
+ return normalise_crash_record(stored)
175
234
 
176
235
 
177
- def _write_verdict(path: str, key: dict, verdict: str, detail: str) -> None:
236
+ def _write_verdict(
237
+ path: str, key: dict, verdict: str, detail: str, crashes: "Optional[list]" = None,
238
+ ) -> None:
178
239
  """Atomic replace; a verdict that cannot be written only costs a re-probe."""
179
240
  tmp = f"{path}.tmp-{os.getpid()}"
241
+ record: dict = {"key": key, "verdict": verdict, "detail": detail}
242
+ if crashes:
243
+ record["crashes"] = crashes
180
244
  try:
181
245
  os.makedirs(os.path.dirname(path), exist_ok=True)
182
246
  with open(tmp, "w", encoding="utf-8") as fh:
183
- json.dump({"key": key, "verdict": verdict, "detail": detail}, fh)
247
+ json.dump(record, fh)
184
248
  os.replace(tmp, path)
185
249
  except OSError as exc:
186
250
  sys.stderr.write(f"CoreML probe: could not persist the verdict for {path} ({exc})\n")
@@ -196,8 +260,11 @@ def ensure_coreml_loadable(
196
260
  device: str,
197
261
  run: Optional[ProbeRunner] = None,
198
262
  os_build: Optional[str] = None,
263
+ now: Optional[Clock] = None,
264
+ loadavg: Optional[LoadAvg] = None,
199
265
  ) -> None:
200
266
  """Return when `model_path` may be loaded in-process; raise when it would crash."""
267
+ clock = now if now is not None else time.time
201
268
  key = {
202
269
  "version": PROBE_VERDICT_VERSION,
203
270
  "fingerprint": _coreml_model_fingerprint(model_path), # a re-download is a new package
@@ -207,17 +274,40 @@ def ensure_coreml_loadable(
207
274
  verdict_path = probe_verdict_path(model_path, device)
208
275
  known = _read_verdict(verdict_path, key)
209
276
  name = os.path.basename(model_path)
277
+ prior_crashes: list = []
210
278
  if known is not None:
211
279
  if known["verdict"] == VERDICT_CRASHED:
212
- sys.stderr.write(f"CoreML probe: {name} device={device} is a KNOWN loader crash — refused\n")
213
- sys.stderr.flush()
280
+ _log(f"{name} device={device} is a KNOWN loader crash "
281
+ f"({len(known['crashes'])} crashes) — refused")
214
282
  raise CoremlLoadCrashed(model_path, device, str(known.get("detail") or "known crash"))
215
- return
283
+ if known["verdict"] == VERDICT_OK:
284
+ return
285
+ prior_crashes = list(known["crashes"])
286
+ retry_at = crash_at(prior_crashes[-1]) + CRASH_COOL_OFF_SEC
287
+ if clock() < retry_at:
288
+ _log(f"{name} device={device} crashed the loader once — refused until the "
289
+ f"cool-off ends ({retry_at - clock():.0f}s left)")
290
+ raise CoremlLoadCrashed(
291
+ model_path, device,
292
+ f"{known.get('detail') or 'one crash'}; one crash so far, "
293
+ f"re-probed after {CRASH_COOL_OFF_SEC:g}s",
294
+ )
216
295
  runner = run if run is not None else DEFAULT_RUNNER
217
296
  verdict, detail = runner(model_path, device)
218
- sys.stderr.write(f"CoreML probe: {name} device={device} -> {verdict} ({detail})\n")
219
- sys.stderr.flush()
220
- if verdict in (VERDICT_OK, VERDICT_CRASHED):
297
+ _log(f"{name} device={device} -> {verdict} ({detail})")
298
+ if verdict == VERDICT_OK:
221
299
  _write_verdict(verdict_path, key, verdict, detail)
222
- if verdict == VERDICT_CRASHED:
223
- raise CoremlLoadCrashed(model_path, device, detail)
300
+ return
301
+ if verdict != VERDICT_CRASHED:
302
+ return # inconclusive: remembered nowhere; an earlier suspect stands
303
+ load = (loadavg if loadavg is not None else host_loadavg)()
304
+ stored, crashes = record_crash(prior_crashes, detail, clock(), load)
305
+ _log(f"{name} device={device} loader crash {len(crashes)}/{CRASHES_TO_CONFIRM} "
306
+ f"(load {load[0]:.1f}) -> {stored}")
307
+ _write_verdict(verdict_path, key, stored, detail, crashes)
308
+ raise CoremlLoadCrashed(model_path, device, detail)
309
+
310
+
311
+ def _log(line: str) -> None:
312
+ sys.stderr.write(f"CoreML probe: {line}\n")
313
+ sys.stderr.flush()
@@ -3150,7 +3150,21 @@ async def _run() -> None:
3150
3150
  )
3151
3151
  sys.stderr.flush()
3152
3152
 
3153
- await writer.send(req_id, {
3153
+ # Compute the estimate scales from (D930). A failed probe is omitted —
3154
+ # never a row of zeros — and must not delay `ready`.
3155
+ accelerator_facts = None
3156
+ try:
3157
+ from accelerator_facts import facts_for_runtime, publish_accelerator_facts
3158
+
3159
+ accelerator_facts = facts_for_runtime(runtime, _runtime_lib, pool_device)
3160
+ if accelerator_facts:
3161
+ publish_accelerator_facts(accelerator_facts)
3162
+ except Exception as exc:
3163
+ sys.stderr.write(f"accelerator facts: probe failed ({exc})\n")
3164
+ sys.stderr.flush()
3165
+ accelerator_facts = None
3166
+
3167
+ ready_payload = {
3154
3168
  "status": "ready",
3155
3169
  "models": loaded_count,
3156
3170
  "total": len(models),
@@ -3161,7 +3175,10 @@ async def _run() -> None:
3161
3175
  # the same min() against what it declared, so both ends derive the
3162
3176
  # identical effective version (D350).
3163
3177
  "protocolVersion": PROTOCOL_VERSION,
3164
- })
3178
+ }
3179
+ if accelerator_facts is not None:
3180
+ ready_payload["acceleratorFacts"] = accelerator_facts
3181
+ await writer.send(req_id, ready_payload)
3165
3182
 
3166
3183
  # ── Window accumulator (per-model) ──────────────────────────────
3167
3184
  #
@@ -19,13 +19,24 @@ therefore writes the blob the pool then loads warm. Mirrors CoreML's
19
19
  ok -> load in-process (a cache HIT), never probe this key again
20
20
  while the blobs the probe wrote are still on disk
21
21
  crashed -> refuse with `OpenvinoLoadCrashed` (reason
22
- `native-load-crashed`), now and on every later load of the
23
- same key, without re-probing
22
+ `native-load-crashed`) now. ONE crash is only `suspect`:
23
+ later loads are refused without probing until a cool-off
24
+ (CRASH_COOL_OFF_SEC) has passed, then probed again. Only a
25
+ SECOND crash at least a cool-off after the first caches
26
+ `crashed` — refused on every later load of the same key,
27
+ without re-probing. An `ok` in between resets the count.
24
28
  inconclusive -> (a Python exception in the child, a probe past its bound, a
25
29
  child that could not start) remembered NOWHERE; the
26
30
  in-process load runs exactly as it did before this gate
27
31
 
28
32
  Only a death by SIGNAL is a crash. CPU-only loads are never probed.
33
+
34
+ Why two (2026-10-23, D926): a single SIGSEGV during an overloaded moment wrote
35
+ `crashed`, and every later request for that model on the GPU was refused for
36
+ good. A destructive verdict needs a second agreeing read (D49). Every crash is
37
+ recorded with its time, the host load average and the child's detail. To
38
+ clear a verdict by hand, delete
39
+ `<models>/.ov-cache/<model>.<DEVICE>.ovprobe.json`.
29
40
  """
30
41
  from __future__ import annotations
31
42
 
@@ -33,17 +44,30 @@ import json
33
44
  import os
34
45
  import subprocess
35
46
  import sys
47
+ import time
36
48
  from typing import Callable, Optional
37
49
 
38
50
  # One-way: inference_pool imports this module lazily, inside its OpenVINO branch.
51
+ # The two-crash rule (D926) lives there too, shared by both probes.
39
52
  from coreml_load_probe import (
53
+ CRASH_COOL_OFF_SEC,
54
+ CRASHES_TO_CONFIRM,
40
55
  VERDICT_CRASHED,
41
56
  VERDICT_INCONCLUSIVE,
42
57
  VERDICT_OK,
58
+ VERDICT_SUSPECT,
59
+ Clock,
60
+ LoadAvg,
43
61
  _signal_name,
44
62
  classify_probe_exit,
63
+ crash_at,
64
+ host_loadavg,
65
+ normalise_crash_record,
66
+ record_crash,
45
67
  )
46
68
 
69
+ # The key's version stays 1: a v1 `crashed` file (no `crashes` list) is read
70
+ # as ONE crash of unknown age, so it is re-probed rather than discarded.
47
71
  PROBE_VERDICT_VERSION = 1
48
72
  PROBE_MARKER = "@@CAMSTACK_OV_PROBE_OK@@"
49
73
  # A cold iGPU compile of a large detector takes tens of seconds; past this the
@@ -178,17 +202,21 @@ def _read_verdict(path: str, key: dict) -> Optional[dict]:
178
202
  return None
179
203
  if not isinstance(stored, dict) or stored.get("key") != key:
180
204
  return None
181
- if stored.get("verdict") not in (VERDICT_OK, VERDICT_CRASHED):
182
- return None
183
- return stored
205
+ return normalise_crash_record(stored)
184
206
 
185
207
 
186
- def _write_verdict(path: str, key: dict, verdict: str, detail: str, blobs: "list[str]") -> None:
208
+ def _write_verdict(
209
+ path: str, key: dict, verdict: str, detail: str, blobs: "list[str]",
210
+ crashes: "Optional[list]" = None,
211
+ ) -> None:
187
212
  """Atomic replace; a verdict that cannot be written only costs a re-probe."""
188
213
  tmp = f"{path}.tmp-{os.getpid()}"
214
+ record: dict = {"key": key, "verdict": verdict, "detail": detail, "blobs": blobs}
215
+ if crashes:
216
+ record["crashes"] = crashes
189
217
  try:
190
218
  with open(tmp, "w", encoding="utf-8") as fh:
191
- json.dump({"key": key, "verdict": verdict, "detail": detail, "blobs": blobs}, fh)
219
+ json.dump(record, fh)
192
220
  os.replace(tmp, path)
193
221
  except OSError as exc:
194
222
  sys.stderr.write(f"OpenVINO probe: could not persist the verdict for {path} ({exc})\n")
@@ -214,32 +242,58 @@ def ensure_openvino_loadable(
214
242
  cache_dir: Optional[str],
215
243
  env_tag: str = "",
216
244
  run: Optional[ProbeRunner] = None,
245
+ now: Optional[Clock] = None,
246
+ loadavg: Optional[LoadAvg] = None,
217
247
  ) -> None:
218
248
  """Return when `model_path` may be compiled in-process; raise when it would crash."""
219
249
  if not needs_probe(candidates):
220
250
  return
251
+ clock = now if now is not None else time.time
221
252
  name = os.path.basename(model_path)
222
253
  device = str(config.get("device", "AUTO"))
223
254
  key = probe_key(model_path, config, candidates, env_tag)
224
255
  verdict_path = probe_verdict_path(model_path, config, cache_dir) if cache_dir else None
256
+ prior_crashes: list = []
225
257
  if verdict_path is not None:
226
258
  known = _read_verdict(verdict_path, key)
227
259
  if known is not None:
228
260
  if known["verdict"] == VERDICT_CRASHED:
229
- sys.stderr.write(
230
- f"OpenVINO probe: {name} device={device} is a KNOWN compiler crash — refused\n"
231
- )
232
- sys.stderr.flush()
261
+ _log(f"{name} device={device} is a KNOWN compiler crash "
262
+ f"({len(known['crashes'])} crashes) — refused")
233
263
  raise OpenvinoLoadCrashed(model_path, device, str(known.get("detail") or "known crash"))
234
- if cache_dir is not None and _warm(known, cache_dir):
264
+ if known["verdict"] == VERDICT_SUSPECT:
265
+ prior_crashes = list(known["crashes"])
266
+ retry_at = crash_at(prior_crashes[-1]) + CRASH_COOL_OFF_SEC
267
+ if clock() < retry_at:
268
+ _log(f"{name} device={device} crashed the compiler once — refused until "
269
+ f"the cool-off ends ({retry_at - clock():.0f}s left)")
270
+ raise OpenvinoLoadCrashed(
271
+ model_path, device,
272
+ f"{known.get('detail') or 'one crash'}; one crash so far, "
273
+ f"re-probed after {CRASH_COOL_OFF_SEC:g}s",
274
+ )
275
+ elif cache_dir is not None and _warm(known, cache_dir):
235
276
  return # blob present: the in-process load is a cache hit
236
277
  before = _blob_names(cache_dir) if cache_dir else set()
237
278
  runner = run if run is not None else DEFAULT_RUNNER
238
279
  verdict, detail = runner(model_path, config)
239
- sys.stderr.write(f"OpenVINO probe: {name} device={device} -> {verdict} ({detail})\n")
280
+ _log(f"{name} device={device} -> {verdict} ({detail})")
281
+ if verdict == VERDICT_OK:
282
+ if verdict_path is not None and cache_dir is not None:
283
+ written = sorted(_blob_names(cache_dir) - before)
284
+ _write_verdict(verdict_path, key, verdict, detail, written)
285
+ return
286
+ if verdict != VERDICT_CRASHED:
287
+ return # inconclusive: remembered nowhere; an earlier suspect stands
288
+ load = (loadavg if loadavg is not None else host_loadavg)()
289
+ stored, crashes = record_crash(prior_crashes, detail, clock(), load)
290
+ _log(f"{name} device={device} compiler crash {len(crashes)}/{CRASHES_TO_CONFIRM} "
291
+ f"(load {load[0]:.1f}) -> {stored}")
292
+ if verdict_path is not None and cache_dir is not None:
293
+ _write_verdict(verdict_path, key, stored, detail, [], crashes)
294
+ raise OpenvinoLoadCrashed(model_path, device, detail)
295
+
296
+
297
+ def _log(line: str) -> None:
298
+ sys.stderr.write(f"OpenVINO probe: {line}\n")
240
299
  sys.stderr.flush()
241
- if verdict in (VERDICT_OK, VERDICT_CRASHED) and verdict_path is not None and cache_dir is not None:
242
- written = sorted(_blob_names(cache_dir) - before) if verdict == VERDICT_OK else []
243
- _write_verdict(verdict_path, key, verdict, detail, written)
244
- if verdict == VERDICT_CRASHED:
245
- raise OpenvinoLoadCrashed(model_path, device, detail)
@@ -6,7 +6,9 @@ SSD MobileNet V2 and EfficientDet-Lite detectors. Those models embed the
6
6
  decoded (boxes + per-box class + score, post-NMS):
7
7
 
8
8
  output 0 boxes [1, N, 4] (ymin, xmin, ymax, xmax) normalised 0..1
9
- output 1 classes [1, N] float class indices (COCO 90-class map)
9
+ output 1 classes [1, N] float class indices (COCO 90-class map,
10
+ or the model's own label list when the
11
+ descriptor carries one)
10
12
  output 2 scores [1, N] float 0..1
11
13
  output 3 count [1] number of valid detections
12
14
 
@@ -118,8 +120,10 @@ def _split_outputs(predictions: dict) -> tuple:
118
120
  return boxes, classes, scores, count
119
121
 
120
122
 
121
- def label_for_class(cls: int) -> str:
122
- """COCO-90 label of a raw class index; ``"n/a"`` for a gap, ``str`` if outside."""
123
+ def label_for_class(cls: int, labels: list | None = None) -> str:
124
+ """Class name for a raw index; default map is COCO-90 when labels is absent/empty."""
125
+ if labels:
126
+ return labels[cls] if 0 <= cls < len(labels) else str(cls)
123
127
  return COCO_90[cls] if 0 <= cls < len(COCO_90) else str(cls)
124
128
 
125
129
 
@@ -134,6 +138,10 @@ def postprocess_ssd(
134
138
  """Decode SSD/EfficientDet postprocess outputs to original-frame detections."""
135
139
  conf_threshold = float(config.get("confidence", 0.4) or 0.4)
136
140
  input_size = int(config.get("inputSize", 300) or 300)
141
+ raw_labels = config.get("labels")
142
+ label_map: list | None = (
143
+ list(raw_labels) if isinstance(raw_labels, list) and len(raw_labels) > 0 else None
144
+ )
137
145
 
138
146
  boxes, classes, scores, count = _split_outputs(predictions)
139
147
  if boxes is None or scores is None:
@@ -172,7 +180,7 @@ def postprocess_ssd(
172
180
  y2 = max(0.0, min(y2, orig_h))
173
181
  if x2 <= x1 or y2 <= y1:
174
182
  continue
175
- label = label_for_class(int(classes[i]))
183
+ label = label_for_class(int(classes[i]), label_map)
176
184
  if label == "n/a":
177
185
  continue
178
186
  detections.append({
@@ -189,6 +189,70 @@ class ScoreCeilingTest(unittest.TestCase):
189
189
  self.assertEqual(len([d for d in _decode(STREET, confidence=0.65) if d["class"] == "person"]), 3)
190
190
 
191
191
 
192
+ class ConfigLabelsTest(unittest.TestCase):
193
+ """Custom label maps from the pool; absent labels keep COCO-90 in Python."""
194
+
195
+ _SYNTH = {
196
+ "0": [[[0.1, 0.1, 0.5, 0.5]]],
197
+ "1": [[3.0]],
198
+ "2": [[0.9]],
199
+ "3": [1.0],
200
+ }
201
+
202
+ def test_custom_labels_name_classes(self) -> None:
203
+ # Index 3 is dog and index 29 is car — the Plus map the COCO-90 table misnames.
204
+ labels = ["person", "bicycle", "x", "dog"] + ["pad"] * 25 + ["car"]
205
+ self.assertEqual(labels[3], "dog")
206
+ self.assertEqual(labels[29], "car")
207
+ dog = postprocess_ssd(
208
+ self._SYNTH,
209
+ {"confidence": 0.4, "inputSize": 100, "labels": labels},
210
+ 100,
211
+ 100,
212
+ 1.0,
213
+ (0, 0),
214
+ )["detections"]
215
+ self.assertEqual(len(dog), 1)
216
+ self.assertEqual(dog[0]["class"], "dog")
217
+ car_out = dict(self._SYNTH)
218
+ car_out["1"] = [[29.0]]
219
+ car = postprocess_ssd(
220
+ car_out,
221
+ {"confidence": 0.4, "inputSize": 100, "labels": labels},
222
+ 100,
223
+ 100,
224
+ 1.0,
225
+ (0, 0),
226
+ )["detections"]
227
+ self.assertEqual(len(car), 1)
228
+ self.assertEqual(car[0]["class"], "car")
229
+
230
+ def test_no_labels_key_uses_coco90_and_skips_na(self) -> None:
231
+ out = dict(self._SYNTH)
232
+ out["1"] = [[29.0]]
233
+ dets = postprocess_ssd(
234
+ out,
235
+ {"confidence": 0.4, "inputSize": 100},
236
+ 100,
237
+ 100,
238
+ 1.0,
239
+ (0, 0),
240
+ )["detections"]
241
+ self.assertEqual(dets, [])
242
+ out2 = dict(self._SYNTH)
243
+ out2["1"] = [[3.0]]
244
+ dets2 = postprocess_ssd(
245
+ out2,
246
+ {"confidence": 0.4, "inputSize": 100},
247
+ 100,
248
+ 100,
249
+ 1.0,
250
+ (0, 0),
251
+ )["detections"]
252
+ self.assertEqual(len(dets2), 1)
253
+ self.assertEqual(dets2[0]["class"], "motorcycle")
254
+
255
+
192
256
  class NumpyInputTest(unittest.TestCase):
193
257
  """The pool hands ndarrays; they must decode exactly like the lists above."""
194
258
 
@@ -0,0 +1,105 @@
1
+ """Unit tests for accelerator compute facts (D930). No OpenVINO import."""
2
+ import json
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+
7
+ import accelerator_facts as facts
8
+
9
+
10
+ class _Core:
11
+ def __init__(self, devices, props):
12
+ self.available_devices = devices
13
+ self._props = props
14
+ self.failed = set()
15
+
16
+ def get_property(self, device, name):
17
+ if (device, name) in self.failed:
18
+ raise RuntimeError(f"probe failed {device} {name}")
19
+ device_props = self._props.get(device, {})
20
+ if name not in device_props:
21
+ raise KeyError(name)
22
+ return device_props[name]
23
+
24
+
25
+ class OpenVinoFactsTest(unittest.TestCase):
26
+ def test_reports_gpu_and_npu_and_null_for_a_failed_query(self):
27
+ core = _Core(
28
+ ["CPU", "GPU", "NPU"],
29
+ {
30
+ "GPU": {
31
+ "FULL_DEVICE_NAME": "Intel(R) Graphics",
32
+ "GPU_EXECUTION_UNITS_COUNT": 32,
33
+ "GPU_DEVICE_MAX_FREQUENCY": 1250,
34
+ "DEVICE_GOPS": {"i8": 400, "f16": 200},
35
+ "DEVICE_ARCHITECTURE": "Xe",
36
+ },
37
+ "NPU": {
38
+ "FULL_DEVICE_NAME": "Intel AI Boost",
39
+ "DEVICE_ARCHITECTURE": "NPU",
40
+ "NPU_MAX_TOPS": 10,
41
+ },
42
+ },
43
+ )
44
+ core.failed.add(("GPU", "DEVICE_ARCHITECTURE"))
45
+ rows = facts.collect_openvino_device_facts(core)
46
+ self.assertEqual([row["deviceKey"] for row in rows], ["openvino:gpu", "openvino:npu"])
47
+ gpu = rows[0]
48
+ self.assertEqual(gpu["executionUnits"], 32)
49
+ self.assertEqual(gpu["clockMHz"], 1250)
50
+ self.assertEqual(gpu["gops"], {"int8": 400, "fp16": 200})
51
+ self.assertIsNone(gpu["architecture"])
52
+ self.assertEqual(rows[1]["npuName"], "Intel AI Boost")
53
+ self.assertEqual(rows[1]["tops"], 10)
54
+ self.assertIsNone(rows[1]["executionUnits"])
55
+
56
+ def test_a_zero_eu_count_is_null(self):
57
+ core = _Core(
58
+ ["GPU"],
59
+ {"GPU": {"FULL_DEVICE_NAME": "Intel", "GPU_EXECUTION_UNITS_COUNT": 0}},
60
+ )
61
+ rows = facts.collect_openvino_device_facts(core)
62
+ self.assertIsNone(rows[0]["executionUnits"])
63
+ self.assertIsNone(rows[0]["gops"])
64
+
65
+ def test_a_failed_device_list_is_none(self):
66
+ class Boom:
67
+ @property
68
+ def available_devices(self):
69
+ raise RuntimeError("no core")
70
+
71
+ self.assertIsNone(facts.collect_openvino_device_facts(Boom()))
72
+
73
+
74
+ class WebGpuFactsTest(unittest.TestCase):
75
+ def test_reads_adapter_fields_and_leaves_a_missing_cu_null(self):
76
+ row = facts.webgpu_facts_from_device(
77
+ {"vendor": "amd", "architecture": "rdna3", "device": "gfx1103"}
78
+ )
79
+ self.assertEqual(row["deviceKey"], "onnx:webgpu")
80
+ self.assertEqual(row["vendor"], "amd")
81
+ self.assertEqual(row["webgpuArchitecture"], "rdna3")
82
+ self.assertEqual(row["webgpuDevice"], "gfx1103")
83
+ self.assertIsNone(row["computeUnits"])
84
+ self.assertIsNone(facts.webgpu_facts_from_device(None))
85
+
86
+
87
+ class PublishTest(unittest.TestCase):
88
+ def test_merges_by_device_key(self):
89
+ with tempfile.TemporaryDirectory() as directory:
90
+ path = os.path.join(directory, "facts.json")
91
+ facts.publish_accelerator_facts(
92
+ [facts.webgpu_facts_from_device({"vendor": "amd", "compute_units": 12})],
93
+ path,
94
+ )
95
+ gpu = facts._blank("openvino:gpu")
96
+ gpu["executionUnits"] = 24
97
+ facts.publish_accelerator_facts([gpu], path)
98
+ with open(path, "r", encoding="utf-8") as handle:
99
+ payload = json.load(handle)
100
+ keys = [row["deviceKey"] for row in payload["accelerators"]]
101
+ self.assertEqual(keys, ["onnx:webgpu", "openvino:gpu"])
102
+
103
+
104
+ if __name__ == "__main__":
105
+ unittest.main()
@@ -224,6 +224,89 @@ class EnsureCoremlLoadableTest(unittest.TestCase):
224
224
  self.assertEqual(len(runner.calls), 1)
225
225
 
226
226
 
227
+ class CoremlCrashNeedsASecondAgreeingReadTest(unittest.TestCase):
228
+ """One native abort must not ban a package forever (D926, D49)."""
229
+
230
+ def setUp(self) -> None:
231
+ self._tmp = tempfile.TemporaryDirectory()
232
+ self.pkg = _make_mlpackage(self._tmp.name)
233
+ self.t = 2_000_000.0
234
+
235
+ def tearDown(self) -> None:
236
+ self._tmp.cleanup()
237
+
238
+ def ensure(self, runner) -> None:
239
+ probe.ensure_coreml_loadable(
240
+ self.pkg, "all", run=runner, os_build="26.2",
241
+ now=lambda: self.t, loadavg=lambda: (4.0, 3.0, 2.0),
242
+ )
243
+
244
+ def stored(self) -> dict:
245
+ with open(probe.probe_verdict_path(self.pkg, "all"), "r", encoding="utf-8") as fh:
246
+ return json.load(fh)
247
+
248
+ def test_a_single_crash_is_retried_after_the_cool_off(self) -> None:
249
+ with self.assertRaises(probe.CoremlLoadCrashed):
250
+ self.ensure(_Runner(probe.VERDICT_CRASHED, "signal 6"))
251
+ self.assertEqual(self.stored()["verdict"], probe.VERDICT_SUSPECT)
252
+ self.t += probe.CRASH_COOL_OFF_SEC + 1
253
+ ok = _Runner(probe.VERDICT_OK)
254
+ self.ensure(ok)
255
+ self.assertEqual(len(ok.calls), 1)
256
+ self.assertEqual(self.stored()["verdict"], probe.VERDICT_OK)
257
+
258
+ def test_within_the_cool_off_a_single_crash_refuses_without_probing(self) -> None:
259
+ crashed = _Runner(probe.VERDICT_CRASHED)
260
+ with self.assertRaises(probe.CoremlLoadCrashed):
261
+ self.ensure(crashed)
262
+ self.t += probe.CRASH_COOL_OFF_SEC - 1
263
+ with self.assertRaises(probe.CoremlLoadCrashed):
264
+ self.ensure(crashed)
265
+ self.assertEqual(len(crashed.calls), 1)
266
+
267
+ def test_two_crashes_a_cool_off_apart_are_cached(self) -> None:
268
+ crashed = _Runner(probe.VERDICT_CRASHED)
269
+ with self.assertRaises(probe.CoremlLoadCrashed):
270
+ self.ensure(crashed)
271
+ self.t += probe.CRASH_COOL_OFF_SEC + 1
272
+ with self.assertRaises(probe.CoremlLoadCrashed):
273
+ self.ensure(crashed)
274
+ stored = self.stored()
275
+ self.assertEqual(stored["verdict"], probe.VERDICT_CRASHED)
276
+ self.assertEqual(len(stored["crashes"]), 2)
277
+ self.assertEqual(stored["crashes"][0]["loadavg"], [4.0, 3.0, 2.0])
278
+ self.t += 10 * probe.CRASH_COOL_OFF_SEC
279
+ with self.assertRaises(probe.CoremlLoadCrashed):
280
+ self.ensure(crashed)
281
+ self.assertEqual(len(crashed.calls), 2)
282
+
283
+ def test_a_success_in_between_resets_the_count(self) -> None:
284
+ crashed = _Runner(probe.VERDICT_CRASHED)
285
+ with self.assertRaises(probe.CoremlLoadCrashed):
286
+ self.ensure(crashed)
287
+ self.t += probe.CRASH_COOL_OFF_SEC + 1
288
+ self.ensure(_Runner(probe.VERDICT_OK))
289
+ # An ok is remembered for good; a changed OS build is what re-probes it.
290
+ with open(probe.probe_verdict_path(self.pkg, "all"), "r", encoding="utf-8") as fh:
291
+ record = json.load(fh)
292
+ self.assertNotIn("crashes", record)
293
+
294
+ def test_a_legacy_single_crash_verdict_is_reprobed(self) -> None:
295
+ key = {
296
+ "version": probe.PROBE_VERDICT_VERSION,
297
+ "fingerprint": ip._coreml_model_fingerprint(self.pkg),
298
+ "device": "all",
299
+ "os": "26.2",
300
+ }
301
+ path = probe.probe_verdict_path(self.pkg, "all")
302
+ os.makedirs(os.path.dirname(path), exist_ok=True)
303
+ with open(path, "w", encoding="utf-8") as fh:
304
+ json.dump({"key": key, "verdict": probe.VERDICT_CRASHED, "detail": "sig 6"}, fh)
305
+ ok = _Runner(probe.VERDICT_OK)
306
+ self.ensure(ok)
307
+ self.assertEqual(len(ok.calls), 1)
308
+
309
+
227
310
  class _FakeCt:
228
311
  """The slice of coremltools `_load_model` touches before the model loads."""
229
312