@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.
Files changed (46) hide show
  1. package/THIRD_PARTY_MODELS.md +241 -0
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/{default-detection-model-0dPKRKUD.mjs → default-detection-model-Co578D8C.mjs} +181 -99
  5. package/dist/{default-detection-model-D24AJOTn.js → default-detection-model-D1daTtqT.js} +181 -99
  6. package/dist/detection-pipeline/index.js +1301 -531
  7. package/dist/detection-pipeline/index.mjs +1301 -531
  8. package/dist/{dist-CJR259Xf.js → dist-8up-f2TX.js} +3688 -2698
  9. package/dist/{dist-RXbmRAwP.mjs → dist-CCd0Q3nr.mjs} +3676 -2698
  10. package/dist/motion-wasm/index.js +1 -1
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-atmRSHPk.mjs → node-DgMSXSWP.mjs} +1 -1
  13. package/dist/{node-DWg9zbY1.js → node-lpQgHes9.js} +1 -1
  14. package/dist/pipeline-runner/index.js +975 -270
  15. package/dist/pipeline-runner/index.mjs +975 -270
  16. package/dist/{process-memory-BJUXvTjd.js → process-memory-CX_92V_r.js} +1 -1
  17. package/dist/{process-memory-BgFOHFnx.mjs → process-memory-DFC_O5zE.mjs} +1 -1
  18. package/dist/recorder/index.js +14 -6
  19. package/dist/recorder/index.mjs +14 -6
  20. package/dist/{segment-demux-js-C_fPJub3.js → segment-demux-js-DzBx6NN2.js} +1 -1
  21. package/dist/{segment-demux-js-G7wFpHzn.mjs → segment-demux-js-FZbBuk3F.mjs} +1 -1
  22. package/dist/session-decode/{decode-worker-child.js → decode-worker-main.js} +481 -72
  23. package/dist/session-decode/{decode-worker-child.mjs → decode-worker-main.mjs} +482 -71
  24. package/dist/stream-broker/_stub.js +2 -2
  25. 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
  26. 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
  27. 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
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-BBYHWS3M.mjs → hostInit-BPtppL3W.mjs} +2 -2
  31. package/dist/stream-broker/index.js +4 -4
  32. package/dist/stream-broker/index.mjs +4 -4
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-B2MfQLlu.js → worker-protocol-C-G8qmye.js} +3 -1
  35. package/dist/{worker-protocol-C_W-P_g-.mjs → worker-protocol-D_NzPcnh.mjs} +3 -1
  36. package/package.json +3 -2
  37. package/python/inference_pool.py +422 -64
  38. package/python/postprocessors/__init__.py +2 -0
  39. package/python/postprocessors/ssd.py +73 -17
  40. package/python/postprocessors/test_ssd.py +205 -0
  41. package/python/postprocessors/test_yunet.py +292 -0
  42. package/python/postprocessors/testdata/ssdlite_mobiledet_outputs.json +1 -0
  43. package/python/postprocessors/yunet.py +275 -0
  44. package/python/test_inference_pool_compile_off_loop.py +414 -0
  45. 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
  46. 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
- import numpy as np
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
- np.asarray(predictions["0"]),
50
- np.asarray(predictions["1"]),
51
- np.asarray(predictions["2"]),
52
- np.asarray(predictions["3"]),
98
+ _as_list(predictions["0"]),
99
+ _as_list(predictions["1"]),
100
+ _as_list(predictions["2"]),
101
+ _as_list(predictions["3"]),
53
102
  )
54
- vals = [np.asarray(v) for v in predictions.values()]
103
+ vals = [_as_list(v) for v in predictions.values()]
55
104
  boxes = count = None
56
- two_d: list[np.ndarray] = []
105
+ two_d: list = []
57
106
  for v in vals:
58
- if v.ndim >= 2 and v.shape[-1] == 4:
107
+ shape = _shape(v)
108
+ ndim = len(shape)
109
+ if ndim >= 2 and shape[-1] == 4:
59
110
  boxes = v
60
- elif v.ndim == 1 or (v.ndim == 2 and v.shape[-1] == 1):
111
+ elif ndim == 1 or (ndim == 2 and shape[-1] == 1):
61
112
  count = v
62
- elif v.ndim == 2:
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.reshape(-1, 4)
87
- scores = scores.reshape(-1)
88
- classes = classes.reshape(-1) if classes is not None else np.zeros_like(scores)
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 and count.size:
91
- n = min(n, int(count.reshape(-1)[0]))
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
- cls = int(classes[i])
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()