@trillboards/edge-sdk 0.2.81 → 0.2.82
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 +108 -21
- package/deploy/detector/bench/verify_against_reference.py +6 -1
- package/deploy/detector/pyproject.toml +1 -1
- package/deploy/detector/tests/test_loader.py +188 -0
- package/deploy/detector/tests/test_modelpath.py +48 -3
- package/deploy/detector/tests/test_registry.py +880 -0
- package/deploy/detector/tests/test_server.py +163 -7
- package/deploy/detector/tests/test_session.py +29 -10
- package/deploy/detector/trillboards_detector/__init__.py +1 -1
- package/deploy/detector/trillboards_detector/cli.py +49 -2
- package/deploy/detector/trillboards_detector/detections.py +98 -0
- package/deploy/detector/trillboards_detector/errors.py +38 -0
- package/deploy/detector/trillboards_detector/labels.py +239 -24
- package/deploy/detector/trillboards_detector/loader.py +186 -148
- package/deploy/detector/trillboards_detector/modelpath.py +32 -0
- package/deploy/detector/trillboards_detector/registry.py +836 -0
- package/deploy/detector/trillboards_detector/server.py +59 -40
- package/deploy/detector/trillboards_detector/session.py +84 -55
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -23,7 +23,8 @@ import urllib.error
|
|
|
23
23
|
import urllib.request
|
|
24
24
|
from dataclasses import dataclass
|
|
25
25
|
|
|
26
|
-
from trillboards_detector.errors import DetectorNotReady
|
|
26
|
+
from trillboards_detector.errors import DetectorNotReady, UnknownClasses
|
|
27
|
+
from trillboards_detector.labels import RFDETR_MEDIUM, ClassIndex, ModelVocabulary
|
|
27
28
|
from trillboards_detector.server import DetectorServer, MAX_BODY_BYTES
|
|
28
29
|
|
|
29
30
|
JPEG = bytes([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46])
|
|
@@ -40,27 +41,57 @@ class FakeDetection:
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
class FakeBatch:
|
|
43
|
-
def __init__(self, detections, inference_ms=63.4):
|
|
44
|
+
def __init__(self, detections, inference_ms=63.4, models=("rfdetr_medium",)):
|
|
44
45
|
self.detections = detections
|
|
45
46
|
self.inference_ms = inference_ms
|
|
46
47
|
self.degenerate_dropped = 0
|
|
47
48
|
self.unnamed_class_dropped = 0
|
|
49
|
+
#: WHICH MODEL(S) ANSWERED. On a single-model box this is one name and
|
|
50
|
+
#: the emitted payload is byte-identical to what it was before the
|
|
51
|
+
#: library existed; the wire's `model` field is now derived from it
|
|
52
|
+
#: rather than from a loader-wide constant, because the answering set is
|
|
53
|
+
#: a property of the REQUEST once several models are resident.
|
|
54
|
+
self.models = list(models)
|
|
48
55
|
|
|
49
56
|
|
|
50
57
|
class FakeLoader:
|
|
51
|
-
"""Everything `server.py` is allowed to know about the
|
|
58
|
+
"""Everything `server.py` is allowed to know about the models.
|
|
59
|
+
|
|
60
|
+
The class index is the REAL one over the REAL shipped vocabulary — only the
|
|
61
|
+
session is faked. Canonicalisation and the unknown-class refusal are the
|
|
62
|
+
behaviour under test here, so stubbing them would test the stub.
|
|
63
|
+
"""
|
|
52
64
|
|
|
53
65
|
model_name = "rfdetr_medium"
|
|
54
66
|
|
|
55
|
-
def __init__(self, batch=None, state="ready", raises=None):
|
|
67
|
+
def __init__(self, batch=None, state="ready", raises=None, vocabularies=(RFDETR_MEDIUM,)):
|
|
56
68
|
self._batch = batch if batch is not None else FakeBatch([])
|
|
57
69
|
self.state = state
|
|
58
70
|
self._raises = raises
|
|
59
71
|
self.calls: list[tuple] = []
|
|
72
|
+
self._index = ClassIndex(list(vocabularies))
|
|
73
|
+
self.resolve_calls: list[list[str]] = []
|
|
60
74
|
|
|
61
75
|
def info(self):
|
|
62
76
|
return {"state": self.state, "model": self.model_name, "model_path": "/stub"}
|
|
63
77
|
|
|
78
|
+
def class_index(self):
|
|
79
|
+
return self._index
|
|
80
|
+
|
|
81
|
+
def resolve_classes(self, requested):
|
|
82
|
+
self.resolve_calls.append(list(requested))
|
|
83
|
+
canonical, unknown = self._index.resolve(requested)
|
|
84
|
+
if unknown:
|
|
85
|
+
raise UnknownClasses(
|
|
86
|
+
unknown,
|
|
87
|
+
self._index.class_names,
|
|
88
|
+
f"requested classes not in this box's vocabulary: {unknown}. "
|
|
89
|
+
f"Known classes: {sorted(self._index.class_names)}",
|
|
90
|
+
)
|
|
91
|
+
if not canonical:
|
|
92
|
+
raise UnknownClasses([], self._index.class_names, "`classes` resolved to nothing requestable")
|
|
93
|
+
return canonical
|
|
94
|
+
|
|
64
95
|
def detect(self, jpeg, min_confidence, wanted):
|
|
65
96
|
self.calls.append((jpeg, min_confidence, wanted))
|
|
66
97
|
if self._raises is not None:
|
|
@@ -180,6 +211,99 @@ class TestHealth(ServerCase):
|
|
|
180
211
|
self.assertEqual(status, 405)
|
|
181
212
|
|
|
182
213
|
|
|
214
|
+
class TestHealthReportsResidency(ServerCase):
|
|
215
|
+
"""`/health` must say WHICH models are resident, and still load nothing.
|
|
216
|
+
|
|
217
|
+
`sensing-model-library.md` §3 turns the loader into a name -> session map,
|
|
218
|
+
so "is the detector up" stops being one word. The top-level `state` still
|
|
219
|
+
describes the pinned general gate — every existing reader parses it, and
|
|
220
|
+
`LocalDetectorClient.isHealthy()` turns anything but 2xx into "send the
|
|
221
|
+
frame to the VLM" — and the per-model detail is additive.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def test_health_passes_the_residency_report_through_untouched(self):
|
|
225
|
+
class ResidencyLoader(FakeLoader):
|
|
226
|
+
def info(self):
|
|
227
|
+
return {
|
|
228
|
+
"state": "ready",
|
|
229
|
+
"model": "rfdetr_medium",
|
|
230
|
+
"model_path": "/stub",
|
|
231
|
+
"models": {
|
|
232
|
+
"rfdetr_medium": {"state": "ready", "resident": True, "pinned": True},
|
|
233
|
+
"ppe": {"state": "idle", "resident": False, "pinned": False},
|
|
234
|
+
},
|
|
235
|
+
"resident_models": ["rfdetr_medium"],
|
|
236
|
+
"ram_budget_bytes": 1610612736,
|
|
237
|
+
"ram_resident_bytes": 662700032,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
loader = ResidencyLoader()
|
|
241
|
+
url = self.serve(loader)
|
|
242
|
+
status, body = self.request(f"{url}/health")
|
|
243
|
+
self.assertEqual(status, 200)
|
|
244
|
+
self.assertEqual(body["resident_models"], ["rfdetr_medium"])
|
|
245
|
+
self.assertEqual(body["models"]["ppe"]["state"], "idle")
|
|
246
|
+
self.assertEqual(body["ram_budget_bytes"], 1610612736)
|
|
247
|
+
# THE ASSERTION THAT MATTERS, restated for the multi-model payload: a
|
|
248
|
+
# bigger report must not have become a probe.
|
|
249
|
+
self.assertEqual(loader.calls, [])
|
|
250
|
+
self.assertEqual(loader.resolve_calls, [])
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TestTheAnsweringSetIsReported(ServerCase):
|
|
254
|
+
"""§3: "The response's `model` field reports the set that answered." """
|
|
255
|
+
|
|
256
|
+
def test_two_models_are_joined_into_the_one_model_string(self):
|
|
257
|
+
"""`LocalDetectorClient` types `model` as `string | null` and never
|
|
258
|
+
branches on it (`model: typeof parsed?.model === 'string' && parsed.model
|
|
259
|
+
? parsed.model : null`), so a joined string is contract-safe — checked
|
|
260
|
+
against the client, not assumed."""
|
|
261
|
+
batch = FakeBatch(
|
|
262
|
+
[FakeDetection("person", 0.9, 1, 1, 5, 5), FakeDetection("glove", 0.8, 2, 2, 4, 4)],
|
|
263
|
+
models=("rfdetr_medium", "ppe"),
|
|
264
|
+
)
|
|
265
|
+
url = self.serve(FakeLoader(batch))
|
|
266
|
+
status, body = self.detect(url)
|
|
267
|
+
self.assertEqual(status, 200)
|
|
268
|
+
self.assertEqual(body["model"], "rfdetr_medium+ppe")
|
|
269
|
+
self.assertEqual([d["label"] for d in body["detections"]], ["person", "glove"])
|
|
270
|
+
|
|
271
|
+
def test_one_model_still_emits_exactly_the_payload_that_shipped(self):
|
|
272
|
+
batch = FakeBatch([FakeDetection("person", 0.91, 10.0, 20.0, 30.0, 40.0)])
|
|
273
|
+
url = self.serve(FakeLoader(batch))
|
|
274
|
+
_, body = self.detect(url)
|
|
275
|
+
self.assertEqual(body["model"], "rfdetr_medium")
|
|
276
|
+
|
|
277
|
+
def test_an_unattributed_batch_is_refused_not_emitted(self):
|
|
278
|
+
"""A result naming no model is a detection nobody can attribute when a
|
|
279
|
+
specialist later turns out to be wrong. `model` would serialise as `""`,
|
|
280
|
+
which the client reads as null — plausible, and permanently lossy. 500,
|
|
281
|
+
a status that cannot be mistaken for a quiet venue."""
|
|
282
|
+
batch = FakeBatch([FakeDetection("person", 0.9, 1, 1, 5, 5)], models=())
|
|
283
|
+
url = self.serve(FakeLoader(batch))
|
|
284
|
+
status, body = self.detect(url)
|
|
285
|
+
self.assertEqual(status, 500)
|
|
286
|
+
self.assertNotIn("detections", body)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestABudgetRefusalIsNotAnEmptyFrame(ServerCase):
|
|
290
|
+
"""A set that will not fit the RAM budget is 503, never a thinner answer."""
|
|
291
|
+
|
|
292
|
+
def test_a_portfolio_refusal_reaches_the_client_as_down(self):
|
|
293
|
+
loader = FakeLoader(
|
|
294
|
+
state="ready",
|
|
295
|
+
raises=DetectorNotReady(
|
|
296
|
+
"this request needs ['ppe', 'small_object'] resident at once, which with "
|
|
297
|
+
"['rfdetr_medium'] comes to 1500000000 B against a 1073741824 B portfolio budget"
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
url = self.serve(loader)
|
|
301
|
+
status, body = self.detect(url, classes=["person"])
|
|
302
|
+
self.assertEqual(status, 503)
|
|
303
|
+
self.assertNotIn("detections", body)
|
|
304
|
+
self.assertIn("portfolio budget", body["error"])
|
|
305
|
+
|
|
306
|
+
|
|
183
307
|
class TestHealthCannotLoadTheModel(unittest.TestCase):
|
|
184
308
|
"""The strongest form of "/health does not load the model": it CANNOT.
|
|
185
309
|
|
|
@@ -326,15 +450,21 @@ class TestClassFilter(ServerCase):
|
|
|
326
450
|
same `yolov8_nano` shape the test above is written against — a capability
|
|
327
451
|
offered and not delivered — only wearing a 200 instead of a 0%.
|
|
328
452
|
|
|
329
|
-
It becomes the COMMON case
|
|
330
|
-
asks for `glove` and `hairnet` alongside `person`, and on a
|
|
331
|
-
only the general detector two thirds of the question would
|
|
453
|
+
It becomes the COMMON case now that specialist models are EXPRESSIBLE: a
|
|
454
|
+
PPE profile asks for `glove` and `hairnet` alongside `person`, and on a
|
|
455
|
+
box holding only the general detector two thirds of the question would
|
|
456
|
+
vanish.
|
|
332
457
|
|
|
333
458
|
Refusing costs the `person` detections too. That is the right trade —
|
|
334
459
|
which models are resident is decided upstream by the profile push, so a
|
|
335
460
|
class the box cannot answer is a configuration error, and the client maps
|
|
336
461
|
400 to `DetectorUnavailableError`, which shadow mode records as
|
|
337
462
|
`unavailable`. Visible beats quieter.
|
|
463
|
+
|
|
464
|
+
THE REFUSAL ITSELF MOVED to `registry.resolve_classes`, because which
|
|
465
|
+
classes exist is now a property of the models on this box rather than a
|
|
466
|
+
constant. What this case pins is the half the WIRE owns: an
|
|
467
|
+
`UnknownClasses` becomes a 400 and the detector is never asked.
|
|
338
468
|
"""
|
|
339
469
|
loader = FakeLoader()
|
|
340
470
|
url = self.serve(loader)
|
|
@@ -352,6 +482,32 @@ class TestClassFilter(ServerCase):
|
|
|
352
482
|
self.assertEqual(status, 200)
|
|
353
483
|
self.assertEqual(loader.calls[0][2], {"person", "cell phone"})
|
|
354
484
|
|
|
485
|
+
def test_a_specialists_class_is_requestable_once_its_model_is_in_the_index(self):
|
|
486
|
+
"""THE PIECE THAT MAKES A SPECIALIST EXPRESSIBLE AT THE WIRE.
|
|
487
|
+
|
|
488
|
+
Against the hardcoded COCO-91 `KNOWN_CLASS_NAMES` this was a 400 no
|
|
489
|
+
matter what was on the box, so publishing a PPE model would have changed
|
|
490
|
+
nothing here. The vocabulary is now the union of what the loader holds.
|
|
491
|
+
"""
|
|
492
|
+
ppe = ModelVocabulary(
|
|
493
|
+
model_name="ppe",
|
|
494
|
+
slot_names={0: "glove", 1: "hairnet"},
|
|
495
|
+
logit_slots=2,
|
|
496
|
+
approx_ram_bytes=400 * 1024 * 1024,
|
|
497
|
+
)
|
|
498
|
+
loader = FakeLoader(vocabularies=(RFDETR_MEDIUM, ppe))
|
|
499
|
+
url = self.serve(loader)
|
|
500
|
+
status, _ = self.detect(url, classes=["person", "Glove"])
|
|
501
|
+
self.assertEqual(status, 200)
|
|
502
|
+
self.assertEqual(loader.calls[0][2], {"person", "glove"})
|
|
503
|
+
|
|
504
|
+
# And the COCO-only box still refuses it, so the widening is a property
|
|
505
|
+
# of what is loaded and not of the build.
|
|
506
|
+
coco_only = FakeLoader()
|
|
507
|
+
status, body = self.detect(self.serve(coco_only), classes=["person", "Glove"])
|
|
508
|
+
self.assertEqual(status, 400)
|
|
509
|
+
self.assertIn("Glove", body["error"])
|
|
510
|
+
|
|
355
511
|
|
|
356
512
|
class TestRequestRefusals(ServerCase):
|
|
357
513
|
def test_min_confidence_bounds(self):
|
|
@@ -17,6 +17,7 @@ import unittest
|
|
|
17
17
|
try:
|
|
18
18
|
import numpy as np
|
|
19
19
|
|
|
20
|
+
from trillboards_detector.labels import RFDETR_MEDIUM
|
|
20
21
|
from trillboards_detector.session import (
|
|
21
22
|
ModelContractError,
|
|
22
23
|
_read_graph_spec,
|
|
@@ -54,6 +55,7 @@ class TestPostprocess(unittest.TestCase):
|
|
|
54
55
|
frame_w=640,
|
|
55
56
|
frame_h=480,
|
|
56
57
|
min_confidence=0.25,
|
|
58
|
+
vocabulary=RFDETR_MEDIUM,
|
|
57
59
|
)
|
|
58
60
|
self.assertEqual(len(batch.detections), 1)
|
|
59
61
|
d = batch.detections[0]
|
|
@@ -71,6 +73,7 @@ class TestPostprocess(unittest.TestCase):
|
|
|
71
73
|
frame_w=100,
|
|
72
74
|
frame_h=100,
|
|
73
75
|
min_confidence=0.25,
|
|
76
|
+
vocabulary=RFDETR_MEDIUM,
|
|
74
77
|
)
|
|
75
78
|
d = batch.detections[0]
|
|
76
79
|
self.assertEqual((d.x, d.y), (0.0, 0.0))
|
|
@@ -84,6 +87,7 @@ class TestPostprocess(unittest.TestCase):
|
|
|
84
87
|
frame_w=200,
|
|
85
88
|
frame_h=200,
|
|
86
89
|
min_confidence=0.25,
|
|
90
|
+
vocabulary=RFDETR_MEDIUM,
|
|
87
91
|
)
|
|
88
92
|
self.assertEqual(sorted(d.label for d in batch.detections), ["car", "person"])
|
|
89
93
|
|
|
@@ -92,8 +96,12 @@ class TestPostprocess(unittest.TestCase):
|
|
|
92
96
|
is OUT, and that has to match: the operating point in the runbook was
|
|
93
97
|
chosen against this comparison, not a >=."""
|
|
94
98
|
args = (_boxes({0: (0.5, 0.5, 0.2, 0.2)}), _logits({(0, 1): 0.5}), 100, 100)
|
|
95
|
-
self.assertEqual(
|
|
96
|
-
|
|
99
|
+
self.assertEqual(
|
|
100
|
+
len(postprocess(*args, min_confidence=0.4, vocabulary=RFDETR_MEDIUM).detections), 1
|
|
101
|
+
)
|
|
102
|
+
self.assertEqual(
|
|
103
|
+
len(postprocess(*args, min_confidence=0.5, vocabulary=RFDETR_MEDIUM).detections), 0
|
|
104
|
+
)
|
|
97
105
|
|
|
98
106
|
def test_an_unnamed_class_slot_is_dropped_and_counted_never_labelled(self):
|
|
99
107
|
"""Eleven of the ninety-one slots are COCO id gaps. Slot 12 is one.
|
|
@@ -108,6 +116,7 @@ class TestPostprocess(unittest.TestCase):
|
|
|
108
116
|
frame_w=100,
|
|
109
117
|
frame_h=100,
|
|
110
118
|
min_confidence=0.25,
|
|
119
|
+
vocabulary=RFDETR_MEDIUM,
|
|
111
120
|
)
|
|
112
121
|
self.assertEqual(batch.detections, [])
|
|
113
122
|
self.assertEqual(batch.unnamed_class_dropped, 1)
|
|
@@ -122,6 +131,7 @@ class TestPostprocess(unittest.TestCase):
|
|
|
122
131
|
frame_w=100,
|
|
123
132
|
frame_h=100,
|
|
124
133
|
min_confidence=0.25,
|
|
134
|
+
vocabulary=RFDETR_MEDIUM,
|
|
125
135
|
)
|
|
126
136
|
self.assertEqual(len(batch.detections), 1)
|
|
127
137
|
self.assertEqual(batch.degenerate_dropped, 1)
|
|
@@ -134,8 +144,16 @@ class TestPostprocess(unittest.TestCase):
|
|
|
134
144
|
100,
|
|
135
145
|
0.25,
|
|
136
146
|
)
|
|
137
|
-
self.assertEqual(
|
|
138
|
-
|
|
147
|
+
self.assertEqual(
|
|
148
|
+
[
|
|
149
|
+
d.label
|
|
150
|
+
for d in postprocess(*args, wanted={"car"}, vocabulary=RFDETR_MEDIUM).detections
|
|
151
|
+
],
|
|
152
|
+
["car"],
|
|
153
|
+
)
|
|
154
|
+
self.assertEqual(
|
|
155
|
+
len(postprocess(*args, wanted=None, vocabulary=RFDETR_MEDIUM).detections), 2
|
|
156
|
+
)
|
|
139
157
|
|
|
140
158
|
def test_selection_keeps_the_top_300_pairs_before_thresholding(self):
|
|
141
159
|
"""`num_select=300` is applied FIRST, exactly as upstream does.
|
|
@@ -152,6 +170,7 @@ class TestPostprocess(unittest.TestCase):
|
|
|
152
170
|
frame_w=100,
|
|
153
171
|
frame_h=100,
|
|
154
172
|
min_confidence=0.25,
|
|
173
|
+
vocabulary=RFDETR_MEDIUM,
|
|
155
174
|
)
|
|
156
175
|
self.assertEqual(len(batch.detections), 300)
|
|
157
176
|
|
|
@@ -159,7 +178,7 @@ class TestPostprocess(unittest.TestCase):
|
|
|
159
178
|
rng = np.random.default_rng(7)
|
|
160
179
|
logits = rng.normal(0.0, 40.0, size=(1, 300, 91)).astype(np.float32)
|
|
161
180
|
boxes = np.abs(rng.normal(0.4, 0.2, size=(1, 300, 4))).astype(np.float32)
|
|
162
|
-
batch = postprocess(boxes, logits, 640, 480, 0.0)
|
|
181
|
+
batch = postprocess(boxes, logits, 640, 480, 0.0, vocabulary=RFDETR_MEDIUM)
|
|
163
182
|
for d in batch.detections:
|
|
164
183
|
self.assertGreaterEqual(d.confidence, 0.0)
|
|
165
184
|
self.assertLessEqual(d.confidence, 1.0)
|
|
@@ -205,7 +224,7 @@ class TestGraphContract(unittest.TestCase):
|
|
|
205
224
|
GOOD_OUT = [_FakeIo("dets", [1, 300, 4]), _FakeIo("labels", [1, 300, 91])]
|
|
206
225
|
|
|
207
226
|
def test_accepts_the_shipped_graph(self):
|
|
208
|
-
spec = _read_graph_spec(_FakeSession(self.GOOD_IN, self.GOOD_OUT))
|
|
227
|
+
spec = _read_graph_spec(_FakeSession(self.GOOD_IN, self.GOOD_OUT), RFDETR_MEDIUM)
|
|
209
228
|
self.assertEqual((spec.width, spec.height), (576, 576))
|
|
210
229
|
self.assertEqual(spec.num_classes, 91)
|
|
211
230
|
self.assertEqual(spec.input_name, "input")
|
|
@@ -213,7 +232,7 @@ class TestGraphContract(unittest.TestCase):
|
|
|
213
232
|
def test_refuses_a_class_axis_the_label_map_does_not_describe(self):
|
|
214
233
|
out = [_FakeIo("dets", [1, 300, 4]), _FakeIo("labels", [1, 300, 80])]
|
|
215
234
|
with self.assertRaisesRegex(ModelContractError, "80 class slots"):
|
|
216
|
-
_read_graph_spec(_FakeSession(self.GOOD_IN, out))
|
|
235
|
+
_read_graph_spec(_FakeSession(self.GOOD_IN, out), RFDETR_MEDIUM)
|
|
217
236
|
|
|
218
237
|
def test_refuses_a_dynamic_resolution(self):
|
|
219
238
|
"""imgsz is one of the two numbers a detector arm is meaningless
|
|
@@ -221,12 +240,12 @@ class TestGraphContract(unittest.TestCase):
|
|
|
221
240
|
the artifact at all."""
|
|
222
241
|
dyn = [_FakeIo("input", [1, 3, "height", "width"])]
|
|
223
242
|
with self.assertRaisesRegex(ModelContractError, "fixed input resolution"):
|
|
224
|
-
_read_graph_spec(_FakeSession(dyn, self.GOOD_OUT))
|
|
243
|
+
_read_graph_spec(_FakeSession(dyn, self.GOOD_OUT), RFDETR_MEDIUM)
|
|
225
244
|
|
|
226
245
|
def test_refuses_unexpected_output_names(self):
|
|
227
246
|
out = [_FakeIo("boxes", [1, 300, 4]), _FakeIo("scores", [1, 300, 91])]
|
|
228
247
|
with self.assertRaisesRegex(ModelContractError, "dets"):
|
|
229
|
-
_read_graph_spec(_FakeSession(self.GOOD_IN, out))
|
|
248
|
+
_read_graph_spec(_FakeSession(self.GOOD_IN, out), RFDETR_MEDIUM)
|
|
230
249
|
|
|
231
250
|
def test_refuses_a_multi_input_graph(self):
|
|
232
251
|
"""An open-vocabulary model would carry a text input. Per the runbook
|
|
@@ -235,7 +254,7 @@ class TestGraphContract(unittest.TestCase):
|
|
|
235
254
|
the label map belongs to."""
|
|
236
255
|
two = [_FakeIo("images", [1, 3, 576, 576]), _FakeIo("text", [1, 512])]
|
|
237
256
|
with self.assertRaisesRegex(ModelContractError, "one graph input"):
|
|
238
|
-
_read_graph_spec(_FakeSession(two, self.GOOD_OUT))
|
|
257
|
+
_read_graph_spec(_FakeSession(two, self.GOOD_OUT), RFDETR_MEDIUM)
|
|
239
258
|
|
|
240
259
|
|
|
241
260
|
if __name__ == "__main__":
|
|
@@ -18,7 +18,7 @@ import threading
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
20
|
from .loader import DEFAULT_POLL_INTERVAL_S, DetectorLoader
|
|
21
|
-
from .modelpath import DEFAULT_MODEL_NAME, resolve_model_path
|
|
21
|
+
from .modelpath import DEFAULT_MODEL_NAME, resolve_model_path, resolve_ram_budget_mb
|
|
22
22
|
from .server import DEFAULT_HOST, DEFAULT_PORT, serve
|
|
23
23
|
|
|
24
24
|
LOG = logging.getLogger("trillboards_detector")
|
|
@@ -77,6 +77,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
77
77
|
"wrong and this exists only to reproduce a bench arm."
|
|
78
78
|
),
|
|
79
79
|
)
|
|
80
|
+
s.add_argument(
|
|
81
|
+
"--ram-budget-mb",
|
|
82
|
+
type=int,
|
|
83
|
+
default=None,
|
|
84
|
+
help=(
|
|
85
|
+
"how much RAM every RESIDENT detector session may occupy TOGETHER, in MiB "
|
|
86
|
+
"(env TRILLBOARDS_DETECTOR_RAM_BUDGET_MB). THERE IS NO DEFAULT AND THERE "
|
|
87
|
+
"MUST NOT BE: the allocation authority is server-side "
|
|
88
|
+
"(sensingProfileService.selectModelForDeviceTier and the model-manifest "
|
|
89
|
+
"min_ram_mb gate), and a ceiling chosen by this service would be a second "
|
|
90
|
+
"authority that cannot see the first. Unset, the service still holds the "
|
|
91
|
+
"model it was told to hold and REFUSES to make any second model resident, "
|
|
92
|
+
"naming why. Over the ceiling, a request is refused with what did not fit — "
|
|
93
|
+
"never answered with the half that did."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
80
96
|
s.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL_S)
|
|
81
97
|
s.add_argument("--log-file", default=None, help="also write to this file (Windows has no task stdout)")
|
|
82
98
|
s.add_argument("--verbose", action="store_true")
|
|
@@ -92,13 +108,44 @@ def main(argv: "list[str] | None" = None) -> int:
|
|
|
92
108
|
_configure_logging(args.log_file, args.verbose)
|
|
93
109
|
|
|
94
110
|
model_path = Path(args.model) if args.model else resolve_model_path(args.model_name)
|
|
95
|
-
|
|
111
|
+
|
|
112
|
+
# PRECEDENCE: flag, then env, then NOTHING. There is deliberately no third
|
|
113
|
+
# rung — an absent ceiling is a state, not a value to fill in.
|
|
114
|
+
budget_mb = args.ram_budget_mb if args.ram_budget_mb is not None else resolve_ram_budget_mb()
|
|
115
|
+
ram_budget_bytes = budget_mb * 1024 * 1024 if budget_mb is not None else None
|
|
116
|
+
|
|
117
|
+
# NOT A FATAL EXIT, and that is deliberate. Every one of the three
|
|
118
|
+
# supervisors restarts this process on a non-zero exit — the crash-loop each
|
|
119
|
+
# of their definitions carries a ThrottleInterval for — and `loader.py`'s
|
|
120
|
+
# header already settles the policy for a required input that has not
|
|
121
|
+
# arrived: START, and report UNHEALTHY with the reason. That is how a missing
|
|
122
|
+
# OTA'd artifact is handled and a missing ceiling is the same shape. The
|
|
123
|
+
# refusal is enforced where it belongs, at admission, per request, loudly.
|
|
124
|
+
if ram_budget_bytes is None:
|
|
125
|
+
LOG.warning(
|
|
126
|
+
"[detector] NO RAM CEILING INJECTED — the pinned model %s will still load and "
|
|
127
|
+
"answer, but NO second model can be made resident and every request that would "
|
|
128
|
+
"need one is refused with the reason. This service does not pick its own ceiling: "
|
|
129
|
+
"the allocation authority is server-side. Pass --ram-budget-mb or set "
|
|
130
|
+
"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB.",
|
|
131
|
+
args.model_name,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
LOG.info(
|
|
135
|
+
"[detector] starting on %s:%s, pinned model %s at %s, ceiling %s",
|
|
136
|
+
args.host,
|
|
137
|
+
args.port,
|
|
138
|
+
args.model_name,
|
|
139
|
+
model_path,
|
|
140
|
+
f"{ram_budget_bytes // (1024 * 1024)} MiB (injected)" if ram_budget_bytes else "NOT INJECTED",
|
|
141
|
+
)
|
|
96
142
|
|
|
97
143
|
loader = DetectorLoader(
|
|
98
144
|
model_path=model_path,
|
|
99
145
|
model_name=args.model_name,
|
|
100
146
|
intra_op_threads=args.threads,
|
|
101
147
|
poll_interval_s=args.poll_interval,
|
|
148
|
+
ram_budget_bytes=ram_budget_bytes,
|
|
102
149
|
log=LOG.info,
|
|
103
150
|
)
|
|
104
151
|
loader.start()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""What one frame's answer looks like, and how two models' answers become one.
|
|
2
|
+
|
|
3
|
+
STDLIB ONLY, and that is why it is its own module rather than staying in
|
|
4
|
+
`session.py`. A merged result is produced by `registry.py`, which must be
|
|
5
|
+
testable on a bare interpreter — the ~128 MiB ONNX is OTA'd and is absent on
|
|
6
|
+
every CI runner, so any part of this service that can only be exercised with
|
|
7
|
+
onnxruntime present is a part nothing tests. `session.py` re-exports both
|
|
8
|
+
dataclasses, so nothing that imported them from there had to move.
|
|
9
|
+
|
|
10
|
+
`models` IS PART OF THE RESULT, not decoration. `docs/runbooks/sensing-model-library.md`
|
|
11
|
+
§3: "A request spanning two models runs both and merges. The response's `model`
|
|
12
|
+
field reports the set that answered." A detection whose producer cannot be named
|
|
13
|
+
is a detection that cannot be attributed when a specialist later turns out to be
|
|
14
|
+
wrong, and `cctv-detector-tier.md` §5 rule 2 is a whole scar about exactly that —
|
|
15
|
+
an unattributed class index once scored BUSES as people for an entire eval run
|
|
16
|
+
and the table looked completely plausible.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Detection:
|
|
26
|
+
"""One wire-shaped detection. Pixel box, canonical label, [0,1] confidence."""
|
|
27
|
+
|
|
28
|
+
label: str
|
|
29
|
+
confidence: float
|
|
30
|
+
x: float
|
|
31
|
+
y: float
|
|
32
|
+
width: float
|
|
33
|
+
height: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DetectionBatch:
|
|
38
|
+
detections: list[Detection]
|
|
39
|
+
#: The ORT forward pass alone, in ms — directly comparable to the ~63 ms in
|
|
40
|
+
#: `docs/runbooks/cctv-detector-tier.md`. Decode and post-processing are
|
|
41
|
+
#: reported separately (`total_ms`) so a slow JPEG decode cannot hide inside
|
|
42
|
+
#: a number the runbook will later be checked against.
|
|
43
|
+
#:
|
|
44
|
+
#: On a merged batch this is the SUM across the models that answered, which
|
|
45
|
+
#: is the truth: they run serially on one box, so the wall clock the service
|
|
46
|
+
#: spent inferring is their sum, not their max. Comparing a two-model figure
|
|
47
|
+
#: against the single-model ~63 ms is therefore a comparison of different
|
|
48
|
+
#: things — `models` is on the same object so the reader can see how many.
|
|
49
|
+
inference_ms: float = 0.0
|
|
50
|
+
total_ms: float = 0.0
|
|
51
|
+
#: Boxes that survived the threshold but cover no pixels after clamping to
|
|
52
|
+
#: the frame. Counted, never silently swallowed — see `session._to_wire`.
|
|
53
|
+
degenerate_dropped: int = 0
|
|
54
|
+
#: Detections whose argmax landed on a slot the model's vocabulary does not
|
|
55
|
+
#: name (for COCO-91, one of the eleven category-id gaps).
|
|
56
|
+
unnamed_class_dropped: int = 0
|
|
57
|
+
#: The model(s) that produced this, in the order they ran. Exactly one
|
|
58
|
+
#: element for a batch off a single session; N for a merged one.
|
|
59
|
+
models: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def merge_batches(batches: "list[DetectionBatch]") -> DetectionBatch:
|
|
63
|
+
"""Concatenate several models' answers for ONE frame into one answer.
|
|
64
|
+
|
|
65
|
+
ORDER IS CATALOGUE ORDER, NOT SCORE ORDER. The wire contract carries no
|
|
66
|
+
ordering promise and `LocalDetectorClient` reads the array straight through
|
|
67
|
+
without sorting, so re-ranking across models here would be inventing a
|
|
68
|
+
cross-model calibration nobody measured — two detectors' confidences are not
|
|
69
|
+
on a common scale, and `cctv-detector-tier.md` §2 is the standing warning
|
|
70
|
+
about comparing models on a number that does not mean the same thing in
|
|
71
|
+
both.
|
|
72
|
+
|
|
73
|
+
THERE IS NO PARTIAL MERGE. Every batch handed here is a model that ran to
|
|
74
|
+
completion; a model that failed raises long before this, so a short list can
|
|
75
|
+
never silently become a thinner frame. That is the single collapse this
|
|
76
|
+
whole component exists to prevent.
|
|
77
|
+
"""
|
|
78
|
+
if not batches:
|
|
79
|
+
# Never reachable from `registry.detect`, which refuses an empty plan
|
|
80
|
+
# with a 503 before inferring. Raising rather than returning an empty
|
|
81
|
+
# batch keeps it that way: an empty merge is indistinguishable from a
|
|
82
|
+
# quiet venue, which is the one answer that must never be manufactured.
|
|
83
|
+
raise ValueError("merge_batches() was given no batches; an empty result must be a 503, not `[]`")
|
|
84
|
+
if len(batches) == 1:
|
|
85
|
+
return batches[0]
|
|
86
|
+
|
|
87
|
+
merged = DetectionBatch(detections=[])
|
|
88
|
+
for b in batches:
|
|
89
|
+
merged.detections.extend(b.detections)
|
|
90
|
+
merged.inference_ms += b.inference_ms
|
|
91
|
+
merged.total_ms += b.total_ms
|
|
92
|
+
merged.degenerate_dropped += b.degenerate_dropped
|
|
93
|
+
merged.unnamed_class_dropped += b.unnamed_class_dropped
|
|
94
|
+
merged.models.extend(b.models)
|
|
95
|
+
return merged
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["Detection", "DetectionBatch", "merge_batches"]
|
|
@@ -4,6 +4,10 @@ Its own module, with NO third-party imports, so `server.py` stays importable —
|
|
|
4
4
|
and therefore unit-testable against a stub — on a machine with no onnxruntime,
|
|
5
5
|
no numpy and no Pillow. The wire contract is the load-bearing half of this
|
|
6
6
|
service; it must not need a 128 MiB model or a 50 MB runtime to be tested.
|
|
7
|
+
|
|
8
|
+
`registry.py` is held to the same rule for the same reason, which is why
|
|
9
|
+
`ModelContractError` lives here rather than in `session.py`: the registry has to
|
|
10
|
+
be able to CLASSIFY a load failure without importing the thing that loads.
|
|
7
11
|
"""
|
|
8
12
|
|
|
9
13
|
|
|
@@ -13,4 +17,38 @@ class DetectorNotReady(RuntimeError):
|
|
|
13
17
|
The distinction is the entire reason `LocalDetectorClient` exists: a cascade
|
|
14
18
|
that reads "detector down" as "nobody there" suppresses every alert while
|
|
15
19
|
every health surface stays green.
|
|
20
|
+
|
|
21
|
+
IT COVERS "WILL NOT FIT" AS WELL AS "IS NOT THERE", deliberately. A request
|
|
22
|
+
spanning models whose committed RAM cost exceeds the portfolio budget is a
|
|
23
|
+
request this box cannot answer, and answering it with the half that fits
|
|
24
|
+
would be a partial result nobody downstream could distinguish from a
|
|
25
|
+
genuinely emptier frame — `docs/runbooks/sensing-model-library.md` §2:
|
|
26
|
+
"report what it could not fit rather than silently dropping it."
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ModelContractError(RuntimeError):
|
|
31
|
+
"""The artifact loaded, but it is not a graph this service can label.
|
|
32
|
+
|
|
33
|
+
Raised by `session.py` against the model's COMMITTED vocabulary. Named here
|
|
34
|
+
so `registry.py` can tell "this artifact is the wrong shape" (a permanent
|
|
35
|
+
fault that will not fix itself on a retry) from "ORT failed to open the
|
|
36
|
+
file" (which a re-download can fix), without importing onnxruntime.
|
|
16
37
|
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UnknownClasses(ValueError):
|
|
41
|
+
"""The caller named classes no resident-or-present model can emit.
|
|
42
|
+
|
|
43
|
+
A 400, and a LOUD one — never a quieter answer computed from the half we
|
|
44
|
+
recognised. `docs/runbooks/cctv-detector-tier.md` §5 rule 5 is the scar:
|
|
45
|
+
`yolov8_nano` was offered to ~97 of 114 profile authors while every device
|
|
46
|
+
hardcoded it unloadable, and `vehicle_count` sat at ~0% fleet-wide for six
|
|
47
|
+
months with every surface green.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, unknown: "list[str]", known: "frozenset[str]", detail: str = ""):
|
|
51
|
+
self.unknown = list(unknown)
|
|
52
|
+
self.known = frozenset(known)
|
|
53
|
+
self.detail = detail
|
|
54
|
+
super().__init__(detail or f"unknown classes: {self.unknown}")
|