@trillboards/edge-sdk 0.2.81 → 0.2.84
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/SensingProfileClient.d.ts +1 -1
- package/dist/SensingProfileClient.d.ts.map +1 -1
- package/dist/SensingProfileClient.js +20 -0
- package/dist/SensingProfileClient.js.map +1 -1
- package/dist/autoSense.d.ts +6 -5
- package/dist/autoSense.d.ts.map +1 -1
- package/dist/autoSense.js +12 -2
- package/dist/autoSense.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -6
- package/dist/cli.js.map +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/modelOta.d.ts +274 -0
- package/dist/modelOta.d.ts.map +1 -0
- package/dist/modelOta.js +492 -0
- package/dist/modelOta.js.map +1 -0
- package/dist/sensingRunMode.d.ts +21 -0
- package/dist/sensingRunMode.d.ts.map +1 -1
- package/dist/sensingRunMode.js +32 -7
- package/dist/sensingRunMode.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""The class
|
|
1
|
+
"""The class vocabularies — COMMITTED, never inferred from the artifact.
|
|
2
2
|
|
|
3
3
|
WHY THIS FILE IS A CONSTANT AND NOT A LOOKUP
|
|
4
4
|
--------------------------------------------
|
|
@@ -10,8 +10,8 @@ alternative to committing them is guessing an index — which
|
|
|
10
10
|
fallback to index 0 once scored BUSES as people for an entire eval run and the
|
|
11
11
|
resulting table looked entirely plausible.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
a pretrained COCO checkpoint, at `rfdetr/detr.py`::
|
|
13
|
+
The COCO map is not hand-typed. It is what the `rfdetr` package itself computes
|
|
14
|
+
for a pretrained COCO checkpoint, at `rfdetr/detr.py`::
|
|
15
15
|
|
|
16
16
|
_class_id_to_name = {coco_id: model_class_names[i]
|
|
17
17
|
for i, coco_id in enumerate(COCO_CLASSES) if i < n}
|
|
@@ -27,11 +27,37 @@ without inventing one, and inventing one is precisely the failure above. It is
|
|
|
27
27
|
dropped and COUNTED (`unnamed_class_drops` on /health), because a drop nobody
|
|
28
28
|
can see is the other failure this codebase keeps hitting — an operation that
|
|
29
29
|
reports success while doing nothing.
|
|
30
|
+
|
|
31
|
+
WHY THERE IS A `ModelVocabulary` AND NOT JUST A MAP
|
|
32
|
+
--------------------------------------------------
|
|
33
|
+
`docs/runbooks/sensing-model-library.md` §3: "`KNOWN_CLASS_NAMES` becomes the
|
|
34
|
+
union of the loaded models' vocabularies rather than a fixed COCO list." A
|
|
35
|
+
frozen COCO set is the reason the service could not express a specialist AT ALL
|
|
36
|
+
— a PPE model's `glove` came back `unknown` and the request 400'd, so publishing
|
|
37
|
+
one would have changed nothing here. The union has to be computed from whatever
|
|
38
|
+
this box actually holds, which means one vocabulary object per model and an
|
|
39
|
+
index over the set of them (`ClassIndex`).
|
|
40
|
+
|
|
41
|
+
ADDING A SPECIALIST IS AN EDIT TO `BUILTIN_VOCABULARIES`, and §4 rule 1 —
|
|
42
|
+
"read the class list, never the title" — is why. A "fine-tuned fisheye" model
|
|
43
|
+
scored worse than raw COCO because its classes were `{bus,bike,car,pedestrian,
|
|
44
|
+
truck}`: a traffic model. The class list IS the model's identity, so it is
|
|
45
|
+
committed alongside the name rather than trusted from a filename, a registry row
|
|
46
|
+
or the artifact.
|
|
47
|
+
|
|
48
|
+
A COMMITTED VOCABULARY ADVERTISES NOTHING ON ITS OWN. `ClassIndex` is built by
|
|
49
|
+
`registry.py` over the models whose ARTIFACT IS ON DISK, so a vocabulary added
|
|
50
|
+
here before its ONNX has been OTA'd contributes no requestable class. That is
|
|
51
|
+
§4 rule 5 ("the artifact, its checksum and its score land together, or it does
|
|
52
|
+
not ship") enforced by construction rather than by remembering.
|
|
30
53
|
"""
|
|
31
54
|
|
|
32
55
|
from __future__ import annotations
|
|
33
56
|
|
|
57
|
+
import re
|
|
58
|
+
from dataclasses import dataclass
|
|
34
59
|
from types import MappingProxyType
|
|
60
|
+
from typing import Mapping, Sequence
|
|
35
61
|
|
|
36
62
|
# Slot index (the position on the model's 91-wide logit axis) -> class name.
|
|
37
63
|
# Slot index IS the COCO category id here; the gaps are real and deliberate.
|
|
@@ -57,7 +83,7 @@ COCO91_CLASS_NAMES: "MappingProxyType[int, str]" = MappingProxyType({
|
|
|
57
83
|
})
|
|
58
84
|
|
|
59
85
|
# How wide the logit axis must be for the map above to mean anything. Checked
|
|
60
|
-
# against the loaded graph at startup — a mismatch takes
|
|
86
|
+
# against the loaded graph at startup — a mismatch takes THAT MODEL unavailable
|
|
61
87
|
# rather than relabelling every box by a constant offset. That is the whole
|
|
62
88
|
# point: an off-by-one on this axis is invisible downstream, it just renames
|
|
63
89
|
# every object in the venue, and the resulting numbers stay plausible.
|
|
@@ -69,9 +95,6 @@ PERSON_SLOT = 1
|
|
|
69
95
|
assert COCO91_CLASS_NAMES[PERSON_SLOT] == "person"
|
|
70
96
|
assert max(COCO91_CLASS_NAMES) < EXPECTED_LOGIT_SLOTS
|
|
71
97
|
|
|
72
|
-
#: Canonical names, for validating a caller's `classes` filter.
|
|
73
|
-
KNOWN_CLASS_NAMES = frozenset(COCO91_CLASS_NAMES.values())
|
|
74
|
-
|
|
75
98
|
|
|
76
99
|
def normalize_class_name(name: str) -> str:
|
|
77
100
|
"""Fold a requested class name to its canonical form.
|
|
@@ -84,24 +107,216 @@ def normalize_class_name(name: str) -> str:
|
|
|
84
107
|
return name.strip().lower().replace("_", " ").replace("-", " ")
|
|
85
108
|
|
|
86
109
|
|
|
87
|
-
|
|
110
|
+
#: A model name is a DIRECTORY name (`models/<name>/model.onnx`) on three
|
|
111
|
+
#: platforms, so it is held to the intersection of what all three accept.
|
|
112
|
+
_MODEL_NAME_RE = re.compile(r"[a-z0-9][a-z0-9._-]*")
|
|
88
113
|
|
|
89
114
|
|
|
90
|
-
|
|
91
|
-
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class ModelVocabulary:
|
|
117
|
+
"""What ONE model can be asked about, and what one resident copy costs.
|
|
92
118
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
`approx_ram_bytes` is committed for the same reason the class list is: the
|
|
120
|
+
portfolio budget has to be decided BEFORE the allocation happens, and there
|
|
121
|
+
is no way to measure a session's footprint without first creating it. An
|
|
122
|
+
admission test that loads the model to find out whether the model fits is
|
|
123
|
+
not an admission test. So the figure is measured once, written down here
|
|
124
|
+
with its provenance, and re-measured when the artifact changes — never
|
|
125
|
+
inferred at runtime.
|
|
126
|
+
|
|
127
|
+
It is deliberately allowed to be wrong by a margin: `cctv-detector-tier.md`
|
|
128
|
+
§6 notes the detector is ~9% of the binding box's RAM, "so the conclusion
|
|
129
|
+
survives being wrong about the 632 MB by several times over".
|
|
130
|
+
|
|
131
|
+
THIS FIGURE IS AN INPUT TO A DECISION MADE ABOVE, NOT THE DECISION. The
|
|
132
|
+
ceiling it is checked against is injected (see `registry.py`) because the
|
|
133
|
+
allocation authority is server-side. What this column supplies is the
|
|
134
|
+
quantity that authority does not have: `min_ram_mb` is a GATE and
|
|
135
|
+
`model_size_mb` is a FILE SIZE, and neither is resident footprint —
|
|
136
|
+
`gemma_4_e2b` carries 8000 and 2580 against a measured working set of
|
|
137
|
+
3.31 GB. `rss_delta_bytes` on /health is the service measuring the same
|
|
138
|
+
quantity per load, so the committed figure can be checked against reality.
|
|
98
139
|
"""
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
140
|
+
|
|
141
|
+
model_name: str
|
|
142
|
+
#: Logit-axis slot -> class name. The slot index is the model's own, so a
|
|
143
|
+
#: sparse map (COCO's id gaps) and a dense one are both fine.
|
|
144
|
+
slot_names: "Mapping[int, str]"
|
|
145
|
+
#: How wide the class axis must be for `slot_names` to describe it.
|
|
146
|
+
logit_slots: int
|
|
147
|
+
#: Peak RSS of ONE resident session, in bytes.
|
|
148
|
+
approx_ram_bytes: int
|
|
149
|
+
|
|
150
|
+
def __post_init__(self) -> None:
|
|
151
|
+
# The model name IS A DIRECTORY NAME — `resolve_model_path` puts the
|
|
152
|
+
# artifact at `models/<model-name>/model.onnx` on every platform — so it
|
|
153
|
+
# is restricted to what is safe in a path on all three, and NOT folded
|
|
154
|
+
# by `normalize_class_name` (which turns `_` into a space; the shipped
|
|
155
|
+
# `rfdetr_medium` is a valid name and an invalid class).
|
|
156
|
+
if not self.model_name or not _MODEL_NAME_RE.fullmatch(self.model_name):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"model name {self.model_name!r} must match {_MODEL_NAME_RE.pattern} — it is a "
|
|
159
|
+
"directory name on macOS, Linux and Windows"
|
|
160
|
+
)
|
|
161
|
+
if self.logit_slots <= 0:
|
|
162
|
+
raise ValueError(f"{self.model_name}: logit_slots must be positive, got {self.logit_slots}")
|
|
163
|
+
if self.approx_ram_bytes <= 0:
|
|
164
|
+
raise ValueError(f"{self.model_name}: approx_ram_bytes must be positive")
|
|
165
|
+
if not self.slot_names:
|
|
166
|
+
raise ValueError(f"{self.model_name}: a model with no named class can answer nothing")
|
|
167
|
+
for slot, name in self.slot_names.items():
|
|
168
|
+
if not isinstance(slot, int) or slot < 0 or slot >= self.logit_slots:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"{self.model_name}: slot {slot!r} is outside the declared {self.logit_slots}-wide axis"
|
|
171
|
+
)
|
|
172
|
+
# ALREADY-CANONICAL NAMES ONLY. `_to_wire` puts the DECLARED name on
|
|
173
|
+
# the wire and `postprocess` filters on the CANONICAL one, so a
|
|
174
|
+
# vocabulary declaring `cell_phone` would emit a label the caller's
|
|
175
|
+
# own request had just been folded away from — a class silently
|
|
176
|
+
# matching nothing, on a box where everything looks loaded. Checked
|
|
177
|
+
# at import, so a sloppy specialist fails at start rather than in a
|
|
178
|
+
# venue.
|
|
179
|
+
if not name or normalize_class_name(name) != name:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"{self.model_name}: class name {name!r} is not canonical "
|
|
182
|
+
f"(expected {normalize_class_name(name)!r})"
|
|
183
|
+
)
|
|
184
|
+
if len(set(self.slot_names.values())) != len(self.slot_names):
|
|
185
|
+
raise ValueError(f"{self.model_name}: two slots share one class name, so a filter would be ambiguous")
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def class_names(self) -> "frozenset[str]":
|
|
189
|
+
return frozenset(self.slot_names.values())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
#: The artifact this service was built around and the ONLY one measured.
|
|
193
|
+
#:
|
|
194
|
+
#: 632 MB peak RSS — `docs/runbooks/cctv-detector-tier.md` §6, measured on
|
|
195
|
+
#: macOS/arm64 against the shipped 135,146,350-byte fp32 export. Treated as MiB
|
|
196
|
+
#: here, which over-reserves by ~4.9% against the runbook's decimal MB: the safe
|
|
197
|
+
#: direction, since under-reserving is what the OOM killer resolves.
|
|
198
|
+
RFDETR_MEDIUM = ModelVocabulary(
|
|
199
|
+
model_name="rfdetr_medium",
|
|
200
|
+
slot_names=COCO91_CLASS_NAMES,
|
|
201
|
+
logit_slots=EXPECTED_LOGIT_SLOTS,
|
|
202
|
+
approx_ram_bytes=632 * 1024 * 1024,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
#: Every model this build knows how to label, IN PRIORITY ORDER.
|
|
206
|
+
#:
|
|
207
|
+
#: Order is load-bearing and it is the whole disambiguation rule: a class named
|
|
208
|
+
#: by more than one vocabulary routes to the FIRST entry here that names it, so
|
|
209
|
+
#: adding a specialist can never move an existing class off the model it was
|
|
210
|
+
#: scored on. `person` stays on `rfdetr_medium` even after a PPE model that also
|
|
211
|
+
#: emits `person` arrives, and that is correct rather than merely deterministic
|
|
212
|
+
#: — the 98.8/98.8 pooled figure in `cctv-detector-tier.md` is a claim about THIS
|
|
213
|
+
#: model's person, not about the concept.
|
|
214
|
+
#:
|
|
215
|
+
#: ONE ENTRY TODAY. That is not the multi-model machinery being unused; it is
|
|
216
|
+
#: §4 rule 5 holding: a vocabulary here without a published artifact advertises
|
|
217
|
+
#: nothing (see `ClassIndex`, built over models whose file is present), and
|
|
218
|
+
#: publishing is a separate task. What changed is that the service can now
|
|
219
|
+
#: EXPRESS a second entry, which before this it structurally could not.
|
|
220
|
+
BUILTIN_VOCABULARIES: "tuple[ModelVocabulary, ...]" = (RFDETR_MEDIUM,)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ClassIndex:
|
|
224
|
+
"""The union vocabulary of a SET of models, and who owns each class.
|
|
225
|
+
|
|
226
|
+
Built by `registry.py` over the models whose artifact is actually on the
|
|
227
|
+
box, so it answers two questions the old fixed `KNOWN_CLASS_NAMES` could
|
|
228
|
+
not: what may a caller ask for right now, and which model has to run to
|
|
229
|
+
answer it.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(self, vocabularies: "Sequence[ModelVocabulary]"):
|
|
233
|
+
self._vocabularies = tuple(vocabularies)
|
|
234
|
+
owner: "dict[str, str]" = {}
|
|
235
|
+
for vocabulary in self._vocabularies:
|
|
236
|
+
for name in sorted(vocabulary.class_names):
|
|
237
|
+
# First-in-catalogue-order wins. `setdefault`, not assignment:
|
|
238
|
+
# a later specialist must not capture a class an earlier model
|
|
239
|
+
# already owns.
|
|
240
|
+
owner.setdefault(name, vocabulary.model_name)
|
|
241
|
+
if len({v.model_name for v in self._vocabularies}) != len(self._vocabularies):
|
|
242
|
+
raise ValueError("two vocabularies share one model name")
|
|
243
|
+
self._owner = owner
|
|
244
|
+
self._class_names = frozenset(owner)
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def class_names(self) -> "frozenset[str]":
|
|
248
|
+
"""The union. This is what a caller may ask for, and nothing else."""
|
|
249
|
+
return self._class_names
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def model_names(self) -> "tuple[str, ...]":
|
|
253
|
+
return tuple(v.model_name for v in self._vocabularies)
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def vocabularies(self) -> "tuple[ModelVocabulary, ...]":
|
|
257
|
+
return self._vocabularies
|
|
258
|
+
|
|
259
|
+
def owner_of(self, canonical_class: str) -> "str | None":
|
|
260
|
+
return self._owner.get(canonical_class)
|
|
261
|
+
|
|
262
|
+
def resolve(self, requested: "list[str]") -> "tuple[set[str], list[str]]":
|
|
263
|
+
"""Map caller-supplied class names onto canonical ones.
|
|
264
|
+
|
|
265
|
+
Returns ``(canonical_names, unknown_inputs)``. Never silently discards
|
|
266
|
+
an unknown, because a profile naming a class no model on this box can
|
|
267
|
+
emit is exactly the `yolov8_nano` shape — offered to profile authors
|
|
268
|
+
everywhere, loadable nowhere, and ~0% for six months while every
|
|
269
|
+
surface looked fine. The caller turns a non-empty `unknown` into a 400.
|
|
270
|
+
"""
|
|
271
|
+
canonical: "set[str]" = set()
|
|
272
|
+
unknown: "list[str]" = []
|
|
273
|
+
for raw in requested:
|
|
274
|
+
folded = normalize_class_name(raw)
|
|
275
|
+
if folded in self._class_names:
|
|
276
|
+
canonical.add(folded)
|
|
277
|
+
else:
|
|
278
|
+
unknown.append(raw)
|
|
279
|
+
return canonical, unknown
|
|
280
|
+
|
|
281
|
+
def assign(self, wanted: "set[str] | None") -> "list[tuple[ModelVocabulary, set[str] | None]]":
|
|
282
|
+
"""Split a request across the models that have to run, in catalogue order.
|
|
283
|
+
|
|
284
|
+
`wanted=None` — the wire's "every class" — expands to the whole union
|
|
285
|
+
FIRST and is then routed like any other request. Running each model over
|
|
286
|
+
its own full vocabulary instead would double-report every class two
|
|
287
|
+
models share: one `person` box from the general detector and another
|
|
288
|
+
from a specialist, for the same person, with nothing downstream able to
|
|
289
|
+
tell that apart from two people. Expanding and routing means an
|
|
290
|
+
overlapping class is answered exactly once, by its owner.
|
|
291
|
+
|
|
292
|
+
A model whose assignment is its ENTIRE vocabulary gets `None`, which is
|
|
293
|
+
the same object the single-model service passed before this existed —
|
|
294
|
+
so the one-model box takes byte-identical code through `_to_wire`.
|
|
295
|
+
"""
|
|
296
|
+
target = self._class_names if wanted is None else wanted
|
|
297
|
+
by_model: "dict[str, set[str]]" = {}
|
|
298
|
+
for name in target:
|
|
299
|
+
owner = self._owner.get(name)
|
|
300
|
+
if owner is None:
|
|
301
|
+
continue
|
|
302
|
+
by_model.setdefault(owner, set()).add(name)
|
|
303
|
+
|
|
304
|
+
plan: "list[tuple[ModelVocabulary, set[str] | None]]" = []
|
|
305
|
+
for vocabulary in self._vocabularies:
|
|
306
|
+
classes = by_model.get(vocabulary.model_name)
|
|
307
|
+
if not classes:
|
|
308
|
+
continue
|
|
309
|
+
plan.append((vocabulary, None if classes == set(vocabulary.class_names) else classes))
|
|
310
|
+
return plan
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
__all__ = [
|
|
314
|
+
"BUILTIN_VOCABULARIES",
|
|
315
|
+
"COCO91_CLASS_NAMES",
|
|
316
|
+
"EXPECTED_LOGIT_SLOTS",
|
|
317
|
+
"PERSON_SLOT",
|
|
318
|
+
"RFDETR_MEDIUM",
|
|
319
|
+
"ClassIndex",
|
|
320
|
+
"ModelVocabulary",
|
|
321
|
+
"normalize_class_name",
|
|
322
|
+
]
|