@trillboards/edge-sdk 0.2.85 → 0.2.89

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,933 @@
1
+ #!/usr/bin/env python3
2
+ """run_candidate — RUN a candidate detector over a labelled frame set and record
3
+ what only the running process can know: per-frame per-class confidences,
4
+ forward-pass latency, resident footprint after a real probe, and whether the
5
+ SHIPPED service would load this graph at all.
6
+
7
+ It does not score anything. Scoring lives in
8
+ `trillboard-api/scripts/sensing/score-detector-candidate.js`, and the split is
9
+ deliberate — it is the same split `scripts/vision/detector-arm-eval.mjs` already
10
+ makes ("Producing --detections is model-specific and deliberately out of scope"),
11
+ and it is what let one eval set score five model families without a rewrite.
12
+ What is NEW here is that four of the things a publish decision needs cannot be
13
+ recovered from a detections file after the fact:
14
+
15
+ * latency p50/p95 — a number without its box is not transferable, and the box
16
+ is only knowable where the forward pass happened.
17
+ * resident footprint — RSS measured AFTER a probe inference. A session that has
18
+ not run a forward pass has not allocated its arenas; measuring before the
19
+ probe reports the loader, not the model.
20
+ * the artifact's sha256 — the publish rule is "the artifact, its checksum and
21
+ its score land together", so the checksum has to be of the bytes that were
22
+ ACTUALLY loaded, not of a path someone typed into a migration later.
23
+ * shipped-runtime loadability — see below. This is the yolov8_nano check made
24
+ mechanical.
25
+
26
+ THE LOADABILITY CHECK IS WHY THIS IS A FILE AND NOT A NOTEBOOK.
27
+ `trillboards_detector.session._read_graph_spec` refuses any graph whose outputs
28
+ are not named `dets`/`labels`, so an Ultralytics export — `output0`, shaped
29
+ (1, 4+C, 8400) — cannot be loaded by the service that would have to run it. A
30
+ candidate can therefore score perfectly and still be unshippable, and that is
31
+ EXACTLY the yolov8_nano failure: advertised to ~97 of 114 profiles, loadable
32
+ nowhere, `vehicle_count` ~0% fleet-wide for six months. So the check runs against
33
+ the real shipped contract (imported, never transcribed — a copy drifts) and its
34
+ refusal string is recorded in the run file, where the scorer turns it into a
35
+ publish verdict.
36
+
37
+ TWO EXECUTION PATHS, and which one ran is recorded.
38
+
39
+ shipped The graph passes `_read_graph_spec`, so inference goes through
40
+ `OnnxDetector` — the same class the connector runs. This is the only
41
+ path whose numbers describe production.
42
+ harness The graph does not pass, so this file decodes it itself. The numbers
43
+ describe the MODEL, not the product, and `shipped_service_loadable`
44
+ is false in the output so nothing downstream can forget that.
45
+
46
+ PRESENCE ONLY, AND THAT IS A LIMIT OF THE LABELS, NOT OF THE RIG. There is no
47
+ box-level ground truth committed anywhere in this repo (grep every eval set for
48
+ `bbox|xmin|x1|polygon`: zero), so localisation is unscorable and this file does
49
+ not pretend otherwise — it emits the max confidence per class per frame and no
50
+ boxes. Count is unscorable for a second, independent reason: the only count
51
+ column in the alert-evidence eval set is `model_person_count`, which is the
52
+ shipped VLM's own output and rides there as the CONTROL ARM. Scoring a candidate
53
+ against it would be scoring a model against a model. Because only per-class
54
+ maxima are needed, NMS is not run and cannot bias the result: suppression moves
55
+ which box carries a score, never the maximum over boxes.
56
+
57
+ Usage:
58
+
59
+ python3 bench/run_candidate.py \
60
+ --onnx /path/to/model.onnx \
61
+ --candidate ppe_construction \
62
+ --manifest trillboard-api/scripts/ai/eval-baselines/alert-evidence-frame-eval-set-2026-08-21.json \
63
+ --frames-dir /tmp/eval134 \
64
+ --out /tmp/run-ppe-construction.json
65
+
66
+ Requires this package's own pinned runtime (onnxruntime 1.29.0 / numpy / pillow).
67
+ Run it from the detector's uv tool env, not the system interpreter.
68
+ """
69
+
70
+ from __future__ import annotations
71
+
72
+ import argparse
73
+ import ast
74
+ import hashlib
75
+ import json
76
+ import os
77
+ import platform
78
+ import statistics
79
+ import subprocess
80
+ import sys
81
+ import time
82
+ from pathlib import Path
83
+
84
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # noqa: E402
85
+
86
+ from trillboards_detector.errors import ModelContractError # noqa: E402
87
+ from trillboards_detector.labels import ( # noqa: E402
88
+ BUILTIN_VOCABULARIES,
89
+ ModelVocabulary,
90
+ normalize_class_name,
91
+ )
92
+ from trillboards_detector.registry import read_process_rss # noqa: E402
93
+ from trillboards_detector.session import OnnxDetector, _read_graph_spec # noqa: E402
94
+
95
+ #: Emitted into the run file so the scorer can refuse a shape it does not know.
96
+ SCHEMA = "detector-candidate-run/2"
97
+ #: v2 added the `variant` block — format, output layout, and MEASURED box units.
98
+ #: The version was bumped rather than the field quietly added because a v1 run
99
+ #: carries no coordinate space at all, and a scorer that treated its absence as
100
+ #: "fine" would publish exactly the variant this field exists to catch.
101
+
102
+ #: HOW A BOX-UNIT VERDICT IS REACHED, and why a threshold rather than a flag.
103
+ #:
104
+ #: LiteRT and ONNX emit box coordinates in DIFFERENT UNITS FOR THE SAME WEIGHTS.
105
+ #: Ultralytics' TFLite export path wraps a `task=detect`, `end2end=False` model in
106
+ #: `_NormalizeCoords`, dividing the four box channels by (W, H). Measured here on
107
+ #: all 17 committed LiteRT twins: 16 of 17 have ONNX boxes in pixels 0..640 and
108
+ #: LiteRT boxes in 0..1, agreeing to 0.00266 px after multiplying by 640, while
109
+ #: their CLASS channels agree to 1.2e-07. `queue_supermarket`, the one end2end
110
+ #: twin, agrees on both sides — end2end heads emit pixels everywhere.
111
+ #:
112
+ #: Every check a publish gate would otherwise run PASSES on that pair: same
113
+ #: output shape, same dtype, loads, runs, class scores identical. An Android
114
+ #: post-processor reusing the ONNX decode draws every box 640x too small and
115
+ #: nothing reports an error. So the units are MEASURED from a forward pass, per
116
+ #: artifact, and recorded — never carried across from the sibling format.
117
+ #:
118
+ #: The discriminator is a ratio, max|box channel| / imgsz, and it is decisive by
119
+ #: two and a half orders of magnitude. Measured over 119 ONNX artifacts the ratio
120
+ #: spans 0.988..1.167; a normalised head sits at ~1/640 = 0.0016. The dead zone
121
+ #: below is EMPTY across every artifact in the tree, and an artifact landing in it
122
+ #: is refused rather than guessed at — that is the whole point of the field.
123
+ BOX_UNITS_PIXEL_RATIO_MIN = 0.2
124
+ BOX_UNITS_NORMALIZED_RATIO_MAX = 0.02
125
+
126
+ #: Warm-up forward passes before any timing is kept. ORT's first run pays for
127
+ #: arena allocation and kernel selection; folding that into a p50 over 134 frames
128
+ #: moves the median a little and the p95 a lot.
129
+ DEFAULT_WARMUP = 3
130
+
131
+
132
+ def read_load_average() -> "dict | None":
133
+ """The machine's run-queue depth, because a ms figure without it is not one.
134
+
135
+ This repo's rule is that every latency number carries its hardware. That is
136
+ necessary and it is NOT sufficient on a shared box: measured here, two
137
+ back-to-back runs of the same artifact on the same machine gave p50 68.3 ms
138
+ and 276.2 ms — 4x — at load average 9.58 on 16 cores, with nothing about the
139
+ model, the frames or the code changing between them. A reader given only
140
+ "68 ms, M4 Max" cannot tell which of those two runs they are holding.
141
+
142
+ So the run queue is recorded beside the timing and `contention_ratio` is
143
+ load1 / cores. Above ~0.7 the p95 is measuring other people's work.
144
+ """
145
+ try:
146
+ one, five, fifteen = os.getloadavg()
147
+ except (OSError, AttributeError): # pragma: no cover - Windows has no getloadavg
148
+ return None
149
+ cores = os.cpu_count() or 1
150
+ return {
151
+ "load_1m": round(one, 2), "load_5m": round(five, 2), "load_15m": round(fifteen, 2),
152
+ "cores": cores, "contention_ratio": round(one / cores, 3),
153
+ }
154
+
155
+
156
+ def sha256_file(path: Path) -> str:
157
+ digest = hashlib.sha256()
158
+ with path.open("rb") as handle:
159
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
160
+ digest.update(chunk)
161
+ return digest.hexdigest()
162
+
163
+
164
+ def describe_box() -> str:
165
+ """Name the machine, because every latency figure in our runbooks carries one.
166
+
167
+ `cctv-detector-tier.md` §6 is explicit that the ACCURACY findings are
168
+ hardware-independent and the latency ones are not, and the whole "INT8 is
169
+ slower than fp32" result is an Apple-arm64 fact nobody has reproduced on the
170
+ AMD Zen4 box carrying live customers. A ms figure whose box is unstated reads
171
+ as if it transfers.
172
+ """
173
+ bits = [platform.system(), platform.machine()]
174
+ cores = os.cpu_count()
175
+ if cores:
176
+ bits.append(f"{cores} cores")
177
+ if platform.system() == "Darwin":
178
+ try:
179
+ brand = subprocess.run(
180
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
181
+ capture_output=True, text=True, timeout=5, check=False,
182
+ ).stdout.strip()
183
+ if brand:
184
+ bits.insert(1, brand)
185
+ except (OSError, subprocess.SubprocessError): # pragma: no cover
186
+ pass
187
+ return " ".join(b for b in bits if b)
188
+
189
+
190
+ def _parse_names_literal(raw, model_path: Path) -> "dict[int, str]":
191
+ """§5 rule 2 of `cctv-detector-tier.md`: NEVER guess a class index.
192
+
193
+ A fallback to index 0 once scored BUSES for a whole run and the table looked
194
+ entirely plausible. Ultralytics writes `names` into the ONNX metadata as a
195
+ Python dict literal; if it is absent there is no way to name a slot, and the
196
+ honest move is to stop rather than to count from zero.
197
+ """
198
+ if not raw:
199
+ raise ModelContractError(
200
+ f"{model_path.name} carries no `names` in its ONNX metadata. Refusing to score it: "
201
+ "a class index this file cannot name is a class it would have to guess, and a "
202
+ "guessed index once scored buses for a whole run against person labels."
203
+ )
204
+ parsed = ast.literal_eval(raw)
205
+ if not isinstance(parsed, dict) or not parsed:
206
+ raise ModelContractError(f"{model_path.name}: `names` metadata is not a non-empty dict")
207
+ return {int(k): str(v) for k, v in parsed.items()}
208
+
209
+
210
+ def read_declared_class_names(model_path: Path) -> "dict[int, str]":
211
+ """Class names the artifact declares ABOUT ITSELF. Both formats are self-describing.
212
+
213
+ ONNX carries them in `custom_metadata_map['names']` as a Python dict literal.
214
+ A LiteRT `.tflite` from Ultralytics is a ZIP holding `metadata.json`, whose
215
+ `names` is a real JSON object — a raw byte search of the file finds nothing
216
+ because the archive is compressed, which is a good way to conclude the format
217
+ is not self-describing and reach for a cross-format import that should never
218
+ exist. Names must come from the artifact: four models were caught tonight
219
+ lying by class list alone, including a "gun and knife" detector that is stock
220
+ YOLOv8n with all 80 COCO classes.
221
+ """
222
+ if model_path.suffix.lower() == ".tflite":
223
+ import zipfile # noqa: PLC0415
224
+
225
+ if not zipfile.is_zipfile(model_path):
226
+ raise ModelContractError(
227
+ f"{model_path.name} carries no embedded metadata archive, so it cannot name its "
228
+ "own classes and this file will not name them for it"
229
+ )
230
+ with zipfile.ZipFile(model_path) as archive:
231
+ if "metadata.json" not in archive.namelist():
232
+ raise ModelContractError(f"{model_path.name}: no metadata.json in the artifact")
233
+ meta = json.loads(archive.read("metadata.json").decode("utf-8"))
234
+ names = meta.get("names")
235
+ if not isinstance(names, dict) or not names:
236
+ raise ModelContractError(f"{model_path.name}: metadata.json declares no `names`")
237
+ return {int(k): str(v) for k, v in names.items()}
238
+
239
+ import onnxruntime as ort # noqa: PLC0415
240
+
241
+ opts = ort.SessionOptions()
242
+ opts.log_severity_level = 3
243
+ session = ort.InferenceSession(
244
+ str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
245
+ )
246
+ meta = session.get_modelmeta().custom_metadata_map or {}
247
+ return _parse_names_literal(meta.get("names"), model_path)
248
+
249
+
250
+ def classify_output_layout(output_shapes: "list[list]") -> str:
251
+ """Which decode does this graph need?
252
+
253
+ A publish gate that records only "onnx, 12,245,970 bytes" tells a consumer
254
+ nothing about how to read the tensor, and two incompatible layouts are in the
255
+ tree right now: 112 of 119 artifacts are the anchor head `[1, 4+nc, A]`, and
256
+ **7 are `[1, N, 6]`** — NMS-free end2end rows of `x1,y1,x2,y2,score,cls`. A
257
+ parser that assumes one reads the other as garbage with no error, so the
258
+ layout is a declared property of the variant, derived from the graph.
259
+ """
260
+ if len(output_shapes) == 2:
261
+ return "rfdetr_dets_labels"
262
+ if len(output_shapes) != 1:
263
+ return f"unknown_{len(output_shapes)}_outputs"
264
+ shape = output_shapes[0]
265
+ if len(shape) == 3 and shape[2] == 6:
266
+ return "end2end_n_6"
267
+ if len(shape) == 3:
268
+ return "anchor_4_plus_nc"
269
+ return f"unknown_rank_{len(shape)}"
270
+
271
+
272
+ def classify_box_units(box_abs_max: float, imgsz: int) -> dict:
273
+ """pixels, normalized, or REFUSE — never a guess.
274
+
275
+ Returns the verdict plus the evidence, because a bare enum in a score card is
276
+ a claim and the ratio is what makes it checkable.
277
+ """
278
+ if imgsz <= 0 or box_abs_max is None:
279
+ return {"units": None, "ratio": None, "scale_to_pixels": None,
280
+ "refusal": "no box channels were observed, so the coordinate space is unmeasured"}
281
+ ratio = box_abs_max / imgsz
282
+ if ratio >= BOX_UNITS_PIXEL_RATIO_MIN:
283
+ return {"units": "pixels", "ratio": ratio, "scale_to_pixels": 1.0, "refusal": None}
284
+ if ratio <= BOX_UNITS_NORMALIZED_RATIO_MAX:
285
+ return {"units": "normalized", "ratio": ratio, "scale_to_pixels": float(imgsz), "refusal": None}
286
+ return {
287
+ "units": None, "ratio": ratio, "scale_to_pixels": None,
288
+ "refusal": (
289
+ f"max|box|/imgsz = {ratio:.4f} falls between {BOX_UNITS_NORMALIZED_RATIO_MAX} and "
290
+ f"{BOX_UNITS_PIXEL_RATIO_MIN}, where neither pixels nor normalised is credible. "
291
+ "Measured over 119 artifacts this band is empty; refusing rather than picking one, "
292
+ "because the wrong pick is silent and scales every box by 640."
293
+ ),
294
+ }
295
+
296
+
297
+ def letterbox(image, width: int, height: int):
298
+ """Ultralytics' resize: scale to fit, pad the remainder 114-grey, centred.
299
+
300
+ The shipped RF-DETR path STRETCHES (`session.preprocess`, transcribed from
301
+ `rfdetr/detr.py`). Ultralytics does not, and using the wrong one for a YOLO
302
+ export is not a rounding difference — it changes the aspect of every object in
303
+ the frame. The two preprocessors are kept apart and each is named in the run
304
+ file, so an arm can never be compared against one preprocessed differently.
305
+ """
306
+ import numpy as np # noqa: PLC0415
307
+ from PIL import Image # noqa: PLC0415
308
+
309
+ src_w, src_h = image.size
310
+ scale = min(width / src_w, height / src_h)
311
+ new_w, new_h = int(round(src_w * scale)), int(round(src_h * scale))
312
+ resized = image.resize((new_w, new_h), resample=Image.BILINEAR)
313
+ canvas = Image.new("RGB", (width, height), (114, 114, 114))
314
+ canvas.paste(resized, ((width - new_w) // 2, (height - new_h) // 2))
315
+ arr = np.asarray(canvas, dtype=np.float32) / np.float32(255.0)
316
+ return arr.transpose(2, 0, 1)[None, ...]
317
+
318
+
319
+ def _decode_yolo(raw, layout: str, slot_names: "dict[int, str]", conf_floor: float):
320
+ """Per-class maxima and the largest box magnitude, for either YOLO layout.
321
+
322
+ Presence only: the maximum confidence per class. NMS is not run and cannot
323
+ change the answer — suppression moves which box carries a score, never the
324
+ maximum over boxes — and running one would add a setting nobody could
325
+ reconstruct from the run file later.
326
+ """
327
+ per_class: "dict[str, list[float]]" = {}
328
+ if layout == "end2end_n_6":
329
+ rows = raw[0] # (N, 6) = x1,y1,x2,y2,score,cls
330
+ box_max = float(abs(rows[:, :4]).max()) if rows.size else 0.0
331
+ for x1, y1, x2, y2, score, cls in rows:
332
+ name = slot_names.get(int(cls))
333
+ if name is None or float(score) < conf_floor:
334
+ continue
335
+ per_class.setdefault(name, []).append(float(score))
336
+ for values in per_class.values():
337
+ values.sort(reverse=True)
338
+ return per_class, box_max
339
+ grid = raw[0] # (4 + nc, anchors), scores already sigmoid
340
+ box_max = float(abs(grid[:4, :]).max()) if grid.size else 0.0
341
+ maxima = grid[4:, :].max(axis=1)
342
+ for idx, name in slot_names.items():
343
+ if idx >= maxima.shape[0]:
344
+ continue
345
+ value = float(maxima[idx])
346
+ if value >= conf_floor:
347
+ per_class[name] = [value]
348
+ return per_class, box_max
349
+
350
+
351
+ class HarnessYoloRunner:
352
+ """Decode an Ultralytics head, for graphs the shipped service refuses.
353
+
354
+ Handles BOTH committed layouts — the anchor head `[1, 4+nc, A]` (112 of 119
355
+ artifacts) and the NMS-free `[1, N, 6]` end2end head (7 of them) — because a
356
+ parser that assumes one reads the other as garbage without erroring.
357
+ """
358
+
359
+ path_kind = "harness-yolo"
360
+ preprocessing = "ultralytics-letterbox-114-centred"
361
+ variant_format = "onnx"
362
+
363
+ def __init__(self, model_path: Path, slot_names: "dict[int, str]"):
364
+ # Names are handed IN, never re-read here: reading them needs a session,
365
+ # and a second session in the measuring process is exactly what
366
+ # `introspect_in_subprocess` exists to prevent.
367
+ import numpy as np # noqa: PLC0415
368
+ import onnxruntime as ort # noqa: PLC0415
369
+
370
+ self._np = np
371
+ opts = ort.SessionOptions()
372
+ opts.log_severity_level = 3
373
+ self.session = ort.InferenceSession(
374
+ str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
375
+ )
376
+ graph_in = self.session.get_inputs()[0]
377
+ shape = graph_in.shape
378
+ if len(shape) != 4 or shape[1] != 3:
379
+ raise ModelContractError(f"expected an NCHW 3-channel input, found {shape}")
380
+ if not isinstance(shape[2], int) or not isinstance(shape[3], int):
381
+ raise ModelContractError(
382
+ f"expected a fixed input resolution, found {shape[2]}x{shape[3]}; imgsz is one "
383
+ "of the two numbers a detector arm is meaningless without"
384
+ )
385
+ self.input_name = graph_in.name
386
+ self.height, self.width = int(shape[2]), int(shape[3])
387
+ outputs = self.session.get_outputs()
388
+ self.output_shapes = [list(o.shape) for o in outputs]
389
+ self.layout = classify_output_layout(self.output_shapes)
390
+ if self.layout.startswith("unknown"):
391
+ raise ModelContractError(
392
+ f"cannot classify this graph's output layout ({self.output_shapes}); refusing "
393
+ "rather than decoding a tensor by guessing what its axes mean"
394
+ )
395
+ self.output_name = outputs[0].name
396
+ self.slot_names = dict(slot_names)
397
+ if self.layout == "anchor_4_plus_nc":
398
+ expected = 4 + len(self.slot_names)
399
+ declared = self.output_shapes[0][1] if len(self.output_shapes[0]) == 3 else None
400
+ if isinstance(declared, int) and declared != expected:
401
+ raise ModelContractError(
402
+ f"output axis is {declared} wide; 4 box channels + {len(self.slot_names)} named "
403
+ f"classes needs {expected}. Refusing rather than labelling a channel by position."
404
+ )
405
+ self.providers = list(self.session.get_providers())
406
+
407
+ def class_names(self) -> "dict[int, str]":
408
+ return dict(self.slot_names)
409
+
410
+ def run(self, frame, conf_floor: float):
411
+ blob = letterbox(frame, self.width, self.height)
412
+ started = time.perf_counter()
413
+ (raw,) = self.session.run([self.output_name], {self.input_name: blob})
414
+ infer_ms = (time.perf_counter() - started) * 1000.0
415
+ per_class, box_max = _decode_yolo(raw, self.layout, self.slot_names, conf_floor)
416
+ return per_class, infer_ms, box_max
417
+
418
+
419
+ class LitertRunner:
420
+ """The SAME weights through LiteRT — a DIFFERENT artifact, scored separately.
421
+
422
+ This class exists because the two formats are not interchangeable and the
423
+ difference is invisible to every other check. Scoring the ONNX and copying the
424
+ verdict onto the `.tflite` row is precisely the mistake the box-units field is
425
+ for: the class channels agree to 1.2e-07 while the boxes are 640x apart.
426
+
427
+ `ai_edge_litert` is imported lazily and is deliberately NOT in the detector
428
+ package's pinned dependencies — `bench/` is excluded from the wheel
429
+ (`[tool.hatch.build.targets.wheel] packages = ["trillboards_detector"]`), so a
430
+ measurement-only dependency must never reach the connector's runtime.
431
+ """
432
+
433
+ path_kind = "harness-litert"
434
+ preprocessing = "ultralytics-letterbox-114-centred"
435
+ variant_format = "litert"
436
+
437
+ def __init__(self, model_path: Path, slot_names: "dict[int, str]"):
438
+ import numpy as np # noqa: PLC0415
439
+
440
+ try:
441
+ from ai_edge_litert.interpreter import Interpreter # noqa: PLC0415
442
+ except ImportError as exc: # pragma: no cover - environment, not logic
443
+ raise SystemExit(
444
+ "scoring a .tflite needs `ai_edge_litert` (uv pip install ai-edge-litert). It is "
445
+ "intentionally absent from the detector package's own dependencies: bench/ is not "
446
+ "shipped in the wheel and a measurement dependency must not reach the connector."
447
+ ) from exc
448
+
449
+ self._np = np
450
+ self.interpreter = Interpreter(model_path=str(model_path))
451
+ self.interpreter.allocate_tensors()
452
+ self._in = self.interpreter.get_input_details()[0]
453
+ outs = self.interpreter.get_output_details()
454
+ self.output_shapes = [list(o["shape"]) for o in outs]
455
+ self._out = outs[0]
456
+ shape = list(self._in["shape"])
457
+ if len(shape) != 4:
458
+ raise ModelContractError(f"expected a rank-4 input, found {shape}")
459
+ # LiteRT exports may be NCHW or NHWC and the difference is silent, so the
460
+ # layout is read off the graph rather than assumed.
461
+ if shape[1] == 3:
462
+ self.channels_first = True
463
+ self.height, self.width = int(shape[2]), int(shape[3])
464
+ elif shape[3] == 3:
465
+ self.channels_first = False
466
+ self.height, self.width = int(shape[1]), int(shape[2])
467
+ else:
468
+ raise ModelContractError(f"cannot find a 3-channel axis in input shape {shape}")
469
+ self.layout = classify_output_layout(self.output_shapes)
470
+ self.slot_names = dict(slot_names)
471
+ self.providers = ["LiteRT-XNNPACK-CPU"]
472
+
473
+ def class_names(self) -> "dict[int, str]":
474
+ return dict(self.slot_names)
475
+
476
+ def run(self, frame, conf_floor: float):
477
+ np = self._np
478
+ blob = letterbox(frame, self.width, self.height)
479
+ if not self.channels_first:
480
+ blob = np.transpose(blob, (0, 2, 3, 1)).copy()
481
+ started = time.perf_counter()
482
+ self.interpreter.set_tensor(self._in["index"], blob)
483
+ self.interpreter.invoke()
484
+ raw = self.interpreter.get_tensor(self._out["index"])
485
+ infer_ms = (time.perf_counter() - started) * 1000.0
486
+ # A LiteRT export can transpose the anchor head relative to ONNX; orient by
487
+ # the named class count rather than by position.
488
+ if self.layout == "anchor_4_plus_nc" and raw.ndim == 3:
489
+ expected = 4 + len(self.slot_names)
490
+ if raw.shape[1] != expected and raw.shape[2] == expected:
491
+ raw = np.transpose(raw, (0, 2, 1)).copy()
492
+ per_class, box_max = _decode_yolo(raw, self.layout, self.slot_names, conf_floor)
493
+ return per_class, infer_ms, box_max
494
+
495
+
496
+ class ShippedRunner:
497
+ """Inference through `OnnxDetector` — the class the connector actually runs."""
498
+
499
+ path_kind = "shipped"
500
+ preprocessing = "session.preprocess (imagenet-normalised stretch)"
501
+ variant_format = "onnx"
502
+ layout = "rfdetr_dets_labels"
503
+
504
+ def __init__(self, model_path: Path, vocabulary: ModelVocabulary):
505
+ self.detector = OnnxDetector(model_path, vocabulary)
506
+ self.vocabulary = vocabulary
507
+ self.width = self.detector.spec.width
508
+ self.height = self.detector.spec.height
509
+ self.providers = list(self.detector.spec.providers)
510
+
511
+ def class_names(self) -> "dict[int, str]":
512
+ return dict(self.vocabulary.slot_names)
513
+
514
+ def run(self, frame, conf_floor: float):
515
+ batch = self.detector.detect(frame.raw_bytes, conf_floor, None)
516
+ per_class: "dict[str, list[float]]" = {}
517
+ box_max = 0.0
518
+ for det in batch.detections:
519
+ per_class.setdefault(det.label, []).append(float(det.confidence))
520
+ # `postprocess` scales by frame_w/frame_h, so the wire carries PIXELS
521
+ # of the submitted frame. Measured from the emitted boxes rather than
522
+ # asserted from that fact, so the record is evidence either way.
523
+ box_max = max(box_max, float(det.x + det.width), float(det.y + det.height))
524
+ for values in per_class.values():
525
+ values.sort(reverse=True)
526
+ return per_class, float(batch.inference_ms), box_max
527
+
528
+
529
+ class Frame:
530
+ """A JPEG on disk, decoded LAZILY and releasable.
531
+
532
+ THE LAZINESS IS THE MEASUREMENT, not an optimisation. RSS is sampled between
533
+ the probe inference and the scored pass, so anything resident at that moment
534
+ is counted as the model's footprint. Eagerly building a list of all 134
535
+ decoded RGB images puts ~123 MB of pixels (640x480x3 x 134) plus their JPEG
536
+ bytes inside that number, and under macOS's `rusage-maxrss` high-water source
537
+ the contamination cannot be subtracted afterwards — the peak is already set.
538
+
539
+ So exactly ONE frame is resident when RSS is read, and the scored pass builds
540
+ and releases the rest one at a time. This is the third instance in this file
541
+ of the same failure: the harness measuring itself instead of the model (see
542
+ also `introspect_in_subprocess`, and the probe inference itself).
543
+ """
544
+
545
+ def __init__(self, path: Path):
546
+ self.path = path
547
+ self._raw: "bytes | None" = None
548
+ self._image = None
549
+
550
+ @property
551
+ def raw_bytes(self) -> bytes:
552
+ if self._raw is None:
553
+ self._raw = self.path.read_bytes()
554
+ return self._raw
555
+
556
+ @property
557
+ def image(self):
558
+ if self._image is None:
559
+ from PIL import Image # noqa: PLC0415
560
+
561
+ with Image.open(self.path) as handle:
562
+ self._image = handle.convert("RGB")
563
+ return self._image
564
+
565
+ @property
566
+ def size(self):
567
+ return self.image.size
568
+
569
+ def resize(self, *args, **kwargs): # Pillow duck-typing for `letterbox`
570
+ return self.image.resize(*args, **kwargs)
571
+
572
+ def release(self) -> None:
573
+ """Drop the decoded pixels and the JPEG bytes. Called after every frame."""
574
+ self._raw = None
575
+ self._image = None
576
+
577
+
578
+ def synthesise_vocabulary(name: str, class_names: "dict[int, str]") -> ModelVocabulary:
579
+ """A ModelVocabulary over a model's OWN names, canonicalised the service's way.
580
+
581
+ Synthesised rather than committed so the loadability check is a FAIR one: it
582
+ must not fail merely because the committed vocabulary describes a different
583
+ model. What survives is the part that is genuinely about the graph — output
584
+ names, rank, and the class axis agreeing with the vocabulary handed in.
585
+ """
586
+ width = max(class_names) + 1 if class_names else 0
587
+ slots: "dict[int, str]" = {}
588
+ for idx, raw in sorted(class_names.items()):
589
+ canonical = normalize_class_name(raw)
590
+ if canonical in slots.values():
591
+ # Two slots folding to one canonical name is a real property of some
592
+ # candidates (`CellPhone` + `phone`). Keep the first rather than
593
+ # raising: this vocabulary exists only to make the graph check fair.
594
+ continue
595
+ slots[idx] = canonical
596
+ return ModelVocabulary(
597
+ model_name=name, slot_names=slots, logit_slots=width, approx_ram_bytes=1
598
+ )
599
+
600
+
601
+ def probe_loadability(model_path: Path, class_names: "dict[int, str]") -> dict:
602
+ """Would the SHIPPED service load this artifact? Ask the shipped contract."""
603
+ import onnxruntime as ort # noqa: PLC0415
604
+
605
+ if model_path.suffix.lower() != ".onnx":
606
+ # Attempted rather than asserted, so the recorded refusal is the real one.
607
+ try:
608
+ ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"])
609
+ except Exception as exc: # noqa: BLE001 - any refusal is the answer
610
+ return {
611
+ "shipped_service_loadable": False,
612
+ "refusal": (
613
+ f"the detector service builds an onnxruntime InferenceSession and this is a "
614
+ f"{model_path.suffix} artifact: {str(exc).splitlines()[0][:200]}"
615
+ ),
616
+ }
617
+
618
+ opts = ort.SessionOptions()
619
+ opts.log_severity_level = 3
620
+ session = ort.InferenceSession(
621
+ str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
622
+ )
623
+ try:
624
+ vocabulary = synthesise_vocabulary("probe-only", class_names)
625
+ except ValueError as exc:
626
+ return {"shipped_service_loadable": False, "refusal": f"vocabulary: {exc}"}
627
+ try:
628
+ _read_graph_spec(session, vocabulary)
629
+ except ModelContractError as exc:
630
+ return {"shipped_service_loadable": False, "refusal": str(exc)}
631
+ return {"shipped_service_loadable": True, "refusal": None}
632
+
633
+
634
+ def introspect_in_subprocess(model_path: Path) -> dict:
635
+ """Read class names and shipped-runtime loadability WITHOUT touching our RSS.
636
+
637
+ Both answers need an ORT session, and a session maps the weights. Building
638
+ one in THIS process before the baseline is taken poisons the footprint
639
+ measurement in a way that is invisible in the output: on macOS the RSS source
640
+ is `rusage-maxrss`, a process high-water mark, so a transient session sets the
641
+ peak and the real session then appears to cost nothing. The delta reads small
642
+ and plausible and describes neither session.
643
+
644
+ So the introspection runs in a child, and the parent creates exactly one
645
+ session in its whole life. This is the same class of error as measuring RSS
646
+ before the probe inference, and it is worth a fork to remove.
647
+ """
648
+ result = subprocess.run(
649
+ [sys.executable, str(Path(__file__).resolve()), "--introspect", "--onnx", str(model_path)],
650
+ capture_output=True, text=True, check=False,
651
+ )
652
+ if result.returncode != 0:
653
+ raise SystemExit(
654
+ f"introspection of {model_path.name} failed ({result.returncode}):\n{result.stderr.strip()}"
655
+ )
656
+ return json.loads(result.stdout)
657
+
658
+
659
+ def percentile(values: "list[float]", q: float):
660
+ if not values:
661
+ return None
662
+ ordered = sorted(values)
663
+ if len(ordered) == 1:
664
+ return ordered[0]
665
+ pos = (len(ordered) - 1) * q
666
+ low = int(pos)
667
+ high = min(low + 1, len(ordered) - 1)
668
+ return ordered[low] + (ordered[high] - ordered[low]) * (pos - low)
669
+
670
+
671
+ def load_manifest_frames(manifest_path: Path) -> "list[str]":
672
+ manifest = json.loads(manifest_path.read_text())
673
+ keys = [f["s3_key"] for ep in manifest.get("episodes", []) for f in ep.get("frames", [])]
674
+ if not keys:
675
+ raise SystemExit(f"{manifest_path}: no frames — nothing to run")
676
+ return keys
677
+
678
+
679
+ def main() -> int:
680
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
681
+ parser.add_argument("--onnx", required=True)
682
+ parser.add_argument("--candidate", help="the model_id it would publish as")
683
+ parser.add_argument("--manifest")
684
+ parser.add_argument("--frames-dir")
685
+ parser.add_argument("--out")
686
+ parser.add_argument(
687
+ "--introspect", action="store_true",
688
+ help="internal: print class names + loadability as JSON and exit. Run in a child so "
689
+ "the measuring process never builds a throwaway session (see "
690
+ "introspect_in_subprocess).",
691
+ )
692
+ parser.add_argument("--conf-floor", type=float, default=0.05)
693
+ parser.add_argument("--warmup", type=int, default=DEFAULT_WARMUP)
694
+ parser.add_argument("--device", default=None, help="override the auto-detected box name")
695
+ parser.add_argument("--note", default=None)
696
+ args = parser.parse_args()
697
+
698
+ model_path = Path(args.onnx).resolve()
699
+ if not model_path.is_file():
700
+ raise SystemExit(f"no artifact at {model_path}")
701
+
702
+ if args.introspect:
703
+ try:
704
+ names = read_declared_class_names(model_path)
705
+ except ModelContractError as exc:
706
+ names = {}
707
+ declared_refusal = str(exc)
708
+ else:
709
+ declared_refusal = None
710
+ loadability = probe_loadability(model_path, names) if names else None
711
+ print(json.dumps({
712
+ "names": {str(k): v for k, v in names.items()},
713
+ "declared_names_refusal": declared_refusal,
714
+ "loadability": loadability,
715
+ }))
716
+ return 0
717
+
718
+ for required in ("candidate", "manifest", "frames_dir", "out"):
719
+ if not getattr(args, required):
720
+ parser.error(f"--{required.replace('_', '-')} is required")
721
+
722
+ manifest_path = Path(args.manifest).resolve()
723
+ frames_dir = Path(args.frames_dir).resolve()
724
+
725
+ keys = load_manifest_frames(manifest_path)
726
+ missing = [k for k in keys if not (frames_dir / os.path.basename(k)).is_file()]
727
+ if missing:
728
+ raise SystemExit(
729
+ f"REFUSING: {len(missing)} of {len(keys)} labelled frames are not in {frames_dir}. "
730
+ "A frame the detector never saw is not a negative, it is a hole, and it would "
731
+ "silently become a false negative."
732
+ )
733
+
734
+ # RSS baseline BEFORE the session exists. Everything after this is the model.
735
+ rss_before = read_process_rss()
736
+
737
+ introspection = introspect_in_subprocess(model_path)
738
+ declared_names = {int(k): v for k, v in introspection["names"].items()}
739
+ loadability = introspection["loadability"]
740
+ runner = None
741
+ builtin_used = None
742
+
743
+ if not declared_names:
744
+ # No self-declared names. The only honest source left is a COMMITTED
745
+ # vocabulary, and only if the graph matches it exactly.
746
+ for vocabulary in BUILTIN_VOCABULARIES:
747
+ try:
748
+ runner = ShippedRunner(model_path, vocabulary)
749
+ builtin_used = vocabulary.model_name
750
+ loadability = {"shipped_service_loadable": True, "refusal": None}
751
+ break
752
+ except ModelContractError as exc:
753
+ loadability = {"shipped_service_loadable": False, "refusal": str(exc)}
754
+ if runner is None:
755
+ raise SystemExit(
756
+ f"REFUSING: {model_path.name} declares no class names of its own and matches no "
757
+ f"committed vocabulary. Last refusal: {loadability and loadability['refusal']}"
758
+ )
759
+ elif model_path.suffix.lower() == ".tflite":
760
+ runner = LitertRunner(model_path, declared_names)
761
+ elif loadability["shipped_service_loadable"]:
762
+ runner = ShippedRunner(model_path, synthesise_vocabulary(args.candidate, declared_names))
763
+ else:
764
+ runner = HarnessYoloRunner(model_path, declared_names)
765
+
766
+ rss_after_session = read_process_rss()
767
+
768
+ # THE PROBE. A session that has not run a forward pass has not allocated its
769
+ # arenas — measuring RSS here rather than above is the difference between the
770
+ # loader's footprint and the model's.
771
+ #
772
+ # EXACTLY ONE FRAME IS RESIDENT WHEN RSS IS READ. Retaining the whole
773
+ # evaluation set here would fold ~123 MB of decoded pixels into the reported
774
+ # footprint, and a high-water RSS source makes that unsubtractable later.
775
+ warmup_n = max(0, int(args.warmup))
776
+ probe_frame = Frame(frames_dir / os.path.basename(keys[0]))
777
+ for _ in range(max(1, warmup_n)):
778
+ runner.run(probe_frame, args.conf_floor)
779
+ rss_after_probe = read_process_rss()
780
+ probe_frame.release()
781
+
782
+ load_before = read_load_average()
783
+ detections: "dict[str, dict[str, list[float]]]" = {}
784
+ infer_ms: "list[float]" = []
785
+ wall_ms: "list[float]" = []
786
+ box_abs_max = 0.0
787
+ for key in keys:
788
+ frame = Frame(frames_dir / os.path.basename(key))
789
+ started = time.perf_counter()
790
+ per_class, ms, box_max = runner.run(frame, args.conf_floor)
791
+ wall_ms.append((time.perf_counter() - started) * 1000.0)
792
+ infer_ms.append(ms)
793
+ # The coordinate space is read off REAL frames, over the whole set, not
794
+ # off one synthetic tensor: a single frame with no large box would drag
795
+ # the ratio toward the dead zone and turn a decisive fact into a refusal.
796
+ box_abs_max = max(box_abs_max, box_max)
797
+ detections[key] = per_class
798
+ frame.release()
799
+
800
+ load_after = read_load_average()
801
+ units = classify_box_units(box_abs_max, max(runner.width, runner.height))
802
+ if units["refusal"]:
803
+ raise SystemExit(f"REFUSING: {units['refusal']}")
804
+
805
+ same_source = bool(rss_before and rss_after_probe and rss_before[1] == rss_after_probe[1])
806
+ rss = {
807
+ "source": rss_before[1] if rss_before else None,
808
+ "before_session_bytes": rss_before[0] if rss_before else None,
809
+ "after_session_bytes": rss_after_session[0] if rss_after_session else None,
810
+ "after_probe_bytes": rss_after_probe[0] if rss_after_probe else None,
811
+ "delta_bytes": (rss_after_probe[0] - rss_before[0]) if same_source else None,
812
+ "probe_ran": True,
813
+ "probe_inferences": max(1, warmup_n),
814
+ "frames_resident_at_measurement": 1,
815
+ "measured_after_probe": True,
816
+ "source_note": (
817
+ "rusage-maxrss is a process HIGH-WATER MARK, so a delta is a LOWER BOUND on this "
818
+ "model's footprint and legitimately reads small once an earlier peak is behind it. "
819
+ "proc-statm and psapi-workingset are current and exact."
820
+ ),
821
+ }
822
+
823
+ payload = {
824
+ "schema": SCHEMA,
825
+ "candidate": args.candidate,
826
+ "produced_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
827
+ "produced_by": "trillboards-edge-sdk/.../deploy/detector/bench/run_candidate.py",
828
+ "artifact": {
829
+ "path": str(model_path),
830
+ "sha256": sha256_file(model_path),
831
+ "bytes": model_path.stat().st_size,
832
+ },
833
+ "eval_set_manifest": {
834
+ "path": str(manifest_path),
835
+ "sha256": sha256_file(manifest_path),
836
+ "frames": len(keys),
837
+ },
838
+ "execution_path": runner.path_kind,
839
+ "preprocessing": runner.preprocessing,
840
+ "variant": {
841
+ "format": runner.variant_format,
842
+ "output_layout": runner.layout,
843
+ "end2end": runner.layout == "end2end_n_6",
844
+ "box_units": units["units"],
845
+ "box_scale_to_pixels": units["scale_to_pixels"],
846
+ "box_units_evidence": (
847
+ f"max|box channel| = {box_abs_max:.4f} over {len(keys)} real frames; "
848
+ f"/ imgsz {max(runner.width, runner.height)} = {units['ratio']:.4f}. "
849
+ f"pixels at >= {BOX_UNITS_PIXEL_RATIO_MIN}, normalized at <= "
850
+ f"{BOX_UNITS_NORMALIZED_RATIO_MAX}, refused in between."
851
+ ),
852
+ "box_units_ratio": units["ratio"],
853
+ "why_this_field_exists": (
854
+ "LiteRT and ONNX emit box coordinates in DIFFERENT UNITS for the same weights "
855
+ "(Ultralytics wraps a non-end2end detect export in _NormalizeCoords), and every "
856
+ "other check passes on the pair: same shape, same dtype, class scores agreeing to "
857
+ "1.2e-07. A consumer reusing the ONNX decode draws boxes 640x too small, silently."
858
+ ),
859
+ },
860
+ "builtin_vocabulary_used": builtin_used,
861
+ "runtime_contract": loadability,
862
+ "imgsz": [runner.width, runner.height],
863
+ "conf_floor": args.conf_floor,
864
+ "classes": [
865
+ {"index": idx, "name": name} for idx, name in sorted(runner.class_names().items())
866
+ ],
867
+ "device": args.device or describe_box(),
868
+ "providers": runner.providers,
869
+ "latency_ms": {
870
+ "inference_p50": percentile(infer_ms, 0.5),
871
+ "inference_p95": percentile(infer_ms, 0.95),
872
+ "inference_mean": statistics.fmean(infer_ms) if infer_ms else None,
873
+ "end_to_end_p50": percentile(wall_ms, 0.5),
874
+ "end_to_end_p95": percentile(wall_ms, 0.95),
875
+ "n": len(infer_ms),
876
+ "warmup_excluded": warmup_n,
877
+ "load_average_before": load_before,
878
+ "load_average_after": load_after,
879
+ "contention_note": (
880
+ "load_average is the machine's run queue during the measurement. Measured on this "
881
+ "box, two back-to-back runs of one artifact gave p50 68.3 ms and 276.2 ms at load "
882
+ "9.58 on 16 cores. A ms figure carries its hardware AND its contention, or a "
883
+ "reader cannot tell which of those two runs they are holding."
884
+ ),
885
+ "what_is_timed": (
886
+ "inference_* is the ORT forward pass only, matching the service's own "
887
+ "`inference_ms`. end_to_end_* adds decode, preprocess and postprocess, which is "
888
+ "what a caller waits for."
889
+ ),
890
+ },
891
+ "rss": rss,
892
+ "detections": detections,
893
+ "note": args.note,
894
+ }
895
+
896
+ out_path = Path(args.out)
897
+ out_path.parent.mkdir(parents=True, exist_ok=True)
898
+ out_path.write_text(json.dumps(payload, indent=2) + "\n")
899
+
900
+ print(f"{args.candidate} | {runner.path_kind} path | {payload['device']}")
901
+ print(
902
+ f" artifact {model_path.name} {payload['artifact']['bytes']:,} B "
903
+ f"sha256 {payload['artifact']['sha256'][:16]}..."
904
+ )
905
+ print(f" imgsz {runner.width}x{runner.height} conf floor {args.conf_floor}")
906
+ print(f" classes {[c['name'] for c in payload['classes']]}")
907
+ v = payload["variant"]
908
+ print(f" variant format {v['format']} layout {v['output_layout']} "
909
+ f"box units {v['box_units']} (x{v['box_scale_to_pixels']:g} to pixels, ratio {v['box_units_ratio']:.4f})")
910
+ print(f" shipped runtime loadable: {loadability['shipped_service_loadable']}")
911
+ if loadability["refusal"]:
912
+ print(f" refusal: {loadability['refusal']}")
913
+ lat = payload["latency_ms"]
914
+ load = lat.get("load_average_before")
915
+ load_bit = (f" load {load['load_1m']}/{load['cores']} cores (ratio {load['contention_ratio']})"
916
+ if load else " load UNKNOWN")
917
+ print(f" inference p50 {lat['inference_p50']:.1f} ms p95 {lat['inference_p95']:.1f} ms n={lat['n']}{load_bit}")
918
+ if load and load["contention_ratio"] > 0.7:
919
+ print(" ^ CONTENDED BOX: this p95 is partly other processes. Re-measure on an idle machine "
920
+ "before quoting these figures.")
921
+ if rss["delta_bytes"] is not None:
922
+ print(
923
+ f" RSS delta {rss['delta_bytes'] / (1024 * 1024):.0f} MiB after "
924
+ f"{max(1, warmup_n)} probe inference(s), 1 frame resident [{rss['source']}]"
925
+ )
926
+ else:
927
+ print(f" RSS delta UNAVAILABLE [{rss['source']}]")
928
+ print(f" wrote {out_path}")
929
+ return 0
930
+
931
+
932
+ if __name__ == "__main__":
933
+ raise SystemExit(main())