@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.
- package/deploy/detector/README.md +76 -7
- package/deploy/detector/bench/run_candidate.py +69 -9
- package/deploy/detector/bench/verify_detector_decode.py +574 -0
- package/deploy/detector/bench/verify_yolo_decode.py +4 -1
- 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/package.json +1 -1
|
@@ -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
|
|
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
|
|
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"},
|
|
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"},
|
|
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"},
|
|
914
|
+
ModelVocabulary("bad", {0: "person", 1: "person"}, 2, 1, ANCHOR_ONNX)
|
|
877
915
|
|
|
878
916
|
|
|
879
917
|
if __name__ == "__main__":
|