@camstack/addon-pipeline 1.2.327 → 1.2.329

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 (44) hide show
  1. package/THIRD_PARTY_MODELS.md +35 -27
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/{default-detection-model-CBIYdAJ5.js → default-detection-model-ChH7Y219.js} +130 -61
  5. package/dist/{default-detection-model-hql6BX9-.mjs → default-detection-model-j0nBpRoB.mjs} +130 -61
  6. package/dist/detection-pipeline/index.js +458 -16
  7. package/dist/detection-pipeline/index.mjs +458 -16
  8. package/dist/{dist-B9LnBPz9.mjs → dist-0HoA3wAi.mjs} +503 -370
  9. package/dist/{dist-BBZwYfSN.js → dist-BlIWhjnu.js} +550 -369
  10. package/dist/motion-wasm/index.js +1 -1
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-mJQBUiEc.js → node-BPpVMnZy.js} +1 -1
  13. package/dist/{node-Ci0E-0VP.mjs → node-HOMWRPzW.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +215 -78
  15. package/dist/pipeline-runner/index.mjs +215 -78
  16. package/dist/{process-memory-DgEfKdtC.js → process-memory-BW06vOIQ.js} +1 -1
  17. package/dist/{process-memory-B63Jlpev.mjs → process-memory-DuF8pa2w.mjs} +1 -1
  18. package/dist/recorder/index.js +2 -2
  19. package/dist/recorder/index.mjs +2 -2
  20. package/dist/{segment-demux-js-DzwHW4Ey.mjs → segment-demux-js-BU2MXpln.mjs} +1 -1
  21. package/dist/{segment-demux-js-C2ufKMu0.js → segment-demux-js-Dl52Zz8w.js} +1 -1
  22. package/dist/session-decode/decode-worker-main.js +13 -21
  23. package/dist/session-decode/decode-worker-main.mjs +13 -21
  24. package/dist/stream-broker/_stub.js +1 -1
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-B4fPzS-8.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-vNfS65In.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DfeHvW4k.mjs +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-Cg4IdtE5.mjs → hostInit-Ciacp783.mjs} +3 -3
  30. package/dist/stream-broker/index.js +4 -3
  31. package/dist/stream-broker/index.mjs +4 -3
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +1 -1
  34. package/python/edgetpu_dequant.py +38 -1
  35. package/python/inference_pool.py +69 -138
  36. package/python/postprocessors/test_bird_edgetpu.py +124 -0
  37. package/python/postprocessors/test_yolox_plate_edgetpu.py +126 -0
  38. package/python/postprocessors/test_yunet_edgetpu.py +149 -0
  39. package/python/postprocessors/testdata/bird_classifier_int8_outputs.json +1 -0
  40. package/python/postprocessors/testdata/yolox_plate_int8_outputs.json +1 -0
  41. package/python/postprocessors/testdata/yunet_int8_outputs.json +1 -0
  42. package/python/postprocessors/yolo.py +44 -0
  43. package/python/test_inference_pool_static_batch.py +2 -4
  44. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BDGzcgLP.mjs +0 -26
@@ -321,26 +321,11 @@ def _ov_request_count(optimal: int) -> int:
321
321
  # entirely. We resolve an ordered candidate list (NPU > GPU > CPU) and compile the
322
322
  # first that succeeds, with CPU as the guaranteed floor.
323
323
  #
324
- # OpenVINO async execution (CAMSTACK_OV_ASYNC, default OFF / "0"). When ON, the
325
- # OpenVINO runtime drives an ov.AsyncInferQueue sized to
326
- # OPTIMAL_NUMBER_OF_INFER_REQUESTS instead of the synchronous InferRequest pool +
327
- # req.infer(). This mirrors how the reference Intel-iGPU async pipeline saturates
328
- # the GPU: keep N inferences in flight, collect each in the queue's callback.
329
- # start_async() BLOCKS once all N jobs are busy, so in-flight work is bounded to N
330
- # with NO extra buffer (respects the "never buffer frames at frame-rate" rule).
331
- # OFF is byte-for-byte the historical synchronous path — the ship-dark default.
332
- _OV_ASYNC_ENABLED: bool = os.environ.get("CAMSTACK_OV_ASYNC", "0") not in (
333
- "0", "false", "no",
334
- )
335
- # OpenVINO PERFORMANCE_HINT (CAMSTACK_OV_HINT, default "LATENCY"). THROUGHPUT
336
- # raises OPTIMAL_NUMBER_OF_INFER_REQUESTS (a larger async queue → more device
337
- # saturation) but spins up multiple internal streams that multiply memory and
338
- # lengthen tail latency — a known N100 OOM-freeze factor (see the ov_config note
339
- # in _load_model). Kept at LATENCY by default; only flip to THROUGHPUT on a node
340
- # with headroom. Any unrecognised value falls back to LATENCY.
341
- _OV_PERF_HINT: str = os.environ.get("CAMSTACK_OV_HINT", "LATENCY").upper()
342
- if _OV_PERF_HINT not in ("LATENCY", "THROUGHPUT"):
343
- _OV_PERF_HINT = "LATENCY"
324
+ # OpenVINO PERFORMANCE_HINT is LATENCY: one deterministic execution stream. The
325
+ # THROUGHPUT hint multiplied memory and lengthened tail latency (an N100
326
+ # OOM-freeze factor, see the ov_config note in _load_model); its env switch and
327
+ # the never-enabled AsyncInferQueue path were removed on 2026-10-06.
328
+ _OV_PERF_HINT: str = "LATENCY"
344
329
 
345
330
  # OpenVINO COMPILATION thread pool (CAMSTACK_OV_COMPILATION_THREADS, default 4).
346
331
  #
@@ -1087,15 +1072,14 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1087
1072
  candidates = [ov_device_req] # operator pin — compiled directly
1088
1073
  sys.stderr.write(f"OpenVINO: operator-pinned device {ov_device_req}\n")
1089
1074
  sys.stderr.flush()
1090
- # PERFORMANCE_HINT defaults to LATENCY (CAMSTACK_OV_HINT): one
1075
+ # PERFORMANCE_HINT is LATENCY: one
1091
1076
  # deterministic execution stream instead of N. THROUGHPUT spins up
1092
1077
  # multiple internal streams whose lazy request creation historically
1093
1078
  # collided with the AUTO handover AND multiplied memory (a factor in the
1094
1079
  # N100 OOM-freeze); it also RAISES OPTIMAL_NUMBER_OF_INFER_REQUESTS, which
1095
- # enlarges the async queue built below at a memory / tail-latency cost.
1080
+ # enlarges the request pool at a memory / tail-latency cost.
1096
1081
  # LATENCY keeps a single stream; camera-level concurrency comes from the
1097
- # fixed InferRequest pool / async queue built below. Operators can opt into
1098
- # THROUGHPUT on a node with headroom via CAMSTACK_OV_HINT=THROUGHPUT.
1082
+ # fixed InferRequest pool built below.
1099
1083
  ov_config = {"PERFORMANCE_HINT": _OV_PERF_HINT}
1100
1084
  # Persist compiled GPU/NPU kernels so the multi-second JIT compile is paid
1101
1085
  # once (first boot) instead of on every pool spawn. The cold compile storm
@@ -1111,7 +1095,7 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1111
1095
  # graph via PrePostProcessor. When it succeeds we compile the PPP model
1112
1096
  # object (fed uint8 NHWC at inference time); on ANY failure we fall back
1113
1097
  # to compiling the plain `path` (byte-identical to the historical float
1114
- # preprocess). Default on for OpenVINO — CAMSTACK_OV_PPP=0 disables.
1098
+ # preprocess). Always on for OpenVINO (tests flip `_OV_PPP_ENABLED`).
1115
1099
  ppp_meta: Optional[dict] = None
1116
1100
  compile_source: Any = path
1117
1101
  if _OV_PPP_ENABLED:
@@ -1222,9 +1206,8 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1222
1206
 
1223
1207
  # Size N ONCE from the device's optimal infer-request count (falling back
1224
1208
  # to OV_DEFAULT_CONCURRENCY only when the property is missing/zero) so it
1225
- # matches the predict-pool worker count sized in _run(). Both the sync
1226
- # request-pool and the async queue below are sized to this same N, and
1227
- # both go through `_ov_request_count` so they cannot drift apart.
1209
+ # matches the predict-pool worker count sized in _run(); `_ov_request_count`
1210
+ # is the one place it is computed.
1228
1211
  import queue as _queue
1229
1212
 
1230
1213
  try:
@@ -1233,97 +1216,31 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1233
1216
  _n_req = 0
1234
1217
  _n_req = _ov_request_count(_n_req)
1235
1218
 
1236
- if _OV_ASYNC_ENABLED:
1237
- # Async pipelining path (CAMSTACK_OV_ASYNC=1). An ov.AsyncInferQueue of
1238
- # N jobs keeps up to N inferences in flight on the device at once — the
1239
- # way the Intel-iGPU reference path saturates the GPU. Each completion
1240
- # fires the queue callback; start_async() BLOCKS when all N jobs are
1241
- # busy, so in-flight submissions are bounded to N with NO extra buffer
1242
- # (repo rule: never buffer frames at frame-rate). The RuntimeDispatcher
1243
- # still calls a BLOCKING predict_fn(input)->dict, so we bridge async->
1244
- # sync per call with a threading.Event + a 1-slot result holder passed
1245
- # as the submission's userdata; the callback fills it and wakes us.
1246
- import threading as _threading
1247
- try:
1248
- from openvino import AsyncInferQueue as _AsyncInferQueue
1249
- except Exception:
1250
- from openvino.runtime import AsyncInferQueue as _AsyncInferQueue
1251
-
1252
- _infer_queue = _AsyncInferQueue(compiled, _n_req)
1253
-
1254
- # Callback runs on an OpenVINO worker thread when a job finishes. Fetch
1255
- # outputs POSITIONALLY (get_output_tensor(i)) so an UNNAMED IR output
1256
- # tensor is tolerated (same reason the sync path indexes by position —
1257
- # see the output_names note above), and .copy() detaches the result
1258
- # from the request's reusable output buffer before the job is freed
1259
- # back into the queue and possibly overwritten by the next inference.
1260
- def _on_done(_request, _userdata, _names=output_names) -> None:
1261
- _event, _holder = _userdata
1262
- try:
1263
- _holder[0] = {
1264
- name: _request.get_output_tensor(i).data.copy()
1265
- for i, name in enumerate(_names)
1266
- }
1267
- except BaseException as _exc: # surface to the waiting caller
1268
- _holder[1] = _exc
1269
- finally:
1270
- _event.set()
1271
-
1272
- _infer_queue.set_callback(_on_done)
1273
- # Serialise only the (cheap) hand-off. start_async internally waits for
1274
- # an idle request; guarding it means two dispatcher threads can never
1275
- # claim the same idle slot. The inference itself stays fully async —
1276
- # N requests keep running on the device concurrently — so this lock
1277
- # does not reduce pipelining, it only makes concurrent submit safe.
1278
- _submit_lock = _threading.Lock()
1279
- sys.stderr.write(
1280
- f"OpenVINO: AsyncInferQueue with {_n_req} job(s) on {ov_device} "
1281
- f"(CAMSTACK_OV_ASYNC=1, hint={_OV_PERF_HINT})\n"
1282
- )
1283
- sys.stderr.flush()
1284
-
1285
- def predict(inp_dict: dict, _q=_infer_queue, _lock=_submit_lock) -> dict:
1286
- inp = list(inp_dict.values())[0]
1287
- done = _threading.Event()
1288
- holder: list = [None, None] # [result_dict, exception]
1289
- with _lock:
1290
- # Blocks here when all N jobs are busy -> bounded in-flight.
1291
- _q.start_async(inp, (done, holder))
1292
- done.wait()
1293
- if holder[1] is not None:
1294
- raise holder[1]
1295
- return holder[0]
1296
-
1297
- slot.model = compiled
1298
- slot.predict_fn = predict
1299
- else:
1300
- # Synchronous path (default, CAMSTACK_OV_ASYNC=0) — byte-for-byte the
1301
- # historical behaviour. Create ALL InferRequests ONCE, now, post-
1302
- # compile. With AUTO gone there is no CPU->GPU handover, so no request
1303
- # can be born mid-handover and bind to stale ports — the score_8 race
1304
- # is structurally removed. Requests are handed out through a thread-
1305
- # safe queue: an InferRequest is never touched by two threads at once
1306
- # (checkout/return), so sharing a fixed pool is safe and avoids the old
1307
- # thread-local lazy creation.
1308
- _req_pool: "_queue.Queue" = _queue.Queue()
1309
- for _ in range(_n_req):
1310
- _req_pool.put(compiled.create_infer_request())
1311
- sys.stderr.write(f"OpenVINO: created {_n_req} InferRequest(s) up-front on {ov_device}\n")
1312
- sys.stderr.flush()
1219
+ # Create ALL InferRequests ONCE, now, post-compile. With AUTO gone there is no CPU->GPU handover, so no request
1220
+ # can be born mid-handover and bind to stale ports — the score_8 race
1221
+ # is structurally removed. Requests are handed out through a thread-
1222
+ # safe queue: an InferRequest is never touched by two threads at once
1223
+ # (checkout/return), so sharing a fixed pool is safe and avoids the old
1224
+ # thread-local lazy creation.
1225
+ _req_pool: "_queue.Queue" = _queue.Queue()
1226
+ for _ in range(_n_req):
1227
+ _req_pool.put(compiled.create_infer_request())
1228
+ sys.stderr.write(f"OpenVINO: created {_n_req} InferRequest(s) up-front on {ov_device}\n")
1229
+ sys.stderr.flush()
1313
1230
 
1314
- def predict(inp_dict: dict, _names=output_names, _pool=_req_pool) -> dict:
1315
- inp = list(inp_dict.values())[0]
1316
- req = _pool.get()
1317
- try:
1318
- result = req.infer(inp)
1319
- finally:
1320
- _pool.put(req)
1321
- # Output fetch by positional index — stable and harmless now that
1322
- # the ports no longer diverge (single explicit device, no handover).
1323
- return {name: result[i] for i, name in enumerate(_names)}
1324
-
1325
- slot.model = compiled
1326
- slot.predict_fn = predict
1231
+ def predict(inp_dict: dict, _names=output_names, _pool=_req_pool) -> dict:
1232
+ inp = list(inp_dict.values())[0]
1233
+ req = _pool.get()
1234
+ try:
1235
+ result = req.infer(inp)
1236
+ finally:
1237
+ _pool.put(req)
1238
+ # Output fetch by positional index — stable and harmless now that
1239
+ # the ports no longer diverge (single explicit device, no handover).
1240
+ return {name: result[i] for i, name in enumerate(_names)}
1241
+
1242
+ slot.model = compiled
1243
+ slot.predict_fn = predict
1327
1244
  # When the PPP preprocessor is folded in, `_preprocess` produces uint8
1328
1245
  # NHWC and short-circuits BEFORE the NHWC/NCHW float logic — so the
1329
1246
  # `_input_shape` heuristic below is irrelevant for PPP models. Record
@@ -1402,9 +1319,19 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1402
1319
  slot.config["_edgetpu_in_dtype"] = in_dtype
1403
1320
  slot.config["inputWidth"] = in_w
1404
1321
  slot.config["inputHeight"] = in_h
1405
- slot.config.setdefault("inputSize", max(in_w, in_h))
1322
+ # The FILE's input size is the truth for a compiled Edge TPU graph: a
1323
+ # Coral build may be compiled at a smaller input than the catalog
1324
+ # entry's other formats (activations share the 8 MB on-chip memory
1325
+ # with the parameter cache, D710), and a decoder sized from the
1326
+ # catalog would put every prior in the wrong cell.
1327
+ slot.config["inputSize"] = max(in_w, in_h)
1406
1328
  in_index = int(in_det["index"])
1407
1329
  out_index = [int(o["index"]) for o in out_dets]
1330
+ from edgetpu_dequant import edgetpu_output_keys
1331
+
1332
+ out_keys = edgetpu_output_keys(
1333
+ [str(o.get("name") or "") for o in out_dets], slot.config.get("postprocessor")
1334
+ )
1408
1335
  sys.stderr.write(
1409
1336
  f"EdgeTPU: loaded {os.path.basename(path)} in={in_w}x{in_h} "
1410
1337
  f"dtype={in_dtype} outputs={len(out_dets)}\n"
@@ -1414,6 +1341,7 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1414
1341
  def predict(
1415
1342
  inp_dict: dict,
1416
1343
  _interp=interp, _lock=lock, _in=in_index, _outs=out_index, _details=out_dets,
1344
+ _keys=out_keys,
1417
1345
  ) -> dict:
1418
1346
  from edgetpu_dequant import dequant_edgetpu_output
1419
1347
 
@@ -1421,22 +1349,25 @@ def _load_model(slot: ModelSlot, config: dict) -> None:
1421
1349
  with _lock:
1422
1350
  _interp.set_tensor(_in, tensor)
1423
1351
  _interp.invoke()
1424
- # Positional keys — ssd reads "0".."3"; yolo-split-dfl pairs by
1425
- # shape. Integer outputs are dequantised here so every
1426
- # postprocessor sees float32 (SSD Detection_PostProcess is
1427
- # already float and passes through).
1428
- result = {}
1429
- for i, idx in enumerate(_outs):
1430
- raw = _interp.get_tensor(idx)
1431
- detail = _details[i]
1432
- q = detail.get("quantization") or (0.0, 0)
1433
- scale = float(q[0]) if q else 0.0
1434
- zp = int(q[1]) if q else 0
1435
- dtype_name = str(np.dtype(detail["dtype"]).name)
1436
- result[str(i)] = dequant_edgetpu_output(
1437
- raw, dtype_name=dtype_name, scale=scale, zero_point=zp
1438
- )
1439
- return result
1352
+ # `get_tensor` returns a copy, so the Coral is released before
1353
+ # the dequantisation below runs.
1354
+ raws = [_interp.get_tensor(idx) for idx in _outs]
1355
+ # Positional keys — ssd reads "0".."3"; yolo-split-dfl pairs by
1356
+ # shape — except for a postprocessor that matches by name
1357
+ # (`edgetpu_output_keys`). Integer outputs are dequantised here so
1358
+ # every postprocessor sees float32 (SSD Detection_PostProcess is
1359
+ # already float and passes through).
1360
+ result = {}
1361
+ for i, raw in enumerate(raws):
1362
+ detail = _details[i]
1363
+ q = detail.get("quantization") or (0.0, 0)
1364
+ scale = float(q[0]) if q else 0.0
1365
+ zp = int(q[1]) if q else 0
1366
+ dtype_name = str(np.dtype(detail["dtype"]).name)
1367
+ result[_keys[i]] = dequant_edgetpu_output(
1368
+ raw, dtype_name=dtype_name, scale=scale, zero_point=zp
1369
+ )
1370
+ return result
1440
1371
 
1441
1372
  slot.model = interp
1442
1373
  slot.predict_fn = predict
@@ -1613,9 +1544,9 @@ def _preprocess_ctc_gray(img: "Image.Image", input_w: int, input_h: int) -> "np.
1613
1544
  # Fix 2 — OpenVINO PrePostProcessor (preprocess folded into the compiled graph)
1614
1545
  # ---------------------------------------------------------------------------
1615
1546
 
1616
- # Default ON for OpenVINO; set CAMSTACK_OV_PPP=0 to fall back to the pure-Python
1617
- # float preprocess (the historical path) if a model/postprocessor misbehaves.
1618
- _OV_PPP_ENABLED: bool = os.environ.get("CAMSTACK_OV_PPP", "1") not in ("0", "false", "no")
1547
+ # Always ON for OpenVINO; a module constant only so tests can exercise the
1548
+ # plain-path compile. A failed fold already falls back per model (above).
1549
+ _OV_PPP_ENABLED: bool = True
1619
1550
 
1620
1551
 
1621
1552
  def _ov_dim_to_int(dim: Any) -> "int | None":
@@ -0,0 +1,124 @@
1
+ """Pins the bird-classifier Coral build on REAL output tensors (D710 enforcement).
2
+
3
+ `bird-classifier` ships a `tflite` format compiled for the Edge TPU
4
+ (`bird-classifier_edgetpu.tflite`). Its single output is `[1,965] uint8`,
5
+ scale 1/256, zero point 0, with the softmax ALREADY in the graph. Two things
6
+ must hold for the step to emit the species the ONNX survivor emits:
7
+
8
+ - the edgetpu predict closure dequantises the tensor (`edgetpu_dequant`) —
9
+ read raw, the top-1 "probability" is a byte count in the hundreds and every
10
+ confidence gate passes everything;
11
+ - the softmax postprocessor consumes it verbatim (`outputProbabilities`) —
12
+ re-softmaxing a 1/256-step distribution flattens it below the 0.6 gate.
13
+
14
+ The fixture (`testdata/bird_classifier_int8_outputs.json`) holds the raw
15
+ outputs of the integer graph the Edge TPU file is compiled from, on two bird
16
+ crops (detector boxes) and one 48-px crop whose top-1 is `background`, next
17
+ to the ONNX survivor's top-1 on the same crop. Produced by
18
+ `scripts/build-bird-classifier-edgetpu.py`; its `producedBy` says how.
19
+
20
+ `unittest.TestCase`: the dequant cases run on CI's bare interpreter (no
21
+ numpy); the postprocessor cases need numpy (`softmax.py` imports it) and are
22
+ skipped, by name, without it.
23
+ """
24
+ import json
25
+ import os
26
+ import sys
27
+ import unittest
28
+
29
+ _HERE = os.path.dirname(os.path.abspath(__file__))
30
+ sys.path.insert(0, os.path.dirname(_HERE)) # edgetpu_dequant lives one level up
31
+
32
+ from edgetpu_dequant import dequant_edgetpu_output # noqa: E402
33
+
34
+ try:
35
+ import numpy as _numpy_probe
36
+ except ImportError:
37
+ _numpy_probe = None
38
+ _HAS_REAL_NUMPY = _numpy_probe is not None and hasattr(_numpy_probe, "ndarray")
39
+
40
+ with open(os.path.join(_HERE, "testdata", "bird_classifier_int8_outputs.json"), encoding="utf-8") as _fh:
41
+ _FIXTURE = json.load(_fh)
42
+
43
+ _OUT = _FIXTURE["output"]
44
+ _BACKGROUND = _FIXTURE["backgroundIndex"]
45
+ _STEP_GATE = 0.6 # STEP_BIRD_CLASSIFIER.defaultConfidence
46
+ _SPECIES = [f for f in _FIXTURE["frames"] if f["onnxTop1Index"] != _BACKGROUND]
47
+ _BACKGROUND_FRAMES = [f for f in _FIXTURE["frames"] if f["onnxTop1Index"] == _BACKGROUND]
48
+ # One crop's ONNX vs int8 top-1 difference is quantisation noise, not a defect;
49
+ # the parity pass of the build script measured p95 of max |dp| at 0.12.
50
+ _SCORE_TOLERANCE = 0.05
51
+
52
+
53
+ def _dequant(frame):
54
+ return dequant_edgetpu_output(
55
+ [frame["raw"]], dtype_name=_OUT["dtype"], scale=_OUT["scale"], zero_point=_OUT["zeroPoint"],
56
+ )
57
+
58
+
59
+ class BirdEdgetpuDequant(unittest.TestCase):
60
+ def test_fixture_is_the_shipped_contract(self):
61
+ self.assertEqual(_OUT["shape"], [1, 965])
62
+ self.assertEqual(_OUT["dtype"], "uint8")
63
+ self.assertAlmostEqual(_OUT["scale"], 1.0 / 256.0, places=9)
64
+ self.assertEqual(_OUT["zeroPoint"], 0)
65
+ self.assertGreaterEqual(len(_SPECIES), 2)
66
+ self.assertEqual(len(_BACKGROUND_FRAMES), 1)
67
+
68
+ def test_raw_tensor_is_not_a_probability(self):
69
+ # The defect the dequant closes: read raw, the top-1 is far above 1.
70
+ for frame in _SPECIES:
71
+ self.assertGreater(max(frame["raw"]), 200)
72
+
73
+ def test_dequantised_tensor_is_the_in_graph_softmax(self):
74
+ for frame in _FIXTURE["frames"]:
75
+ probs = _dequant(frame)[0]
76
+ self.assertEqual(len(probs), 965)
77
+ # Mass below one step (1/256) quantises to 0, so the sum lands a
78
+ # little under 1 — the flatter the distribution, the more is lost
79
+ # (0.71 on the background crop, 0.92+ on the species) — never above.
80
+ self.assertGreater(sum(probs), 0.6)
81
+ self.assertLessEqual(sum(probs), 1.0 + 1e-6)
82
+ self.assertTrue(all(0.0 <= p <= 1.0 for p in probs))
83
+
84
+ def test_dequantised_top1_is_the_onnx_survivors(self):
85
+ for frame in _SPECIES:
86
+ probs = _dequant(frame)[0]
87
+ top = max(range(len(probs)), key=probs.__getitem__)
88
+ self.assertEqual(top, frame["onnxTop1Index"], frame["crop"])
89
+ self.assertAlmostEqual(probs[top], frame["onnxTop1Score"], delta=_SCORE_TOLERANCE)
90
+ self.assertGreaterEqual(probs[top], _STEP_GATE)
91
+
92
+
93
+ @unittest.skipUnless(_HAS_REAL_NUMPY, "softmax.py needs numpy")
94
+ class BirdEdgetpuSoftmax(unittest.TestCase):
95
+ def _classify(self, frame, output_probabilities=True):
96
+ import numpy as np
97
+ from softmax import postprocess_softmax
98
+
99
+ tensor = np.array(_dequant(frame), dtype=np.float32)
100
+ config = {"labels": [str(i) for i in range(965)], "outputProbabilities": output_probabilities}
101
+ return postprocess_softmax({"0": tensor}, config, 224, 224, 1.0, (0, 0))["classifications"]
102
+
103
+ def test_species_crop_emits_the_onnx_species_above_the_step_gate(self):
104
+ for frame in _SPECIES:
105
+ top = self._classify(frame)[0]
106
+ self.assertEqual(top["class"], str(frame["onnxTop1Index"]), frame["crop"])
107
+ self.assertGreaterEqual(top["score"], _STEP_GATE)
108
+
109
+ def test_background_crop_tops_out_as_background(self):
110
+ # The step rejects `background` downstream (rejectClasses); the
111
+ # postprocessor must still name it, not a species.
112
+ top = self._classify(_BACKGROUND_FRAMES[0])[0]
113
+ self.assertEqual(top["class"], str(_BACKGROUND))
114
+
115
+ def test_re_softmax_would_sink_every_species_below_the_gate(self):
116
+ # Why the catalog sets outputProbabilities on this entry.
117
+ for frame in _SPECIES:
118
+ # Flattened so far that nothing clears the postprocessor's 0.01 floor.
119
+ ranked = self._classify(frame, output_probabilities=False)
120
+ self.assertTrue(all(c["score"] < _STEP_GATE for c in ranked))
121
+
122
+
123
+ if __name__ == "__main__":
124
+ unittest.main()
@@ -0,0 +1,126 @@
1
+ """Pins the YOLOX-tiny plate Coral build on REAL output tensors (D710 enforcement).
2
+
3
+ `yolox-tiny-plate` ships a `tflite` format compiled for the Edge TPU
4
+ (`camstack-yolox-tiny-plate_edgetpu.tflite`). Unlike its other builds — which
5
+ bake YOLOX's grid decode into the graph and emit the YOLOv8 layout — the Coral
6
+ graph stops at the raw head (an `Exp` and a coordinate Concat would share one
7
+ int8 scale between pixels and probabilities): per stride, `reg_s` (dx, dy,
8
+ log w, log h) and `score_s` (obj*cls), NHWC, int8. Three things must hold for
9
+ the step to emit the box the ONNX build emits:
10
+
11
+ - the edgetpu predict closure keys the `yolo` postprocessor's outputs by
12
+ tensor NAME (`edgetpu_output_keys`) — the head is found by its names;
13
+ - every output is dequantised (`edgetpu_dequant`);
14
+ - `yolo.py`'s `yolox_head_to_v8` decodes the head (row-major cells, centre =
15
+ (offset + cell) * stride, size = exp(log) * stride) into the layout the rest
16
+ of the postprocessor already reads.
17
+
18
+ The fixture (`testdata/yolox_plate_int8_outputs.json`) holds the raw outputs of
19
+ the int8 graph the Edge TPU file is compiled from, on the public
20
+ `reference-images/1-car-plate.jpg` letterboxed to 640, and the published ONNX
21
+ build's detections on the same frame. Produced by
22
+ `scripts/build-yolox-plate-edgetpu.py`.
23
+
24
+ `unittest.TestCase`: the key and dequant cases run on CI's bare interpreter;
25
+ the decode cases need numpy and are skipped, by name, without it.
26
+ """
27
+ import base64
28
+ import json
29
+ import os
30
+ import sys
31
+ import unittest
32
+ import zlib
33
+
34
+ _HERE = os.path.dirname(os.path.abspath(__file__))
35
+ sys.path.insert(0, os.path.dirname(_HERE)) # edgetpu_dequant lives one level up
36
+
37
+ from edgetpu_dequant import dequant_edgetpu_output, edgetpu_output_keys # noqa: E402
38
+
39
+ try:
40
+ import numpy as _numpy_probe
41
+ except ImportError:
42
+ _numpy_probe = None
43
+ _HAS_REAL_NUMPY = _numpy_probe is not None and hasattr(_numpy_probe, "ndarray")
44
+
45
+ with open(os.path.join(_HERE, "testdata", "yolox_plate_int8_outputs.json"), encoding="utf-8") as _fh:
46
+ _FIXTURE = json.load(_fh)
47
+
48
+ _OUTPUTS = _FIXTURE["outputs"]
49
+ _FRAME = _FIXTURE["frame"]
50
+ _STEP_GATE = 0.5 # plate-detection.defaultConfidence
51
+ _HEAD = ["reg_8", "score_8", "reg_16", "score_16", "reg_32", "score_32"]
52
+
53
+
54
+ def _raw(name):
55
+ data = zlib.decompress(base64.b64decode(_OUTPUTS[name]["zlibB64"]))
56
+ return [b - 256 if b > 127 else b for b in data]
57
+
58
+
59
+ def _iou(a, b):
60
+ ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
61
+ iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
62
+ inter = ix * iy
63
+ union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
64
+ return inter / union if union > 0 else 0.0
65
+
66
+
67
+ class YoloxPlateEdgetpuContract(unittest.TestCase):
68
+ def test_fixture_is_the_shipped_contract(self):
69
+ self.assertEqual(sorted(_OUTPUTS), sorted(_HEAD))
70
+ self.assertEqual(_OUTPUTS["reg_8"]["shape"], [1, 80, 80, 4])
71
+ self.assertEqual(_OUTPUTS["score_32"]["shape"], [1, 20, 20, 1])
72
+ for name, o in _OUTPUTS.items():
73
+ self.assertEqual(o["dtype"], "int8", name)
74
+ self.assertEqual(len(_FIXTURE["onnxDetections"]), 1)
75
+
76
+ def test_the_yolo_postprocessor_gets_named_outputs_on_the_coral(self):
77
+ listed = list(_OUTPUTS)
78
+ self.assertEqual(edgetpu_output_keys(listed, "yolo"), listed)
79
+
80
+ def test_dequantised_scores_are_probabilities(self):
81
+ for name in ("score_8", "score_16", "score_32"):
82
+ o = _OUTPUTS[name]
83
+ probs = dequant_edgetpu_output(_raw(name), dtype_name="int8", scale=o["scale"], zero_point=o["zeroPoint"])
84
+ self.assertTrue(all(-1e-6 <= p <= 1.0 + 1e-6 for p in probs), name)
85
+
86
+
87
+ @unittest.skipUnless(_HAS_REAL_NUMPY, "yolo.py needs numpy")
88
+ class YoloxPlateEdgetpuDecode(unittest.TestCase):
89
+ def _decode(self, keys):
90
+ import numpy as np
91
+ from yolo import postprocess_yolo
92
+
93
+ preds = {}
94
+ for key, name in zip(keys, _OUTPUTS):
95
+ o = _OUTPUTS[name]
96
+ raw = np.array(_raw(name), dtype=np.int8).reshape(o["shape"])
97
+ preds[key] = dequant_edgetpu_output(raw, dtype_name="int8", scale=o["scale"], zero_point=o["zeroPoint"])
98
+ config = {"confidence": _STEP_GATE, "labels": ["plate"], "numClasses": 1, "nmsIouThreshold": 0.45,
99
+ "inputSize": 640}
100
+ return postprocess_yolo(preds, config, _FRAME["w"], _FRAME["h"], _FRAME["scale"],
101
+ tuple(_FRAME["pad"]))["detections"]
102
+
103
+ def test_the_int8_head_decodes_to_the_onnx_plate(self):
104
+ got = self._decode(edgetpu_output_keys(list(_OUTPUTS), "yolo"))
105
+ ref = _FIXTURE["onnxDetections"]
106
+ self.assertEqual(len(got), len(ref))
107
+ self.assertEqual(got[0]["class"], "plate")
108
+ self.assertGreaterEqual(_iou(got[0]["bbox"], ref[0]["bbox"]), 0.9)
109
+ self.assertAlmostEqual(got[0]["score"], ref[0]["score"], delta=0.05)
110
+
111
+ def test_a_head_sized_for_another_input_is_refused(self):
112
+ import numpy as np
113
+ from yolo import yolox_head_to_v8
114
+
115
+ preds = {name: np.zeros(_OUTPUTS[name]["shape"], dtype=np.float32) for name in _OUTPUTS}
116
+ with self.assertRaises(ValueError):
117
+ yolox_head_to_v8(preds, 416)
118
+
119
+ def test_positional_keys_never_reach_the_head_decode(self):
120
+ from yolo import yolox_head_to_v8
121
+
122
+ self.assertIsNone(yolox_head_to_v8({str(i): None for i in range(6)}, 640))
123
+
124
+
125
+ if __name__ == "__main__":
126
+ unittest.main()