@camstack/addon-pipeline 1.2.291 → 1.2.293
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/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-DcTy-nJl.mjs → default-detection-model-DCQlW9fx.mjs} +2 -1
- package/dist/{default-detection-model-jjdmKKr3.js → default-detection-model-DTKHLLcC.js} +2 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-Bn6BQW8q.mjs → dist-C0Fr4hM5.mjs} +415 -21
- package/dist/{dist-D7FonEUB.js → dist-Ce8s7XqV.js} +415 -21
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-D3C1krEi.js → node-DpOrymbW.js} +1 -1
- package/dist/{node-BN0Na3HN.mjs → node-Xm7amG2e.mjs} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-CeZ8ms80.mjs → process-memory-BN5qYGsD.mjs} +1 -1
- package/dist/{process-memory-DI4jrCtX.js → process-memory-CBRjuhyg.js} +1 -1
- package/dist/recorder/index.js +2 -2
- package/dist/recorder/index.mjs +2 -2
- package/dist/{segment-demux-js-COooJ-t0.js → segment-demux-js-BhMlS_Ow.js} +1 -1
- package/dist/{segment-demux-js-CNPzlcyO.mjs → segment-demux-js-Bhwpj5Wl.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BrD7TK3W.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DKd7oYba.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-6IHzlLJ_.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-DhImOdbA.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DoyA71_q.mjs} +1 -1
- 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-CbSGtrCj.mjs → hostInit-CCMIHjff.mjs} +2 -2
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/python/inference_pool.py +33 -10
- package/python/postprocessors/scrfd.py +21 -3
- package/python/postprocessors/test_scrfd.py +151 -0
- package/python/test_inference_pool_ov_ppp_scrfd.py +264 -0
- package/python/test_inference_pool_scrfd_normalization.py +142 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-pSFdAaR0.mjs +0 -26
package/python/inference_pool.py
CHANGED
|
@@ -1522,24 +1522,40 @@ def _to_model_tensor(arr: "np.ndarray", channels_last: bool) -> "np.ndarray":
|
|
|
1522
1522
|
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
|
1523
1523
|
_IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
|
1524
1524
|
|
|
1525
|
+
# InsightFace's SCRFD input contract: upstream feeds `(pixel - 127.5) / 128`
|
|
1526
|
+
# ([-0.996, 0.996]), not the pool's historical plain `/255` ([0,1]). Expressed
|
|
1527
|
+
# against the pool's already-`/255` HWC array (`arr`, not raw pixels):
|
|
1528
|
+
# (arr*255 - 127.5) / 128 == (arr - _SCRFD_MEAN) / _SCRFD_STD
|
|
1529
|
+
# with mean/std pre-divided by 255, the same shape of transform as the
|
|
1530
|
+
# ImageNet branch below. See `postprocessors/scrfd.py` for the upstream
|
|
1531
|
+
# citation and the measured effect (188 → 196 GT faces found,
|
|
1532
|
+
# 2026-09-26 model-replacement spike, §2.4-2).
|
|
1533
|
+
_SCRFD_MEAN = 127.5 / 255.0
|
|
1534
|
+
_SCRFD_STD = 128.0 / 255.0
|
|
1535
|
+
|
|
1525
1536
|
|
|
1526
1537
|
def _apply_normalization(arr: "np.ndarray", config: dict) -> "np.ndarray":
|
|
1527
1538
|
"""Apply per-model input normalization to an HWC RGB float array in [0,1].
|
|
1528
1539
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
``
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1540
|
+
``inputNormalization == 'imagenet'`` subtracts the ImageNet per-channel
|
|
1541
|
+
mean and divides by the std (RGB), matching the EfficientNet-Lite0 /
|
|
1542
|
+
MobileNetV3 animal + vehicle classifiers whose ``labels.json`` declares
|
|
1543
|
+
``normalize: imagenet``. ``'scrfd'`` applies InsightFace's SCRFD contract
|
|
1544
|
+
(`_SCRFD_MEAN`/`_SCRFD_STD` above) — the `scrfd-2.5g` catalog entry.
|
|
1545
|
+
Absent / ``'none'`` / ``'zero-one'`` keep the plain ``/255`` array
|
|
1546
|
+
byte-identical to the historical unconditional path (most detectors,
|
|
1547
|
+
CLIP/ArcFace, and the AIY bird classifier which bakes its own scale). Runs
|
|
1548
|
+
on the HWC array BEFORE the optional NHWC/NCHW transpose in
|
|
1549
|
+
:func:`_to_model_tensor`, so it is layout-agnostic (channel axis is always
|
|
1550
|
+
last here). Pure numpy.
|
|
1538
1551
|
"""
|
|
1539
|
-
|
|
1552
|
+
mode = config.get("inputNormalization")
|
|
1553
|
+
if mode not in ("imagenet", "scrfd"):
|
|
1540
1554
|
return arr
|
|
1541
1555
|
if arr.ndim != 3 or arr.shape[-1] != 3:
|
|
1542
1556
|
return arr
|
|
1557
|
+
if mode == "scrfd":
|
|
1558
|
+
return (arr - _SCRFD_MEAN) / _SCRFD_STD
|
|
1543
1559
|
return (arr - _IMAGENET_MEAN) / _IMAGENET_STD
|
|
1544
1560
|
|
|
1545
1561
|
|
|
@@ -1700,6 +1716,13 @@ def _build_ov_ppp_model(core: Any, path: str, config: dict) -> "tuple[Any, dict]
|
|
|
1700
1716
|
steps.scale([255.0, 255.0, 255.0])
|
|
1701
1717
|
steps.mean([float(v) for v in _IMAGENET_MEAN])
|
|
1702
1718
|
steps.scale([float(v) for v in _IMAGENET_STD])
|
|
1719
|
+
elif plan["normalization"] == "scrfd":
|
|
1720
|
+
# ((x/255) - _SCRFD_MEAN) / _SCRFD_STD — InsightFace's SCRFD contract,
|
|
1721
|
+
# folded the same way as the ImageNet branch above (see
|
|
1722
|
+
# `_apply_normalization`/`_SCRFD_MEAN`/`_SCRFD_STD`).
|
|
1723
|
+
steps.scale([255.0, 255.0, 255.0])
|
|
1724
|
+
steps.mean([_SCRFD_MEAN, _SCRFD_MEAN, _SCRFD_MEAN])
|
|
1725
|
+
steps.scale([_SCRFD_STD, _SCRFD_STD, _SCRFD_STD])
|
|
1703
1726
|
else:
|
|
1704
1727
|
steps.scale([255.0, 255.0, 255.0])
|
|
1705
1728
|
built = ppp.build()
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
"""SCRFD face detection postprocessor.
|
|
2
2
|
|
|
3
3
|
Multi-stride anchor-based face detector (strides 8, 16, 32).
|
|
4
|
-
|
|
4
|
+
Originally ported from packages/addon-vision/src/shared/postprocess/scrfd.ts,
|
|
5
|
+
whose Node.js raw-tensor onnxruntime path was retired in favour of doing all
|
|
6
|
+
postprocessing here, in the Python inference pool — that TS file, and the
|
|
7
|
+
addon-vision package it lived in, no longer exist (see the header comment in
|
|
8
|
+
packages/addon-pipeline/src/detection-pipeline/postprocess/dispatch.ts). This
|
|
9
|
+
module is now the sole SCRFD decoder in the repo.
|
|
5
10
|
|
|
6
11
|
Output: {"kind": "detections", "detections": [{"class": "face", "score", "bbox": [x1,y1,x2,y2], "landmarks?"}]}
|
|
7
12
|
"""
|
|
@@ -13,13 +18,26 @@ NUM_ANCHORS_PER_STRIDE = 2
|
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
def _generate_anchors(stride: int, input_size: int) -> list[tuple[float, float]]:
|
|
16
|
-
"""Generate anchor centers for a given stride.
|
|
21
|
+
"""Generate anchor centers for a given stride.
|
|
22
|
+
|
|
23
|
+
Anchor centers are `(x * stride, y * stride)` — NO half-cell `+0.5` offset.
|
|
24
|
+
This matches upstream InsightFace's reference decoder exactly
|
|
25
|
+
(`insightface/model_zoo/scrfd.py`: `anchor_centers = np.stack([anchor_centers_x,
|
|
26
|
+
anchor_centers_y], axis=-1).astype(np.float32)`, built from a raw
|
|
27
|
+
`np.mgrid[:h, :w]` and then `anchor_centers * stride` — no `+0.5` anywhere
|
|
28
|
+
in that path). The previous `(x + 0.5) * stride` formula shifted every box
|
|
29
|
+
and landmark down-and-right by half a stride (4/8/16 px at 640), which is a
|
|
30
|
+
large fraction of a face after the letterbox upscale from a person crop.
|
|
31
|
+
Fixing this moved NME (COCO eyes+nose vs GT) 0.352 → 0.115 and AuraFace
|
|
32
|
+
self-consistency cosine 0.79 → 0.91 (2026-09-26 model-replacement spike,
|
|
33
|
+
docs/superpowers/specs/2026-09-26-replacement-models-spike.md §2.4-1).
|
|
34
|
+
"""
|
|
17
35
|
feat_size = int(np.ceil(input_size / stride))
|
|
18
36
|
anchors = []
|
|
19
37
|
for y in range(feat_size):
|
|
20
38
|
for x in range(feat_size):
|
|
21
39
|
for _ in range(NUM_ANCHORS_PER_STRIDE):
|
|
22
|
-
anchors.append((
|
|
40
|
+
anchors.append((x * stride, y * stride))
|
|
23
41
|
return anchors
|
|
24
42
|
|
|
25
43
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Pins SCRFD's anchor-center placement to InsightFace's upstream convention.
|
|
2
|
+
|
|
3
|
+
The production defect (2026-09-26 model-replacement spike,
|
|
4
|
+
docs/superpowers/specs/2026-09-26-replacement-models-spike.md §2.4-1): anchor
|
|
5
|
+
centers were computed as `(x + 0.5) * stride` (a "half-cell" YOLO-style
|
|
6
|
+
offset). Upstream InsightFace (`insightface/model_zoo/scrfd.py`) uses
|
|
7
|
+
`x * stride` — no half-cell. The offset shifted every box and landmark
|
|
8
|
+
down-and-right by half a stride (4/8/16 px at 640), degrading alignment and
|
|
9
|
+
therefore face-recognition accuracy on every install. Fixing it moved NME
|
|
10
|
+
(COCO eyes+nose GT) 0.352 → 0.115.
|
|
11
|
+
|
|
12
|
+
`unittest.TestCase`, not pytest-style: `scripts/test-python-pool.sh` classifies
|
|
13
|
+
test modules by content and runs the `unittest.TestCase` ones on the bare
|
|
14
|
+
interpreter (no pytest) — the path CI actually exercises
|
|
15
|
+
(`.github/workflows/ci.yml` `Test the inference pool (python)` step). Real
|
|
16
|
+
numpy when present; only `np.ceil` is stubbed on `ImportError` so `scrfd.py`
|
|
17
|
+
still imports and `_generate_anchors` (no ndarray ops, just scalar `np.ceil`)
|
|
18
|
+
still runs correctly — the anchor-formula regression is caught either way. The
|
|
19
|
+
fuller `postprocess_scrfd` round trip needs real ndarray behaviour a stub
|
|
20
|
+
cannot reasonably fake, so it is skipped (not silently passed) without real
|
|
21
|
+
numpy.
|
|
22
|
+
"""
|
|
23
|
+
import math
|
|
24
|
+
import sys
|
|
25
|
+
import types
|
|
26
|
+
import unittest
|
|
27
|
+
|
|
28
|
+
try: # real numpy when the sandbox has it
|
|
29
|
+
import numpy as _numpy_probe # noqa: F401
|
|
30
|
+
except ImportError:
|
|
31
|
+
_numpy_probe = None
|
|
32
|
+
|
|
33
|
+
# `import numpy` can SUCCEED against a MINIMAL STUB another test module in
|
|
34
|
+
# this same `python3 -m unittest` process already installed (this file's own
|
|
35
|
+
# `postprocessors/` subprocess currently runs alone, but this is the same
|
|
36
|
+
# defensive check `test_inference_pool_scrfd_normalization.py` needs — a bare
|
|
37
|
+
# "did the import raise" check is not reliable once more than one stubbing
|
|
38
|
+
# test module shares a process). `ndarray` is real-numpy-only.
|
|
39
|
+
_HAS_REAL_NUMPY = _numpy_probe is not None and hasattr(_numpy_probe, "ndarray")
|
|
40
|
+
if _numpy_probe is None:
|
|
41
|
+
_np = types.ModuleType("numpy")
|
|
42
|
+
_np.ceil = math.ceil # the only numpy call `_generate_anchors` makes
|
|
43
|
+
sys.modules["numpy"] = _np
|
|
44
|
+
|
|
45
|
+
from scrfd import _generate_anchors, postprocess_scrfd # noqa: E402
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AnchorCentersHaveNoHalfCellOffsetTest(unittest.TestCase):
|
|
49
|
+
"""Numpy-independent: `_generate_anchors` only calls `np.ceil` on a
|
|
50
|
+
scalar, so this runs — and catches the regression — with or without real
|
|
51
|
+
numpy installed."""
|
|
52
|
+
|
|
53
|
+
def test_first_anchor_sits_at_the_pixel_origin(self):
|
|
54
|
+
# stride=8, input_size=16 → feat_size=2 → grid x,y in {0,1}. The FIRST
|
|
55
|
+
# anchor center (grid cell 0,0) must sit at the pixel origin (0,0),
|
|
56
|
+
# not at the half-cell (4,4) the old `(x+0.5)*stride` formula
|
|
57
|
+
# produced.
|
|
58
|
+
anchors = _generate_anchors(stride=8, input_size=16)
|
|
59
|
+
self.assertEqual(anchors[0], (0.0, 0.0))
|
|
60
|
+
self.assertEqual(anchors[1], (0.0, 0.0)) # NUM_ANCHORS_PER_STRIDE=2
|
|
61
|
+
|
|
62
|
+
def test_anchor_centers_match_upstream_grid_exactly(self):
|
|
63
|
+
stride, input_size = 16, 32 # feat_size = 2
|
|
64
|
+
anchors = _generate_anchors(stride, input_size)
|
|
65
|
+
# Upstream: anchor_centers = mgrid(feat, feat) * stride — plain
|
|
66
|
+
# multiples of stride, row-major (y outer, x inner), 2 anchors/cell.
|
|
67
|
+
expected = []
|
|
68
|
+
for y in range(2):
|
|
69
|
+
for x in range(2):
|
|
70
|
+
expected.append((x * stride, y * stride))
|
|
71
|
+
expected.append((x * stride, y * stride))
|
|
72
|
+
self.assertEqual(anchors, expected)
|
|
73
|
+
# The rejected half-cell formula would have placed the first center
|
|
74
|
+
# at (8.0, 8.0), not (0.0, 0.0) — the exact regression this test
|
|
75
|
+
# would catch if `_generate_anchors` reverted to `(x + 0.5) * stride`.
|
|
76
|
+
self.assertNotEqual(anchors[0], (stride / 2, stride / 2))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _single_anchor_output(np_mod, stride: int, input_size: int, box_offsets, kps_offsets=None):
|
|
80
|
+
"""Build a raw SCRFD-shaped output with exactly ONE live anchor (grid cell
|
|
81
|
+
0,0, first of the two anchors), all others below threshold. Needs real
|
|
82
|
+
ndarray behaviour, so only called from the real-numpy-gated tests below."""
|
|
83
|
+
feat = int(np_mod.ceil(input_size / stride))
|
|
84
|
+
n = feat * feat * 2
|
|
85
|
+
scores = np_mod.zeros((n, 1), dtype=np_mod.float32)
|
|
86
|
+
scores[0, 0] = 0.99
|
|
87
|
+
bboxes = np_mod.zeros((n, 4), dtype=np_mod.float32)
|
|
88
|
+
bboxes[0] = box_offsets
|
|
89
|
+
out = {f"score_{stride}": scores, f"bbox_{stride}": bboxes}
|
|
90
|
+
if kps_offsets is not None:
|
|
91
|
+
kps = np_mod.zeros((n, 10), dtype=np_mod.float32)
|
|
92
|
+
kps[0] = kps_offsets
|
|
93
|
+
out[f"kps_{stride}"] = kps
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@unittest.skipUnless(_HAS_REAL_NUMPY, "postprocess_scrfd round trip needs real ndarray ops")
|
|
98
|
+
class PostprocessScrfdRoundTripTest(unittest.TestCase):
|
|
99
|
+
def setUp(self):
|
|
100
|
+
import numpy as np
|
|
101
|
+
|
|
102
|
+
self.np = np
|
|
103
|
+
|
|
104
|
+
def test_box_position_uses_unshifted_anchor_center(self):
|
|
105
|
+
# Grid cell (0,0) at stride 8 → anchor center (0,0) after the fix. A
|
|
106
|
+
# symmetric box offset of 5 stride-units around the anchor center
|
|
107
|
+
# lands the box at [0, 0, 40, 40] pre-clamp on the un-clamped side.
|
|
108
|
+
stride, input_size = 8, 640
|
|
109
|
+
raw = _single_anchor_output(self.np, stride, input_size, box_offsets=[5.0, 5.0, 5.0, 5.0])
|
|
110
|
+
out = postprocess_scrfd(
|
|
111
|
+
raw,
|
|
112
|
+
{"confidence": 0.5, "inputSize": input_size, "strides": [stride]},
|
|
113
|
+
orig_w=1000,
|
|
114
|
+
orig_h=1000,
|
|
115
|
+
scale=1.0,
|
|
116
|
+
pad=(0, 0),
|
|
117
|
+
)
|
|
118
|
+
dets = out["detections"]
|
|
119
|
+
self.assertEqual(len(dets), 1)
|
|
120
|
+
_x1, _y1, x2, y2 = dets[0]["bbox"]
|
|
121
|
+
self.assertEqual(x2, 40.0) # 0 + 5*8
|
|
122
|
+
self.assertEqual(y2, 40.0)
|
|
123
|
+
# With the OLD (x+0.5)*stride formula the anchor center would have
|
|
124
|
+
# been (4, 4) and x2/y2 would be 44.0, not 40.0 — the regression
|
|
125
|
+
# guard.
|
|
126
|
+
self.assertNotEqual(x2, 44.0)
|
|
127
|
+
|
|
128
|
+
def test_landmarks_use_unshifted_anchor_center(self):
|
|
129
|
+
stride, input_size = 16, 640
|
|
130
|
+
kps = [1.0, 1.0] * 5 # 5 landmarks, each offset (1,1) stride-units
|
|
131
|
+
raw = _single_anchor_output(
|
|
132
|
+
self.np, stride, input_size, box_offsets=[1.0, 1.0, 1.0, 1.0], kps_offsets=kps
|
|
133
|
+
)
|
|
134
|
+
out = postprocess_scrfd(
|
|
135
|
+
raw,
|
|
136
|
+
{"confidence": 0.5, "inputSize": input_size, "strides": [stride]},
|
|
137
|
+
orig_w=1000,
|
|
138
|
+
orig_h=1000,
|
|
139
|
+
scale=1.0,
|
|
140
|
+
pad=(0, 0),
|
|
141
|
+
)
|
|
142
|
+
dets = out["detections"]
|
|
143
|
+
self.assertEqual(len(dets), 1)
|
|
144
|
+
for p in dets[0]["landmarks"]:
|
|
145
|
+
# anchor (0,0) + 1*stride = 16, not 8 (the half-cell) + 16 = 24.
|
|
146
|
+
self.assertEqual(p["x"], 16.0)
|
|
147
|
+
self.assertEqual(p["y"], 16.0)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
unittest.main()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Pins the OpenVINO PPP fold's `'scrfd'` branch (`_build_ov_ppp_model`,
|
|
2
|
+
`inference_pool.py` ~1719) — the code path that folds InsightFace's SCRFD
|
|
3
|
+
input contract (`(x-127.5)/128`) into a compiled OpenVINO graph, mirrored
|
|
4
|
+
against `_apply_normalization`'s plain-Python path (covered separately by
|
|
5
|
+
`test_inference_pool_scrfd_normalization.py`).
|
|
6
|
+
|
|
7
|
+
`_ov_ppp_plan` (tested elsewhere) only proves the CONFIG VALUE `'scrfd'`
|
|
8
|
+
reaches a plan dict — it does not touch `_build_ov_ppp_model`'s actual
|
|
9
|
+
`elif plan["normalization"] == "scrfd":` branch, so renaming or deleting that
|
|
10
|
+
branch (falling through to the `else` — plain `/255`, no shift) would leave
|
|
11
|
+
every other test green. This file drives `_build_ov_ppp_model` itself and
|
|
12
|
+
asserts the exact preprocessing STEPS it records for `'scrfd'`.
|
|
13
|
+
|
|
14
|
+
No real OpenVINO install needed: `openvino` / `openvino.preprocess` are
|
|
15
|
+
FAKE modules that record every `PrePostProcessor` call instead of running one,
|
|
16
|
+
built fresh in `setUp` and removed in `tearDown` so nothing leaks into a
|
|
17
|
+
sibling test run in the same `python3 -m unittest` process (this repo's own
|
|
18
|
+
warning: "a blind stub POISONS the whole session"). If real `openvino` is
|
|
19
|
+
importable, this test still uses its own fakes (swapped in for the duration
|
|
20
|
+
of the test only) rather than depending on optional real behaviour — so it
|
|
21
|
+
runs the same way whether or not OpenVINO is installed.
|
|
22
|
+
|
|
23
|
+
`unittest.TestCase`, not pytest-style — runs on the bare interpreter, no
|
|
24
|
+
pytest needed (`scripts/test-python-pool.sh`, the path CI exercises).
|
|
25
|
+
"""
|
|
26
|
+
import sys
|
|
27
|
+
import types
|
|
28
|
+
import unittest
|
|
29
|
+
|
|
30
|
+
try: # real numpy when the sandbox has it — a blind stub POISONS the whole
|
|
31
|
+
import numpy # noqa: F401 # pytest session for every sibling that needs it
|
|
32
|
+
except ImportError:
|
|
33
|
+
_np = types.ModuleType("numpy")
|
|
34
|
+
_np.array = lambda seq, dtype=None: list(seq) # noqa: E731
|
|
35
|
+
_np.float32 = "float32"
|
|
36
|
+
sys.modules["numpy"] = _np
|
|
37
|
+
try: # real PIL when present, for the same reason
|
|
38
|
+
import PIL.Image # noqa: F401
|
|
39
|
+
except ImportError:
|
|
40
|
+
_pil = types.ModuleType("PIL")
|
|
41
|
+
_pil_image = types.ModuleType("PIL.Image")
|
|
42
|
+
_pil.Image = _pil_image
|
|
43
|
+
sys.modules["PIL"] = _pil
|
|
44
|
+
sys.modules["PIL.Image"] = _pil_image
|
|
45
|
+
if "postprocessors" not in sys.modules:
|
|
46
|
+
_pp = types.ModuleType("postprocessors")
|
|
47
|
+
_pp.POSTPROCESSORS = {}
|
|
48
|
+
sys.modules["postprocessors"] = _pp
|
|
49
|
+
|
|
50
|
+
import inference_pool # noqa: E402
|
|
51
|
+
from inference_pool import _SCRFD_MEAN, _SCRFD_STD, _build_ov_ppp_model # noqa: E402
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Fakes standing in for the real `openvino` / `openvino.preprocess` API
|
|
56
|
+
# surface `_build_ov_ppp_model` calls. Each records the calls made on it.
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _FakeSteps:
|
|
61
|
+
"""Stands in for OpenVINO's `PreProcessSteps` — every call is recorded,
|
|
62
|
+
in order, so the test can assert the EXACT scale/mean sequence."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, calls: list) -> None:
|
|
65
|
+
self._calls = calls
|
|
66
|
+
|
|
67
|
+
def convert_element_type(self, t):
|
|
68
|
+
self._calls.append(("convert_element_type", t))
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def scale(self, v):
|
|
72
|
+
self._calls.append(("scale", tuple(v)))
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def mean(self, v):
|
|
76
|
+
self._calls.append(("mean", tuple(v)))
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _FakeTensorInfo:
|
|
81
|
+
def set_element_type(self, t):
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def set_layout(self, layout):
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _FakeModelInfo:
|
|
89
|
+
def set_layout(self, layout):
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _FakeInputInfo:
|
|
94
|
+
def __init__(self, calls: list) -> None:
|
|
95
|
+
self._steps = _FakeSteps(calls)
|
|
96
|
+
|
|
97
|
+
def tensor(self):
|
|
98
|
+
return _FakeTensorInfo()
|
|
99
|
+
|
|
100
|
+
def model(self):
|
|
101
|
+
return _FakeModelInfo()
|
|
102
|
+
|
|
103
|
+
def preprocess(self):
|
|
104
|
+
return self._steps
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _FakePrePostProcessor:
|
|
108
|
+
"""Records every preprocessing step applied, keyed by the model it was
|
|
109
|
+
built for — `_build_ov_ppp_model` builds exactly one per call."""
|
|
110
|
+
|
|
111
|
+
last_calls: "list | None" = None
|
|
112
|
+
|
|
113
|
+
def __init__(self, model) -> None:
|
|
114
|
+
self._calls: list = []
|
|
115
|
+
_FakePrePostProcessor.last_calls = self._calls
|
|
116
|
+
self._input = _FakeInputInfo(self._calls)
|
|
117
|
+
|
|
118
|
+
def input(self):
|
|
119
|
+
return self._input
|
|
120
|
+
|
|
121
|
+
def build(self):
|
|
122
|
+
return "BUILT_MODEL"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _FakeLayout:
|
|
126
|
+
def __init__(self, name: str) -> None:
|
|
127
|
+
self.name = name
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _FakeType:
|
|
131
|
+
u8 = "u8"
|
|
132
|
+
f32 = "f32"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _FakeDim:
|
|
136
|
+
"""Mimics an OpenVINO static Dimension (see `test_inference_pool_ov_ppp.py`)."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, length: int) -> None:
|
|
139
|
+
self._length = length
|
|
140
|
+
self.is_static = True
|
|
141
|
+
|
|
142
|
+
def __int__(self) -> int:
|
|
143
|
+
raise TypeError("Dimension is not directly int()-able")
|
|
144
|
+
|
|
145
|
+
def get_length(self) -> int:
|
|
146
|
+
return self._length
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _FakeInput:
|
|
150
|
+
def __init__(self, dims) -> None:
|
|
151
|
+
self._dims = dims
|
|
152
|
+
|
|
153
|
+
def get_partial_shape(self):
|
|
154
|
+
return self._dims
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _FakeModel:
|
|
158
|
+
def __init__(self, dims) -> None:
|
|
159
|
+
self._input = _FakeInput(dims)
|
|
160
|
+
|
|
161
|
+
def input(self):
|
|
162
|
+
return self._input
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _FakeCore:
|
|
166
|
+
def __init__(self, dims) -> None:
|
|
167
|
+
self._dims = dims
|
|
168
|
+
|
|
169
|
+
def read_model(self, path):
|
|
170
|
+
return _FakeModel(self._dims)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# A standard 640x640 NCHW 3-channel static-shape detector input — the shape
|
|
174
|
+
# `_ov_ppp_plan` requires to fold preprocessing at all.
|
|
175
|
+
_NCHW_640_DIMS = [_FakeDim(1), _FakeDim(3), _FakeDim(640), _FakeDim(640)]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class BuildOvPppModelScrfdBranchTest(unittest.TestCase):
|
|
179
|
+
def setUp(self):
|
|
180
|
+
# Swap in the fakes for the duration of this test ONLY, whether or
|
|
181
|
+
# not real openvino/openvino.preprocess were already in sys.modules —
|
|
182
|
+
# restored in tearDown so nothing leaks into a sibling test run in
|
|
183
|
+
# the same `python3 -m unittest` process.
|
|
184
|
+
self._saved_openvino = sys.modules.get("openvino")
|
|
185
|
+
self._saved_openvino_preprocess = sys.modules.get("openvino.preprocess")
|
|
186
|
+
|
|
187
|
+
fake_openvino = types.ModuleType("openvino")
|
|
188
|
+
fake_openvino.Layout = _FakeLayout
|
|
189
|
+
fake_openvino.Type = _FakeType
|
|
190
|
+
fake_preprocess = types.ModuleType("openvino.preprocess")
|
|
191
|
+
fake_preprocess.PrePostProcessor = _FakePrePostProcessor
|
|
192
|
+
fake_openvino.preprocess = fake_preprocess
|
|
193
|
+
|
|
194
|
+
sys.modules["openvino"] = fake_openvino
|
|
195
|
+
sys.modules["openvino.preprocess"] = fake_preprocess
|
|
196
|
+
|
|
197
|
+
def tearDown(self):
|
|
198
|
+
for name, saved in (
|
|
199
|
+
("openvino", self._saved_openvino),
|
|
200
|
+
("openvino.preprocess", self._saved_openvino_preprocess),
|
|
201
|
+
):
|
|
202
|
+
if saved is None:
|
|
203
|
+
sys.modules.pop(name, None)
|
|
204
|
+
else:
|
|
205
|
+
sys.modules[name] = saved
|
|
206
|
+
|
|
207
|
+
def test_scrfd_branch_applies_the_scrfd_mean_and_std(self):
|
|
208
|
+
core = _FakeCore(_NCHW_640_DIMS)
|
|
209
|
+
config = {
|
|
210
|
+
"inputSize": 640,
|
|
211
|
+
"inputWidth": 640,
|
|
212
|
+
"inputHeight": 640,
|
|
213
|
+
"inputChannels": 3,
|
|
214
|
+
"preprocessMode": "letterbox",
|
|
215
|
+
"inputNormalization": "scrfd",
|
|
216
|
+
}
|
|
217
|
+
built, plan = _build_ov_ppp_model(core, "dummy-path", config)
|
|
218
|
+
self.assertEqual(built, "BUILT_MODEL")
|
|
219
|
+
self.assertEqual(plan["normalization"], "scrfd")
|
|
220
|
+
|
|
221
|
+
calls = _FakePrePostProcessor.last_calls
|
|
222
|
+
# convert_element_type, then scale(255) → mean(_SCRFD_MEAN) →
|
|
223
|
+
# scale(_SCRFD_STD) — exactly 4 recorded steps, in this order. If the
|
|
224
|
+
# 'scrfd' branch is removed (falling to the bare `else: scale(255)`),
|
|
225
|
+
# this drops to 2 steps and the assertion below fails.
|
|
226
|
+
self.assertEqual(
|
|
227
|
+
calls,
|
|
228
|
+
[
|
|
229
|
+
("convert_element_type", "f32"),
|
|
230
|
+
("scale", (255.0, 255.0, 255.0)),
|
|
231
|
+
("mean", (_SCRFD_MEAN, _SCRFD_MEAN, _SCRFD_MEAN)),
|
|
232
|
+
("scale", (_SCRFD_STD, _SCRFD_STD, _SCRFD_STD)),
|
|
233
|
+
],
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def test_scrfd_branch_is_distinct_from_imagenet_and_none(self):
|
|
237
|
+
core = _FakeCore(_NCHW_640_DIMS)
|
|
238
|
+
|
|
239
|
+
def steps_for(normalization):
|
|
240
|
+
config = {
|
|
241
|
+
"inputSize": 640,
|
|
242
|
+
"inputWidth": 640,
|
|
243
|
+
"inputHeight": 640,
|
|
244
|
+
"inputChannels": 3,
|
|
245
|
+
"preprocessMode": "letterbox",
|
|
246
|
+
"inputNormalization": normalization,
|
|
247
|
+
}
|
|
248
|
+
_build_ov_ppp_model(core, "dummy-path", config)
|
|
249
|
+
return _FakePrePostProcessor.last_calls
|
|
250
|
+
|
|
251
|
+
scrfd_calls = steps_for("scrfd")
|
|
252
|
+
imagenet_calls = steps_for("imagenet")
|
|
253
|
+
none_calls = steps_for(None)
|
|
254
|
+
|
|
255
|
+
self.assertEqual(len(scrfd_calls), 4)
|
|
256
|
+
self.assertEqual(len(imagenet_calls), 4)
|
|
257
|
+
self.assertEqual(len(none_calls), 2) # convert_element_type + scale(255) only
|
|
258
|
+
self.assertNotEqual(scrfd_calls, imagenet_calls)
|
|
259
|
+
# The imagenet branch uses the real _IMAGENET_MEAN/_STD, not SCRFD's.
|
|
260
|
+
self.assertNotIn(("mean", (_SCRFD_MEAN, _SCRFD_MEAN, _SCRFD_MEAN)), imagenet_calls)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
unittest.main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Pins the SCRFD input-normalization contract (`inputNormalization: 'scrfd'`).
|
|
2
|
+
|
|
3
|
+
The production defect (2026-09-26 model-replacement spike,
|
|
4
|
+
docs/superpowers/specs/2026-09-26-replacement-models-spike.md §2.4-2): the
|
|
5
|
+
pool fed SCRFD the historical plain `/255` ([0,1]) tensor, but upstream
|
|
6
|
+
InsightFace expects `(pixel - 127.5) / 128` ([-0.996, 0.996]). The re-hosted
|
|
7
|
+
ONNX/OpenVINO/CoreML artifacts are unmodified upstream graphs (first op is a
|
|
8
|
+
Conv — nothing absorbs the shift), so this has to be threaded through the
|
|
9
|
+
runtime preprocessor rather than baked into the graph (contrast with
|
|
10
|
+
AuraFace/CLIP, whose graphs bake their own normalization).
|
|
11
|
+
|
|
12
|
+
This one contract, `_apply_normalization`, is the SAME function the ONNX path
|
|
13
|
+
and the CoreML `multiArrayType` path both call — verified 2026-09-26 that the
|
|
14
|
+
hosted `camstack-scrfd-2.5g.mlpackage`'s input is `multiArrayType`, not
|
|
15
|
+
`imageType`, so it goes through this same function. The OpenVINO PPP fast
|
|
16
|
+
path (`_build_ov_ppp_model`) folds the identical scale/mean/scale sequence
|
|
17
|
+
into the compiled graph instead (covered separately, without needing a real
|
|
18
|
+
OpenVINO install, by `test_inference_pool_ov_ppp_scrfd.py`), and is mutually
|
|
19
|
+
exclusive with `_apply_normalization` (`_preprocess` returns early on the PPP
|
|
20
|
+
fast path, `inference_pool.py` ~1828) — so there is no double application on
|
|
21
|
+
any backend.
|
|
22
|
+
|
|
23
|
+
`unittest.TestCase`, not pytest-style: `scripts/test-python-pool.sh` runs the
|
|
24
|
+
`unittest.TestCase` modules on the bare interpreter (no pytest) — the path CI
|
|
25
|
+
actually exercises. Heavy deps (numpy/PIL/postprocessors) are stubbed on
|
|
26
|
+
`ImportError`, the same pattern as `test_inference_pool_ov_ppp.py`, so the
|
|
27
|
+
module always imports; the tests that need real ndarray arithmetic are
|
|
28
|
+
skipped (not silently passed) without real numpy, while the ones that don't
|
|
29
|
+
(the constants pin, and `_ov_ppp_plan`'s pure config threading) always run.
|
|
30
|
+
|
|
31
|
+
Run: python3 -m unittest test_inference_pool_scrfd_normalization -v
|
|
32
|
+
"""
|
|
33
|
+
import sys
|
|
34
|
+
import types
|
|
35
|
+
import unittest
|
|
36
|
+
|
|
37
|
+
try: # real numpy when the sandbox has it — a blind stub POISONS the whole
|
|
38
|
+
import numpy as _numpy_probe # noqa: F401 # session for every sibling that needs it
|
|
39
|
+
except ImportError:
|
|
40
|
+
_numpy_probe = None
|
|
41
|
+
|
|
42
|
+
# `import numpy` can SUCCEED against a MINIMAL STUB another test module in
|
|
43
|
+
# this same `python3 -m unittest` process already installed into
|
|
44
|
+
# `sys.modules['numpy']` (`test_inference_pool_ov_ppp.py` and siblings do the
|
|
45
|
+
# same guard) — a bare "did the import raise" check would then wrongly treat
|
|
46
|
+
# that stub as real numpy and crash on the first `arr.ndim` access. `ndarray`
|
|
47
|
+
# is real-numpy-only (no stub in this file tree defines it), so check for it
|
|
48
|
+
# instead of trusting import success alone.
|
|
49
|
+
_HAS_REAL_NUMPY = _numpy_probe is not None and hasattr(_numpy_probe, "ndarray")
|
|
50
|
+
if _numpy_probe is None:
|
|
51
|
+
_np = types.ModuleType("numpy")
|
|
52
|
+
# `inference_pool` builds `_IMAGENET_MEAN = np.array(...)` at import time.
|
|
53
|
+
_np.array = lambda seq, dtype=None: list(seq) # noqa: E731
|
|
54
|
+
_np.float32 = "float32"
|
|
55
|
+
sys.modules["numpy"] = _np
|
|
56
|
+
try: # real PIL when present, for the same reason
|
|
57
|
+
import PIL.Image # noqa: F401
|
|
58
|
+
except ImportError:
|
|
59
|
+
_pil = types.ModuleType("PIL")
|
|
60
|
+
_pil_image = types.ModuleType("PIL.Image")
|
|
61
|
+
_pil.Image = _pil_image
|
|
62
|
+
sys.modules["PIL"] = _pil
|
|
63
|
+
sys.modules["PIL.Image"] = _pil_image
|
|
64
|
+
if "postprocessors" not in sys.modules:
|
|
65
|
+
_pp = types.ModuleType("postprocessors")
|
|
66
|
+
_pp.POSTPROCESSORS = {}
|
|
67
|
+
sys.modules["postprocessors"] = _pp
|
|
68
|
+
|
|
69
|
+
import inference_pool # noqa: E402
|
|
70
|
+
from inference_pool import ( # noqa: E402
|
|
71
|
+
_SCRFD_MEAN,
|
|
72
|
+
_SCRFD_STD,
|
|
73
|
+
_apply_normalization,
|
|
74
|
+
_ov_ppp_plan,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ScrfdNormalizationConstantsTest(unittest.TestCase):
|
|
79
|
+
"""Plain floats, no ndarray ops — always runs, with or without numpy."""
|
|
80
|
+
|
|
81
|
+
def test_constants_match_upstream_formula(self):
|
|
82
|
+
# _SCRFD_MEAN/_SCRFD_STD are the pool's own pre-divided-by-255
|
|
83
|
+
# constants; cross-check them against the raw-pixel upstream formula
|
|
84
|
+
# `(pixel - 127.5) / 128` directly, so a future edit to either
|
|
85
|
+
# constant is caught here rather than only in the end-to-end face
|
|
86
|
+
# count.
|
|
87
|
+
self.assertAlmostEqual(_SCRFD_MEAN, 127.5 / 255.0)
|
|
88
|
+
self.assertAlmostEqual(_SCRFD_STD, 128.0 / 255.0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OvPppPlanThreadsScrfdTest(unittest.TestCase):
|
|
92
|
+
"""`_ov_ppp_plan` is pure Python (no numpy) — always runs."""
|
|
93
|
+
|
|
94
|
+
def test_scrfd_normalization_reaches_the_plan(self):
|
|
95
|
+
plan = _ov_ppp_plan(
|
|
96
|
+
dims=[1, 3, 640, 640],
|
|
97
|
+
input_channels=3,
|
|
98
|
+
preprocess_mode="letterbox",
|
|
99
|
+
normalization="scrfd",
|
|
100
|
+
enabled=True,
|
|
101
|
+
)
|
|
102
|
+
self.assertIsNotNone(plan)
|
|
103
|
+
self.assertEqual(plan["normalization"], "scrfd")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@unittest.skipUnless(_HAS_REAL_NUMPY, "_apply_normalization needs real ndarray ops")
|
|
107
|
+
class ApplyNormalizationScrfdTest(unittest.TestCase):
|
|
108
|
+
def setUp(self):
|
|
109
|
+
import numpy as np
|
|
110
|
+
|
|
111
|
+
self.np = np
|
|
112
|
+
|
|
113
|
+
def _flat_hwc(self, value: float):
|
|
114
|
+
"""A 1x1 HWC array holding a single already-`/255` pixel value."""
|
|
115
|
+
return self.np.array([[[value, value, value]]], dtype=self.np.float32)
|
|
116
|
+
|
|
117
|
+
def test_scrfd_mode_matches_upstream_formula_at_pixel_extremes(self):
|
|
118
|
+
# Upstream, on raw pixels: (pixel - 127.5) / 128.
|
|
119
|
+
# pixel=0 → -0.99609375 ; pixel=255 → 0.99609375 ; pixel=127.5 → 0.0
|
|
120
|
+
for pixel, expected in [(0.0, -0.99609375), (255.0, 0.99609375), (127.5, 0.0)]:
|
|
121
|
+
arr = self._flat_hwc(pixel / 255.0)
|
|
122
|
+
out = _apply_normalization(arr, {"inputNormalization": "scrfd"})
|
|
123
|
+
self.assertEqual(out.shape, arr.shape)
|
|
124
|
+
self.assertAlmostEqual(float(out[0, 0, 0]), expected, places=6)
|
|
125
|
+
|
|
126
|
+
def test_absent_and_none_and_zero_one_are_unchanged(self):
|
|
127
|
+
arr = self._flat_hwc(0.6)
|
|
128
|
+
for config in ({}, {"inputNormalization": "none"}, {"inputNormalization": "zero-one"}):
|
|
129
|
+
out = _apply_normalization(arr, config)
|
|
130
|
+
self.assertTrue(self.np.array_equal(out, arr))
|
|
131
|
+
|
|
132
|
+
def test_imagenet_mode_is_unaffected_by_the_scrfd_branch(self):
|
|
133
|
+
# Regression guard: adding the 'scrfd' branch must not perturb the
|
|
134
|
+
# existing 'imagenet' branch (animal/vehicle classifiers).
|
|
135
|
+
arr = self._flat_hwc(0.5)
|
|
136
|
+
out = _apply_normalization(arr, {"inputNormalization": "imagenet"})
|
|
137
|
+
expected = (arr - inference_pool._IMAGENET_MEAN) / inference_pool._IMAGENET_STD
|
|
138
|
+
self.assertTrue(self.np.allclose(out, expected))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
unittest.main()
|