@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.
- package/THIRD_PARTY_MODELS.md +35 -27
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-CBIYdAJ5.js → default-detection-model-ChH7Y219.js} +130 -61
- package/dist/{default-detection-model-hql6BX9-.mjs → default-detection-model-j0nBpRoB.mjs} +130 -61
- package/dist/detection-pipeline/index.js +458 -16
- package/dist/detection-pipeline/index.mjs +458 -16
- package/dist/{dist-B9LnBPz9.mjs → dist-0HoA3wAi.mjs} +503 -370
- package/dist/{dist-BBZwYfSN.js → dist-BlIWhjnu.js} +550 -369
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-mJQBUiEc.js → node-BPpVMnZy.js} +1 -1
- package/dist/{node-Ci0E-0VP.mjs → node-HOMWRPzW.mjs} +1 -1
- package/dist/pipeline-runner/index.js +215 -78
- package/dist/pipeline-runner/index.mjs +215 -78
- package/dist/{process-memory-DgEfKdtC.js → process-memory-BW06vOIQ.js} +1 -1
- package/dist/{process-memory-B63Jlpev.mjs → process-memory-DuF8pa2w.mjs} +1 -1
- package/dist/recorder/index.js +2 -2
- package/dist/recorder/index.mjs +2 -2
- package/dist/{segment-demux-js-DzwHW4Ey.mjs → segment-demux-js-BU2MXpln.mjs} +1 -1
- package/dist/{segment-demux-js-C2ufKMu0.js → segment-demux-js-Dl52Zz8w.js} +1 -1
- package/dist/session-decode/decode-worker-main.js +13 -21
- package/dist/session-decode/decode-worker-main.mjs +13 -21
- package/dist/stream-broker/_stub.js +1 -1
- 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
- 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
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-Cg4IdtE5.mjs → hostInit-Ciacp783.mjs} +3 -3
- package/dist/stream-broker/index.js +4 -3
- package/dist/stream-broker/index.mjs +4 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/python/edgetpu_dequant.py +38 -1
- package/python/inference_pool.py +69 -138
- package/python/postprocessors/test_bird_edgetpu.py +124 -0
- package/python/postprocessors/test_yolox_plate_edgetpu.py +126 -0
- package/python/postprocessors/test_yunet_edgetpu.py +149 -0
- package/python/postprocessors/testdata/bird_classifier_int8_outputs.json +1 -0
- package/python/postprocessors/testdata/yolox_plate_int8_outputs.json +1 -0
- package/python/postprocessors/testdata/yunet_int8_outputs.json +1 -0
- package/python/postprocessors/yolo.py +44 -0
- package/python/test_inference_pool_static_batch.py +2 -4
- 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
package/python/inference_pool.py
CHANGED
|
@@ -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
|
|
325
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
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()
|
|
1226
|
-
#
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1425
|
-
#
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
#
|
|
1617
|
-
#
|
|
1618
|
-
_OV_PPP_ENABLED: bool =
|
|
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()
|