@camstack/addon-pipeline 1.2.294 → 1.2.296
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 +241 -0
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-0dPKRKUD.mjs → default-detection-model-Co578D8C.mjs} +181 -99
- package/dist/{default-detection-model-D24AJOTn.js → default-detection-model-D1daTtqT.js} +181 -99
- package/dist/detection-pipeline/index.js +1301 -531
- package/dist/detection-pipeline/index.mjs +1301 -531
- package/dist/{dist-CJR259Xf.js → dist-8up-f2TX.js} +3688 -2698
- package/dist/{dist-RXbmRAwP.mjs → dist-CCd0Q3nr.mjs} +3676 -2698
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-atmRSHPk.mjs → node-DgMSXSWP.mjs} +1 -1
- package/dist/{node-DWg9zbY1.js → node-lpQgHes9.js} +1 -1
- package/dist/pipeline-runner/index.js +975 -270
- package/dist/pipeline-runner/index.mjs +975 -270
- package/dist/{process-memory-BJUXvTjd.js → process-memory-CX_92V_r.js} +1 -1
- package/dist/{process-memory-BgFOHFnx.mjs → process-memory-DFC_O5zE.mjs} +1 -1
- package/dist/recorder/index.js +14 -6
- package/dist/recorder/index.mjs +14 -6
- package/dist/{segment-demux-js-C_fPJub3.js → segment-demux-js-DzBx6NN2.js} +1 -1
- package/dist/{segment-demux-js-G7wFpHzn.mjs → segment-demux-js-FZbBuk3F.mjs} +1 -1
- package/dist/session-decode/{decode-worker-child.js → decode-worker-main.js} +481 -72
- package/dist/session-decode/{decode-worker-child.mjs → decode-worker-main.mjs} +482 -71
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-6IyM-BIn.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C_i7oFBl.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DUGQKsKL.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CkbplMHA.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-BBYHWS3M.mjs → hostInit-BPtppL3W.mjs} +2 -2
- package/dist/stream-broker/index.js +4 -4
- package/dist/stream-broker/index.mjs +4 -4
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-B2MfQLlu.js → worker-protocol-C-G8qmye.js} +3 -1
- package/dist/{worker-protocol-C_W-P_g-.mjs → worker-protocol-D_NzPcnh.mjs} +3 -1
- package/package.json +3 -2
- package/python/inference_pool.py +422 -64
- package/python/postprocessors/__init__.py +2 -0
- package/python/postprocessors/ssd.py +73 -17
- package/python/postprocessors/test_ssd.py +205 -0
- package/python/postprocessors/test_yunet.py +292 -0
- package/python/postprocessors/testdata/ssdlite_mobiledet_outputs.json +1 -0
- package/python/postprocessors/yunet.py +275 -0
- package/python/test_inference_pool_compile_off_loop.py +414 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-6IHzlLJ_.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DoyA71_q.mjs +0 -26
|
@@ -14,10 +14,25 @@ The inference_pool ``edgetpu`` predict closure returns them keyed by their
|
|
|
14
14
|
positional output index ("0".."3"). Output matches the yolo postprocessor:
|
|
15
15
|
``{"kind": "detections", "detections": [{"class", "score", "bbox":[x1,y1,x2,y2]}]}``
|
|
16
16
|
with pixel bboxes in ORIGINAL frame coordinates.
|
|
17
|
+
|
|
18
|
+
The same layout, verified per model on the upstream graph (2026-09-26):
|
|
19
|
+
|
|
20
|
+
ssd-mobilenet-v2-coco-edgetpu 300x300 uint8, N=20
|
|
21
|
+
efficientdet-lite0-edgetpu 320x320 uint8, N=25
|
|
22
|
+
ssdlite-mobiledet-coco-edgetpu 320x320 uint8, N=100, 91 score columns
|
|
23
|
+
(background + COCO-90), so the emitted
|
|
24
|
+
class index is already 0-based: 0 = person
|
|
25
|
+
|
|
26
|
+
The decode is plain Python over nested lists. Each tensor is turned into a list
|
|
27
|
+
once (``ndarray.tolist()``), so this module needs no numpy: CI's self-hosted
|
|
28
|
+
runner has none, and the MobileDet fixture test (``test_ssd.py``) must run
|
|
29
|
+
there, not be skipped there. Measured: ~85 µs per frame against ~14 µs for
|
|
30
|
+
the old numpy decode (about 6×), under 1% of a core at 100 fps and small next
|
|
31
|
+
to a ~9 ms Coral invoke.
|
|
17
32
|
"""
|
|
18
33
|
from __future__ import annotations
|
|
19
34
|
|
|
20
|
-
|
|
35
|
+
from typing import Any
|
|
21
36
|
|
|
22
37
|
# Coral COCO 90-class label map (github.com/google-coral/test_data coco_labels.txt).
|
|
23
38
|
# The "n/a" placeholders keep the raw model class indices aligned and are skipped.
|
|
@@ -38,6 +53,40 @@ COCO_90 = [
|
|
|
38
53
|
]
|
|
39
54
|
|
|
40
55
|
|
|
56
|
+
def _as_list(value: Any) -> Any:
|
|
57
|
+
"""A tensor as nested Python lists (a scalar stays a scalar)."""
|
|
58
|
+
return value.tolist() if hasattr(value, "tolist") else value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _shape(value: Any) -> tuple:
|
|
62
|
+
"""numpy-style shape of a nested list; ``()`` for a scalar."""
|
|
63
|
+
dims: list[int] = []
|
|
64
|
+
while isinstance(value, (list, tuple)):
|
|
65
|
+
dims.append(len(value))
|
|
66
|
+
if not value:
|
|
67
|
+
break
|
|
68
|
+
value = value[0]
|
|
69
|
+
return tuple(dims)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _flatten(value: Any) -> list:
|
|
73
|
+
"""Every scalar of a nested list, in row-major order (``reshape(-1)``)."""
|
|
74
|
+
if not isinstance(value, (list, tuple)):
|
|
75
|
+
return [value]
|
|
76
|
+
flat: list = []
|
|
77
|
+
for item in value:
|
|
78
|
+
flat.extend(_flatten(item))
|
|
79
|
+
return flat
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _rows_of_4(value: Any) -> list:
|
|
83
|
+
"""``reshape(-1, 4)`` of a nested list; refuses a size numpy would refuse."""
|
|
84
|
+
flat = _flatten(value)
|
|
85
|
+
if len(flat) % 4 != 0:
|
|
86
|
+
raise ValueError(f"cannot reshape {len(flat)} box values into rows of 4")
|
|
87
|
+
return [flat[i:i + 4] for i in range(0, len(flat), 4)]
|
|
88
|
+
|
|
89
|
+
|
|
41
90
|
def _split_outputs(predictions: dict) -> tuple:
|
|
42
91
|
"""Resolve (boxes, classes, scores, count) from the raw prediction dict.
|
|
43
92
|
|
|
@@ -46,20 +95,22 @@ def _split_outputs(predictions: dict) -> tuple:
|
|
|
46
95
|
"""
|
|
47
96
|
if all(k in predictions for k in ("0", "1", "2", "3")):
|
|
48
97
|
return (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
98
|
+
_as_list(predictions["0"]),
|
|
99
|
+
_as_list(predictions["1"]),
|
|
100
|
+
_as_list(predictions["2"]),
|
|
101
|
+
_as_list(predictions["3"]),
|
|
53
102
|
)
|
|
54
|
-
vals = [
|
|
103
|
+
vals = [_as_list(v) for v in predictions.values()]
|
|
55
104
|
boxes = count = None
|
|
56
|
-
two_d: list
|
|
105
|
+
two_d: list = []
|
|
57
106
|
for v in vals:
|
|
58
|
-
|
|
107
|
+
shape = _shape(v)
|
|
108
|
+
ndim = len(shape)
|
|
109
|
+
if ndim >= 2 and shape[-1] == 4:
|
|
59
110
|
boxes = v
|
|
60
|
-
elif
|
|
111
|
+
elif ndim == 1 or (ndim == 2 and shape[-1] == 1):
|
|
61
112
|
count = v
|
|
62
|
-
elif
|
|
113
|
+
elif ndim == 2:
|
|
63
114
|
two_d.append(v)
|
|
64
115
|
# TFLite_Detection_PostProcess emits classes BEFORE scores.
|
|
65
116
|
classes = two_d[0] if len(two_d) > 0 else None
|
|
@@ -67,6 +118,11 @@ def _split_outputs(predictions: dict) -> tuple:
|
|
|
67
118
|
return boxes, classes, scores, count
|
|
68
119
|
|
|
69
120
|
|
|
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
|
+
return COCO_90[cls] if 0 <= cls < len(COCO_90) else str(cls)
|
|
124
|
+
|
|
125
|
+
|
|
70
126
|
def postprocess_ssd(
|
|
71
127
|
predictions: dict,
|
|
72
128
|
config: dict,
|
|
@@ -83,12 +139,13 @@ def postprocess_ssd(
|
|
|
83
139
|
if boxes is None or scores is None:
|
|
84
140
|
return {"kind": "detections", "detections": []}
|
|
85
141
|
|
|
86
|
-
boxes = boxes
|
|
87
|
-
scores = scores
|
|
88
|
-
classes = classes
|
|
142
|
+
boxes = _rows_of_4(boxes)
|
|
143
|
+
scores = _flatten(scores)
|
|
144
|
+
classes = _flatten(classes) if classes is not None else [0.0] * len(scores)
|
|
89
145
|
n = len(scores)
|
|
90
|
-
if count is not None
|
|
91
|
-
|
|
146
|
+
count_flat = _flatten(count) if count is not None else []
|
|
147
|
+
if count_flat:
|
|
148
|
+
n = min(n, int(count_flat[0]))
|
|
92
149
|
n = min(n, len(boxes), len(classes))
|
|
93
150
|
|
|
94
151
|
pad_x, pad_y = pad
|
|
@@ -115,8 +172,7 @@ def postprocess_ssd(
|
|
|
115
172
|
y2 = max(0.0, min(y2, orig_h))
|
|
116
173
|
if x2 <= x1 or y2 <= y1:
|
|
117
174
|
continue
|
|
118
|
-
|
|
119
|
-
label = COCO_90[cls] if 0 <= cls < len(COCO_90) else str(cls)
|
|
175
|
+
label = label_for_class(int(classes[i]))
|
|
120
176
|
if label == "n/a":
|
|
121
177
|
continue
|
|
122
178
|
detections.append({
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Pins the SSD decoder on REAL SSDLite MobileDet output tensors.
|
|
2
|
+
|
|
3
|
+
`ssdlite-mobiledet-coco-edgetpu` joins the two Coral detectors that already use
|
|
4
|
+
`postprocessor: 'ssd'`. Its graph ends in the same `TFLite_Detection_PostProcess`
|
|
5
|
+
op, but nothing had checked that the decoder reads MobileDet's outputs the way
|
|
6
|
+
the model writes them. Each class below pins one thing that could silently turn
|
|
7
|
+
a working model into wrong labels or empty frames:
|
|
8
|
+
|
|
9
|
+
- the output ORDER (boxes, classes, scores, count on positional keys "0".."3");
|
|
10
|
+
- the class-index OFFSET (91 score columns, background dropped by the op, so
|
|
11
|
+
the emitted index is 0-based and 0 is person);
|
|
12
|
+
- the COUNT clamp (MobileDet emits N=100 rows, not 20 like SSD MobileNet V2);
|
|
13
|
+
- the letterbox inversion, against boxes an unrelated detector (DETR) drew on
|
|
14
|
+
the same COCO frame;
|
|
15
|
+
- the score CEILING of this quantised graph (about 0.771), which decides which
|
|
16
|
+
confidence thresholds can ever pass.
|
|
17
|
+
|
|
18
|
+
The fixture (`testdata/ssdlite_mobiledet_outputs.json`) holds the model's raw
|
|
19
|
+
outputs on two COCO val2017 frames; its `_provenance` says how they were made.
|
|
20
|
+
|
|
21
|
+
`unittest.TestCase` with no numpy: `scripts/test-python-pool.sh` runs these on
|
|
22
|
+
CI's bare interpreter, where numpy is absent, and `ssd.py` decodes plain lists
|
|
23
|
+
for exactly that reason. The one case that needs numpy (ndarray inputs decode
|
|
24
|
+
the same as lists) is skipped, by name, without it.
|
|
25
|
+
"""
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import unittest
|
|
29
|
+
|
|
30
|
+
try: # real numpy when the sandbox has it
|
|
31
|
+
import numpy as _numpy_probe
|
|
32
|
+
except ImportError:
|
|
33
|
+
_numpy_probe = None
|
|
34
|
+
|
|
35
|
+
# `ndarray` is real-numpy-only: a stub another test module installed would import.
|
|
36
|
+
_HAS_REAL_NUMPY = _numpy_probe is not None and hasattr(_numpy_probe, "ndarray")
|
|
37
|
+
|
|
38
|
+
from ssd import COCO_90, label_for_class, postprocess_ssd # noqa: E402
|
|
39
|
+
|
|
40
|
+
_FIXTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testdata",
|
|
41
|
+
"ssdlite_mobiledet_outputs.json")
|
|
42
|
+
with open(_FIXTURE, encoding="utf-8") as _fh:
|
|
43
|
+
_FRAMES = json.load(_fh)["frames"]
|
|
44
|
+
|
|
45
|
+
CATS = _FRAMES["coco_val2017_000000039769_two_cats"]
|
|
46
|
+
STREET = _FRAMES["coco_val2017_000000252219_street"]
|
|
47
|
+
|
|
48
|
+
# The catalog entry's input side; the pool sets `inputSize` from the graph.
|
|
49
|
+
INPUT_SIZE = 320
|
|
50
|
+
# The score column of this graph is LOGISTIC over a uint8 logit quantised at
|
|
51
|
+
# scale 0.046695, zero point 229, so the largest logit it can carry is
|
|
52
|
+
# (255 - 229) * 0.046695 = 1.2141 and sigmoid(1.2141) = 0.77102.
|
|
53
|
+
SCORE_CEILING = 0.77102
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _decode(frame: dict, confidence: float = 0.5, outputs: "dict | None" = None) -> list:
|
|
57
|
+
width, height = frame["orig"]
|
|
58
|
+
result = postprocess_ssd(
|
|
59
|
+
outputs if outputs is not None else frame["outputs"],
|
|
60
|
+
{"confidence": confidence, "inputSize": INPUT_SIZE},
|
|
61
|
+
width, height, frame["scale"], tuple(frame["pad"]),
|
|
62
|
+
)
|
|
63
|
+
return result["detections"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _iou(a: list, b: list) -> float:
|
|
67
|
+
ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
|
|
68
|
+
iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
|
|
69
|
+
inter = ix * iy
|
|
70
|
+
union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter
|
|
71
|
+
return inter / union if union > 0 else 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FixtureShapeTest(unittest.TestCase):
|
|
75
|
+
"""The fixture is what the MobileDet graph emits: [1,100,4] [1,100] [1,100] [1]."""
|
|
76
|
+
|
|
77
|
+
def test_output_shapes(self) -> None:
|
|
78
|
+
for frame in (CATS, STREET):
|
|
79
|
+
out = frame["outputs"]
|
|
80
|
+
self.assertEqual(len(out["0"]), 1)
|
|
81
|
+
self.assertEqual(len(out["0"][0]), 100)
|
|
82
|
+
self.assertTrue(all(len(row) == 4 for row in out["0"][0]))
|
|
83
|
+
self.assertEqual(len(out["1"][0]), 100)
|
|
84
|
+
self.assertEqual(len(out["2"][0]), 100)
|
|
85
|
+
self.assertEqual(out["3"], [100.0])
|
|
86
|
+
|
|
87
|
+
def test_scores_are_sorted_descending(self) -> None:
|
|
88
|
+
# TFLite_Detection_PostProcess ranks by score; a reordered export would not.
|
|
89
|
+
scores = STREET["outputs"]["2"][0]
|
|
90
|
+
self.assertEqual(scores, sorted(scores, reverse=True))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class OutputOrderTest(unittest.TestCase):
|
|
94
|
+
"""Positional "1" is CLASSES and "2" is SCORES. Swapped, the labels are nonsense."""
|
|
95
|
+
|
|
96
|
+
def test_cats_decode_as_cats(self) -> None:
|
|
97
|
+
labels = [d["class"] for d in _decode(CATS)]
|
|
98
|
+
self.assertEqual(labels.count("cat"), 2, labels)
|
|
99
|
+
|
|
100
|
+
def test_street_decodes_three_people(self) -> None:
|
|
101
|
+
people = [d for d in _decode(STREET) if d["class"] == "person"]
|
|
102
|
+
self.assertEqual(len(people), 3)
|
|
103
|
+
# Left, middle and right of the frame.
|
|
104
|
+
centres = sorted((d["bbox"][0] + d["bbox"][2]) / 2 for d in people)
|
|
105
|
+
self.assertLess(centres[0], 200)
|
|
106
|
+
self.assertTrue(250 < centres[1] < 450, centres)
|
|
107
|
+
self.assertGreater(centres[2], 500)
|
|
108
|
+
|
|
109
|
+
def test_every_score_is_a_probability(self) -> None:
|
|
110
|
+
for frame in (CATS, STREET):
|
|
111
|
+
self.assertTrue(all(0.0 <= s <= 1.0 for s in frame["outputs"]["2"][0]))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ClassOffsetTest(unittest.TestCase):
|
|
115
|
+
"""Emitted class index = COCO-90 line number of `coco_labels.txt`, 0 = person."""
|
|
116
|
+
|
|
117
|
+
def test_raw_indices_behind_the_labels(self) -> None:
|
|
118
|
+
classes = STREET["outputs"]["1"][0]
|
|
119
|
+
scores = STREET["outputs"]["2"][0]
|
|
120
|
+
top = [int(c) for c, s in zip(classes, scores) if s >= 0.7]
|
|
121
|
+
self.assertEqual(set(top), {0}) # the three people
|
|
122
|
+
cat_classes = {int(c) for c, s in zip(CATS["outputs"]["1"][0], CATS["outputs"]["2"][0]) if s >= 0.7}
|
|
123
|
+
self.assertEqual(cat_classes, {16})
|
|
124
|
+
|
|
125
|
+
def test_label_map(self) -> None:
|
|
126
|
+
self.assertEqual(len(COCO_90), 90)
|
|
127
|
+
self.assertEqual(label_for_class(0), "person")
|
|
128
|
+
self.assertEqual(label_for_class(2), "car")
|
|
129
|
+
self.assertEqual(label_for_class(16), "cat")
|
|
130
|
+
self.assertEqual(label_for_class(17), "dog")
|
|
131
|
+
self.assertEqual(label_for_class(89), "toothbrush")
|
|
132
|
+
self.assertEqual(label_for_class(11), "n/a")
|
|
133
|
+
self.assertEqual(label_for_class(90), "90")
|
|
134
|
+
|
|
135
|
+
def test_gap_classes_are_dropped(self) -> None:
|
|
136
|
+
out = {k: v for k, v in STREET["outputs"].items()}
|
|
137
|
+
out["1"] = [[11.0] + list(STREET["outputs"]["1"][0][1:])] # "n/a" on the top row
|
|
138
|
+
labels = [d["class"] for d in _decode(STREET, outputs=out)]
|
|
139
|
+
self.assertNotIn("n/a", labels)
|
|
140
|
+
self.assertEqual(labels.count("person"), 2)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CountClampTest(unittest.TestCase):
|
|
144
|
+
"""All 100 rows are read when count says 100, and only `count` rows otherwise."""
|
|
145
|
+
|
|
146
|
+
def test_low_threshold_reads_past_row_20(self) -> None:
|
|
147
|
+
# SSD MobileNet V2 stops at 20 rows; MobileDet's detections go on to row 100.
|
|
148
|
+
dets = _decode(STREET, confidence=0.01)
|
|
149
|
+
self.assertGreater(len(dets), 20)
|
|
150
|
+
|
|
151
|
+
def test_count_limits_the_rows(self) -> None:
|
|
152
|
+
out = dict(STREET["outputs"])
|
|
153
|
+
out["3"] = [2.0]
|
|
154
|
+
self.assertEqual(len(_decode(STREET, confidence=0.01, outputs=out)), 2)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class LetterboxGeometryTest(unittest.TestCase):
|
|
158
|
+
"""Boxes land in ORIGINAL-frame pixels, where another detector puts the same cats."""
|
|
159
|
+
|
|
160
|
+
# DETR ResNet-50 on this exact COCO frame (the facebook/detr-resnet-50 model
|
|
161
|
+
# card example): an independent reference for where the two cats are.
|
|
162
|
+
DETR_CATS = ([13.24, 52.05, 314.02, 470.93], [345.4, 23.85, 640.37, 368.72])
|
|
163
|
+
|
|
164
|
+
def test_cats_match_the_reference_boxes(self) -> None:
|
|
165
|
+
cats = [d["bbox"] for d in _decode(CATS) if d["class"] == "cat"]
|
|
166
|
+
for ref in self.DETR_CATS:
|
|
167
|
+
best = max(_iou(ref, box) for box in cats)
|
|
168
|
+
self.assertGreater(best, 0.85, (ref, cats))
|
|
169
|
+
|
|
170
|
+
def test_boxes_stay_inside_the_frame(self) -> None:
|
|
171
|
+
for frame in (CATS, STREET):
|
|
172
|
+
width, height = frame["orig"]
|
|
173
|
+
for d in _decode(frame, confidence=0.01):
|
|
174
|
+
x1, y1, x2, y2 = d["bbox"]
|
|
175
|
+
self.assertTrue(0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height, d)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ScoreCeilingTest(unittest.TestCase):
|
|
179
|
+
"""This graph cannot score above ~0.771, so a stricter threshold returns nothing."""
|
|
180
|
+
|
|
181
|
+
def test_no_score_exceeds_the_ceiling(self) -> None:
|
|
182
|
+
for frame in (CATS, STREET):
|
|
183
|
+
self.assertLessEqual(max(frame["outputs"]["2"][0]), SCORE_CEILING)
|
|
184
|
+
|
|
185
|
+
def test_the_ceiling_is_reached(self) -> None:
|
|
186
|
+
self.assertAlmostEqual(max(CATS["outputs"]["2"][0]), 0.76953125)
|
|
187
|
+
|
|
188
|
+
def test_threshold_above_the_ceiling_finds_nothing(self) -> None:
|
|
189
|
+
self.assertEqual(_decode(STREET, confidence=0.78), [])
|
|
190
|
+
self.assertEqual(len([d for d in _decode(STREET, confidence=0.65) if d["class"] == "person"]), 3)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class NumpyInputTest(unittest.TestCase):
|
|
194
|
+
"""The pool hands ndarrays; they must decode exactly like the lists above."""
|
|
195
|
+
|
|
196
|
+
@unittest.skipUnless(_HAS_REAL_NUMPY, "needs real numpy (not on CI's bare interpreter)")
|
|
197
|
+
def test_ndarrays_decode_like_lists(self) -> None:
|
|
198
|
+
np = _numpy_probe
|
|
199
|
+
for frame in (CATS, STREET):
|
|
200
|
+
arrays = {k: np.asarray(v, dtype=np.float32) for k, v in frame["outputs"].items()}
|
|
201
|
+
self.assertEqual(_decode(frame, confidence=0.3, outputs=arrays), _decode(frame, confidence=0.3))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
unittest.main()
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Pins the YuNet decoder: prior layout, box/landmark decode, landmark order.
|
|
2
|
+
|
|
3
|
+
YuNet out of the box found 0 faces on 413 person crops through the SCRFD
|
|
4
|
+
decoder (2026-09-26 model-replacement spike, §2.2): one prior per cell instead
|
|
5
|
+
of two, centre+exp(size) boxes instead of anchor distances, and a score split
|
|
6
|
+
across `cls_*` and `obj_*`. Each class below pins one of those.
|
|
7
|
+
|
|
8
|
+
`unittest.TestCase`, not pytest-style: `scripts/test-python-pool.sh` runs the
|
|
9
|
+
`unittest.TestCase` modules on CI's bare interpreter — the self-hosted runner
|
|
10
|
+
has NO numpy. So everything that decides is plain Python and tested here
|
|
11
|
+
without it: prior layout, decode, landmark order, the score, NMS, the top-K cap,
|
|
12
|
+
and the output gate (`check_outputs` runs before `postprocess_yunet` touches
|
|
13
|
+
numpy, so its refusals are exercised through `postprocess_yunet` itself).
|
|
14
|
+
|
|
15
|
+
What genuinely needs real numpy, and is SKIPPED (named) without it: the
|
|
16
|
+
vectorised pre-filter (`np.maximum(cls, obj) >= conf`), and the decode of a
|
|
17
|
+
live prior through `postprocess_yunet` end to end (flatten/reshape of the
|
|
18
|
+
tensors, the cap applied inside it). Those run wherever numpy is installed —
|
|
19
|
+
every pool host — and in the spike harness.
|
|
20
|
+
"""
|
|
21
|
+
import math
|
|
22
|
+
import sys
|
|
23
|
+
import types
|
|
24
|
+
import unittest
|
|
25
|
+
|
|
26
|
+
try: # real numpy when the sandbox has it
|
|
27
|
+
import numpy as _numpy_probe # noqa: F401
|
|
28
|
+
except ImportError:
|
|
29
|
+
_numpy_probe = None
|
|
30
|
+
|
|
31
|
+
# `ndarray` is real-numpy-only: a stub another module installed would import.
|
|
32
|
+
_HAS_REAL_NUMPY = _numpy_probe is not None and hasattr(_numpy_probe, "ndarray")
|
|
33
|
+
|
|
34
|
+
# Without numpy, `yunet.py`'s `import numpy` is satisfied by an EMPTY stub for
|
|
35
|
+
# the duration of the import only, and the stub is removed again at once: a
|
|
36
|
+
# stub left in `sys.modules` would be picked up by the next test module in the
|
|
37
|
+
# same `python3 -m unittest` process (test_scrfd needs `np.ceil` from its OWN
|
|
38
|
+
# stub), making the result depend on module order. `yunet` keeps its own
|
|
39
|
+
# reference; nothing it runs here touches it.
|
|
40
|
+
if _numpy_probe is None:
|
|
41
|
+
sys.modules["numpy"] = types.ModuleType("numpy")
|
|
42
|
+
try:
|
|
43
|
+
import yunet # noqa: F401
|
|
44
|
+
finally:
|
|
45
|
+
del sys.modules["numpy"]
|
|
46
|
+
|
|
47
|
+
from yunet import ( # noqa: E402
|
|
48
|
+
NUM_LANDMARKS,
|
|
49
|
+
STRIDES,
|
|
50
|
+
TOP_K,
|
|
51
|
+
cap_top_k,
|
|
52
|
+
check_outputs,
|
|
53
|
+
decode_prior,
|
|
54
|
+
grid_cols,
|
|
55
|
+
missing_outputs,
|
|
56
|
+
nms,
|
|
57
|
+
postprocess_yunet,
|
|
58
|
+
prior_cell,
|
|
59
|
+
score_of,
|
|
60
|
+
to_source,
|
|
61
|
+
value_count,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PriorLayoutTest(unittest.TestCase):
|
|
66
|
+
"""One prior per cell, row-major — NOT SCRFD's two anchors per cell."""
|
|
67
|
+
|
|
68
|
+
def test_grid_is_one_prior_per_cell_at_640(self):
|
|
69
|
+
self.assertEqual([grid_cols(s, 640) for s in STRIDES], [80, 40, 20])
|
|
70
|
+
# 6400 + 1600 + 400. SCRFD's layout would be twice this (16800).
|
|
71
|
+
self.assertEqual(sum(grid_cols(s, 640) ** 2 for s in STRIDES), 8400)
|
|
72
|
+
|
|
73
|
+
def test_index_is_row_major(self):
|
|
74
|
+
cols = grid_cols(8, 640)
|
|
75
|
+
self.assertEqual(prior_cell(0, cols), (0, 0))
|
|
76
|
+
self.assertEqual(prior_cell(1, cols), (0, 1)) # SCRFD: index 1 is still cell (0,0)
|
|
77
|
+
self.assertEqual(prior_cell(cols + 2, cols), (1, 2))
|
|
78
|
+
self.assertEqual(prior_cell(cols * cols - 1, cols), (cols - 1, cols - 1))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DecodeTest(unittest.TestCase):
|
|
82
|
+
def test_box_is_centre_plus_exp_size_from_the_cell_corner(self):
|
|
83
|
+
# cell (row 1, col 2) at stride 8; centre offset (0.5, 0.5) → centre
|
|
84
|
+
# ((2+.5)*8, (1+.5)*8) = (20, 12); log size 0 → 8×8.
|
|
85
|
+
box, _ = decode_prior(8, 1, 2, [0.5, 0.5, 0.0, 0.0], [0.0] * 10)
|
|
86
|
+
self.assertEqual(box, (16.0, 8.0, 24.0, 16.0))
|
|
87
|
+
|
|
88
|
+
def test_size_is_exponential(self):
|
|
89
|
+
box, _ = decode_prior(16, 0, 0, [0.0, 0.0, math.log(3.0), math.log(2.0)], [0.0] * 10)
|
|
90
|
+
x1, y1, x2, y2 = box
|
|
91
|
+
self.assertAlmostEqual(x2 - x1, 48.0, places=4) # 3 * 16
|
|
92
|
+
self.assertAlmostEqual(y2 - y1, 32.0, places=4) # 2 * 16
|
|
93
|
+
|
|
94
|
+
def test_landmarks_are_cell_relative_in_stride_units(self):
|
|
95
|
+
kps = [0.25, 0.5, 0.75, 0.5, 0.5, 1.0, 0.25, 1.5, 0.75, 1.5]
|
|
96
|
+
_, points = decode_prior(32, 3, 4, [0.0, 0.0, 0.0, 0.0], kps)
|
|
97
|
+
self.assertEqual(len(points), NUM_LANDMARKS)
|
|
98
|
+
self.assertEqual(points[0], ((4 + 0.25) * 32, (3 + 0.5) * 32))
|
|
99
|
+
self.assertEqual(points[2], ((4 + 0.5) * 32, (3 + 1.0) * 32))
|
|
100
|
+
|
|
101
|
+
def test_score_is_geometric_mean_of_clamped_cls_and_obj(self):
|
|
102
|
+
self.assertAlmostEqual(score_of(0.81, 1.0), 0.9)
|
|
103
|
+
self.assertAlmostEqual(score_of(0.64, 0.25), 0.4)
|
|
104
|
+
self.assertEqual(score_of(1.3, 1.0), 1.0)
|
|
105
|
+
self.assertEqual(score_of(-0.2, 1.0), 0.0)
|
|
106
|
+
|
|
107
|
+
def test_to_source_undoes_the_letterbox(self):
|
|
108
|
+
# 320×160 crop letterboxed into 640: scale 2, pad (0, 160).
|
|
109
|
+
self.assertEqual(to_source(100.0, 260.0, 2.0, (0, 160)), (50.0, 50.0))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class LandmarkOrderTest(unittest.TestCase):
|
|
113
|
+
"""YuNet emits the SUBJECT's right eye first = image-LEFT eye, which is
|
|
114
|
+
the order `face-align.ts` maps onto ARCFACE_TEMPLATE_112. The decoder must
|
|
115
|
+
pass the five points through in model order — a sort or a swap here
|
|
116
|
+
mirrors every aligned face."""
|
|
117
|
+
|
|
118
|
+
def test_points_keep_model_order(self):
|
|
119
|
+
# A frontal face: image-left eye, image-right eye, nose, mouth L, mouth R.
|
|
120
|
+
kps = [0.2, 0.3, 0.8, 0.3, 0.5, 0.55, 0.3, 0.8, 0.7, 0.8]
|
|
121
|
+
_, points = decode_prior(8, 0, 0, [0.5, 0.5, 0.0, 0.0], kps)
|
|
122
|
+
xs = [p[0] for p in points]
|
|
123
|
+
self.assertLess(xs[0], xs[1]) # eye 0 is image-left
|
|
124
|
+
self.assertLess(xs[3], xs[4]) # mouth 3 is image-left
|
|
125
|
+
self.assertEqual([round(x, 3) for x in xs], [1.6, 6.4, 4.0, 2.4, 5.6])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class MissingOutputsTest(unittest.TestCase):
|
|
129
|
+
"""Name-only matching: a model without YuNet's twelve named tensors is
|
|
130
|
+
refused, not decoded as "no faces"."""
|
|
131
|
+
|
|
132
|
+
def test_all_twelve_present(self):
|
|
133
|
+
names = [f"{k}_{s}" for k in ("cls", "obj", "bbox", "kps") for s in STRIDES]
|
|
134
|
+
self.assertEqual(missing_outputs(names), [])
|
|
135
|
+
|
|
136
|
+
def test_names_what_is_missing(self):
|
|
137
|
+
names = [f"{k}_{s}" for k in ("cls", "bbox", "kps") for s in STRIDES]
|
|
138
|
+
self.assertEqual(missing_outputs(names), ["obj_8", "obj_16", "obj_32"])
|
|
139
|
+
|
|
140
|
+
def test_scrfd_style_outputs_are_refused(self):
|
|
141
|
+
names = [f"{k}_{s}" for k in ("score", "bbox", "kps") for s in STRIDES]
|
|
142
|
+
self.assertIn("cls_8", missing_outputs(names))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _list_outputs(input_size=640, drop=()):
|
|
146
|
+
"""Correctly-SHAPED outputs as nested lists — no numpy needed."""
|
|
147
|
+
out = {}
|
|
148
|
+
for s in STRIDES:
|
|
149
|
+
n = grid_cols(s, input_size) ** 2
|
|
150
|
+
for kind, width in (("cls", 1), ("obj", 1), ("bbox", 4), ("kps", 10)):
|
|
151
|
+
if f"{kind}_{s}" not in drop:
|
|
152
|
+
out[f"{kind}_{s}"] = [[[0.0] * width for _ in range(n)]]
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class OutputGateTest(unittest.TestCase):
|
|
157
|
+
"""`check_outputs` runs before any numpy call, so these go through
|
|
158
|
+
`postprocess_yunet` itself on the bare interpreter."""
|
|
159
|
+
|
|
160
|
+
def test_missing_output_raises_instead_of_reporting_no_faces(self):
|
|
161
|
+
raw = _list_outputs(drop=("obj_32",))
|
|
162
|
+
with self.assertRaises(ValueError) as ctx:
|
|
163
|
+
postprocess_yunet(raw, {"confidence": 0.5}, 640, 640, 1.0, (0, 0))
|
|
164
|
+
self.assertIn("obj_32", str(ctx.exception))
|
|
165
|
+
|
|
166
|
+
def test_wrong_input_size_raises(self):
|
|
167
|
+
raw = _list_outputs(input_size=320)
|
|
168
|
+
with self.assertRaises(ValueError) as ctx:
|
|
169
|
+
postprocess_yunet(raw, {"confidence": 0.5, "inputSize": 640}, 640, 640, 1.0, (0, 0))
|
|
170
|
+
self.assertIn("does not match the model", str(ctx.exception))
|
|
171
|
+
|
|
172
|
+
def test_correct_outputs_pass_the_gate(self):
|
|
173
|
+
check_outputs(_list_outputs(), 640) # no raise
|
|
174
|
+
|
|
175
|
+
def test_value_count_of_nested_lists(self):
|
|
176
|
+
self.assertEqual(value_count([[[0.0] * 4 for _ in range(3)]]), 12)
|
|
177
|
+
self.assertEqual(value_count(7.0), 1)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TopKCapTest(unittest.TestCase):
|
|
181
|
+
"""The pure-Python NMS is O(n·kept): 5000 disjoint candidates took 2.8 s.
|
|
182
|
+
At most TOP_K, the best by score, may reach it."""
|
|
183
|
+
|
|
184
|
+
def _cands(self, n):
|
|
185
|
+
return [{"score": (i % 997) / 997.0, "_xyxy": (i, 0, i + 1, 1)} for i in range(n)]
|
|
186
|
+
|
|
187
|
+
def test_under_the_cap_is_untouched(self):
|
|
188
|
+
cands = self._cands(10)
|
|
189
|
+
kept, dropped = cap_top_k(cands)
|
|
190
|
+
self.assertIs(kept, cands)
|
|
191
|
+
self.assertEqual(dropped, 0)
|
|
192
|
+
|
|
193
|
+
def test_over_the_cap_keeps_the_best_k(self):
|
|
194
|
+
cands = self._cands(TOP_K + 250)
|
|
195
|
+
kept, dropped = cap_top_k(cands)
|
|
196
|
+
self.assertEqual(len(kept), TOP_K)
|
|
197
|
+
self.assertEqual(dropped, 250)
|
|
198
|
+
worst_kept = min(c["score"] for c in kept)
|
|
199
|
+
best_dropped = max(c["score"] for c in cands if c not in kept)
|
|
200
|
+
self.assertGreaterEqual(worst_kept, best_dropped)
|
|
201
|
+
|
|
202
|
+
def test_cap_is_well_below_upstream_and_above_real_frames(self):
|
|
203
|
+
# upstream 5000 (C++ NMS); measured max 110 candidates/frame at 0.1.
|
|
204
|
+
self.assertLessEqual(TOP_K, 5000)
|
|
205
|
+
self.assertGreaterEqual(TOP_K, 110 * 5)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class NmsTest(unittest.TestCase):
|
|
209
|
+
def test_keeps_best_of_overlapping_and_disjoint(self):
|
|
210
|
+
cands = [
|
|
211
|
+
{"score": 0.6, "_xyxy": (0, 0, 10, 10)},
|
|
212
|
+
{"score": 0.9, "_xyxy": (1, 1, 11, 11)},
|
|
213
|
+
{"score": 0.7, "_xyxy": (50, 50, 60, 60)},
|
|
214
|
+
]
|
|
215
|
+
kept = nms(cands, 0.45)
|
|
216
|
+
self.assertEqual([k["score"] for k in kept], [0.9, 0.7])
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _outputs(np, input_size=640, live=None):
|
|
220
|
+
"""Raw YuNet-shaped outputs, all priors dead except `live`:
|
|
221
|
+
{stride: (index, cls, obj, bbox4, kps10)}."""
|
|
222
|
+
out = {}
|
|
223
|
+
for s in STRIDES:
|
|
224
|
+
n = grid_cols(s, input_size) ** 2
|
|
225
|
+
out[f"cls_{s}"] = np.zeros((1, n, 1), np.float32)
|
|
226
|
+
out[f"obj_{s}"] = np.zeros((1, n, 1), np.float32)
|
|
227
|
+
out[f"bbox_{s}"] = np.zeros((1, n, 4), np.float32)
|
|
228
|
+
out[f"kps_{s}"] = np.zeros((1, n, 10), np.float32)
|
|
229
|
+
for s, (index, cls, obj, bbox, kps) in (live or {}).items():
|
|
230
|
+
out[f"cls_{s}"][0, index, 0] = cls
|
|
231
|
+
out[f"obj_{s}"][0, index, 0] = obj
|
|
232
|
+
out[f"bbox_{s}"][0, index] = bbox
|
|
233
|
+
out[f"kps_{s}"][0, index] = kps
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@unittest.skipUnless(_HAS_REAL_NUMPY, "postprocess_yunet round trip needs real ndarray ops")
|
|
238
|
+
class PostprocessYunetRoundTripTest(unittest.TestCase):
|
|
239
|
+
def setUp(self):
|
|
240
|
+
import numpy as np
|
|
241
|
+
|
|
242
|
+
self.np = np
|
|
243
|
+
|
|
244
|
+
def test_one_live_prior_decodes_to_source_pixels(self):
|
|
245
|
+
cols = grid_cols(16, 640)
|
|
246
|
+
index = 10 * cols + 20 # row 10, col 20
|
|
247
|
+
kps = [0.2, 0.3, 0.8, 0.3, 0.5, 0.55, 0.3, 0.8, 0.7, 0.8]
|
|
248
|
+
raw = _outputs(self.np, live={16: (index, 0.81, 1.0, [0.5, 0.5, math.log(4), math.log(4)], kps)})
|
|
249
|
+
# 1280×640 frame letterboxed into 640: scale 0.5, pad (0, 160).
|
|
250
|
+
out = postprocess_yunet(raw, {"confidence": 0.5, "inputSize": 640}, 1280, 640, 0.5, (0, 160))
|
|
251
|
+
dets = out["detections"]
|
|
252
|
+
self.assertEqual(len(dets), 1)
|
|
253
|
+
d = dets[0]
|
|
254
|
+
self.assertEqual(d["class"], "face")
|
|
255
|
+
self.assertAlmostEqual(d["score"], 0.9, places=3)
|
|
256
|
+
# network centre (328, 168), size 64 → [296,136,360,200] → source /0.5, y-160.
|
|
257
|
+
self.assertEqual(d["bbox"], [592.0, 0.0, 720.0, 80.0])
|
|
258
|
+
self.assertEqual(len(d["landmarks"]), NUM_LANDMARKS)
|
|
259
|
+
self.assertEqual(d["landmarks"][0], {"x": round((20.2 * 16) / 0.5, 1), "y": round((10.3 * 16 - 160) / 0.5, 1)})
|
|
260
|
+
self.assertLess(d["landmarks"][0]["x"], d["landmarks"][1]["x"])
|
|
261
|
+
|
|
262
|
+
def test_score_uses_both_tensors(self):
|
|
263
|
+
cols = grid_cols(8, 640)
|
|
264
|
+
# cls alone high, obj low → sqrt(0.9*0.1)=0.3 < 0.5: dropped.
|
|
265
|
+
raw = _outputs(self.np, live={8: (cols + 1, 0.9, 0.1, [0.5, 0.5, 1.0, 1.0], [0.0] * 10)})
|
|
266
|
+
out = postprocess_yunet(raw, {"confidence": 0.5}, 640, 640, 1.0, (0, 0))
|
|
267
|
+
self.assertEqual(out["detections"], [])
|
|
268
|
+
|
|
269
|
+
def test_prior_with_low_cls_but_high_obj_still_passes(self):
|
|
270
|
+
# sqrt(0.45 * 0.95) = 0.654 >= 0.5 although cls alone is under it: a
|
|
271
|
+
# prefilter on ONE tensor would drop a real face here.
|
|
272
|
+
cols = grid_cols(32, 640)
|
|
273
|
+
raw = _outputs(self.np, live={32: (cols + 3, 0.45, 0.95, [0.5, 0.5, 0.0, 0.0], [0.0] * 10)})
|
|
274
|
+
out = postprocess_yunet(raw, {"confidence": 0.5}, 640, 640, 1.0, (0, 0))
|
|
275
|
+
self.assertEqual(len(out["detections"]), 1)
|
|
276
|
+
self.assertAlmostEqual(out["detections"][0]["score"], 0.6538, places=3)
|
|
277
|
+
|
|
278
|
+
def test_cap_applies_inside_postprocess(self):
|
|
279
|
+
# TOP_K + 50 disjoint live priors on the stride-8 grid: all pass the
|
|
280
|
+
# score gate, none overlap, so only the cap can remove any.
|
|
281
|
+
n = grid_cols(8, 640) ** 2
|
|
282
|
+
raw = _outputs(self.np)
|
|
283
|
+
live = list(range(0, n, 2))[: TOP_K + 50]
|
|
284
|
+
raw["cls_8"][0, live, 0] = 0.9
|
|
285
|
+
raw["obj_8"][0, live, 0] = 0.9
|
|
286
|
+
raw["bbox_8"][0, live] = [0.5, 0.5, -1.0, -1.0] # ~3 px boxes, disjoint
|
|
287
|
+
out = postprocess_yunet(raw, {"confidence": 0.5}, 640, 640, 1.0, (0, 0))
|
|
288
|
+
self.assertEqual(len(out["detections"]), TOP_K)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if __name__ == "__main__":
|
|
292
|
+
unittest.main()
|