@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.
- package/deploy/detector/README.md +76 -7
- 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 +993 -0
- package/deploy/detector/bench/verify_detector_decode.py +574 -0
- package/deploy/detector/bench/verify_yolo_decode.py +135 -0
- package/deploy/detector/tests/test_decode.py +559 -0
- package/deploy/detector/tests/test_loader.py +10 -1
- package/deploy/detector/tests/test_registry.py +42 -4
- package/deploy/detector/tests/test_server.py +9 -1
- package/deploy/detector/tests/test_session.py +402 -1
- package/deploy/detector/trillboards_detector/decode.py +373 -0
- package/deploy/detector/trillboards_detector/labels.py +139 -0
- package/deploy/detector/trillboards_detector/loader.py +15 -1
- package/deploy/detector/trillboards_detector/registry.py +9 -0
- package/deploy/detector/trillboards_detector/session.py +233 -26
- 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 +1 -1
|
@@ -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
|
-
|
|
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
|