@trillboards/edge-sdk 0.2.89 → 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,559 @@
1
+ """The two YOLO decodes, against hand-built tensors. No model, no ORT session.
2
+
3
+ WHY THESE ASSERTIONS AND NOT "IT RETURNS SOMETHING"
4
+ --------------------------------------------------
5
+ Every failure this file is guarding against produces a complete, plausible table:
6
+
7
+ * a transposed anchor axis reads box channels as class scores;
8
+ * a second sigmoid squashes every confidence into 0.5..0.73;
9
+ * a missing `box_scale_to_pixels` draws every box 640x too small;
10
+ * suppression skipped or run twice changes the COUNT and nothing else;
11
+ * `end2end_n_6` decoded as `anchor_4_plus_nc` reads x1,y1,x2,y2,score,cls as
12
+ four box channels and two classes.
13
+
14
+ None of them raises, and none of them is visible in a shape or dtype check. So
15
+ each is asserted against a tensor whose right answer is known by construction,
16
+ and `bench/verify_detector_decode.py` is the second control: the same decode
17
+ against `ultralytics`' own NMS on real artifacts.
18
+
19
+ The whole file is skipped without numpy — the wire contract in `test_server.py`
20
+ needs no third-party package and is the half that must always run. It does NOT
21
+ need onnxruntime, deliberately: `decode.py` imports numpy and nothing else, so
22
+ the arithmetic is exercisable on any runner that can `import numpy`, which is
23
+ strictly more than the 24 cases in `test_session.py` reach.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import unittest
29
+
30
+ try:
31
+ import numpy as np
32
+
33
+ from trillboards_detector.decode import (
34
+ ANCHOR_4_PLUS_NC,
35
+ CLASS_OFFSET_STRIDE,
36
+ DECODABLE_LAYOUTS,
37
+ END2END_N_6,
38
+ MAX_DETECTIONS,
39
+ MAX_NMS_CANDIDATES,
40
+ NMS_IOU_THRESHOLD,
41
+ PREPROCESSING_BY_LAYOUT,
42
+ RFDETR_DETS_LABELS,
43
+ YOLO_DECODERS,
44
+ LetterboxGeometry,
45
+ box_iou_against,
46
+ decode_anchor_4_plus_nc,
47
+ decode_end2end_n_6,
48
+ greedy_nms,
49
+ )
50
+ from trillboards_detector.labels import (
51
+ BOX_UNITS,
52
+ BOX_UNITS_NORMALIZED,
53
+ BOX_UNITS_PIXELS,
54
+ OUTPUT_LAYOUTS,
55
+ VariantContract,
56
+ )
57
+
58
+ HAVE_NUMPY = True
59
+ except ImportError: # pragma: no cover - exercised only on a bare interpreter
60
+ HAVE_NUMPY = False
61
+
62
+
63
+ #: An identity mapping: no scaling, no padding, and a "frame" the size of the
64
+ #: model input. Model-input pixels and frame pixels then coincide, so a box's
65
+ #: expected value can be written down directly.
66
+ def _identity(size=640):
67
+ return LetterboxGeometry(1.0, 0.0, 0.0, size, size)
68
+
69
+
70
+ def _anchor(boxes_and_scores, num_classes, anchors=None):
71
+ """Build `[1, 4+nc, A]` from `[(cx, cy, w, h, {cls: score}), ...]`."""
72
+ rows = list(boxes_and_scores)
73
+ total = anchors or len(rows)
74
+ grid = np.zeros((1, 4 + num_classes, total), dtype=np.float32)
75
+ for i, (cx, cy, w, h, scores) in enumerate(rows):
76
+ grid[0, 0, i], grid[0, 1, i], grid[0, 2, i], grid[0, 3, i] = cx, cy, w, h
77
+ for cls, score in scores.items():
78
+ grid[0, 4 + cls, i] = score
79
+ return grid
80
+
81
+
82
+ def _end2end(rows):
83
+ """Build `[1, N, 6]` from `[(x1, y1, x2, y2, score, cls), ...]`."""
84
+ return np.asarray([list(rows)], dtype=np.float32)
85
+
86
+
87
+ def _anchor_decode(raw, geometry, min_confidence, box_scale_to_pixels=1.0):
88
+ """Decode a hand-built anchor tensor whose class width is right BY
89
+ CONSTRUCTION, so `expected_classes` is not the subject of the case.
90
+
91
+ Reading it off the tensor is exactly what production must never do, which is
92
+ why it happens HERE and nowhere in `decode.py`: the fixture author knows the
93
+ width because they wrote it. `TestAnchorClassWidthRefusal` is where a wrong
94
+ one is passed on purpose.
95
+ """
96
+ return decode_anchor_4_plus_nc(
97
+ raw, geometry, min_confidence,
98
+ box_scale_to_pixels=box_scale_to_pixels,
99
+ expected_classes=raw.shape[1] - 4,
100
+ )
101
+
102
+
103
+ def _end2end_decode(raw, geometry, min_confidence, box_scale_to_pixels=1.0, expected_classes=4):
104
+ return decode_end2end_n_6(
105
+ raw, geometry, min_confidence,
106
+ box_scale_to_pixels=box_scale_to_pixels,
107
+ expected_classes=expected_classes,
108
+ )
109
+
110
+
111
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
112
+ class TestDeclaredLayoutSet(unittest.TestCase):
113
+ """The two halves of one declaration, in two languages, must agree.
114
+
115
+ `OUTPUT_LAYOUTS` and `BOX_UNITS` are the box-side copies of the CHECK
116
+ constraints in
117
+ `trillboard-api/postgres/migrations/20260825032444_add_variant_coordinate_space.sql`
118
+ (`model_platform_variants_output_layout_ck`, `..._box_units_ck`). Nothing
119
+ mechanical can compare a Python frozenset to a SQL CHECK across two
120
+ languages, so the values are pinned here literally: a value the registry
121
+ accepts and this build drops publishes a variant nothing can decode, and a
122
+ value this build accepts and the registry rejects is a model that can never
123
+ ship. If the SQL set widens, this test is what has to be edited — and the
124
+ edit is where the new layout's decode gets written.
125
+ """
126
+
127
+ def test_output_layouts_match_the_migrations_check_constraint(self):
128
+ self.assertEqual(
129
+ OUTPUT_LAYOUTS,
130
+ frozenset({"anchor_4_plus_nc", "end2end_n_6", "rfdetr_dets_labels"}),
131
+ )
132
+
133
+ def test_box_units_match_the_migrations_check_constraint(self):
134
+ self.assertEqual(BOX_UNITS, frozenset({"pixels", "normalized"}))
135
+
136
+ def test_every_declarable_layout_has_a_decode(self):
137
+ """A layout with no decode is a model that loads and answers `[]`."""
138
+ self.assertEqual(DECODABLE_LAYOUTS, OUTPUT_LAYOUTS)
139
+ self.assertEqual(frozenset(YOLO_DECODERS), OUTPUT_LAYOUTS - {RFDETR_DETS_LABELS})
140
+
141
+ def test_every_layout_names_its_preprocessing(self):
142
+ self.assertEqual(frozenset(PREPROCESSING_BY_LAYOUT), OUTPUT_LAYOUTS)
143
+ # The two YOLO heads share one preprocessor and RF-DETR does not share it
144
+ # with either: that non-sharing is the fact worth pinning.
145
+ self.assertEqual(
146
+ PREPROCESSING_BY_LAYOUT[ANCHOR_4_PLUS_NC],
147
+ PREPROCESSING_BY_LAYOUT[END2END_N_6],
148
+ )
149
+ self.assertNotEqual(
150
+ PREPROCESSING_BY_LAYOUT[RFDETR_DETS_LABELS],
151
+ PREPROCESSING_BY_LAYOUT[ANCHOR_4_PLUS_NC],
152
+ )
153
+
154
+ def test_the_ultralytics_defaults_are_the_ones_a_score_assumes(self):
155
+ """`ultralytics/cfg/default.yaml`: iou 0.7, max_det 300, max_wh 7680."""
156
+ self.assertEqual(NMS_IOU_THRESHOLD, 0.7)
157
+ self.assertEqual(MAX_DETECTIONS, 300)
158
+ self.assertEqual(MAX_NMS_CANDIDATES, 30000)
159
+ self.assertEqual(CLASS_OFFSET_STRIDE, 7680)
160
+
161
+
162
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
163
+ class TestVariantContract(unittest.TestCase):
164
+ def test_an_unknown_layout_is_refused_at_declaration(self):
165
+ with self.assertRaisesRegex(ValueError, "not one of"):
166
+ VariantContract("yolo_v99_head", BOX_UNITS_PIXELS, 1.0)
167
+
168
+ def test_unknown_units_are_refused(self):
169
+ with self.assertRaisesRegex(ValueError, "box_units"):
170
+ VariantContract(ANCHOR_4_PLUS_NC, "0..640", 1.0)
171
+
172
+ def test_pixels_with_a_scale_that_is_not_one_is_refused(self):
173
+ """Mirrors `model_platform_variants_box_scale_ck`. A scale of 640 next to
174
+ `pixels` reads as a declaration and would multiply every box by 640."""
175
+ with self.assertRaisesRegex(ValueError, "already pixels"):
176
+ VariantContract(ANCHOR_4_PLUS_NC, BOX_UNITS_PIXELS, 640.0)
177
+
178
+ def test_normalized_without_a_scale_is_refused(self):
179
+ with self.assertRaisesRegex(ValueError, "positive box_scale_to_pixels"):
180
+ VariantContract(ANCHOR_4_PLUS_NC, BOX_UNITS_NORMALIZED, 0.0)
181
+
182
+ def test_normalized_with_a_scale_is_accepted(self):
183
+ contract = VariantContract(ANCHOR_4_PLUS_NC, BOX_UNITS_NORMALIZED, 640.0)
184
+ self.assertEqual(contract.box_scale_to_pixels, 640.0)
185
+
186
+
187
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
188
+ class TestIouAndSuppression(unittest.TestCase):
189
+ def test_iou_of_identical_boxes_is_one_and_of_disjoint_boxes_is_zero(self):
190
+ box = np.asarray([0.0, 0.0, 10.0, 10.0], dtype=np.float32)
191
+ others = np.asarray([[0.0, 0.0, 10.0, 10.0], [20.0, 20.0, 30.0, 30.0]], dtype=np.float32)
192
+ got = box_iou_against(box, others)
193
+ self.assertAlmostEqual(float(got[0]), 1.0, places=6)
194
+ self.assertAlmostEqual(float(got[1]), 0.0, places=6)
195
+
196
+ def test_half_overlap_is_one_third(self):
197
+ """Two 10x10 boxes sharing a 5x10 strip: 50 / (100 + 100 - 50)."""
198
+ box = np.asarray([0.0, 0.0, 10.0, 10.0], dtype=np.float32)
199
+ others = np.asarray([[5.0, 0.0, 15.0, 10.0]], dtype=np.float32)
200
+ self.assertAlmostEqual(float(box_iou_against(box, others)[0]), 1.0 / 3.0, places=6)
201
+
202
+ def test_a_zero_area_box_does_not_suppress_a_real_one(self):
203
+ box = np.asarray([5.0, 5.0, 5.0, 5.0], dtype=np.float32)
204
+ others = np.asarray([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32)
205
+ self.assertEqual(float(box_iou_against(box, others)[0]), 0.0)
206
+
207
+ def test_nms_keeps_the_best_and_drops_what_overlaps_it(self):
208
+ boxes = np.asarray(
209
+ [[0, 0, 10, 10], [1, 1, 11, 11], [50, 50, 60, 60]], dtype=np.float32
210
+ )
211
+ scores = np.asarray([0.9, 0.8, 0.7], dtype=np.float32)
212
+ self.assertEqual(list(greedy_nms(boxes, scores, 0.5)), [0, 2])
213
+
214
+ def test_nms_keeps_a_neighbour_below_the_threshold(self):
215
+ boxes = np.asarray([[0, 0, 10, 10], [5, 0, 15, 10]], dtype=np.float32)
216
+ scores = np.asarray([0.9, 0.8], dtype=np.float32)
217
+ # IoU is 1/3, under 0.7, so both survive at the shipped threshold.
218
+ self.assertEqual(list(greedy_nms(boxes, scores, NMS_IOU_THRESHOLD)), [0, 1])
219
+ self.assertEqual(list(greedy_nms(boxes, scores, 0.3)), [0])
220
+
221
+ def test_nms_of_nothing_is_an_empty_index_and_not_an_exception(self):
222
+ empty = np.zeros((0, 4), dtype=np.float32)
223
+ self.assertEqual(greedy_nms(empty, np.zeros((0,), dtype=np.float32), 0.7).shape, (0,))
224
+
225
+ def test_ties_break_by_first_index_and_not_by_reversal(self):
226
+ """`argsort(scores)[::-1]` reverses ties too, so the survivor flips."""
227
+ boxes = np.asarray([[0, 0, 10, 10], [0, 0, 10, 10]], dtype=np.float32)
228
+ scores = np.asarray([0.5, 0.5], dtype=np.float32)
229
+ self.assertEqual(list(greedy_nms(boxes, scores, 0.7)), [0])
230
+
231
+
232
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
233
+ class TestAnchorDecode(unittest.TestCase):
234
+ def test_cxcywh_becomes_frame_pixels(self):
235
+ raw = _anchor([(100.0, 200.0, 40.0, 60.0, {1: 0.9})], num_classes=3)
236
+ scores, classes, x0, y0, x1, y1 = _anchor_decode(
237
+ raw, _identity(), 0.25, 1.0
238
+ )
239
+ self.assertEqual(len(scores), 1)
240
+ self.assertAlmostEqual(float(scores[0]), 0.9, places=6)
241
+ self.assertEqual(int(classes[0]), 1)
242
+ self.assertAlmostEqual(float(x0[0]), 80.0, places=4)
243
+ self.assertAlmostEqual(float(y0[0]), 170.0, places=4)
244
+ self.assertAlmostEqual(float(x1[0]), 120.0, places=4)
245
+ self.assertAlmostEqual(float(y1[0]), 230.0, places=4)
246
+
247
+ def test_scores_are_taken_as_given_and_never_sigmoided_again(self):
248
+ """An Ultralytics detect head applies it inside the graph. A second one
249
+ turns 0.9 into 0.711 and leaves a table that still looks like
250
+ confidences."""
251
+ raw = _anchor([(10.0, 10.0, 4.0, 4.0, {0: 0.9})], num_classes=1)
252
+ scores, *_ = _anchor_decode(raw, _identity(), 0.25)
253
+ self.assertAlmostEqual(float(scores[0]), 0.9, places=6)
254
+
255
+ def test_the_best_class_wins_and_the_others_are_not_emitted(self):
256
+ """`multi_label` is False in Ultralytics' predict path, so one anchor
257
+ yields at most one detection however many classes clear the floor."""
258
+ raw = _anchor([(10.0, 10.0, 4.0, 4.0, {0: 0.4, 2: 0.8, 3: 0.6})], num_classes=4)
259
+ scores, classes, *_ = _anchor_decode(
260
+ raw, _identity(), 0.25, 1.0
261
+ )
262
+ self.assertEqual(len(scores), 1)
263
+ self.assertEqual(int(classes[0]), 2)
264
+
265
+ def test_normalized_boxes_are_scaled_to_pixels_by_the_declared_factor(self):
266
+ """THE 640x TRAP. Same tensor, same shape, same dtype, same scores."""
267
+ raw = _anchor([(0.5, 0.5, 0.1, 0.2, {0: 0.9})], num_classes=1)
268
+ _, _, x0, y0, x1, y1 = _anchor_decode(
269
+ raw, _identity(), 0.25, 640.0
270
+ )
271
+ self.assertAlmostEqual(float(x0[0]), 288.0, places=3)
272
+ self.assertAlmostEqual(float(y0[0]), 256.0, places=3)
273
+ self.assertAlmostEqual(float(x1[0]), 352.0, places=3)
274
+ self.assertAlmostEqual(float(y1[0]), 384.0, places=3)
275
+
276
+ def test_the_same_tensor_read_as_pixels_is_640x_too_small_and_never_raises(self):
277
+ """The negative control for the trap above, asserted rather than trusted:
278
+ a box 64 px wide becomes 0.1 px wide, with an identical score and class.
279
+ Nothing about the result says it is wrong, which is why the units are a
280
+ declaration and not an inference."""
281
+ raw = _anchor([(0.5, 0.5, 0.1, 0.2, {0: 0.9})], num_classes=1)
282
+ scores, classes, x0, _, x1, _ = _anchor_decode(
283
+ raw, _identity(), 0.25, 1.0
284
+ )
285
+ self.assertAlmostEqual(float(scores[0]), 0.9, places=6)
286
+ self.assertEqual(int(classes[0]), 0)
287
+ self.assertAlmostEqual(float(x1[0]) - float(x0[0]), 0.1, places=5)
288
+
289
+ def test_overlapping_boxes_of_one_class_are_suppressed(self):
290
+ raw = _anchor(
291
+ [
292
+ (100.0, 100.0, 50.0, 50.0, {0: 0.9}),
293
+ (102.0, 102.0, 50.0, 50.0, {0: 0.8}),
294
+ (400.0, 400.0, 50.0, 50.0, {0: 0.7}),
295
+ ],
296
+ num_classes=1,
297
+ )
298
+ scores, *_ = _anchor_decode(raw, _identity(), 0.25)
299
+ self.assertEqual([round(float(s), 3) for s in scores], [0.9, 0.7])
300
+
301
+ def test_suppression_never_crosses_classes(self):
302
+ """`agnostic=False`: two labels on the same object must both survive, or
303
+ a PPE model's `helmet` silently deletes its own `person`."""
304
+ raw = _anchor(
305
+ [
306
+ (100.0, 100.0, 50.0, 50.0, {0: 0.9}),
307
+ (100.0, 100.0, 50.0, 50.0, {1: 0.8}),
308
+ ],
309
+ num_classes=2,
310
+ )
311
+ scores, classes, *_ = _anchor_decode(
312
+ raw, _identity(), 0.25, 1.0
313
+ )
314
+ self.assertEqual(sorted(int(c) for c in classes), [0, 1])
315
+ self.assertEqual(len(scores), 2)
316
+
317
+ def test_the_confidence_floor_is_strict(self):
318
+ raw = _anchor([(10.0, 10.0, 4.0, 4.0, {0: 0.25})], num_classes=1)
319
+ scores, *_ = _anchor_decode(raw, _identity(), 0.25)
320
+ self.assertEqual(len(scores), 0)
321
+
322
+ def test_an_empty_frame_returns_empty_arrays_of_the_right_dtype(self):
323
+ raw = _anchor([(10.0, 10.0, 4.0, 4.0, {0: 0.01})], num_classes=1)
324
+ scores, classes, x0, y0, x1, y1 = _anchor_decode(
325
+ raw, _identity(), 0.25, 1.0
326
+ )
327
+ for arr in (scores, x0, y0, x1, y1):
328
+ self.assertEqual(arr.shape, (0,))
329
+ self.assertEqual(classes.dtype, np.int64)
330
+
331
+ def test_more_than_max_detections_are_truncated(self):
332
+ rows = [
333
+ (float(20 * i + 10), 10.0, 4.0, 4.0, {0: 0.9})
334
+ for i in range(MAX_DETECTIONS + 25)
335
+ ]
336
+ raw = _anchor(rows, num_classes=1)
337
+ scores, *_ = _anchor_decode(raw, _identity(), 0.25)
338
+ self.assertEqual(len(scores), MAX_DETECTIONS)
339
+
340
+ def test_letterbox_padding_is_removed_and_the_gain_undone(self):
341
+ """A 1200x900 frame at 640: gain 0.5333, 80 px of padding top and bottom.
342
+ A box centred in the letterboxed square is centred in the frame."""
343
+ gain = 640.0 / 1200.0
344
+ geometry = LetterboxGeometry(gain, 0.0, 80.0, 1200, 900)
345
+ raw = _anchor([(320.0, 320.0, 64.0, 64.0, {0: 0.9})], num_classes=1)
346
+ _, _, x0, y0, x1, y1 = _anchor_decode(
347
+ raw, geometry, 0.25, box_scale_to_pixels=1.0
348
+ )
349
+ self.assertAlmostEqual((float(x0[0]) + float(x1[0])) / 2.0, 600.0, places=3)
350
+ self.assertAlmostEqual((float(y0[0]) + float(y1[0])) / 2.0, 450.0, places=3)
351
+
352
+ def test_a_box_running_off_the_frame_is_clipped_to_it(self):
353
+ raw = _anchor([(0.0, 0.0, 100.0, 100.0, {0: 0.9})], num_classes=1)
354
+ _, _, x0, y0, x1, y1 = _anchor_decode(
355
+ raw, _identity(100), 0.25, 1.0
356
+ )
357
+ self.assertEqual((float(x0[0]), float(y0[0])), (0.0, 0.0))
358
+ self.assertAlmostEqual(float(x1[0]), 50.0, places=4)
359
+
360
+ def test_a_tensor_with_no_class_channels_is_refused(self):
361
+ with self.assertRaisesRegex(ValueError, "at least one class"):
362
+ decode_anchor_4_plus_nc(
363
+ np.zeros((1, 4, 10), dtype=np.float32), _identity(), 0.25,
364
+ box_scale_to_pixels=1.0, expected_classes=1,
365
+ )
366
+
367
+ def test_a_rank_two_tensor_is_refused_rather_than_reshaped(self):
368
+ with self.assertRaisesRegex(ValueError, ANCHOR_4_PLUS_NC):
369
+ decode_anchor_4_plus_nc(
370
+ np.zeros((5, 8400), dtype=np.float32), _identity(), 0.25,
371
+ box_scale_to_pixels=1.0, expected_classes=1,
372
+ )
373
+
374
+
375
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
376
+ class TestEnd2EndDecode(unittest.TestCase):
377
+ def test_rows_are_x1y1x2y2_score_cls_in_that_order(self):
378
+ raw = _end2end([(10.0, 20.0, 30.0, 40.0, 0.9, 2.0)])
379
+ scores, classes, x0, y0, x1, y1 = _end2end_decode(
380
+ raw, _identity(), 0.25, box_scale_to_pixels=1.0
381
+ )
382
+ self.assertAlmostEqual(float(scores[0]), 0.9, places=6)
383
+ self.assertEqual(int(classes[0]), 2)
384
+ self.assertEqual(
385
+ (float(x0[0]), float(y0[0]), float(x1[0]), float(y1[0])), (10.0, 20.0, 30.0, 40.0)
386
+ )
387
+
388
+ def test_already_suppressed_rows_are_not_suppressed_again(self):
389
+ """Two heavily overlapping rows are the model's own answer here. Running
390
+ NMS would delete one and produce a count nothing upstream ever measured."""
391
+ raw = _end2end(
392
+ [
393
+ (100.0, 100.0, 150.0, 150.0, 0.9, 0.0),
394
+ (101.0, 101.0, 151.0, 151.0, 0.8, 0.0),
395
+ ]
396
+ )
397
+ scores, *_ = _end2end_decode(raw, _identity(), 0.25)
398
+ self.assertEqual(len(scores), 2)
399
+
400
+ def test_the_row_order_is_preserved_and_not_re_ranked(self):
401
+ raw = _end2end(
402
+ [
403
+ (0.0, 0.0, 10.0, 10.0, 0.9, 0.0),
404
+ (20.0, 20.0, 30.0, 30.0, 0.4, 1.0),
405
+ (40.0, 40.0, 50.0, 50.0, 0.6, 2.0),
406
+ ]
407
+ )
408
+ scores, classes, *_ = _end2end_decode(raw, _identity(), 0.25)
409
+ self.assertEqual([int(c) for c in classes], [0, 1, 2])
410
+ self.assertEqual([round(float(s), 3) for s in scores], [0.9, 0.4, 0.6])
411
+
412
+ def test_the_confidence_floor_is_strict(self):
413
+ raw = _end2end([(0.0, 0.0, 10.0, 10.0, 0.25, 0.0)])
414
+ scores, *_ = _end2end_decode(raw, _identity(), 0.25)
415
+ self.assertEqual(len(scores), 0)
416
+
417
+ def test_normalized_rows_are_scaled_by_the_declared_factor(self):
418
+ raw = _end2end([(0.25, 0.25, 0.5, 0.5, 0.9, 0.0)])
419
+ _, _, x0, y0, x1, y1 = _end2end_decode(
420
+ raw, _identity(), 0.25, box_scale_to_pixels=640.0
421
+ )
422
+ self.assertEqual(
423
+ (float(x0[0]), float(y0[0]), float(x1[0]), float(y1[0])), (160.0, 160.0, 320.0, 320.0)
424
+ )
425
+
426
+ def test_a_padded_tail_of_zero_rows_is_dropped_by_the_floor(self):
427
+ """These graphs emit a fixed 300 rows and pad with zeros; a zero row has
428
+ score 0 and class 0, and would otherwise be a `person` at the origin."""
429
+ rows = [(10.0, 10.0, 20.0, 20.0, 0.9, 1.0)] + [(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)] * 299
430
+ scores, classes, *_ = _end2end_decode(
431
+ _end2end(rows), _identity(), 0.25, box_scale_to_pixels=1.0
432
+ )
433
+ self.assertEqual(len(scores), 1)
434
+ self.assertEqual(int(classes[0]), 1)
435
+
436
+ def test_a_tensor_whose_last_axis_is_not_six_is_refused(self):
437
+ with self.assertRaisesRegex(ValueError, END2END_N_6):
438
+ _end2end_decode(np.zeros((1, 300, 5), dtype=np.float32), _identity(), 0.25)
439
+
440
+ def test_an_anchor_tensor_handed_to_this_decode_is_refused_not_misread(self):
441
+ """`[1, 6, 8400]` and `[1, 300, 6]` are both rank-3 with a 6 in them.
442
+ This is the confusion the declared layout exists to make impossible, and
443
+ the refusal is what keeps a mis-declaration loud instead of plausible."""
444
+ with self.assertRaisesRegex(ValueError, END2END_N_6):
445
+ _end2end_decode(np.zeros((1, 6, 8400), dtype=np.float32), _identity(), 0.25)
446
+
447
+
448
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
449
+ class TestCrossLayoutRefusal(unittest.TestCase):
450
+ """Each decode handed the OTHER layout's tensor must refuse, both ways.
451
+
452
+ This is the failure the declared layout exists to prevent, checked at the
453
+ tensor rather than at the graph. `_read_graph_spec` refuses a mis-declared
454
+ artifact at LOAD from the shape the graph DECLARES; this refuses it from the
455
+ array ORT actually returned, which is a different claim the moment an axis is
456
+ dynamic. Neither check is redundant and neither alone is the whole guard.
457
+
458
+ A decode that "works" on the wrong layout is the shape that matters: there is
459
+ no exception, no NaN, no wrong dtype and no out-of-range confidence to notice
460
+ — one measurement of it produced a confident `cls_maxabs` of 632 from a box
461
+ coordinate read as a class score.
462
+ """
463
+
464
+ def test_an_end2end_tensor_sent_to_the_anchor_decode_is_refused(self):
465
+ """`[1, 300, 6]` arrives here as 300 - 4 = 296 "classes" over 6
466
+ "anchors" and decodes without raising into a meaningless table."""
467
+ raw = _end2end([(0.0, 0.0, 10.0, 10.0, 0.9, 1.0)] * 300)
468
+ with self.assertRaisesRegex(ValueError, r"296 class channels but this model's"):
469
+ decode_anchor_4_plus_nc(
470
+ raw, _identity(), 0.25, box_scale_to_pixels=1.0, expected_classes=2
471
+ )
472
+
473
+ def test_an_anchor_tensor_sent_to_the_end2end_decode_is_refused(self):
474
+ """`[1, 6, 8400]` has a 6 in it too — read as rows it would return six
475
+ detections whose `cls` column is a box coordinate."""
476
+ with self.assertRaisesRegex(ValueError, END2END_N_6):
477
+ _end2end_decode(np.zeros((1, 6, 8400), dtype=np.float32), _identity(), 0.25)
478
+
479
+ def test_the_anchor_refusal_names_the_other_layout(self):
480
+ """The message has to point at the fix. A bare shape mismatch sends the
481
+ reader to the artifact; naming `end2end_n_6` sends them to the row."""
482
+ raw = _end2end([(0.0, 0.0, 10.0, 10.0, 0.9, 1.0)] * 300)
483
+ with self.assertRaises(ValueError) as caught:
484
+ decode_anchor_4_plus_nc(
485
+ raw, _identity(), 0.25, box_scale_to_pixels=1.0, expected_classes=2
486
+ )
487
+ self.assertIn(END2END_N_6, str(caught.exception))
488
+
489
+ def test_a_correct_width_is_not_refused(self):
490
+ """The negative half of the control: the check must not fire on the
491
+ artifact it is meant to let through."""
492
+ raw = _anchor([(10.0, 10.0, 4.0, 4.0, {0: 0.9})], num_classes=2)
493
+ scores, *_ = decode_anchor_4_plus_nc(
494
+ raw, _identity(), 0.25, box_scale_to_pixels=1.0, expected_classes=2
495
+ )
496
+ self.assertEqual(len(scores), 1)
497
+
498
+
499
+ @unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
500
+ class TestNoClampBeforeSuppression(unittest.TestCase):
501
+ """A real head puts boxes off the frame; clipping early hides a layout error.
502
+
503
+ Measured on a committed artifact: box values reach -45.1 px. Clipping to the
504
+ frame before NMS moves a box's area, moves its IoU with its neighbours, and
505
+ changes which one survives — so the shipped answer would depend on a step
506
+ Ultralytics does not take, and an off-frame coordinate that should look wrong
507
+ would have been made to look sane first.
508
+ """
509
+
510
+ def test_a_box_outside_the_frame_still_participates_in_suppression(self):
511
+ # Two boxes overlapping at IoU 1.0, both hanging off the left edge. If
512
+ # either were clipped first they would stop being identical.
513
+ raw = _anchor(
514
+ [
515
+ (-20.0, 100.0, 100.0, 100.0, {0: 0.9}),
516
+ (-20.0, 100.0, 100.0, 100.0, {0: 0.8}),
517
+ ],
518
+ num_classes=1,
519
+ )
520
+ scores, _, x0, *_ = _anchor_decode(raw, _identity(), 0.25)
521
+ self.assertEqual(len(scores), 1)
522
+ self.assertAlmostEqual(float(scores[0]), 0.9, places=6)
523
+ # And the survivor IS clamped on the way out — the wire carries pixels of
524
+ # the submitted frame, and a negative one is not one.
525
+ self.assertEqual(float(x0[0]), 0.0)
526
+
527
+ def test_clipping_first_would_suppress_a_box_that_must_survive(self):
528
+ """The discriminating case, and it took a second attempt to build one.
529
+
530
+ Two boxes hanging off the left edge:
531
+
532
+ A xyxy [-100, 0, 100, 100] area 20,000
533
+ B xyxy [-300, 0, 100, 100] area 40,000
534
+
535
+ UNCLIPPED their IoU is 20,000 / 40,000 = 0.5, under the 0.7 threshold, so
536
+ both survive — which is what Ultralytics does and therefore what the
537
+ published number was measured on. CLIPPED to the frame first, both become
538
+ [0, 0, 100, 100], IoU 1.0, and the second is suppressed. One detection
539
+ disappears and nothing anywhere reports it.
540
+
541
+ The first fixture written for this did NOT discriminate: both boxes
542
+ clipped to ZERO width, `box_iou_against` scores a zero-area box 0, and
543
+ the mutant passed. A negative control that survives its own mutant is
544
+ worth nothing, so the geometry here is chosen for the property rather
545
+ than for looking off-frame.
546
+ """
547
+ raw = _anchor(
548
+ [
549
+ (0.0, 50.0, 200.0, 100.0, {0: 0.9}),
550
+ (-100.0, 50.0, 400.0, 100.0, {0: 0.8}),
551
+ ],
552
+ num_classes=1,
553
+ )
554
+ scores, *_ = _anchor_decode(raw, _identity(), 0.25)
555
+ self.assertEqual([round(float(v), 3) for v in scores], [0.9, 0.8])
556
+
557
+
558
+ if __name__ == "__main__":
559
+ unittest.main()
@@ -16,7 +16,13 @@ from __future__ import annotations
16
16
  import unittest
17
17
 
18
18
  try:
19
- from trillboards_detector.labels import RFDETR_MEDIUM, ModelVocabulary
19
+ from trillboards_detector.labels import (
20
+ ANCHOR_4_PLUS_NC,
21
+ BOX_UNITS_PIXELS,
22
+ RFDETR_MEDIUM,
23
+ ModelVocabulary,
24
+ VariantContract,
25
+ )
20
26
  from trillboards_detector.loader import DetectorLoader, build_catalogue
21
27
 
22
28
  HAVE_RUNTIME = True
@@ -30,6 +36,9 @@ if HAVE_RUNTIME:
30
36
  slot_names={0: "glove", 1: "hairnet"},
31
37
  logit_slots=2,
32
38
  approx_ram_bytes=400 * 1024 * 1024,
39
+ # A specialist is an Ultralytics export, which is the whole reason the
40
+ # catalogue can hold one now.
41
+ onnx_variant=VariantContract(ANCHOR_4_PLUS_NC, BOX_UNITS_PIXELS, 1.0),
33
42
  )
34
43
 
35
44
 
@@ -25,7 +25,20 @@ from pathlib import Path
25
25
 
26
26
  from trillboards_detector.detections import Detection, DetectionBatch, merge_batches
27
27
  from trillboards_detector.errors import DetectorNotReady, ModelContractError, UnknownClasses
28
- from trillboards_detector.labels import COCO91_CLASS_NAMES, RFDETR_MEDIUM, ModelVocabulary
28
+ from trillboards_detector.labels import (
29
+ ANCHOR_4_PLUS_NC,
30
+ BOX_UNITS_PIXELS,
31
+ COCO91_CLASS_NAMES,
32
+ RFDETR_DETS_LABELS,
33
+ RFDETR_MEDIUM,
34
+ ModelVocabulary,
35
+ VariantContract,
36
+ )
37
+
38
+ #: Stand-ins for the two variant rows a catalogue holds today: the shipped
39
+ #: RF-DETR contract and an Ultralytics specialist's.
40
+ DETS_LABELS_ONNX = VariantContract(RFDETR_DETS_LABELS, BOX_UNITS_PIXELS, 1.0)
41
+ ANCHOR_ONNX = VariantContract(ANCHOR_4_PLUS_NC, BOX_UNITS_PIXELS, 1.0)
29
42
  from trillboards_detector.registry import (
30
43
  STATE_ABSENT,
31
44
  STATE_IDLE,
@@ -44,6 +57,7 @@ GENERAL = ModelVocabulary(
44
57
  slot_names={1: "person", 3: "car"},
45
58
  logit_slots=91,
46
59
  approx_ram_bytes=600 * MB,
60
+ onnx_variant=DETS_LABELS_ONNX,
47
61
  )
48
62
 
49
63
  #: A PPE specialist — the §3 worked example. It ALSO emits `person`, which is
@@ -53,6 +67,7 @@ PPE = ModelVocabulary(
53
67
  slot_names={0: "person", 1: "glove", 2: "hairnet"},
54
68
  logit_slots=3,
55
69
  approx_ram_bytes=400 * MB,
70
+ onnx_variant=ANCHOR_ONNX,
56
71
  )
57
72
 
58
73
  #: A third specialist, so eviction has something to choose between.
@@ -61,6 +76,7 @@ SMALL_OBJECT = ModelVocabulary(
61
76
  slot_names={0: "rodent"},
62
77
  logit_slots=1,
63
78
  approx_ram_bytes=400 * MB,
79
+ onnx_variant=ANCHOR_ONNX,
64
80
  )
65
81
 
66
82
 
@@ -558,6 +574,28 @@ class TestDownIsNeverEmpty(RegistryCase):
558
574
  self.assertIn("does not match the detector contract", str(caught.exception))
559
575
  self.assertEqual(reg.snapshot()["models"]["ppe"]["state"], STATE_UNAVAILABLE)
560
576
 
577
+ def test_an_undecodable_layout_is_a_refusal_and_never_an_empty_frame(self):
578
+ """DOWN IS NEVER EMPTY, and this is the shape it now arrives in.
579
+
580
+ `_read_graph_spec` raises `ModelContractError` when the graph disagrees
581
+ with the variant's declared `output_layout` — an artifact re-exported
582
+ end2end under a row still saying `anchor_4_plus_nc`, say. That has to
583
+ become a 503, because the alternative is a decode that reads the tensor
584
+ the wrong way and returns a full, plausible, wrong table, or an empty one
585
+ that nothing downstream can tell from a quiet venue.
586
+ """
587
+ self.write_artifact("general")
588
+ self.write_artifact("ppe")
589
+ self.factory_raises["ppe"] = ModelContractError(
590
+ "end2end_n_6 is not (1, N, 6) — x1,y1,x2,y2,score,cls: [1, 6, 8400]"
591
+ )
592
+ reg = self.registry([GENERAL, PPE], pinned="general")
593
+ reg.refresh()
594
+ with self.assertRaises(DetectorNotReady) as caught:
595
+ reg.detect(b"jpeg", 0.25, reg.resolve_classes(["glove"]))
596
+ self.assertIn("x1,y1,x2,y2,score,cls", str(caught.exception))
597
+ self.assertEqual(reg.snapshot()["models"]["ppe"]["state"], STATE_UNAVAILABLE)
598
+
561
599
  def test_a_session_that_constructs_but_cannot_infer_is_not_ready(self):
562
600
  """PROVE IT, PER MODEL. `/v1/models` answers 200 without ever building
563
601
  an engine, which is how a UTF-8 BOM 500'd every frame on every Windows
@@ -863,17 +901,17 @@ class TestTheShippedCatalogue(unittest.TestCase):
863
901
 
864
902
  def test_a_vocabulary_naming_a_slot_outside_its_axis_is_refused(self):
865
903
  with self.assertRaisesRegex(ValueError, "outside the declared"):
866
- ModelVocabulary("bad", {5: "person"}, logit_slots=3, approx_ram_bytes=1)
904
+ ModelVocabulary("bad", {5: "person"}, 3, 1, ANCHOR_ONNX)
867
905
 
868
906
  def test_a_vocabulary_with_a_non_canonical_class_name_is_refused(self):
869
907
  """`cell_phone` would be emitted on the wire while the filter matched
870
908
  `cell phone` — a class that silently matches nothing."""
871
909
  with self.assertRaisesRegex(ValueError, "not canonical"):
872
- ModelVocabulary("bad", {0: "cell_phone"}, logit_slots=1, approx_ram_bytes=1)
910
+ ModelVocabulary("bad", {0: "cell_phone"}, 1, 1, ANCHOR_ONNX)
873
911
 
874
912
  def test_two_slots_sharing_a_name_is_refused(self):
875
913
  with self.assertRaisesRegex(ValueError, "ambiguous"):
876
- ModelVocabulary("bad", {0: "person", 1: "person"}, logit_slots=2, approx_ram_bytes=1)
914
+ ModelVocabulary("bad", {0: "person", 1: "person"}, 2, 1, ANCHOR_ONNX)
877
915
 
878
916
 
879
917
  if __name__ == "__main__":