@trillboards/edge-sdk 0.2.84 → 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.
- package/deploy/detector/bench/count_quantization.py +174 -0
- package/deploy/detector/bench/quantization-control.json +52 -0
- package/deploy/detector/bench/run_candidate.py +933 -0
- package/deploy/detector/bench/verify_yolo_decode.py +132 -0
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/sensingCatchRate.d.ts +69 -5
- package/dist/sensingCatchRate.d.ts.map +1 -1
- package/dist/sensingCatchRate.js +41 -2
- package/dist/sensingCatchRate.js.map +1 -1
- package/package.json +2 -2
|
@@ -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())
|