@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,373 @@
1
+ """Three output layouts, three decodes, and the coordinate space each is in.
2
+
3
+ WHY THIS IS A SEPARATE MODULE FROM `session.py`
4
+ -----------------------------------------------
5
+ `session.py` needs onnxruntime and Pillow. This file needs numpy and nothing
6
+ else, so the arithmetic that turns a tensor into boxes can be exercised against
7
+ hand-built arrays on any machine that has numpy — no 128 MiB artifact, no ORT.
8
+ That is the same argument `detections.py` and `registry.py` already make for
9
+ being stdlib-only, one dependency further up: the decode is the newest and
10
+ least-exercised code in the service, and a decode that transposes an axis, or
11
+ applies a sigmoid twice, or reads the box channels as class scores, produces a
12
+ complete and entirely plausible table.
13
+
14
+ It is also what lets the LiteRT cross-check in `bench/verify_detector_decode.py`
15
+ run the SAME decode over a `.tflite` tensor. Two decoders for one contract is how
16
+ the units drift apart in the first place.
17
+
18
+ THE LAYOUT IS DECLARED, NEVER SNIFFED
19
+ -------------------------------------
20
+ Every function here takes the layout from the caller and each is reachable only
21
+ through `YOLO_DECODERS`. Nothing in this file looks at a tensor and decides what
22
+ it is.
23
+ That is deliberate and it is the whole reason `model_platform_variants` grew an
24
+ `output_layout` column: `[1, 300, 6]` and `[1, 6, 8400]` are both rank-3 with a 6
25
+ in them, and a sniffer that gets it backwards reads x1,y1,x2,y2,score,cls as four
26
+ box channels and two classes without erroring. The declaration is the artifact's
27
+ own property, committed next to its class list in `labels.py` and mirrored in the
28
+ registry row; the graph is then CHECKED against it (`session._read_graph_spec`)
29
+ and refused on disagreement.
30
+
31
+ THE COORDINATE SPACE IS DECLARED TOO, AND FOR A SHARPER REASON
32
+ --------------------------------------------------------------
33
+ Ultralytics wraps a `task=detect`, `end2end=False` TFLite export in
34
+ `_NormalizeCoords`, dividing the four box channels by (W, H). Measured on all 17
35
+ committed LiteRT twins: 16 of 17 emit ONNX boxes in pixels 0..640 and LiteRT
36
+ boxes in 0..1, agreeing to 0.00018 px after multiplying by 640, with class scores
37
+ agreeing to 5.5e-06. `queue_supermarket`, the one end2end twin, is pixels on both
38
+ sides — an end2end head emits pixels everywhere. Every other check passes on that
39
+ pair: same shape, same dtype, both load, both run, identical scores. A decoder
40
+ that assumes pixels draws every box 640x too small on 16 of 17 models and nothing
41
+ reports an error. So `box_scale_to_pixels` arrives with the tensor and is applied
42
+ here; a contract that declares neither units nor a scale is refused in
43
+ `labels.py` rather than defaulted.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import numpy as np
49
+
50
+ # The layout and box-unit names live in `labels.py`, which imports nothing
51
+ # heavier than the standard library. `registry.py` and `server.py` are testable
52
+ # on a bare interpreter BECAUSE `labels.py` is, so the vocabulary that names
53
+ # these values cannot be allowed to reach numpy through this module.
54
+ from .labels import ANCHOR_4_PLUS_NC, END2END_N_6, OUTPUT_LAYOUTS, RFDETR_DETS_LABELS
55
+
56
+ #: `ultralytics/cfg/default.yaml`: `iou: 0.7`, `max_det: 300`. These are the
57
+ #: settings the published accuracy of an Ultralytics detector is quoted at, so
58
+ #: they are the settings a candidate's score has to be reproducible under.
59
+ #:
60
+ #: THEY ARE CONSTANTS AND NOT A KNOB, deliberately. `run_candidate.py` makes the
61
+ #: same argument about its own conf floor: a suppression setting that can vary
62
+ #: per request is one nobody can reconstruct from a score card later, and two
63
+ #: arms at different IoU thresholds are not a comparison
64
+ #: (`docs/runbooks/cctv-detector-tier.md` §5 rule 1).
65
+ NMS_IOU_THRESHOLD = 0.7
66
+ MAX_DETECTIONS = 300
67
+
68
+ #: `ultralytics/utils/nms.py::non_max_suppression`, `max_nms=30000`. Never binds
69
+ #: at imgsz 640 (8,400 anchors) and does bind at 1280 (33,600), so it is
70
+ #: transcribed rather than dropped as dead — an artifact at a larger imgsz would
71
+ #: otherwise decode differently here than it scored upstream.
72
+ MAX_NMS_CANDIDATES = 30000
73
+
74
+ #: `max_wh=7680` in the same function. Boxes are offset by `class * 7680` so one
75
+ #: suppression pass over all classes can never let one class suppress another —
76
+ #: `agnostic=False` is the Ultralytics default and the one every score assumes.
77
+ CLASS_OFFSET_STRIDE = 7680
78
+
79
+
80
+ class LetterboxGeometry:
81
+ """How a submitted frame was mapped onto the model's square input.
82
+
83
+ Carried from preprocessing to decode rather than recomputed, because the two
84
+ must agree exactly: a box is un-mapped with the SAME gain and padding that
85
+ put the pixels there. Recomputing it from shapes is how a half-pixel
86
+ convention drifts between the two halves of one transform.
87
+ """
88
+
89
+ __slots__ = ("gain", "pad_x", "pad_y", "frame_w", "frame_h")
90
+
91
+ def __init__(self, gain: float, pad_x: float, pad_y: float, frame_w: int, frame_h: int):
92
+ if not gain > 0.0:
93
+ raise ValueError(f"letterbox gain must be positive, got {gain}")
94
+ self.gain = float(gain)
95
+ self.pad_x = float(pad_x)
96
+ self.pad_y = float(pad_y)
97
+ self.frame_w = int(frame_w)
98
+ self.frame_h = int(frame_h)
99
+
100
+
101
+ def box_iou_against(box: np.ndarray, others: np.ndarray) -> np.ndarray:
102
+ """IoU of one xyxy box against many. Symmetric similarity, for suppression.
103
+
104
+ THE ONE IoU IN THE PYTHON TREE, and it is written here rather than imported
105
+ because there was none: `FaceDetector.ts:243` is the TypeScript kernel and
106
+ `BoxTracking.kt:36` the Kotlin one, and neither is reachable from a Python
107
+ service. `zoneContainment.ts` records the standing instruction for this file
108
+ type — "if NMS lands it must call FaceDetector's iou, not a copy" — which is
109
+ a rule about the TypeScript tree; the equivalent obligation here is that this
110
+ stays the only one. `edge-core/src/CentroidTracker.ts` is not a precedent to
111
+ follow either: that tracker is nearest-centroid and contains no IoU at all.
112
+ """
113
+ x1 = np.maximum(box[0], others[:, 0])
114
+ y1 = np.maximum(box[1], others[:, 1])
115
+ x2 = np.minimum(box[2], others[:, 2])
116
+ y2 = np.minimum(box[3], others[:, 3])
117
+ inter = np.clip(x2 - x1, 0.0, None) * np.clip(y2 - y1, 0.0, None)
118
+ area = (box[2] - box[0]) * (box[3] - box[1])
119
+ areas = (others[:, 2] - others[:, 0]) * (others[:, 3] - others[:, 1])
120
+ union = area + areas - inter
121
+ # A zero-area box can only arise from a degenerate prediction; scoring it 0
122
+ # keeps it from suppressing a real neighbour, and `_to_wire` drops it later.
123
+ return np.where(union > 0.0, inter / np.where(union > 0.0, union, 1.0), 0.0)
124
+
125
+
126
+ def greedy_nms(boxes: np.ndarray, scores: np.ndarray, iou_threshold: float) -> np.ndarray:
127
+ """torchvision's `ops.nms` semantics: keep the best, drop what overlaps it.
128
+
129
+ `np.argsort(-scores, kind="stable")` and NOT `argsort(scores)[::-1]`, for the
130
+ reason `postprocess` already spells out one file over: reversing an ascending
131
+ sort reverses the ties too, so two boxes with identical scores swap places
132
+ and the one that survives suppression changes. A stable descending sort is
133
+ what torch's `argsort(descending=True, stable=True)` does.
134
+ """
135
+ if scores.shape[0] == 0:
136
+ return np.zeros((0,), dtype=np.int64)
137
+ order = np.argsort(-scores, kind="stable")
138
+ keep: "list[int]" = []
139
+ while order.size:
140
+ best = int(order[0])
141
+ keep.append(best)
142
+ if order.size == 1:
143
+ break
144
+ rest = order[1:]
145
+ overlaps = box_iou_against(boxes[best], boxes[rest])
146
+ order = rest[overlaps <= iou_threshold]
147
+ return np.asarray(keep, dtype=np.int64)
148
+
149
+
150
+ def _to_frame_pixels(xyxy: np.ndarray, geometry: LetterboxGeometry):
151
+ """Un-letterbox: model-input pixels -> the SUBMITTED frame's pixels.
152
+
153
+ Mirrors `ultralytics/utils/ops.py::scale_boxes` — subtract the padding,
154
+ divide by the gain, clip to the frame — because the wire contract is the
155
+ submitted frame's pixels (`session.postprocess` scales by frame_w/frame_h for
156
+ the same reason) and a consumer that received model-input coordinates would
157
+ draw every box on the wrong geometry with no error anywhere.
158
+ """
159
+ out = xyxy.astype(np.float32, copy=True)
160
+ out[:, 0] -= geometry.pad_x
161
+ out[:, 2] -= geometry.pad_x
162
+ out[:, 1] -= geometry.pad_y
163
+ out[:, 3] -= geometry.pad_y
164
+ out /= np.float32(geometry.gain)
165
+ x0 = np.clip(out[:, 0], 0.0, geometry.frame_w)
166
+ y0 = np.clip(out[:, 1], 0.0, geometry.frame_h)
167
+ x1 = np.clip(out[:, 2], 0.0, geometry.frame_w)
168
+ y1 = np.clip(out[:, 3], 0.0, geometry.frame_h)
169
+ return x0, y0, x1, y1
170
+
171
+
172
+ def _scaled_box_channels(raw: np.ndarray, box_scale_to_pixels: float) -> np.ndarray:
173
+ """Box channels in pixels of the model input, whatever they arrived as."""
174
+ scaled = raw.astype(np.float32, copy=True)
175
+ if box_scale_to_pixels != 1.0:
176
+ scaled *= np.float32(box_scale_to_pixels)
177
+ return scaled
178
+
179
+
180
+ def decode_anchor_4_plus_nc(
181
+ raw: np.ndarray,
182
+ geometry: LetterboxGeometry,
183
+ min_confidence: float,
184
+ *,
185
+ box_scale_to_pixels: float,
186
+ expected_classes: int,
187
+ ):
188
+ """`[1, 4+nc, A]` — the raw Ultralytics head. NEEDS SUPPRESSION.
189
+
190
+ Transcribed from `ultralytics/utils/nms.py::non_max_suppression`, the branch
191
+ taken when the last axis is not 6 and `end2end` is false, followed by
192
+ `ops.scale_boxes`. Four steps, each named at its call site:
193
+
194
+ candidates `xc = prediction[:, 4:mi].amax(1) > conf_thres`
195
+ boxes `prediction[..., :4] = xywh2xyxy(...)`
196
+ best class `conf, j = cls.max(1, keepdim=True)` (multi_label is False)
197
+ suppression `boxes = x[:, :4] + cls * max_wh`, then `nms(boxes, scores)`
198
+
199
+ THE SCORES ARE ALREADY SIGMOID. An Ultralytics detect head applies it inside
200
+ the graph, unlike RF-DETR's, whose `labels` output is logits and which
201
+ `session.postprocess` sigmoids itself. Applying it twice here would squash
202
+ every confidence toward 0.5-0.73 and leave a table that still looks like
203
+ confidences — no NaN, no exception, nothing out of range.
204
+
205
+ CLASS FILTERING DOES NOT HAPPEN HERE. `wanted` is applied by `_to_wire`,
206
+ after `MAX_DETECTIONS`, so asking for one class cannot change which boxes the
207
+ model was allowed to keep. Narrowing first would make a two-class profile and
208
+ a ten-class profile disagree about the same frame.
209
+
210
+ NOTHING IS CLAMPED BEFORE SUPPRESSION. A real head puts boxes off the frame —
211
+ measured min -45.1 px on a committed artifact — and clipping them to the
212
+ frame first would move their IoU, change which one survives, and hide a
213
+ wrong-layout decode behind coordinates that had been made to look sane. The
214
+ only clamp is `_to_frame_pixels`, at the end, on what is about to go out.
215
+ """
216
+ if raw.ndim != 3 or raw.shape[0] != 1:
217
+ raise ValueError(f"{ANCHOR_4_PLUS_NC} expects a (1, 4+nc, anchors) tensor, got {raw.shape}")
218
+ grid = raw[0]
219
+ num_classes = grid.shape[0] - 4
220
+ if num_classes <= 0:
221
+ raise ValueError(f"{ANCHOR_4_PLUS_NC} needs 4 box channels plus at least one class, got {grid.shape}")
222
+ if num_classes != expected_classes:
223
+ # THE SECOND HALF OF THE LAYOUT REFUSAL, and the one that catches the
224
+ # case the graph check cannot: an `end2end_n_6` tensor is `[1, 300, 6]`,
225
+ # which arrives here as 300 - 4 = 296 "classes" over 6 "anchors" and
226
+ # decodes without raising into a confident, meaningless table.
227
+ # `_read_graph_spec` refuses that at LOAD from the DECLARED shape; this
228
+ # refuses it from the tensor ORT actually returned, which is not the same
229
+ # claim when an axis is dynamic. A decode that "works" on the wrong
230
+ # layout is the failure mode — there is no clamp, no NaN and no wrong
231
+ # dtype to notice.
232
+ raise ValueError(
233
+ f"{ANCHOR_4_PLUS_NC}: tensor has {num_classes} class channels but this model's "
234
+ f"vocabulary names {expected_classes}. Refusing rather than labelling a channel by "
235
+ f"position — a {raw.shape} tensor read as an anchor head is what {END2END_N_6} "
236
+ "looks like from here."
237
+ )
238
+
239
+ scores_by_class = grid[4:, :].T # (A, nc)
240
+ cxcywh = _scaled_box_channels(grid[:4, :].T, box_scale_to_pixels)
241
+
242
+ best = scores_by_class.max(axis=1)
243
+ class_idx = scores_by_class.argmax(axis=1).astype(np.int64)
244
+ keep = best > np.float32(min_confidence)
245
+ cxcywh, best, class_idx = cxcywh[keep], best[keep], class_idx[keep]
246
+ if best.shape[0] == 0:
247
+ return _empty()
248
+
249
+ xyxy = np.empty_like(cxcywh)
250
+ half = cxcywh[:, 2:] / np.float32(2.0)
251
+ xyxy[:, :2] = cxcywh[:, :2] - half
252
+ xyxy[:, 2:] = cxcywh[:, :2] + half
253
+
254
+ if best.shape[0] > MAX_NMS_CANDIDATES:
255
+ trimmed = np.argsort(-best, kind="stable")[:MAX_NMS_CANDIDATES]
256
+ xyxy, best, class_idx = xyxy[trimmed], best[trimmed], class_idx[trimmed]
257
+
258
+ offset = xyxy + (class_idx[:, None] * np.float32(CLASS_OFFSET_STRIDE))
259
+ kept = greedy_nms(offset, best, NMS_IOU_THRESHOLD)[:MAX_DETECTIONS]
260
+ x0, y0, x1, y1 = _to_frame_pixels(xyxy[kept], geometry)
261
+ return best[kept], class_idx[kept], x0, y0, x1, y1
262
+
263
+
264
+ def decode_end2end_n_6(
265
+ raw: np.ndarray,
266
+ geometry: LetterboxGeometry,
267
+ min_confidence: float,
268
+ *,
269
+ box_scale_to_pixels: float,
270
+ expected_classes: int,
271
+ ):
272
+ """`[1, N, 6]` — x1,y1,x2,y2,score,cls, ALREADY SUPPRESSED.
273
+
274
+ Transcribed from the first branch of the same function
275
+ (`if prediction.shape[-1] == 6 or end2end`), which is a threshold and a
276
+ `[:max_det]` and nothing else. Running NMS over these rows would be a second
277
+ suppression the model was neither trained nor scored with.
278
+
279
+ The rows arrive score-descending and that order is preserved: the `[:max_det]`
280
+ upstream takes the FIRST max_det survivors of the mask, not the highest, so
281
+ re-ranking here would keep a different set on a frame with more than 300
282
+ detections.
283
+
284
+ `expected_classes` IS TAKEN AND DELIBERATELY NOT ENFORCED. The class rides in
285
+ a COLUMN here rather than being an axis, so there is no width to check it
286
+ against, and refusing a row whose `cls` is out of range would turn what
287
+ `_to_wire` correctly handles as one dropped-and-counted detection into the
288
+ loss of the whole frame. It is in the signature so both decodes are one
289
+ dispatch: a table whose entries take different arguments is a dispatch with a
290
+ branch in it, and the branch is the thing being removed. The shape check
291
+ above is what refuses an anchor tensor sent here.
292
+ """
293
+ if raw.ndim != 3 or raw.shape[0] != 1 or raw.shape[2] != 6:
294
+ raise ValueError(f"{END2END_N_6} expects a (1, N, 6) tensor, got {raw.shape}")
295
+ rows = raw[0]
296
+ if rows.shape[0] == 0:
297
+ return _empty()
298
+
299
+ keep = np.nonzero(rows[:, 4] > np.float32(min_confidence))[0][:MAX_DETECTIONS]
300
+ if keep.shape[0] == 0:
301
+ return _empty()
302
+
303
+ selected = rows[keep]
304
+ xyxy = _scaled_box_channels(selected[:, :4], box_scale_to_pixels)
305
+ x0, y0, x1, y1 = _to_frame_pixels(xyxy, geometry)
306
+ return (
307
+ selected[:, 4].astype(np.float32, copy=True),
308
+ selected[:, 5].astype(np.int64),
309
+ x0,
310
+ y0,
311
+ x1,
312
+ y1,
313
+ )
314
+
315
+
316
+ def _empty():
317
+ z32 = np.zeros((0,), dtype=np.float32)
318
+ return z32, np.zeros((0,), dtype=np.int64), z32, z32.copy(), z32.copy(), z32.copy()
319
+
320
+
321
+ #: Layout -> the decode that reads it. A layout with no entry here is not
322
+ #: decodable by this build, and `labels.VariantContract` refuses one at import
323
+ #: rather than letting a model load and answer `[]` — the collapse
324
+ #: `DetectorNotReady` exists to prevent. `rfdetr_dets_labels` is absent on
325
+ #: purpose: its decode is `session.postprocess`, which needs the top-k selection
326
+ #: and the sigmoid this file does not do, and giving it a stub entry here would
327
+ #: put two decodes in the tree for one contract.
328
+ YOLO_DECODERS = {
329
+ ANCHOR_4_PLUS_NC: decode_anchor_4_plus_nc,
330
+ END2END_N_6: decode_end2end_n_6,
331
+ }
332
+
333
+ #: Every layout this build can decode at all, by whatever route. Asserted equal
334
+ #: to the declarable set at import: a layout `labels.py` will accept and this
335
+ #: file cannot read would load a model that answers `[]` forever.
336
+ DECODABLE_LAYOUTS = frozenset(YOLO_DECODERS) | {RFDETR_DETS_LABELS}
337
+ assert DECODABLE_LAYOUTS == OUTPUT_LAYOUTS, (
338
+ f"every declarable output layout must have a decode: {sorted(OUTPUT_LAYOUTS - DECODABLE_LAYOUTS)} has none"
339
+ )
340
+
341
+ #: Which preprocessing each layout's artifact was exported with, REPORTED on
342
+ #: /health rather than left implicit. The shipped RF-DETR path STRETCHES to a
343
+ #: square and normalises by ImageNet statistics; an Ultralytics export is
344
+ #: letterboxed to 114-grey and scaled by 1/255 and nothing else. Using the wrong
345
+ #: one is not a rounding difference — it changes the aspect of every object in
346
+ #: the frame — and it is invisible to every shape and dtype check, so the name
347
+ #: rides on the health payload where a reader can see which arm produced a
348
+ #: number. `bench/run_candidate.py` names its two preprocessors for the same
349
+ #: reason: an arm can never be compared against one preprocessed differently.
350
+ PREPROCESSING_BY_LAYOUT = {
351
+ RFDETR_DETS_LABELS: "imagenet-normalised-stretch",
352
+ ANCHOR_4_PLUS_NC: "ultralytics-letterbox-114-centred",
353
+ END2END_N_6: "ultralytics-letterbox-114-centred",
354
+ }
355
+
356
+
357
+ __all__ = [
358
+ "ANCHOR_4_PLUS_NC",
359
+ "CLASS_OFFSET_STRIDE",
360
+ "DECODABLE_LAYOUTS",
361
+ "END2END_N_6",
362
+ "LetterboxGeometry",
363
+ "MAX_DETECTIONS",
364
+ "MAX_NMS_CANDIDATES",
365
+ "NMS_IOU_THRESHOLD",
366
+ "PREPROCESSING_BY_LAYOUT",
367
+ "RFDETR_DETS_LABELS",
368
+ "YOLO_DECODERS",
369
+ "box_iou_against",
370
+ "decode_anchor_4_plus_nc",
371
+ "decode_end2end_n_6",
372
+ "greedy_nms",
373
+ ]
@@ -111,6 +111,122 @@ def normalize_class_name(name: str) -> str:
111
111
  #: platforms, so it is held to the intersection of what all three accept.
112
112
  _MODEL_NAME_RE = re.compile(r"[a-z0-9][a-z0-9._-]*")
113
113
 
114
+ # ── what a variant's numbers MEAN ────────────────────────────────────────────
115
+ #
116
+ # These three names and their value sets are the box-side copy of three columns
117
+ # on `model_platform_variants`, added by
118
+ # `trillboard-api/postgres/migrations/20260825032444_add_variant_coordinate_space.sql`
119
+ # (`output_layout`, `box_units`, `box_scale_to_pixels`) — the registry row is the
120
+ # authority and this is the copy the service reads, because there is no
121
+ # PostgreSQL on a shop PC. The two sets must stay identical: a value the
122
+ # registry accepts and this build drops is a variant that publishes and then
123
+ # cannot be decoded, and a value this build accepts and the registry rejects is
124
+ # a model that can never ship. `test_decode.py` pins both sets against that
125
+ # migration's CHECK constraints by name.
126
+
127
+ #: `dets` + `labels`, the shipped RF-DETR contract. Decoded by
128
+ #: `session.postprocess` (top-k over sigmoid'd logits, cxcywh normalised).
129
+ RFDETR_DETS_LABELS = "rfdetr_dets_labels"
130
+ #: `[1, 4+nc, A]`, the raw Ultralytics head. Scores already sigmoid, boxes
131
+ #: cxcywh in model-input units, and it NEEDS non-maximum suppression.
132
+ ANCHOR_4_PLUS_NC = "anchor_4_plus_nc"
133
+ #: `[1, N, 6]`, rows of x1,y1,x2,y2,score,cls. Already suppressed.
134
+ END2END_N_6 = "end2end_n_6"
135
+
136
+ OUTPUT_LAYOUTS = frozenset({RFDETR_DETS_LABELS, ANCHOR_4_PLUS_NC, END2END_N_6})
137
+
138
+ #: The box channels are already in pixels of the model input.
139
+ BOX_UNITS_PIXELS = "pixels"
140
+ #: They are 0..1 and must be multiplied by `box_scale_to_pixels`.
141
+ BOX_UNITS_NORMALIZED = "normalized"
142
+
143
+ BOX_UNITS = frozenset({BOX_UNITS_PIXELS, BOX_UNITS_NORMALIZED})
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class VariantContract:
148
+ """What one ARTIFACT's output numbers mean. Declared, never inferred.
149
+
150
+ NAMED FOR THE FORMAT, NOT THE MODEL, and that is the entire point.
151
+ `model_platform_variants` is keyed `(model_id, format)` because the answer
152
+ differs by format for identical weights: Ultralytics wraps a `task=detect`,
153
+ `end2end=False` TFLite export in `_NormalizeCoords`, dividing the four box
154
+ channels by (W, H). Measured across all 17 committed LiteRT twins, 16 emit
155
+ ONNX boxes in pixels 0..640 and LiteRT boxes in 0..1 — the same weights,
156
+ 640x apart — while their class channels agree to 5.5e-06. So a contract
157
+ carried across from a sibling format is wrong on 94% of the library, and
158
+ every shape, dtype and score check passes on the pair.
159
+ `queue_supermarket`, the one `end2end_n_6` twin, is pixels on both sides;
160
+ an end2end head emits pixels everywhere, which is why the exemption is real
161
+ and why this cannot be a property of the model.
162
+
163
+ THERE IS NO DEFAULT AND THERE MUST NOT BE. A default would be a guess about
164
+ an artifact nobody measured, and ON AN UNPADDED FRAME the wrong guess is
165
+ perfectly silent. Measured on `ppe_keremberke_v8n` over 200 frames resized
166
+ square, declaring a normalised artifact `pixels` kept all 707 detections,
167
+ dropped none, and changed nothing but the boxes: zero label mismatches,
168
+ `max |confidence delta|` 0.0 EXACTLY, widths a clean 639.9987-640.0012x too
169
+ small.
170
+
171
+ It is NOT silent on a letterboxed frame, and the difference is `decode.
172
+ _to_frame_pixels` subtracting the padding before it clips: the same 640x-small
173
+ boxes land at a negative offset, clip to zero extent, and every one is dropped
174
+ as degenerate — 848 of 848 on the same frames as submitted. So the failure is
175
+ loud exactly when the camera happens to be padded and silent when it does not,
176
+ which is not a property anything may rely on to notice.
177
+ `test_session.TestWrongBoxUnitsAtTheWire` pins both regimes.
178
+
179
+ `run_candidate.py` measures the units from a forward pass over real frames and
180
+ REFUSES the run when the ratio lands between the two bands; this is where that
181
+ measurement gets written down.
182
+ """
183
+
184
+ #: Which decode reads this artifact. One of `OUTPUT_LAYOUTS`.
185
+ output_layout: str
186
+ #: `pixels` or `normalized`.
187
+ box_units: str
188
+ #: Multiply a box coordinate by this to get pixels of the model input. 1.0
189
+ #: for `pixels`, the input edge (e.g. 640.0) for `normalized`. STORED rather
190
+ #: than derived from imgsz because a non-square input has two scales and the
191
+ #: consumer must not have to work out which one it needs — the same reason
192
+ #: the column exists rather than a boolean.
193
+ box_scale_to_pixels: float
194
+
195
+ def __post_init__(self) -> None:
196
+ if self.output_layout not in OUTPUT_LAYOUTS:
197
+ raise ValueError(
198
+ f"output_layout {self.output_layout!r} is not one of {sorted(OUTPUT_LAYOUTS)}; "
199
+ "refusing rather than decoding a tensor by guessing what its axes mean"
200
+ )
201
+ if self.box_units not in BOX_UNITS:
202
+ raise ValueError(
203
+ f"box_units {self.box_units!r} is not one of {sorted(BOX_UNITS)}. A variant that "
204
+ "cannot say what its coordinates mean is as unusable as one with a NULL checksum: "
205
+ "the consumer cannot tell it is wrong"
206
+ )
207
+ # Mirrors `model_platform_variants_box_scale_ck` exactly. A scale without
208
+ # units, or normalised units without a scale, reads as a declaration and
209
+ # cannot be acted on.
210
+ if self.box_units == BOX_UNITS_PIXELS and self.box_scale_to_pixels != 1.0:
211
+ raise ValueError(
212
+ f"box_units 'pixels' means the numbers are already pixels, so the scale must be "
213
+ f"1.0, got {self.box_scale_to_pixels}"
214
+ )
215
+ if self.box_units == BOX_UNITS_NORMALIZED and not self.box_scale_to_pixels > 0.0:
216
+ raise ValueError(
217
+ f"box_units 'normalized' needs a positive box_scale_to_pixels, got "
218
+ f"{self.box_scale_to_pixels}"
219
+ )
220
+
221
+
222
+ #: The shipped RF-DETR ONNX export, measured. `detector-score-cards/rfdetr_medium.json`
223
+ #: records max|box| 639.29 over imgsz 640 = 0.9989, so `pixels`.
224
+ RFDETR_MEDIUM_ONNX = VariantContract(
225
+ output_layout=RFDETR_DETS_LABELS,
226
+ box_units=BOX_UNITS_PIXELS,
227
+ box_scale_to_pixels=1.0,
228
+ )
229
+
114
230
 
115
231
  @dataclass(frozen=True)
116
232
  class ModelVocabulary:
@@ -146,6 +262,19 @@ class ModelVocabulary:
146
262
  logit_slots: int
147
263
  #: Peak RSS of ONE resident session, in bytes.
148
264
  approx_ram_bytes: int
265
+ #: What the ONNX artifact's numbers MEAN — the box-side copy of this model's
266
+ #: `('onnx')` row in `model_platform_variants`. NAMED FOR THE FORMAT because
267
+ #: that table is keyed `(model_id, format)` and the answer genuinely differs
268
+ #: between them: the LiteRT twin of the same weights is a DIFFERENT row with
269
+ #: a different `box_units`. `OnnxDetector` is the only consumer and it loads
270
+ #: ONNX, so a future LiteRT loader must read its own row rather than this
271
+ #: one — which is what the name is for.
272
+ #:
273
+ #: REQUIRED, like the class list and for the same reason: §4 rule 1 is "read
274
+ #: the class list, never the title", and this is the same instruction one
275
+ #: axis over. A model whose output layout is inferred is a model whose boxes
276
+ #: are plausible and wrong.
277
+ onnx_variant: "VariantContract"
149
278
 
150
279
  def __post_init__(self) -> None:
151
280
  # The model name IS A DIRECTORY NAME — `resolve_model_path` puts the
@@ -200,6 +329,7 @@ RFDETR_MEDIUM = ModelVocabulary(
200
329
  slot_names=COCO91_CLASS_NAMES,
201
330
  logit_slots=EXPECTED_LOGIT_SLOTS,
202
331
  approx_ram_bytes=632 * 1024 * 1024,
332
+ onnx_variant=RFDETR_MEDIUM_ONNX,
203
333
  )
204
334
 
205
335
  #: Every model this build knows how to label, IN PRIORITY ORDER.
@@ -311,12 +441,21 @@ class ClassIndex:
311
441
 
312
442
 
313
443
  __all__ = [
444
+ "ANCHOR_4_PLUS_NC",
445
+ "BOX_UNITS",
446
+ "BOX_UNITS_NORMALIZED",
447
+ "BOX_UNITS_PIXELS",
314
448
  "BUILTIN_VOCABULARIES",
315
449
  "COCO91_CLASS_NAMES",
450
+ "END2END_N_6",
316
451
  "EXPECTED_LOGIT_SLOTS",
452
+ "OUTPUT_LAYOUTS",
317
453
  "PERSON_SLOT",
454
+ "RFDETR_DETS_LABELS",
318
455
  "RFDETR_MEDIUM",
456
+ "RFDETR_MEDIUM_ONNX",
319
457
  "ClassIndex",
320
458
  "ModelVocabulary",
459
+ "VariantContract",
321
460
  "normalize_class_name",
322
461
  ]
@@ -264,7 +264,21 @@ class DetectorLoader:
264
264
  reason = pinned.get("reason")
265
265
  if reason:
266
266
  payload["reason"] = reason
267
- for key in ("warmup_inference_ms", "loaded_at", "input", "classes", "providers"):
267
+ # `output_layout` and `preprocessing` are additive, like every key added
268
+ # here since the first release: the three installers' post-install probes
269
+ # and the integration test read `state`, and nothing has ever failed on
270
+ # an unrecognised key. They are worth the two bytes because an operator
271
+ # reading `curl /health` on a box that answers plausible nonsense needs
272
+ # to see WHICH decode ran before anything else is worth checking.
273
+ for key in (
274
+ "warmup_inference_ms",
275
+ "loaded_at",
276
+ "input",
277
+ "classes",
278
+ "providers",
279
+ "output_layout",
280
+ "preprocessing",
281
+ ):
268
282
  if key in pinned:
269
283
  payload[key] = pinned[key]
270
284
  return payload
@@ -707,6 +707,15 @@ class ModelRegistry:
707
707
  row["input"] = f"{spec.width}x{spec.height}"
708
708
  row["classes"] = spec.num_classes
709
709
  row["providers"] = list(spec.providers)
710
+ # WHICH DECODE AND WHICH PREPROCESSING PRODUCED THIS MODEL'S
711
+ # NUMBERS. Three layouts now reach this service and their
712
+ # preprocessors are not interchangeable, so a confidence with
713
+ # no attributable arm is the `agent_version` shape:
714
+ # `docs/runbooks/ctv-agent-version-map.md` is a whole runbook
715
+ # about one column that nine artifacts write. Cheap: two
716
+ # strings already on the spec, read under the same lock.
717
+ row["output_layout"] = getattr(spec, "output_layout", None)
718
+ row["preprocessing"] = getattr(spec, "preprocessing", None)
710
719
  models[name] = row
711
720
 
712
721
  # Counted from `present` directly rather than through