@trillboards/edge-sdk 0.2.85 → 0.2.91
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/deploy/detector/README.md +76 -7
- package/deploy/detector/bench/count_quantization.py +174 -0
- package/deploy/detector/bench/quantization-control.json +52 -0
- package/deploy/detector/bench/run_candidate.py +993 -0
- package/deploy/detector/bench/verify_detector_decode.py +574 -0
- package/deploy/detector/bench/verify_yolo_decode.py +135 -0
- package/deploy/detector/tests/test_decode.py +559 -0
- package/deploy/detector/tests/test_loader.py +10 -1
- package/deploy/detector/tests/test_registry.py +42 -4
- package/deploy/detector/tests/test_server.py +9 -1
- package/deploy/detector/tests/test_session.py +402 -1
- package/deploy/detector/trillboards_detector/decode.py +373 -0
- package/deploy/detector/trillboards_detector/labels.py +139 -0
- package/deploy/detector/trillboards_detector/loader.py +15 -1
- package/deploy/detector/trillboards_detector/registry.py +9 -0
- package/deploy/detector/trillboards_detector/session.py +233 -26
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/sensingCatchRate.d.ts +69 -5
- package/dist/sensingCatchRate.d.ts.map +1 -1
- package/dist/sensingCatchRate.js +41 -2
- package/dist/sensingCatchRate.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""verify_detector_decode — are the YOLO decodes the same instrument Ultralytics is?
|
|
3
|
+
|
|
4
|
+
`verify_against_reference.py` does this for the RF-DETR path against the `rfdetr`
|
|
5
|
+
package. This file does it for the two Ultralytics layouts against `ultralytics`
|
|
6
|
+
itself, and it compares BOXES rather than presence, because boxes are where every
|
|
7
|
+
one of this decode's failure modes lives: a transposed axis, a doubled sigmoid,
|
|
8
|
+
suppression skipped or run twice, and a coordinate space read in the wrong units
|
|
9
|
+
all leave the class names and the frame count untouched.
|
|
10
|
+
|
|
11
|
+
THREE ARMS, EACH ISOLATING ONE VARIABLE
|
|
12
|
+
---------------------------------------
|
|
13
|
+
`--arm decode` (the strict one). Our decode and Ultralytics'
|
|
14
|
+
`utils/nms.non_max_suppression` + `utils/ops.scale_boxes` run on THE SAME RAW
|
|
15
|
+
TENSOR. Preprocessing, the model and the frame are all held identical, so the
|
|
16
|
+
only thing that can differ is the arithmetic under test. Agreement is asserted in
|
|
17
|
+
pixels, on every detection, at 0.0.
|
|
18
|
+
|
|
19
|
+
`--arm pipeline`. The whole service path — JPEG in, `OnnxDetector.detect` out —
|
|
20
|
+
against `YOLO(...).predict()`. The reference is handed OUR letterboxed 640x640
|
|
21
|
+
frame, so its own `LetterBox` is the identity and the resize kernel drops out.
|
|
22
|
+
Without that step the two arms disagree for a reason that is not a decode bug:
|
|
23
|
+
Pillow's BILINEAR low-passes on downscale and Ultralytics' cv2 INTER_LINEAR does
|
|
24
|
+
not, and on a 1200x900 frame that moved one class from 0.18 to 0.51. That is §5
|
|
25
|
+
rule 1 one level down — an arm at one resize kernel compared against an arm at
|
|
26
|
+
another is not a comparison — and it is the same trap as comparing against the
|
|
27
|
+
`.pt`, which letterboxes to 640x480 while the ONNX graph is fixed 640x640.
|
|
28
|
+
|
|
29
|
+
`--arm litert`. The SAME weights through both runtimes, decoded by THIS decode,
|
|
30
|
+
with each side's DECLARED `box_scale_to_pixels` applied. This is the arm the
|
|
31
|
+
`box_units` column exists for: 16 of 17 committed twins emit ONNX boxes in pixels
|
|
32
|
+
and LiteRT boxes in 0..1, every shape and dtype check passes on the pair, and a
|
|
33
|
+
consumer reusing the ONNX decode draws every box 640x too small in silence. It
|
|
34
|
+
needs a manifest naming each twin's declared units (`--litert-manifest`), because
|
|
35
|
+
the point is to check a DECLARATION against a measurement — deriving the units
|
|
36
|
+
from the tensor here would make the arm prove nothing.
|
|
37
|
+
|
|
38
|
+
Needs `ultralytics` (and `ai_edge_litert` for the litert arm), neither of which is
|
|
39
|
+
a dependency of this package: `bench/` is excluded from the wheel, and a
|
|
40
|
+
measurement-only dependency must never reach a connector's runtime. A documented
|
|
41
|
+
manual run, never CI.
|
|
42
|
+
|
|
43
|
+
python3 bench/verify_detector_decode.py --arm decode \
|
|
44
|
+
--models-dir /tmp/onnx --frames-dir /tmp/frames [--limit 8]
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import argparse
|
|
50
|
+
import json
|
|
51
|
+
import sys
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # noqa: E402
|
|
55
|
+
|
|
56
|
+
from trillboards_detector.decode import ( # noqa: E402
|
|
57
|
+
ANCHOR_4_PLUS_NC,
|
|
58
|
+
END2END_N_6,
|
|
59
|
+
NMS_IOU_THRESHOLD,
|
|
60
|
+
YOLO_DECODERS,
|
|
61
|
+
)
|
|
62
|
+
from trillboards_detector.labels import ( # noqa: E402
|
|
63
|
+
BOX_UNITS_NORMALIZED,
|
|
64
|
+
BOX_UNITS_PIXELS,
|
|
65
|
+
ModelVocabulary,
|
|
66
|
+
VariantContract,
|
|
67
|
+
)
|
|
68
|
+
from trillboards_detector.session import ( # noqa: E402
|
|
69
|
+
LETTERBOX_FILL,
|
|
70
|
+
OnnxDetector,
|
|
71
|
+
preprocess_letterbox,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
THRESHOLD = 0.25
|
|
75
|
+
|
|
76
|
+
#: A score tolerance, ENFORCED, and separate from the box tolerance because the
|
|
77
|
+
#: two catch different faults. `d_score` used to be computed and only reported,
|
|
78
|
+
#: so a decode whose boxes and counts were right and whose CONFIDENCES were
|
|
79
|
+
#: wrong printed AGREE — which is exactly the sigmoid-applied-twice fault this
|
|
80
|
+
#: file's header names, and it would have invalidated every threshold and score
|
|
81
|
+
#: card downstream while the strict arm called it clean. The default is 0.0: the
|
|
82
|
+
#: `decode` arm feeds both sides the same tensor, so any difference at all is
|
|
83
|
+
#: arithmetic, not noise.
|
|
84
|
+
DEFAULT_SCORE_TOLERANCE = 0.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _graph_layout(session) -> str:
|
|
88
|
+
"""The layout of an artifact under test, for a BENCH and nowhere else.
|
|
89
|
+
|
|
90
|
+
The service never does this — its layout is declared and checked. A bench
|
|
91
|
+
sweeping a directory of candidates has no registry row to read, and inventing
|
|
92
|
+
one by hand for 96 files would be a hand-list nobody could trust. So the
|
|
93
|
+
shape is read HERE, in the file whose whole job is to check the decodes, and
|
|
94
|
+
the service's refusal to do the same is the property being protected.
|
|
95
|
+
"""
|
|
96
|
+
outputs = session.get_outputs()
|
|
97
|
+
if len(outputs) == 2:
|
|
98
|
+
return "rfdetr_dets_labels"
|
|
99
|
+
shape = outputs[0].shape
|
|
100
|
+
if len(shape) == 3 and shape[2] == 6:
|
|
101
|
+
return END2END_N_6
|
|
102
|
+
if len(shape) == 3:
|
|
103
|
+
return ANCHOR_4_PLUS_NC
|
|
104
|
+
raise SystemExit(f"cannot classify {[o.shape for o in outputs]}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _names(model_path: Path) -> "dict[int, str]":
|
|
108
|
+
"""Ultralytics writes its class list into the ONNX metadata; read it there."""
|
|
109
|
+
import ast
|
|
110
|
+
|
|
111
|
+
import onnxruntime as ort
|
|
112
|
+
|
|
113
|
+
opts = ort.SessionOptions()
|
|
114
|
+
opts.log_severity_level = 3
|
|
115
|
+
session = ort.InferenceSession(str(model_path), sess_options=opts, providers=["CPUExecutionProvider"])
|
|
116
|
+
raw = session.get_modelmeta().custom_metadata_map.get("names")
|
|
117
|
+
if not raw:
|
|
118
|
+
raise SystemExit(f"{model_path.name} carries no `names` metadata; a class list cannot be guessed")
|
|
119
|
+
return {int(k): str(v) for k, v in ast.literal_eval(raw).items()}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _canonical(name: str) -> str:
|
|
123
|
+
return name.strip().lower().replace("_", " ").replace("-", " ")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _vocabulary(model_path: Path, layout: str, units=BOX_UNITS_PIXELS, scale=1.0) -> ModelVocabulary:
|
|
127
|
+
names = _names(model_path)
|
|
128
|
+
slots: "dict[int, str]" = {}
|
|
129
|
+
for slot, raw in names.items():
|
|
130
|
+
folded = _canonical(raw)
|
|
131
|
+
# A bench vocabulary must still be a legal one: two slots sharing a name
|
|
132
|
+
# would make a class filter ambiguous, and `ModelVocabulary` refuses it.
|
|
133
|
+
while folded in slots.values():
|
|
134
|
+
folded = f"{folded} {slot}"
|
|
135
|
+
slots[slot] = folded
|
|
136
|
+
return ModelVocabulary(
|
|
137
|
+
model_name="bench",
|
|
138
|
+
slot_names=slots,
|
|
139
|
+
logit_slots=len(slots),
|
|
140
|
+
approx_ram_bytes=1,
|
|
141
|
+
onnx_variant=VariantContract(layout, units, scale),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _sorted_index(scores, classes):
|
|
146
|
+
import numpy as np
|
|
147
|
+
|
|
148
|
+
return np.lexsort((classes, -scores))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _compare(mine, theirs, tolerance_px: float, score_tolerance: float, label: str, problems: list):
|
|
152
|
+
"""Count, class, box AND score. All four are enforced; none is only reported.
|
|
153
|
+
|
|
154
|
+
THE SCORE CHECK IS NOT OPTIONAL. Boxes and counts can agree exactly while
|
|
155
|
+
confidences do not — a doubled sigmoid maps a monotone score to a monotone
|
|
156
|
+
score, so on a frame whose ranking does not change it moves every confidence
|
|
157
|
+
and nothing else. Computing `d_score` and not asserting on it is how that
|
|
158
|
+
fault passes the strict arm, and every threshold and score card downstream
|
|
159
|
+
is a function of the confidences rather than of the boxes.
|
|
160
|
+
"""
|
|
161
|
+
import numpy as np
|
|
162
|
+
|
|
163
|
+
(mb, ms, mc), (rb, rs, rc) = mine, theirs
|
|
164
|
+
if mb.shape[0] != rb.shape[0]:
|
|
165
|
+
problems.append(f"{label}: detection COUNT differs — ours {mb.shape[0]}, reference {rb.shape[0]}")
|
|
166
|
+
return 0, 0.0, 0.0
|
|
167
|
+
if mb.shape[0] == 0:
|
|
168
|
+
return 0, 0.0, 0.0
|
|
169
|
+
a, b = _sorted_index(ms, mc), _sorted_index(rs, rc)
|
|
170
|
+
if not np.array_equal(mc[a], rc[b]):
|
|
171
|
+
problems.append(f"{label}: CLASS assignment differs")
|
|
172
|
+
return mb.shape[0], 0.0, 0.0
|
|
173
|
+
d_box = float(np.abs(mb[a] - rb[b]).max())
|
|
174
|
+
d_score = float(np.abs(ms[a] - rs[b]).max())
|
|
175
|
+
if d_box > tolerance_px:
|
|
176
|
+
problems.append(f"{label}: box differs by {d_box:.5f} px (tolerance {tolerance_px})")
|
|
177
|
+
if d_score > score_tolerance:
|
|
178
|
+
problems.append(
|
|
179
|
+
f"{label}: CONFIDENCE differs by {d_score:.3g} (tolerance {score_tolerance})"
|
|
180
|
+
)
|
|
181
|
+
return mb.shape[0], d_box, d_score
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _reference_nms(raw, layout, geometry, width, height):
|
|
185
|
+
"""Ultralytics' own suppression and box rescaling, on our raw tensor."""
|
|
186
|
+
import torch
|
|
187
|
+
from ultralytics.utils import nms as unms
|
|
188
|
+
from ultralytics.utils import ops
|
|
189
|
+
|
|
190
|
+
out = unms.non_max_suppression(
|
|
191
|
+
torch.from_numpy(raw),
|
|
192
|
+
THRESHOLD,
|
|
193
|
+
NMS_IOU_THRESHOLD,
|
|
194
|
+
None,
|
|
195
|
+
False,
|
|
196
|
+
max_det=300,
|
|
197
|
+
nc=0,
|
|
198
|
+
end2end=layout == END2END_N_6,
|
|
199
|
+
)[0]
|
|
200
|
+
boxes = out[:, :4].clone()
|
|
201
|
+
if boxes.shape[0]:
|
|
202
|
+
boxes = ops.scale_boxes((height, width), boxes, (geometry.frame_h, geometry.frame_w))
|
|
203
|
+
return boxes.numpy(), out[:, 4].numpy(), out[:, 5].numpy().astype("int64")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def arm_decode(models, frames, tolerance_px, score_tolerance, problems):
|
|
207
|
+
"""Same raw tensor, two decodes. The strict control."""
|
|
208
|
+
import onnxruntime as ort
|
|
209
|
+
|
|
210
|
+
compared = frames_seen = 0
|
|
211
|
+
worst_box = worst_score = 0.0
|
|
212
|
+
layouts: "dict[str, int]" = {}
|
|
213
|
+
# A MODEL THIS ARM NEVER COMPARED IS NOT A MODEL THIS ARM CHECKED, and the
|
|
214
|
+
# two ways that happens are different facts: `skipped` is a graph whose
|
|
215
|
+
# layout this arm does not handle (an RF-DETR export swept up by a
|
|
216
|
+
# directory glob), `unexercised` is one that handled fine and produced no
|
|
217
|
+
# above-threshold detection on these frames. Counting either into `models`
|
|
218
|
+
# would let "96 models agree" cover artifacts whose decode never ran — the
|
|
219
|
+
# same vacuous-row failure the litert arm already names.
|
|
220
|
+
skipped: "list[str]" = []
|
|
221
|
+
unexercised: "list[str]" = []
|
|
222
|
+
for model_path in models:
|
|
223
|
+
opts = ort.SessionOptions()
|
|
224
|
+
opts.log_severity_level = 3
|
|
225
|
+
session = ort.InferenceSession(
|
|
226
|
+
str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
|
|
227
|
+
)
|
|
228
|
+
layout = _graph_layout(session)
|
|
229
|
+
if layout not in YOLO_DECODERS:
|
|
230
|
+
skipped.append(f"{model_path.name} ({layout})")
|
|
231
|
+
continue
|
|
232
|
+
layouts[layout] = layouts.get(layout, 0) + 1
|
|
233
|
+
model_dets = 0
|
|
234
|
+
graph_in = session.get_inputs()[0]
|
|
235
|
+
_, _, height, width = graph_in.shape
|
|
236
|
+
output_name = session.get_outputs()[0].name
|
|
237
|
+
for frame in frames:
|
|
238
|
+
blob, geometry = preprocess_letterbox(frame.read_bytes(), width, height)
|
|
239
|
+
raw = session.run([output_name], {graph_in.name: blob})[0]
|
|
240
|
+
expected_classes = len(_names(model_path))
|
|
241
|
+
scores, classes, x0, y0, x1, y1 = YOLO_DECODERS[layout](
|
|
242
|
+
raw, geometry, THRESHOLD,
|
|
243
|
+
box_scale_to_pixels=1.0,
|
|
244
|
+
expected_classes=expected_classes,
|
|
245
|
+
)
|
|
246
|
+
import numpy as np
|
|
247
|
+
|
|
248
|
+
ours = (np.stack([x0, y0, x1, y1], axis=1) if scores.shape[0] else np.zeros((0, 4)), scores, classes)
|
|
249
|
+
n, d_box, d_score = _compare(
|
|
250
|
+
ours,
|
|
251
|
+
_reference_nms(raw, layout, geometry, width, height),
|
|
252
|
+
tolerance_px,
|
|
253
|
+
score_tolerance,
|
|
254
|
+
f"{model_path.name} {frame.name}",
|
|
255
|
+
problems,
|
|
256
|
+
)
|
|
257
|
+
compared += n
|
|
258
|
+
model_dets += n
|
|
259
|
+
frames_seen += 1
|
|
260
|
+
worst_box, worst_score = max(worst_box, d_box), max(worst_score, d_score)
|
|
261
|
+
if model_dets == 0:
|
|
262
|
+
unexercised.append(model_path.name)
|
|
263
|
+
return {
|
|
264
|
+
"arm": "decode",
|
|
265
|
+
"models_in_directories": len(models),
|
|
266
|
+
"models_compared": sum(layouts.values()) - len(unexercised),
|
|
267
|
+
"layouts": layouts,
|
|
268
|
+
# NAMED, never merely counted. Each of these produced no evidence and
|
|
269
|
+
# saying which is the whole point of reporting them at all.
|
|
270
|
+
"models_skipped_wrong_layout": skipped,
|
|
271
|
+
"models_with_no_above_threshold_detection": unexercised,
|
|
272
|
+
"frames_decoded": frames_seen,
|
|
273
|
+
"detections_compared": compared,
|
|
274
|
+
"max_box_delta_px": worst_box,
|
|
275
|
+
"max_score_delta": worst_score,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def arm_pipeline(models, frames, tolerance_px, score_tolerance, problems):
|
|
280
|
+
"""The whole service path against `YOLO.predict`, resize kernel held constant."""
|
|
281
|
+
import numpy as np
|
|
282
|
+
import onnxruntime as ort
|
|
283
|
+
from PIL import Image
|
|
284
|
+
from ultralytics import YOLO
|
|
285
|
+
|
|
286
|
+
compared = decisions = frames_seen = 0
|
|
287
|
+
worst_box = worst_score = 0.0
|
|
288
|
+
skipped: "list[str]" = []
|
|
289
|
+
unexercised: "list[str]" = []
|
|
290
|
+
for model_path in models:
|
|
291
|
+
opts = ort.SessionOptions()
|
|
292
|
+
opts.log_severity_level = 3
|
|
293
|
+
probe = ort.InferenceSession(
|
|
294
|
+
str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
|
|
295
|
+
)
|
|
296
|
+
layout = _graph_layout(probe)
|
|
297
|
+
if layout not in YOLO_DECODERS:
|
|
298
|
+
skipped.append(f"{model_path.name} ({layout})")
|
|
299
|
+
continue
|
|
300
|
+
model_dets = 0
|
|
301
|
+
_, _, height, width = probe.get_inputs()[0].shape
|
|
302
|
+
del probe
|
|
303
|
+
|
|
304
|
+
vocabulary = _vocabulary(model_path, layout)
|
|
305
|
+
detector = OnnxDetector(model_path, vocabulary)
|
|
306
|
+
slot_of = {name: slot for slot, name in vocabulary.slot_names.items()}
|
|
307
|
+
reference = YOLO(str(model_path), task="detect")
|
|
308
|
+
|
|
309
|
+
for frame in frames:
|
|
310
|
+
# Letterbox ONCE, to uint8, and feed both arms those bytes: the
|
|
311
|
+
# reference's own LetterBox is then 640 -> 640, i.e. the identity.
|
|
312
|
+
with Image.open(frame) as im:
|
|
313
|
+
rgb = im.convert("RGB")
|
|
314
|
+
src_w, src_h = rgb.size
|
|
315
|
+
gain = min(width / src_w, height / src_h)
|
|
316
|
+
new_w, new_h = int(round(src_w * gain)), int(round(src_h * gain))
|
|
317
|
+
canvas = Image.new("RGB", (width, height), LETTERBOX_FILL)
|
|
318
|
+
canvas.paste(rgb.resize((new_w, new_h), resample=Image.BILINEAR),
|
|
319
|
+
((width - new_w) // 2, (height - new_h) // 2))
|
|
320
|
+
|
|
321
|
+
import io as _io
|
|
322
|
+
|
|
323
|
+
# The service takes JPEG BYTES, so the rig has to hand it bytes — and
|
|
324
|
+
# then hand the REFERENCE the pixels that came back out of that same
|
|
325
|
+
# JPEG, not the canvas that went in. JPEG is lossy even at quality
|
|
326
|
+
# 100 with no chroma subsampling, and the ±1 it costs is enough to
|
|
327
|
+
# move a borderline detection: this rig reported box deltas of
|
|
328
|
+
# hundreds of pixels and count mismatches until the round trip was
|
|
329
|
+
# closed, all of it the instrument. That is the third variable in a
|
|
330
|
+
# comparison meant to hold two.
|
|
331
|
+
buf = _io.BytesIO()
|
|
332
|
+
canvas.save(buf, format="JPEG", quality=100, subsampling=0)
|
|
333
|
+
jpeg = buf.getvalue()
|
|
334
|
+
batch = detector.detect(jpeg, THRESHOLD, None)
|
|
335
|
+
|
|
336
|
+
with Image.open(_io.BytesIO(jpeg)) as decoded:
|
|
337
|
+
bgr = np.asarray(decoded.convert("RGB"), dtype=np.uint8)[..., ::-1].copy()
|
|
338
|
+
result = reference.predict(
|
|
339
|
+
bgr, conf=THRESHOLD, iou=NMS_IOU_THRESHOLD, imgsz=width,
|
|
340
|
+
rect=False, verbose=False, device="cpu",
|
|
341
|
+
)[0]
|
|
342
|
+
|
|
343
|
+
ours = (
|
|
344
|
+
np.asarray(
|
|
345
|
+
[[d.x, d.y, d.x + d.width, d.y + d.height] for d in batch.detections],
|
|
346
|
+
dtype=np.float32,
|
|
347
|
+
).reshape(-1, 4),
|
|
348
|
+
np.asarray([d.confidence for d in batch.detections], dtype=np.float32),
|
|
349
|
+
np.asarray([slot_of[d.label] for d in batch.detections], dtype="int64"),
|
|
350
|
+
)
|
|
351
|
+
theirs = (
|
|
352
|
+
result.boxes.xyxy.numpy(),
|
|
353
|
+
result.boxes.conf.numpy(),
|
|
354
|
+
result.boxes.cls.numpy().astype("int64"),
|
|
355
|
+
)
|
|
356
|
+
n, d_box, d_score = _compare(
|
|
357
|
+
ours, theirs, tolerance_px, score_tolerance,
|
|
358
|
+
f"{model_path.name} {frame.name}", problems
|
|
359
|
+
)
|
|
360
|
+
compared += n
|
|
361
|
+
model_dets += n
|
|
362
|
+
frames_seen += 1
|
|
363
|
+
worst_box, worst_score = max(worst_box, d_box), max(worst_score, d_score)
|
|
364
|
+
|
|
365
|
+
for slot in vocabulary.slot_names:
|
|
366
|
+
mine = float(ours[1][ours[2] == slot].max()) if (ours[2] == slot).any() else 0.0
|
|
367
|
+
ref = float(theirs[1][theirs[2] == slot].max()) if (theirs[2] == slot).any() else 0.0
|
|
368
|
+
decisions += 1
|
|
369
|
+
if (mine >= THRESHOLD) != (ref >= THRESHOLD):
|
|
370
|
+
problems.append(
|
|
371
|
+
f"{model_path.name} {frame.name} slot {slot}: presence DECISION differs — "
|
|
372
|
+
f"ours {mine:.4f}, reference {ref:.4f}"
|
|
373
|
+
)
|
|
374
|
+
if model_dets == 0:
|
|
375
|
+
unexercised.append(model_path.name)
|
|
376
|
+
return {
|
|
377
|
+
"arm": "pipeline",
|
|
378
|
+
"models_in_directories": len(models),
|
|
379
|
+
"models_compared": len(models) - len(skipped) - len(unexercised),
|
|
380
|
+
"models_skipped_wrong_layout": skipped,
|
|
381
|
+
"models_with_no_above_threshold_detection": unexercised,
|
|
382
|
+
"frames_decoded": frames_seen,
|
|
383
|
+
"presence_decisions": decisions,
|
|
384
|
+
"detections_compared": compared,
|
|
385
|
+
"max_box_delta_px": worst_box,
|
|
386
|
+
"max_score_delta": worst_score,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def arm_litert(manifest_path: Path, models_dirs, frames, tolerance_px, score_tolerance, problems):
|
|
391
|
+
"""The same weights through both runtimes, in PIXELS, per the declaration."""
|
|
392
|
+
import numpy as np
|
|
393
|
+
import onnxruntime as ort
|
|
394
|
+
from ai_edge_litert.interpreter import Interpreter
|
|
395
|
+
|
|
396
|
+
manifest = json.loads(manifest_path.read_text())
|
|
397
|
+
litert_dir = manifest_path.parent
|
|
398
|
+
compared = twins = frames_seen = 0
|
|
399
|
+
worst_box = worst_score = 0.0
|
|
400
|
+
rows: "list[str]" = []
|
|
401
|
+
vacuous: "list[str]" = []
|
|
402
|
+
|
|
403
|
+
for entry in manifest:
|
|
404
|
+
alias = entry["alias"]
|
|
405
|
+
onnx_path = next(
|
|
406
|
+
(d / f"{alias}.onnx" for d in models_dirs if (d / f"{alias}.onnx").exists()), None
|
|
407
|
+
)
|
|
408
|
+
if onnx_path is None:
|
|
409
|
+
problems.append(f"{alias}: no ONNX twin found under {[str(d) for d in models_dirs]}")
|
|
410
|
+
continue
|
|
411
|
+
opts = ort.SessionOptions()
|
|
412
|
+
opts.log_severity_level = 3
|
|
413
|
+
session = ort.InferenceSession(
|
|
414
|
+
str(onnx_path), sess_options=opts, providers=["CPUExecutionProvider"]
|
|
415
|
+
)
|
|
416
|
+
layout = _graph_layout(session)
|
|
417
|
+
if layout not in YOLO_DECODERS:
|
|
418
|
+
continue
|
|
419
|
+
graph_in = session.get_inputs()[0]
|
|
420
|
+
_, _, height, width = graph_in.shape
|
|
421
|
+
output_name = session.get_outputs()[0].name
|
|
422
|
+
|
|
423
|
+
interpreter = Interpreter(model_path=str(litert_dir / entry["litert"]))
|
|
424
|
+
interpreter.allocate_tensors()
|
|
425
|
+
lit_in = interpreter.get_input_details()[0]
|
|
426
|
+
lit_out = interpreter.get_output_details()[0]
|
|
427
|
+
channels_first = list(lit_in["shape"])[1] == 3
|
|
428
|
+
|
|
429
|
+
# DECLARED, from the manifest row — the box-side copy of each variant's
|
|
430
|
+
# `model_platform_variants` row. Deriving either from its tensor here
|
|
431
|
+
# would make this arm a self-consistency check.
|
|
432
|
+
onnx_units = BOX_UNITS_PIXELS if entry["box_units_onnx"].startswith("pixels") else BOX_UNITS_NORMALIZED
|
|
433
|
+
lit_units = BOX_UNITS_PIXELS if entry["box_units_litert"].startswith("pixels") else BOX_UNITS_NORMALIZED
|
|
434
|
+
declared_scale = float(entry["litert_box_scale_to_pixels"])
|
|
435
|
+
onnx_scale = 1.0 if onnx_units == BOX_UNITS_PIXELS else declared_scale
|
|
436
|
+
lit_scale = 1.0 if lit_units == BOX_UNITS_PIXELS else declared_scale
|
|
437
|
+
# Refused, not guessed, exactly as `VariantContract` would.
|
|
438
|
+
VariantContract(layout, onnx_units, onnx_scale)
|
|
439
|
+
VariantContract(layout, lit_units, lit_scale)
|
|
440
|
+
|
|
441
|
+
twins += 1
|
|
442
|
+
twin_box = twin_score = 0.0
|
|
443
|
+
twin_dets = 0
|
|
444
|
+
transposes = 0
|
|
445
|
+
for frame in frames:
|
|
446
|
+
blob, geometry = preprocess_letterbox(frame.read_bytes(), width, height)
|
|
447
|
+
raw_onnx = session.run([output_name], {graph_in.name: blob})[0]
|
|
448
|
+
feed = blob if channels_first else np.transpose(blob, (0, 2, 3, 1)).copy()
|
|
449
|
+
interpreter.set_tensor(lit_in["index"], feed.astype(lit_in["dtype"]))
|
|
450
|
+
interpreter.invoke()
|
|
451
|
+
raw_lit = interpreter.get_tensor(lit_out["index"])
|
|
452
|
+
expected = 4 + len(_names(onnx_path))
|
|
453
|
+
if layout == ANCHOR_4_PLUS_NC and raw_lit.ndim == 3 and raw_lit.shape[1] != expected \
|
|
454
|
+
and raw_lit.shape[2] == expected:
|
|
455
|
+
# COUNTED, because a transpose nobody can see is a second way to
|
|
456
|
+
# make two runtimes agree for the wrong reason. Measured across
|
|
457
|
+
# all 17 committed twins on 2026-08-25: it fires ZERO times —
|
|
458
|
+
# every LiteRT export here is NCHW in and channels-first out. The
|
|
459
|
+
# branch stays because a future export need not be, and the count
|
|
460
|
+
# is printed so its first firing is visible.
|
|
461
|
+
transposes += 1
|
|
462
|
+
raw_lit = np.transpose(raw_lit, (0, 2, 1)).copy()
|
|
463
|
+
|
|
464
|
+
decode = YOLO_DECODERS[layout]
|
|
465
|
+
s_o, c_o, *box_o = decode(
|
|
466
|
+
raw_onnx, geometry, THRESHOLD,
|
|
467
|
+
box_scale_to_pixels=onnx_scale, expected_classes=expected - 4,
|
|
468
|
+
)
|
|
469
|
+
s_l, c_l, *box_l = decode(
|
|
470
|
+
raw_lit, geometry, THRESHOLD,
|
|
471
|
+
box_scale_to_pixels=lit_scale, expected_classes=expected - 4,
|
|
472
|
+
)
|
|
473
|
+
ours = (np.stack(box_o, axis=1) if s_o.shape[0] else np.zeros((0, 4)), s_o, c_o)
|
|
474
|
+
theirs = (np.stack(box_l, axis=1) if s_l.shape[0] else np.zeros((0, 4)), s_l, c_l)
|
|
475
|
+
n, d_box, d_score = _compare(
|
|
476
|
+
ours, theirs, tolerance_px, score_tolerance, f"{alias} {frame.name}", problems
|
|
477
|
+
)
|
|
478
|
+
compared += n
|
|
479
|
+
twin_dets += n
|
|
480
|
+
frames_seen += 1
|
|
481
|
+
twin_box, twin_score = max(twin_box, d_box), max(twin_score, d_score)
|
|
482
|
+
worst_box, worst_score = max(worst_box, twin_box), max(worst_score, twin_score)
|
|
483
|
+
if twin_dets == 0:
|
|
484
|
+
# A ROW THAT COMPARED NOTHING IS NOT AGREEMENT. Every arm here scores
|
|
485
|
+
# only ABOVE-THRESHOLD detections, so a model that fires on none of
|
|
486
|
+
# these frames produces a 0.00000 px row that reads exactly like a
|
|
487
|
+
# perfect one. Measured: `forklift_akdve_11n` fires on none of a
|
|
488
|
+
# 16-frame retail set, and `queue_supermarket` fires on 34 of them
|
|
489
|
+
# while firing on NONE of a bus-interior set — so the corpus, not the
|
|
490
|
+
# twin, decides which rows this arm can speak about.
|
|
491
|
+
vacuous.append(alias)
|
|
492
|
+
rows.append(
|
|
493
|
+
f" {alias:28s} onnx={onnx_units:10s} litert={lit_units:10s} "
|
|
494
|
+
f"scale={declared_scale:g} dets={twin_dets:4d} max|dbox|={twin_box:.5f}px "
|
|
495
|
+
f"max|dscore|={twin_score:.3g}"
|
|
496
|
+
+ (" <-- NO above-threshold detections: this row is VACUOUS" if twin_dets == 0 else "")
|
|
497
|
+
+ (f" ({transposes} litert transposes)" if transposes else "")
|
|
498
|
+
)
|
|
499
|
+
for row in rows:
|
|
500
|
+
print(row)
|
|
501
|
+
return {
|
|
502
|
+
"arm": "litert",
|
|
503
|
+
"twins": twins,
|
|
504
|
+
"twins_with_evidence": twins - len(vacuous),
|
|
505
|
+
# NAMED, not counted. These twins were not checked by this run and saying
|
|
506
|
+
# so is the whole point; a summary that reported 17/17 agreeing would be
|
|
507
|
+
# a claim about models that produced nothing to compare.
|
|
508
|
+
"twins_with_no_above_threshold_detection": vacuous,
|
|
509
|
+
"frames_decoded": frames_seen,
|
|
510
|
+
"detections_compared": compared,
|
|
511
|
+
"max_box_delta_px": worst_box,
|
|
512
|
+
"max_score_delta": worst_score,
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def main() -> int:
|
|
517
|
+
parser = argparse.ArgumentParser()
|
|
518
|
+
parser.add_argument("--arm", choices=("decode", "pipeline", "litert"), default="decode")
|
|
519
|
+
parser.add_argument("--models-dir", action="append", default=[], required=True)
|
|
520
|
+
parser.add_argument("--frames-dir", required=True)
|
|
521
|
+
parser.add_argument("--limit", type=int, default=8, help="frames per model")
|
|
522
|
+
parser.add_argument("--models", type=int, default=0, help="0 = every model in the directories")
|
|
523
|
+
parser.add_argument(
|
|
524
|
+
"--tolerance-px", type=float, default=0.0,
|
|
525
|
+
help="0.0 for the decode arm: same tensor in, the arithmetic must agree exactly.",
|
|
526
|
+
)
|
|
527
|
+
parser.add_argument(
|
|
528
|
+
"--score-tolerance", type=float, default=DEFAULT_SCORE_TOLERANCE,
|
|
529
|
+
help="ENFORCED, not merely reported. A doubled sigmoid moves every confidence "
|
|
530
|
+
"without moving a box, so a box-only tolerance passes it.",
|
|
531
|
+
)
|
|
532
|
+
parser.add_argument("--litert-manifest", default=None)
|
|
533
|
+
args = parser.parse_args()
|
|
534
|
+
|
|
535
|
+
dirs = [Path(d) for d in args.models_dir]
|
|
536
|
+
models = sorted(p for d in dirs for p in d.glob("*.onnx"))
|
|
537
|
+
if args.models:
|
|
538
|
+
step = max(1, len(models) // args.models)
|
|
539
|
+
models = models[::step][: args.models]
|
|
540
|
+
frames = sorted(Path(args.frames_dir).glob("*.jpg"))[: args.limit]
|
|
541
|
+
if not models:
|
|
542
|
+
raise SystemExit(f"no .onnx under {args.models_dir}")
|
|
543
|
+
if not frames:
|
|
544
|
+
raise SystemExit(f"no frames in {args.frames_dir}")
|
|
545
|
+
|
|
546
|
+
problems: "list[str]" = []
|
|
547
|
+
if args.arm == "decode":
|
|
548
|
+
summary = arm_decode(models, frames, args.tolerance_px, args.score_tolerance, problems)
|
|
549
|
+
elif args.arm == "pipeline":
|
|
550
|
+
summary = arm_pipeline(models, frames, args.tolerance_px, args.score_tolerance, problems)
|
|
551
|
+
else:
|
|
552
|
+
if not args.litert_manifest:
|
|
553
|
+
raise SystemExit("--arm litert needs --litert-manifest naming each twin's DECLARED units")
|
|
554
|
+
summary = arm_litert(
|
|
555
|
+
Path(args.litert_manifest), dirs, frames, args.tolerance_px, args.score_tolerance, problems
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
print(json.dumps(summary, indent=2))
|
|
559
|
+
if problems:
|
|
560
|
+
print(f"DISAGREEMENT — {len(problems)} problem(s); the decode is not the reference instrument:")
|
|
561
|
+
for problem in problems[:40]:
|
|
562
|
+
print(f" - {problem}")
|
|
563
|
+
if len(problems) > 40:
|
|
564
|
+
print(f" ... and {len(problems) - 40} more")
|
|
565
|
+
return 1
|
|
566
|
+
print(
|
|
567
|
+
f"AGREE: every detection matches within {args.tolerance_px} px and "
|
|
568
|
+
f"{args.score_tolerance} confidence."
|
|
569
|
+
)
|
|
570
|
+
return 0
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
if __name__ == "__main__":
|
|
574
|
+
raise SystemExit(main())
|