@trillboards/edge-sdk 0.2.92 → 0.2.94

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,313 @@
1
+ {
2
+ "_comment": [
3
+ "MEASURED, not asserted. Produced by bench/verify_litert_twin_parity.py on 2026-08-25,",
4
+ "one fp32 input tensor fed to both runtimes (onnxruntime 1.29.0 CPU, ai-edge-litert),",
5
+ "seed 1234, both taking NCHW [1,3,640,640] \u2014 the LiteRT export of an ultralytics detect",
6
+ "model is NOT NHWC.",
7
+ "",
8
+ "THE RESULT: the box scale is a property of the LAYOUT, not of the runtime.",
9
+ " anchor_4_plus_nc LiteRT boxes are NORMALISED; box*imgsz recovers ONNX pixels",
10
+ " to <= 0.00588 px, and the class channels already agree to",
11
+ " <= 4.2e-06 with no scaling at all.",
12
+ " end2end_n_6 the _NormalizeCoords wrapper is NOT applied; both runtimes",
13
+ " emit pixels, so the scale is 1.0.",
14
+ "A decoder that scales every LiteRT model is exactly as wrong as one that scales",
15
+ "none \u2014 it just corrupts the other half of the library, and silently, because",
16
+ "shape, dtype, class count and every score still match.",
17
+ "",
18
+ "queue_supermarket is recorded FAILING on purpose. Its box value RANGE is identical",
19
+ "in both runtimes and already in pixels (litert [-45.104, 652.062], onnx the same) and",
20
+ "its scores agree to 7.2e-08, but row-wise the two disagree and lexsorting by box does",
21
+ "not collapse it. The model returns ZERO detections above 0.25 on 40 real production",
22
+ "frames, so there is no above-threshold sample to compare and row-level parity is",
23
+ "UNVERIFIED. It is not published. Settle it on a corpus the model actually fires on."
24
+ ],
25
+ "measured_at": "2026-08-25",
26
+ "tolerances": {
27
+ "box_residual_px": 0.05,
28
+ "cls_residual": 0.0001
29
+ },
30
+ "box_scale_by_layout": {
31
+ "anchor_4_plus_nc": "imgsz",
32
+ "end2end_n_6": 1.0,
33
+ "rfdetr_dets_labels": 1.0
34
+ },
35
+ "twins": [
36
+ {
37
+ "layout": "anchor_4_plus_nc",
38
+ "imgsz": 640,
39
+ "box_scale_applied": 640.0,
40
+ "box_residual_px": 0.000865936279296875,
41
+ "box_residual_unscaled": 641.0272229909897,
42
+ "cls_residual": 1.996755599975586e-06,
43
+ "pass": true,
44
+ "reason": "ok",
45
+ "onnx": "apron_murugu.onnx",
46
+ "litert": "apron_murugu.tflite"
47
+ },
48
+ {
49
+ "layout": "anchor_4_plus_nc",
50
+ "imgsz": 640,
51
+ "box_scale_applied": 640.0,
52
+ "box_residual_px": 0.000476837158203125,
53
+ "box_residual_unscaled": 635.8220509290695,
54
+ "cls_residual": 1.1929387255804613e-07,
55
+ "pass": true,
56
+ "reason": "ok",
57
+ "onnx": "fall_posture_3cls_11n.onnx",
58
+ "litert": "fall_posture_3cls_11n.tflite"
59
+ },
60
+ {
61
+ "layout": "anchor_4_plus_nc",
62
+ "imgsz": 640,
63
+ "box_scale_applied": 640.0,
64
+ "box_residual_px": 0.0030517578125,
65
+ "box_residual_unscaled": 656.5295736789703,
66
+ "cls_residual": 6.020069122314453e-06,
67
+ "pass": true,
68
+ "reason": "ok",
69
+ "onnx": "fire_5cls_fpsuppress_11n.onnx",
70
+ "litert": "fire_5cls_fpsuppress_11n.tflite"
71
+ },
72
+ {
73
+ "layout": "anchor_4_plus_nc",
74
+ "imgsz": 640,
75
+ "box_scale_applied": 640.0,
76
+ "box_residual_px": 0.00335693359375,
77
+ "box_residual_unscaled": 635.6288108825684,
78
+ "cls_residual": 1.237494871020317e-07,
79
+ "pass": true,
80
+ "reason": "ok",
81
+ "onnx": "forklift_akdve_11n.onnx",
82
+ "litert": "forklift_akdve_11n.tflite"
83
+ },
84
+ {
85
+ "layout": "anchor_4_plus_nc",
86
+ "imgsz": 640,
87
+ "box_scale_applied": 640.0,
88
+ "box_residual_px": 0.001003265380859375,
89
+ "box_residual_unscaled": 651.8335539102554,
90
+ "cls_residual": 1.3380153518482985e-07,
91
+ "pass": true,
92
+ "reason": "ok",
93
+ "onnx": "gh_forklift_combine.onnx",
94
+ "litert": "gh_forklift_combine.tflite",
95
+ "model_id": "gh_forklift_combine",
96
+ "box_units": "normalized",
97
+ "box_scale_to_pixels": 640.0,
98
+ "litert_sha256": "c196a45613726e1a0bd522cd7140fde58784d71d9ffadfec00b033f8914b9df5",
99
+ "litert_bytes": 12257807,
100
+ "evidence": "twin parity: box residual 0.001003 px after x640, UNSCALED 651.8 px \u2014 the scale is load-bearing, not cosmetic. Class residual 1.34e-07 with no scaling at all."
101
+ },
102
+ {
103
+ "layout": "anchor_4_plus_nc",
104
+ "imgsz": 640,
105
+ "box_scale_applied": 640.0,
106
+ "box_residual_px": 0.001369476318359375,
107
+ "box_residual_unscaled": 634.430369079113,
108
+ "cls_residual": 1.3195858628023416e-07,
109
+ "pass": true,
110
+ "reason": "ok",
111
+ "onnx": "glove_madesh.onnx",
112
+ "litert": "glove_madesh.tflite"
113
+ },
114
+ {
115
+ "layout": "anchor_4_plus_nc",
116
+ "imgsz": 640,
117
+ "box_scale_applied": 640.0,
118
+ "box_residual_px": 0.00102996826171875,
119
+ "box_residual_unscaled": 648.4288479089737,
120
+ "cls_residual": 7.897615432739258e-07,
121
+ "pass": true,
122
+ "reason": "ok",
123
+ "onnx": "glove_sunita.onnx",
124
+ "litert": "glove_sunita.tflite"
125
+ },
126
+ {
127
+ "layout": "anchor_4_plus_nc",
128
+ "imgsz": 640,
129
+ "box_scale_applied": 640.0,
130
+ "box_residual_px": 0.0028209686279296875,
131
+ "box_residual_unscaled": 637.0318885445595,
132
+ "cls_residual": 1.6111880540847778e-07,
133
+ "pass": true,
134
+ "reason": "ok",
135
+ "onnx": "hf_crowd_irail.onnx",
136
+ "litert": "hf_crowd_irail.tflite"
137
+ },
138
+ {
139
+ "layout": "anchor_4_plus_nc",
140
+ "imgsz": 640,
141
+ "box_scale_applied": 640.0,
142
+ "box_residual_px": 0.001659393310546875,
143
+ "box_residual_unscaled": 639.9944154024124,
144
+ "cls_residual": 1.6391277313232422e-06,
145
+ "pass": true,
146
+ "reason": "ok",
147
+ "onnx": "hf_smoking.onnx",
148
+ "litert": "hf_smoking.tflite"
149
+ },
150
+ {
151
+ "layout": "anchor_4_plus_nc",
152
+ "imgsz": 640,
153
+ "box_scale_applied": 640.0,
154
+ "box_residual_px": 0.00424957275390625,
155
+ "box_residual_unscaled": 637.0888672471046,
156
+ "cls_residual": 1.2430425044840376e-07,
157
+ "pass": true,
158
+ "reason": "ok",
159
+ "onnx": "hg-v6.onnx",
160
+ "litert": "hg-v6.tflite"
161
+ },
162
+ {
163
+ "layout": "anchor_4_plus_nc",
164
+ "imgsz": 640,
165
+ "box_scale_applied": 640.0,
166
+ "box_residual_px": 0.001068115234375,
167
+ "box_residual_unscaled": 634.6248278617859,
168
+ "cls_residual": 1.2764758139383048e-07,
169
+ "pass": true,
170
+ "reason": "ok",
171
+ "onnx": "ppe_construction_17cls_v8n.onnx",
172
+ "litert": "ppe_construction_17cls_v8n.tflite",
173
+ "model_id": "ppe_construction_17cls_v8n",
174
+ "box_units": "normalized",
175
+ "box_scale_to_pixels": 640.0,
176
+ "litert_sha256": "08ebf94fec8c864eb4c1fae009d8f7686252c622fafed37c71d80f117884173b",
177
+ "litert_bytes": 12268767,
178
+ "evidence": "twin parity: box residual 0.001068 px after x640, UNSCALED 634.6 px \u2014 the scale is load-bearing, not cosmetic. Class residual 1.28e-07 with no scaling at all."
179
+ },
180
+ {
181
+ "layout": "anchor_4_plus_nc",
182
+ "imgsz": 640,
183
+ "box_scale_applied": 640.0,
184
+ "box_residual_px": 0.00177001953125,
185
+ "box_residual_unscaled": 638.4268003106117,
186
+ "cls_residual": 1.3917764363213792e-07,
187
+ "pass": true,
188
+ "reason": "ok",
189
+ "onnx": "ppe_keremberke_v8m.onnx",
190
+ "litert": "ppe_keremberke_v8m.tflite"
191
+ },
192
+ {
193
+ "layout": "anchor_4_plus_nc",
194
+ "imgsz": 640,
195
+ "box_scale_applied": 640.0,
196
+ "box_residual_px": 0.002044677734375,
197
+ "box_residual_unscaled": 663.3222885131836,
198
+ "cls_residual": 1.227854227181524e-07,
199
+ "pass": true,
200
+ "reason": "ok",
201
+ "onnx": "ppe_keremberke_v8n.onnx",
202
+ "litert": "ppe_keremberke_v8n.tflite"
203
+ },
204
+ {
205
+ "layout": "anchor_4_plus_nc",
206
+ "imgsz": 640,
207
+ "box_scale_applied": 640.0,
208
+ "box_residual_px": 0.00238800048828125,
209
+ "box_residual_unscaled": 652.3816465139389,
210
+ "cls_residual": 1.3384212138589646e-07,
211
+ "pass": true,
212
+ "reason": "ok",
213
+ "onnx": "ppe_keremberke_v8s.onnx",
214
+ "litert": "ppe_keremberke_v8s.tflite"
215
+ },
216
+ {
217
+ "layout": "anchor_4_plus_nc",
218
+ "imgsz": 640,
219
+ "box_scale_applied": 640.0,
220
+ "box_residual_px": 0.00225830078125,
221
+ "box_residual_unscaled": 641.4581891298294,
222
+ "cls_residual": 1.3920431740643835e-07,
223
+ "pass": true,
224
+ "reason": "ok",
225
+ "onnx": "ppe_killuminati_19cls_v8s.onnx",
226
+ "litert": "ppe_killuminati_19cls_v8s.tflite",
227
+ "model_id": "ppe_killuminati_19cls_v8s",
228
+ "box_units": "normalized",
229
+ "box_scale_to_pixels": 640.0,
230
+ "litert_sha256": "493f93835ba89904296f14b5dd8dc1451612ec319fdf968a29bede69b24debb5",
231
+ "litert_bytes": 44764391,
232
+ "evidence": "twin parity: box residual 0.002258 px after x640, UNSCALED 641.5 px \u2014 the scale is load-bearing, not cosmetic. Class residual 1.39e-07 with no scaling at all."
233
+ },
234
+ {
235
+ "layout": "anchor_4_plus_nc",
236
+ "imgsz": 640,
237
+ "box_scale_applied": 640.0,
238
+ "box_residual_px": 0.0002117156982421875,
239
+ "box_residual_unscaled": 636.5309634804726,
240
+ "cls_residual": 1.373183451391924e-07,
241
+ "pass": true,
242
+ "reason": "ok",
243
+ "onnx": "ppe_vyra_14cls_v8m.onnx",
244
+ "litert": "ppe_vyra_14cls_v8m.tflite",
245
+ "model_id": "ppe_vyra_14cls_v8m",
246
+ "box_units": "normalized",
247
+ "box_scale_to_pixels": 640.0,
248
+ "litert_sha256": "bef4efe403c834d1b05fa5217d97fcbcefde3da890b964141692fad66ca260f1",
249
+ "litert_bytes": 103663784,
250
+ "evidence": "twin parity: box residual 0.0002117 px after x640, UNSCALED 636.5 px \u2014 the scale is load-bearing, not cosmetic. Class residual 1.37e-07 with no scaling at all."
251
+ },
252
+ {
253
+ "layout": "end2end_n_6",
254
+ "imgsz": 640,
255
+ "box_scale_applied": 1.0,
256
+ "box_residual_px": 630.3428831100464,
257
+ "box_residual_unscaled": 630.3428831100464,
258
+ "cls_residual": 8.372444426640868e-08,
259
+ "pass": false,
260
+ "reason": "box residual 630.3 px exceeds 0.05 under layout end2end_n_6 (scale x1.0)",
261
+ "onnx": "queue_supermarket.onnx",
262
+ "litert": "queue_supermarket.tflite"
263
+ },
264
+ {
265
+ "layout": "anchor_4_plus_nc",
266
+ "imgsz": 640,
267
+ "box_scale_applied": 640.0,
268
+ "box_residual_px": 0.000308990478515625,
269
+ "box_residual_unscaled": 647.0199807882309,
270
+ "cls_residual": 1.218754732690286e-07,
271
+ "pass": true,
272
+ "reason": "ok",
273
+ "onnx": "shoplift_pavan.onnx",
274
+ "litert": "shoplift_pavan.tflite"
275
+ },
276
+ {
277
+ "layout": "anchor_4_plus_nc",
278
+ "imgsz": 640,
279
+ "box_scale_applied": 640.0,
280
+ "box_residual_px": 0.000621795654296875,
281
+ "box_residual_unscaled": 634.978705227375,
282
+ "cls_residual": 1.3541824728235952e-07,
283
+ "pass": true,
284
+ "reason": "ok",
285
+ "onnx": "spill_fluid.onnx",
286
+ "litert": "spill_fluid.tflite"
287
+ },
288
+ {
289
+ "layout": "anchor_4_plus_nc",
290
+ "imgsz": 640,
291
+ "box_scale_applied": 640.0,
292
+ "box_residual_px": 0.0025310516357421875,
293
+ "box_residual_unscaled": 633.9265187978745,
294
+ "cls_residual": 1.9371509552001953e-07,
295
+ "pass": true,
296
+ "reason": "ok",
297
+ "onnx": "spill_robot.onnx",
298
+ "litert": "spill_robot.tflite"
299
+ },
300
+ {
301
+ "layout": "anchor_4_plus_nc",
302
+ "imgsz": 640,
303
+ "box_scale_applied": 640.0,
304
+ "box_residual_px": 0.00058746337890625,
305
+ "box_residual_unscaled": 641.6996326446533,
306
+ "cls_residual": 1.0132789611816406e-06,
307
+ "pass": true,
308
+ "reason": "ok",
309
+ "onnx": "weapon_gunknife_11n.onnx",
310
+ "litert": "weapon_gunknife_11n.tflite"
311
+ }
312
+ ]
313
+ }
@@ -0,0 +1,113 @@
1
+ """The pure-logic half of the twin-parity check — STDLIB ONLY, deliberately.
2
+
3
+ `verify_litert_twin_parity.py` needs onnxruntime, ai-edge-litert, numpy and
4
+ Pillow, which together are about a gigabyte of wheels and are on no CI runner.
5
+ The `detector-service` step in `ci.yml` runs
6
+
7
+ python3 -m unittest discover -s <this package> -p "test_*.py"
8
+
9
+ with NO pip install, and that constraint is the point rather than an accident:
10
+ `server.py` imports no numpy either, so the whole wire contract runs on a bare
11
+ runner. A test that imported the verifier would drag numpy into discovery and
12
+ error the entire lane before its existing tests ran — which is the same defect
13
+ this file exists to catch, one level up: a check that appears to run and does
14
+ not.
15
+
16
+ So the decisions a reader has to trust — which layout a graph is, what scale
17
+ that layout implies, and whether a recorded verdict follows from its recorded
18
+ residuals — live here, in stdlib, and are exercised in CI. The arithmetic over
19
+ real tensors lives in the verifier and is a documented manual run.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ #: Tolerances. Measured across 16 anchor-layout twins on 2026-08-25: the worst
25
+ #: scaled box residual was 0.00588 px and the worst class residual 4.2e-06.
26
+ #: These ceilings sit ~2 orders above the observed worst, so a genuine export
27
+ #: defect fails while fp32 reassociation noise does not.
28
+ BOX_TOL_PX = 0.05
29
+ CLS_TOL = 1e-04
30
+
31
+ ANCHOR = "anchor_4_plus_nc"
32
+ END2END = "end2end_n_6"
33
+ DETS_LABELS = "rfdetr_dets_labels"
34
+
35
+ #: Which layouts this bench can COMPARE. `DETS_LABELS` is deliberately absent:
36
+ #: RF-DETR has no LiteRT twin and its own control is `verify_against_reference.py`,
37
+ #: so classifying it correctly and then refusing it by name is the honest
38
+ #: behaviour. Advertising a layout the comparison cannot split would be the
39
+ #: `yolov8_nano` shape in a bench script.
40
+ COMPARABLE_LAYOUTS = frozenset({ANCHOR, END2END})
41
+
42
+
43
+ class ParityError(RuntimeError):
44
+ """The twin does not agree with its ONNX source, or cannot be compared."""
45
+
46
+
47
+ def classify_layout(shape, output_names) -> str:
48
+ """Name the layout from the GRAPH, never from the values.
49
+
50
+ `dets`/`labels` is RF-DETR's two-output form. Otherwise a rank-3 tensor is
51
+ `end2end_n_6` when its LAST axis is 6 (N rows of x1,y1,x2,y2,score,class),
52
+ and `anchor_4_plus_nc` when the MIDDLE axis is the channel axis. Those two
53
+ are separated by which axis is the small one — a fact about the graph's
54
+ declared shape, needing no sample to decide.
55
+
56
+ The runtime decoder does not even do this: `session.py` reads the layout
57
+ from the artifact's DECLARED variant contract, because `[1, 300, 6]` and
58
+ `[1, 6, 8400]` are both rank-3 with a 6 in them and a reader that decides
59
+ from shape alone gets one of them backwards with no exception and a
60
+ plausible table. This function is the bench's convenience, and it is
61
+ deliberately stricter than it needs to be so a new head shape refuses
62
+ rather than being absorbed.
63
+ """
64
+ shape = tuple(int(v) for v in shape)
65
+ lowered = [str(n).lower() for n in output_names]
66
+ if "dets" in lowered and "labels" in lowered:
67
+ return DETS_LABELS
68
+ if len(shape) != 3:
69
+ raise ParityError(f"unknown output rank {len(shape)} for shape {shape}")
70
+ if shape[2] == 6 and shape[1] > 6:
71
+ return END2END
72
+ if shape[1] >= 5 and shape[2] > shape[1]:
73
+ return ANCHOR
74
+ raise ParityError(f"shape {shape} matches no known detector layout")
75
+
76
+
77
+ def box_scale_for(layout: str, imgsz: int) -> float:
78
+ """The multiplier that puts LiteRT boxes into the ONNX pixel frame.
79
+
80
+ Ultralytics wraps a LiteRT export of a `task=detect` / `end2end=False`
81
+ model in `_NormalizeCoords`, which divides the four BOX channels by (W, H)
82
+ and leaves the class channels alone. An end2end head is `end2end=True`, so
83
+ the wrapper never runs and both runtimes emit pixels.
84
+
85
+ No layout gets a scale by default. A fall-through to 1.0 would be a guess
86
+ about an artifact nobody measured, and the wrong guess is silent.
87
+ """
88
+ if layout == ANCHOR:
89
+ return float(imgsz)
90
+ if layout in (END2END, DETS_LABELS):
91
+ return 1.0
92
+ raise ParityError(f"no box scale defined for layout {layout!r}")
93
+
94
+
95
+ def verdict(result) -> "tuple[bool, str]":
96
+ """Does this recorded measurement pass, and if not, which assertion failed."""
97
+ layout = result["layout"]
98
+ # The scale must be LOAD-BEARING on the anchor layout. If scaling and not
99
+ # scaling both pass, the model is not telling us which one is right and the
100
+ # tolerance is doing no work — that is the 640x bug passing its own check.
101
+ if layout == ANCHOR and result["box_residual_unscaled"] <= BOX_TOL_PX:
102
+ return False, (
103
+ "the imgsz scale changed nothing — an anchor-layout twin whose raw boxes "
104
+ "already match its ONNX source is not normalised, so the layout is misread"
105
+ )
106
+ if result["box_residual_px"] > BOX_TOL_PX:
107
+ return False, (
108
+ f"box residual {result['box_residual_px']:.4g} px exceeds {BOX_TOL_PX} "
109
+ f"under layout {layout} (scale x{result['box_scale_applied']})"
110
+ )
111
+ if result["cls_residual"] > CLS_TOL:
112
+ return False, f"class residual {result['cls_residual']:.4g} exceeds {CLS_TOL}"
113
+ return True, "ok"
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """The control for the SECOND runtime: one model, two runtimes, same tensor.
3
+
4
+ WHY THIS EXISTS. Every detector we publish ships twice — an ONNX artifact for
5
+ the venue box and a LiteRT twin for the Android screen device. They are exported
6
+ from one `.pt`, so it is tempting to treat the twin as free. It is not, and the
7
+ way it fails is the way this repo's defects always fail: the check runs, passes,
8
+ and measures the wrong thing.
9
+
10
+ Ultralytics wraps a LiteRT export of a `task=detect` / `end2end=False` model in
11
+ `_NormalizeCoords`, which divides the four box channels by (W, H). The class
12
+ channels are untouched. So a twin whose boxes are 640x wrong still matches its
13
+ ONNX source on output shape, on dtype, on class count, and on every score — the
14
+ four things a parity check naturally asserts. Only the boxes disagree, and only
15
+ if you look at them separately from the scores.
16
+
17
+ The scale is a property of the LAYOUT, not of the runtime:
18
+
19
+ * `anchor_4_plus_nc` [1, 4+nc, 8400] LiteRT emits NORMALISED boxes.
20
+ Multiply by imgsz to recover ONNX px.
21
+ * `end2end_n_6` [1, 300, 6] the wrapper is NOT applied; both
22
+ runtimes already emit pixels.
23
+
24
+ Which means a decoder that scales every LiteRT model is as wrong as one that
25
+ scales none — it just picks a different half of the library to corrupt. See
26
+ `docs/runbooks/cctv-detector-tier.md`.
27
+
28
+ Detect the layout from the GRAPH — output names and rank — never from the
29
+ magnitude of the values being interpreted. A layout guess that reads the numbers
30
+ it is trying to interpret will always find a self-consistent story: dispatching
31
+ on `shape[1] >= 5` routes a [1, 300, 6] end2end tensor into the anchor branch
32
+ and reports a confident, meaningless class residual of 632.
33
+
34
+ NOT A CI STEP, deliberately: it needs both runtimes and the artifact pair, which
35
+ together are ~1 GB of wheels. It is a documented manual run, the same shape as
36
+ `verify_against_reference.py` beside it. What CI holds instead is the measured
37
+ result, in `bench/litert-twin-parity.json`, which the publish path reads.
38
+
39
+ uv venv /tmp/twin --python 3.11
40
+ uv pip install --python /tmp/twin/bin/python onnxruntime ai-edge-litert numpy pillow
41
+ /tmp/twin/bin/python bench/verify_litert_twin_parity.py \\
42
+ --onnx <exported>.onnx --litert <exported>.tflite
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import argparse
48
+ import json
49
+ import sys
50
+ from pathlib import Path
51
+
52
+ import numpy as np
53
+
54
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
55
+
56
+ from twin_parity_contract import ( # noqa: E402 (same directory)
57
+ ANCHOR,
58
+ BOX_TOL_PX,
59
+ CLS_TOL,
60
+ COMPARABLE_LAYOUTS,
61
+ DETS_LABELS,
62
+ END2END,
63
+ ParityError,
64
+ box_scale_for,
65
+ classify_layout,
66
+ verdict,
67
+ )
68
+
69
+
70
+ def split_channels(arr: np.ndarray, layout: str):
71
+ """Return (boxes, class_or_score) for one output tensor."""
72
+ if layout == ANCHOR:
73
+ return arr[:, :4, :], arr[:, 4:, :]
74
+ if layout == END2END:
75
+ return arr[..., :4], arr[..., 4:]
76
+ raise ParityError(f"cannot split channels for layout {layout}")
77
+
78
+
79
+ def compare(onnx_out: np.ndarray, litert_out: np.ndarray, layout: str, imgsz: int) -> dict:
80
+ # Refuse BEFORE touching the tensors. `classify_layout` names RF-DETR
81
+ # correctly and `box_scale_for` knows its scale, but this bench cannot split
82
+ # a two-output graph into box and class halves — and RF-DETR has no LiteRT
83
+ # twin to compare against anyway. Its control is `verify_against_reference.py`
84
+ # beside this file. Advertising a layout the comparison cannot handle is the
85
+ # `yolov8_nano` shape in a bench script, so it stops here with the pointer.
86
+ if layout not in COMPARABLE_LAYOUTS:
87
+ raise ParityError(
88
+ f"layout {layout!r} is classified but not comparable by this bench "
89
+ f"(comparable: {sorted(COMPARABLE_LAYOUTS)}). For {DETS_LABELS} use "
90
+ "bench/verify_against_reference.py, which is RF-DETR's control."
91
+ )
92
+ if onnx_out.shape != litert_out.shape:
93
+ raise ParityError(f"shape mismatch onnx={onnx_out.shape} litert={litert_out.shape}")
94
+ a = litert_out.astype(np.float64)
95
+ b = onnx_out.astype(np.float64)
96
+ scale = box_scale_for(layout, imgsz)
97
+ box_l, cls_l = split_channels(a, layout)
98
+ box_o, cls_o = split_channels(b, layout)
99
+ return {
100
+ "layout": layout,
101
+ "imgsz": imgsz,
102
+ "box_scale_applied": scale,
103
+ "box_residual_px": float(np.abs(box_l * scale - box_o).max()),
104
+ "box_residual_unscaled": float(np.abs(box_l - box_o).max()),
105
+ "cls_residual": float(np.abs(cls_l - cls_o).max()),
106
+ }
107
+
108
+
109
+ def verdict(result: dict) -> tuple[bool, str]:
110
+ # The scale must be LOAD-BEARING on the anchor layout. If scaling and not
111
+ # scaling both pass, the model is not telling us which one is right and the
112
+ # tolerance is doing no work — that is the 640x bug passing its own check.
113
+ if result["layout"] == ANCHOR and result["box_residual_unscaled"] <= BOX_TOL_PX:
114
+ return False, (
115
+ "the imgsz scale changed nothing — an anchor-layout twin whose raw boxes "
116
+ "already match its ONNX source is not normalised, so the layout is misread"
117
+ )
118
+ if result["box_residual_px"] > BOX_TOL_PX:
119
+ return False, (
120
+ f"box residual {result['box_residual_px']:.4g} px exceeds {BOX_TOL_PX} "
121
+ f"under layout {result['layout']} (scale x{result['box_scale_applied']})"
122
+ )
123
+ if result["cls_residual"] > CLS_TOL:
124
+ return False, f"class residual {result['cls_residual']:.4g} exceeds {CLS_TOL}"
125
+ return True, "ok"
126
+
127
+
128
+ def main() -> int:
129
+ ap = argparse.ArgumentParser()
130
+ ap.add_argument("--onnx", required=True)
131
+ ap.add_argument("--litert", required=True)
132
+ ap.add_argument("--image", help="optional JPEG; random input is used when omitted")
133
+ ap.add_argument("--seed", type=int, default=1234)
134
+ ap.add_argument("--json", action="store_true", help="emit the measurement as JSON")
135
+ args = ap.parse_args()
136
+
137
+ import onnxruntime as ort
138
+ from ai_edge_litert.interpreter import Interpreter
139
+
140
+ interp = Interpreter(model_path=args.litert, num_threads=4)
141
+ interp.allocate_tensors()
142
+ in_detail = interp.get_input_details()[0]
143
+ out_details = interp.get_output_details()
144
+ shape = tuple(int(v) for v in in_detail["shape"])
145
+
146
+ # Both runtimes take NCHW here — the LiteRT export of an ultralytics detect
147
+ # model is NOT NHWC. Transposing "because LiteRT is channels-last" silently
148
+ # feeds a rotated image and every residual then looks like export noise.
149
+ if len(shape) != 4 or shape[1] != 3:
150
+ raise ParityError(f"expected an NCHW [1,3,H,W] input, got {shape}")
151
+ imgsz = shape[2]
152
+
153
+ if args.image:
154
+ from PIL import Image
155
+
156
+ pixels = np.asarray(Image.open(args.image).convert("RGB").resize((imgsz, imgsz)))
157
+ x = (pixels.astype(np.float32) / 255.0).transpose(2, 0, 1)[None]
158
+ else:
159
+ x = np.random.default_rng(args.seed).random(shape).astype(np.float32)
160
+
161
+ interp.set_tensor(in_detail["index"], x)
162
+ interp.invoke()
163
+ litert_out = interp.get_tensor(out_details[0]["index"])
164
+
165
+ sess = ort.InferenceSession(args.onnx, providers=["CPUExecutionProvider"])
166
+ onnx_names = [o.name for o in sess.get_outputs()]
167
+ onnx_out = sess.run(None, {sess.get_inputs()[0].name: x})[0]
168
+
169
+ layout = classify_layout(tuple(int(v) for v in onnx_out.shape), onnx_names)
170
+ result = compare(onnx_out, litert_out, layout, imgsz)
171
+ ok, why = verdict(result)
172
+ result["pass"] = ok
173
+ result["reason"] = why
174
+ result["onnx"] = Path(args.onnx).name
175
+ result["litert"] = Path(args.litert).name
176
+
177
+ if args.json:
178
+ print(json.dumps(result, indent=2))
179
+ else:
180
+ print(f"{'PASS' if ok else 'FAIL'} {result['onnx']} layout={layout} {why}")
181
+ print(f" box x{result['box_scale_applied']:g}: {result['box_residual_px']:.4g} px"
182
+ f" (unscaled {result['box_residual_unscaled']:.4g})")
183
+ print(f" cls : {result['cls_residual']:.4g}")
184
+ return 0 if ok else 1
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())