@trillboards/edge-sdk 0.2.85 → 0.2.91

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