@trillboards/edge-sdk 0.2.89 → 0.2.92

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.
@@ -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())
@@ -85,7 +85,10 @@ def main() -> int:
85
85
  decisions = 0
86
86
  for path in frame_paths:
87
87
  frame = Frame(path)
88
- mine, _ = ours.run(frame, 0.0)
88
+ # `HarnessYoloRunner.run` returns (per_class, infer_ms, box_max); the
89
+ # third arrived with the box-units verdict and this call still unpacked
90
+ # two, so this file could not execute at all.
91
+ mine, _, _ = ours.run(frame, 0.0)
89
92
  # imgsz is pinned to the graph's own input size and verbose is off; a
90
93
  # default imgsz here would compare an arm at 640 against one at 1280,
91
94
  # which §5 rule 1 says is not a comparison.