@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,8 +1,9 @@
|
|
|
1
1
|
# `trillboards-detector` — the on-device object-detector service
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A LIBRARY of detectors, served over loopback on **127.0.0.1:9380**. One is
|
|
4
|
+
shipped today — RF-DETR Medium, fp32 ONNX — and it is the cascade gate that sits
|
|
5
|
+
in front of the on-device VLM: at the ANALYZE cadence it removes ~84.7% of
|
|
6
|
+
5,645 ms VLM calls for ~2.1% of events.
|
|
6
7
|
|
|
7
8
|
The decision record — which model, why not the alternatives, and every number
|
|
8
9
|
above — is [`docs/runbooks/cctv-detector-tier.md`](../../../../../docs/runbooks/cctv-detector-tier.md).
|
|
@@ -21,37 +22,116 @@ POST /detect { image_b64, classes?, min_confidence }
|
|
|
21
22
|
200 { detections: [{ label, confidence, box: {x,y,width,height} }],
|
|
22
23
|
inference_ms, model }
|
|
23
24
|
400 the request cannot be acted on (bad base64, empty body,
|
|
24
|
-
min_confidence outside [0,1],
|
|
25
|
+
min_confidence outside [0,1], ANY requested class unknown)
|
|
25
26
|
413 body over 32 MiB
|
|
26
27
|
500 inference failed, or a detection could not be put on the wire
|
|
27
28
|
503 no usable model — NEVER a 200 with an empty array
|
|
28
29
|
|
|
29
|
-
GET /health 200 when
|
|
30
|
-
|
|
30
|
+
GET /health 200 when the PINNED model's load completed AND its probe
|
|
31
|
+
inference succeeded; 503 otherwise, with the reason. Also
|
|
32
|
+
reports which models are resident. Runs no inference.
|
|
31
33
|
```
|
|
32
34
|
|
|
33
35
|
* `label` is the class **name**, never an index.
|
|
34
36
|
* `box` is in **pixels of the submitted frame**, not normalised.
|
|
35
37
|
* `classes` filters at the **service**, so the wire stays small. Absent means
|
|
36
38
|
every class; an empty array is a 400, never a synonym for "all".
|
|
39
|
+
* `model` names the **set that answered** — `rfdetr_medium`, or
|
|
40
|
+
`rfdetr_medium+ppe` when a request spans two. The client types it
|
|
41
|
+
`string | null` and does not branch on it, so a joined string is contract-safe.
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
## The model library
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
[`docs/runbooks/sensing-model-library.md`](../../../../../docs/runbooks/sensing-model-library.md)
|
|
46
|
+
is the architecture; §3 is what this service implements. **The wire contract
|
|
47
|
+
carries no model selector and it stays that way** — the connector must not hold
|
|
48
|
+
the catalogue, and `classes` already expresses the request completely, so the
|
|
49
|
+
service owns class → model routing because it is the only component that knows
|
|
50
|
+
what is resident on that box right now.
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
the
|
|
52
|
+
```
|
|
53
|
+
labels.py one committed ModelVocabulary per model (slots -> class names,
|
|
54
|
+
logit width, measured RAM). ClassIndex is the UNION over them.
|
|
55
|
+
registry.py name -> session map, the class index, lazy load, LRU eviction
|
|
56
|
+
under a RAM budget, and the merge. STDLIB ONLY.
|
|
57
|
+
loader.py the poll loop + the real ORT session factory and probe frame.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
* **A class routes to the FIRST catalogue model that names it.** Adding a
|
|
61
|
+
specialist can never move an existing class off the model it was scored on —
|
|
62
|
+
`person` stays on `rfdetr_medium` even after a PPE model that also emits it.
|
|
63
|
+
* **A committed vocabulary advertises nothing until its artifact is on disk.**
|
|
64
|
+
`ClassIndex` is built over models whose file is present, so a vocabulary added
|
|
65
|
+
before its ONNX ships contributes no requestable class. That is the
|
|
66
|
+
`yolov8_nano` failure made unreachable rather than merely forbidden.
|
|
67
|
+
* **The pinned model is eager and never evicted; specialists are lazy.**
|
|
68
|
+
`/health` has always meant "a load completed and a probe inference succeeded",
|
|
69
|
+
and `LocalDetectorClient.isHealthy()` turns anything else into "send the frame
|
|
70
|
+
to the VLM" — so the general gate has to be resident for the service to claim
|
|
71
|
+
it is up. One 632 MiB model fits any budget that can hold anything.
|
|
72
|
+
* **Every model runs a probe inference before it may answer**, on its first
|
|
73
|
+
request and on every reload. A session that constructs is not a session that
|
|
74
|
+
infers.
|
|
75
|
+
* **A request spanning two models runs both and merges**, in catalogue order.
|
|
76
|
+
`inference_ms` is the SUM, because they run serially on one box.
|
|
77
|
+
|
|
78
|
+
### The RAM ceiling is INJECTED — this service owns no RAM policy
|
|
79
|
+
|
|
80
|
+
`--ram-budget-mb`, or `TRILLBOARDS_DETECTOR_RAM_BUDGET_MB`. **There is no
|
|
81
|
+
default and there must not be one.**
|
|
82
|
+
|
|
83
|
+
There is already an allocation authority and it is server-side:
|
|
84
|
+
`sensingProfileService.selectModelForDeviceTier` picks a model from
|
|
85
|
+
`deviceRamMb` + `deviceTier`, and `registerInternalControlSurface` gates each
|
|
86
|
+
model on `ramMb >= model.min_ram_mb`. A ceiling chosen *here* would be a second
|
|
87
|
+
authority that cannot see the first — the server deciding what a device gets
|
|
88
|
+
while the service independently decides what it keeps. That is the
|
|
89
|
+
`yolov8_nano` shape: advertised by one component, unloadable by another, for six
|
|
90
|
+
months, with every surface green.
|
|
91
|
+
|
|
92
|
+
So the ceiling is a parameter, and **absent is a state, not a value to fill in**:
|
|
93
|
+
|
|
94
|
+
| ceiling | what happens |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| injected | the resident set must fit it; over it, a request is **refused with what did not fit**, never answered with the half that did |
|
|
97
|
+
| **not injected** | the pinned model still loads and answers — the server already chose it, and holding what we were told to hold is the mechanism doing its job — and **any second model is refused**, naming who supplies the ceiling |
|
|
98
|
+
|
|
99
|
+
`/health` carries `ram_ceiling_injected` so a reader can see which of those two
|
|
100
|
+
states the box is in. The service does not exit when nothing was injected: all
|
|
101
|
+
three supervisors restart on a non-zero exit, and this file's own rule for a
|
|
102
|
+
required input that has not arrived is *start, and report the reason*.
|
|
103
|
+
|
|
104
|
+
**Eviction is a last-resort safety valve, not the allocator.** It logs a WARNING
|
|
105
|
+
naming the box as oversubscribed against what it was allocated, and `/health`
|
|
106
|
+
carries a top-level `evictions` count — because a service quietly thrashing
|
|
107
|
+
under a ceiling looks exactly like one working fine.
|
|
108
|
+
|
|
109
|
+
### The footprint column the server does not have
|
|
110
|
+
|
|
111
|
+
`min_ram_mb` is a **gate**. `model_size_mb` is a **file size**. `gemma_4_e2b`
|
|
112
|
+
carries 8000 and 2580 against a *measured working set* of 3.31 GB — three
|
|
113
|
+
different quantities, and the one portfolio admission needs, **resident
|
|
114
|
+
footprint**, is in none of them. Summing `min_ram_mb` over-rejects; summing
|
|
115
|
+
`model_size_mb` under-rejects.
|
|
116
|
+
|
|
117
|
+
So each load is measured, after the probe (a session that has not run a forward
|
|
118
|
+
pass has not allocated its working buffers), and published per model:
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
rss_delta_bytes bytes the process grew across this model's load
|
|
122
|
+
rss_source proc-statm Linux, CURRENT rss, exact
|
|
123
|
+
psapi-workingset Windows, CURRENT working set, exact
|
|
124
|
+
rusage-maxrss macOS/fallback, a HIGH-WATER MARK — the
|
|
125
|
+
delta is a LOWER BOUND and 0 is legitimate
|
|
126
|
+
```
|
|
49
127
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
128
|
+
The instrument is named with the number because the three platforms do not
|
|
129
|
+
answer the same question, and a reader that cannot tell them apart will average
|
|
130
|
+
a high-water mark into a current reading.
|
|
131
|
+
|
|
132
|
+
**It is reported and never used for admission.** Admission follows the
|
|
133
|
+
*committed* `approx_ram_bytes`; admitting on numbers only this service can see
|
|
134
|
+
would be the second authority again, by a different route.
|
|
55
135
|
|
|
56
136
|
## Where it runs
|
|
57
137
|
|
|
@@ -87,6 +167,11 @@ reclaims the old bytes. There is no GC for abandoned model dirs anywhere on this
|
|
|
87
167
|
path and the existing OTA already orphans 752 MiB per update; a version-named
|
|
88
168
|
directory would add to that.
|
|
89
169
|
|
|
170
|
+
**One directory per model NAME is also what makes the library work**: each
|
|
171
|
+
catalogue entry resolves its own `models/<name>/model.onnx`, a specialist's
|
|
172
|
+
artifact arriving later widens the requestable vocabulary within one poll
|
|
173
|
+
interval with no restart, and its disappearing narrows it again.
|
|
174
|
+
|
|
90
175
|
## Tests
|
|
91
176
|
|
|
92
177
|
```bash
|
|
@@ -94,7 +179,9 @@ directory would add to that.
|
|
|
94
179
|
python3 -m unittest discover -s . -p "test_*.py" -v
|
|
95
180
|
```
|
|
96
181
|
|
|
97
|
-
Runs in `guards-fast`.
|
|
182
|
+
Runs in `guards-fast`. `test_registry.py` covers the whole residency policy —
|
|
183
|
+
routing, lazy load, eviction, and every refusal — with FAKE sessions, so it runs
|
|
184
|
+
there too. **Everything except the post-processing cases needs
|
|
98
185
|
nothing but the standard library** — those are the only ones that touch numpy,
|
|
99
186
|
and they skip themselves with a stated reason when it is absent. That invariant
|
|
100
187
|
is the durable claim; the counts (45 of 60 on 2026-08-24) are a snapshot and go
|
|
@@ -33,6 +33,7 @@ from pathlib import Path
|
|
|
33
33
|
|
|
34
34
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
35
35
|
|
|
36
|
+
from trillboards_detector.labels import RFDETR_MEDIUM # noqa: E402
|
|
36
37
|
from trillboards_detector.session import OnnxDetector # noqa: E402
|
|
37
38
|
|
|
38
39
|
|
|
@@ -75,7 +76,11 @@ def main() -> int:
|
|
|
75
76
|
)
|
|
76
77
|
|
|
77
78
|
# ── this service ─────────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
+
# The VOCABULARY, not just the name: a session labels its slots from the
|
|
80
|
+
# committed vocabulary it was built with, so the control has to hand over
|
|
81
|
+
# the same one the service would — otherwise it would compare the reference
|
|
82
|
+
# against a differently-labelled run and still report agreement.
|
|
83
|
+
detector = OnnxDetector(Path(args.onnx), RFDETR_MEDIUM)
|
|
79
84
|
batch = detector.detect(image_path.read_bytes(), args.threshold, None)
|
|
80
85
|
ours_rows = sorted(
|
|
81
86
|
(d.label, round(d.confidence, 4), (round(d.x, 2), round(d.y, 2), round(d.x + d.width, 2), round(d.y + d.height, 2)))
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The half of the loader that needs a real runtime to even import.
|
|
2
|
+
|
|
3
|
+
`loader.py` imports `session.py`, which imports onnxruntime, numpy and Pillow —
|
|
4
|
+
so unlike `test_registry.py` these cases cannot run on a bare interpreter and
|
|
5
|
+
skip themselves with a stated reason, exactly as `test_session.py` does. The
|
|
6
|
+
residency policy itself is deliberately NOT here: it lives in `registry.py`,
|
|
7
|
+
which imports none of that, so every rule about routing, eviction and refusal is
|
|
8
|
+
exercised where the artifact is absent.
|
|
9
|
+
|
|
10
|
+
What is left for this file is the catalogue construction and the ONE-SNAPSHOT
|
|
11
|
+
property of `/health` — both of which are about `DetectorLoader` specifically.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import unittest
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from trillboards_detector.labels import RFDETR_MEDIUM, ModelVocabulary
|
|
20
|
+
from trillboards_detector.loader import DetectorLoader, build_catalogue
|
|
21
|
+
|
|
22
|
+
HAVE_RUNTIME = True
|
|
23
|
+
except ImportError: # pragma: no cover - exercised only on a bare interpreter
|
|
24
|
+
HAVE_RUNTIME = False
|
|
25
|
+
|
|
26
|
+
PPE = None
|
|
27
|
+
if HAVE_RUNTIME:
|
|
28
|
+
PPE = ModelVocabulary(
|
|
29
|
+
model_name="ppe",
|
|
30
|
+
slot_names={0: "glove", 1: "hairnet"},
|
|
31
|
+
logit_slots=2,
|
|
32
|
+
approx_ram_bytes=400 * 1024 * 1024,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@unittest.skipUnless(HAVE_RUNTIME, "onnxruntime/numpy/Pillow are not installed")
|
|
37
|
+
class TestBuildCatalogue(unittest.TestCase):
|
|
38
|
+
def test_the_pinned_model_comes_first_so_it_wins_every_class_tie(self):
|
|
39
|
+
"""Catalogue order IS the disambiguation rule. `person` must keep coming
|
|
40
|
+
from the model the 98.8/98.8 was measured on, whatever a later
|
|
41
|
+
specialist also emits."""
|
|
42
|
+
entries = build_catalogue("rfdetr_medium", None, (PPE, RFDETR_MEDIUM), {"HOME": "/h"})
|
|
43
|
+
self.assertEqual([e.name for e in entries], ["rfdetr_medium", "ppe"])
|
|
44
|
+
self.assertTrue(entries[0].pinned)
|
|
45
|
+
self.assertFalse(entries[1].pinned)
|
|
46
|
+
|
|
47
|
+
def test_every_entry_resolves_its_own_per_model_directory(self):
|
|
48
|
+
entries = build_catalogue("rfdetr_medium", None, (RFDETR_MEDIUM, PPE), {"HOME": "/h"})
|
|
49
|
+
self.assertEqual(
|
|
50
|
+
[str(e.path) for e in entries],
|
|
51
|
+
["/h/.trillboards/models/rfdetr_medium/model.onnx",
|
|
52
|
+
"/h/.trillboards/models/ppe/model.onnx"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_an_explicit_path_overrides_only_the_pinned_entry(self):
|
|
56
|
+
"""`--model` / `TRILLBOARDS_DETECTOR_MODEL` is the bench escape hatch and
|
|
57
|
+
it names ONE artifact, so it must not relocate every specialist onto the
|
|
58
|
+
same file."""
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
entries = build_catalogue(
|
|
62
|
+
"rfdetr_medium", Path("/bench/rfdetr.onnx"), (RFDETR_MEDIUM, PPE), {"HOME": "/h"}
|
|
63
|
+
)
|
|
64
|
+
self.assertEqual(str(entries[0].path), "/bench/rfdetr.onnx")
|
|
65
|
+
self.assertEqual(str(entries[1].path), "/h/.trillboards/models/ppe/model.onnx")
|
|
66
|
+
|
|
67
|
+
def test_a_default_model_with_no_committed_vocabulary_is_refused(self):
|
|
68
|
+
"""A name with no class list is a model nothing can label — `yolov8_nano`
|
|
69
|
+
exactly, and it must fail at start rather than in a venue."""
|
|
70
|
+
with self.assertRaisesRegex(ValueError, "no committed vocabulary"):
|
|
71
|
+
build_catalogue("yolov8_nano", None, (RFDETR_MEDIUM,), {"HOME": "/h"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@unittest.skipUnless(HAVE_RUNTIME, "onnxruntime/numpy/Pillow are not installed")
|
|
75
|
+
class TestHealthIsOneRead(unittest.TestCase):
|
|
76
|
+
def test_info_takes_exactly_one_snapshot(self):
|
|
77
|
+
"""`/health` must describe ONE moment.
|
|
78
|
+
|
|
79
|
+
Reading the top-level `state` from a second snapshot lets the watcher
|
|
80
|
+
thread move between the two, so the payload could answer
|
|
81
|
+
`state: "ready"` next to a `models` map showing the pinned model still
|
|
82
|
+
loading. Neither half would be wrong and the pair would describe no
|
|
83
|
+
moment — the same shape `postgres-tls-uri-override.md` records for two
|
|
84
|
+
scans a minute apart.
|
|
85
|
+
"""
|
|
86
|
+
loader = DetectorLoader(
|
|
87
|
+
model_path="/nonexistent/model.onnx",
|
|
88
|
+
model_name="rfdetr_medium",
|
|
89
|
+
vocabularies=(RFDETR_MEDIUM, PPE),
|
|
90
|
+
log=lambda _m: None,
|
|
91
|
+
)
|
|
92
|
+
real = loader.registry.snapshot
|
|
93
|
+
calls = []
|
|
94
|
+
|
|
95
|
+
def counting():
|
|
96
|
+
calls.append(1)
|
|
97
|
+
return real()
|
|
98
|
+
|
|
99
|
+
loader.registry.snapshot = counting
|
|
100
|
+
payload = loader.info()
|
|
101
|
+
self.assertEqual(len(calls), 1, "`/health` read the registry twice; it must describe one moment")
|
|
102
|
+
# And it is still the payload every existing reader parses.
|
|
103
|
+
self.assertEqual(payload["model"], "rfdetr_medium")
|
|
104
|
+
self.assertEqual(sorted(payload["models"]), ["ppe", "rfdetr_medium"])
|
|
105
|
+
self.assertEqual(payload["resident_models"], [])
|
|
106
|
+
|
|
107
|
+
def test_before_the_first_stat_the_answer_is_loading_and_names_no_reason(self):
|
|
108
|
+
"""`absent` is a CLAIM about the disk, and at construction nobody has
|
|
109
|
+
looked. The service starts before its watcher thread does, so a probe
|
|
110
|
+
can land in that window — and the single-model loader this replaced
|
|
111
|
+
correctly said `loading` there. Reporting `absent` for a file we have not
|
|
112
|
+
yet stat'd is a fact asserted without evidence, which is the failure
|
|
113
|
+
shape this whole service is written against."""
|
|
114
|
+
loader = DetectorLoader(
|
|
115
|
+
model_path="/nonexistent/model.onnx",
|
|
116
|
+
model_name="rfdetr_medium",
|
|
117
|
+
vocabularies=(RFDETR_MEDIUM, PPE),
|
|
118
|
+
log=lambda _m: None,
|
|
119
|
+
)
|
|
120
|
+
payload = loader.info()
|
|
121
|
+
self.assertEqual(payload["state"], "loading")
|
|
122
|
+
self.assertNotIn("reason", payload)
|
|
123
|
+
|
|
124
|
+
def test_after_the_first_stat_a_missing_artifact_is_unavailable_WITH_a_reason(self):
|
|
125
|
+
"""And once we HAVE looked, the answer must carry why — the OTA has not
|
|
126
|
+
landed, which is a different problem from a corrupt artifact and the one
|
|
127
|
+
the installers' post-install probes print."""
|
|
128
|
+
loader = DetectorLoader(
|
|
129
|
+
model_path="/nonexistent/model.onnx",
|
|
130
|
+
model_name="rfdetr_medium",
|
|
131
|
+
vocabularies=(RFDETR_MEDIUM, PPE),
|
|
132
|
+
log=lambda _m: None,
|
|
133
|
+
)
|
|
134
|
+
loader.registry.refresh()
|
|
135
|
+
payload = loader.info()
|
|
136
|
+
self.assertEqual(payload["state"], "unavailable")
|
|
137
|
+
self.assertIn("arrives over the air", payload["reason"])
|
|
138
|
+
self.assertEqual(payload["models"]["rfdetr_medium"]["state"], "absent")
|
|
139
|
+
|
|
140
|
+
def test_the_ceiling_state_and_the_eviction_count_reach_the_payload(self):
|
|
141
|
+
"""The registry knowing a fact is not the same as `/health` carrying it.
|
|
142
|
+
|
|
143
|
+
Both of these were computed in `snapshot()` and dropped on the floor by
|
|
144
|
+
`info()` for a commit — the registry was honest and the wire was silent,
|
|
145
|
+
which is the exact shape of a consumer with no producer. A reader has to
|
|
146
|
+
be able to see that NO authority has set a ceiling (distinct from a
|
|
147
|
+
ceiling of some number), and that a box is evicting at all.
|
|
148
|
+
"""
|
|
149
|
+
loader = DetectorLoader(
|
|
150
|
+
model_path="/nonexistent/model.onnx",
|
|
151
|
+
model_name="rfdetr_medium",
|
|
152
|
+
vocabularies=(RFDETR_MEDIUM, PPE),
|
|
153
|
+
log=lambda _m: None,
|
|
154
|
+
)
|
|
155
|
+
payload = loader.info()
|
|
156
|
+
self.assertIn("ram_ceiling_injected", payload)
|
|
157
|
+
self.assertFalse(payload["ram_ceiling_injected"])
|
|
158
|
+
self.assertIsNone(payload["ram_budget_bytes"])
|
|
159
|
+
self.assertEqual(payload["evictions"], 0)
|
|
160
|
+
|
|
161
|
+
told = DetectorLoader(
|
|
162
|
+
model_path="/nonexistent/model.onnx",
|
|
163
|
+
model_name="rfdetr_medium",
|
|
164
|
+
ram_budget_bytes=1024 * 1024 * 1024,
|
|
165
|
+
vocabularies=(RFDETR_MEDIUM, PPE),
|
|
166
|
+
log=lambda _m: None,
|
|
167
|
+
)
|
|
168
|
+
self.assertTrue(told.info()["ram_ceiling_injected"])
|
|
169
|
+
|
|
170
|
+
def test_the_top_level_state_stays_the_three_words_that_shipped(self):
|
|
171
|
+
"""`idle` and `absent` have no top-level equivalent: every existing
|
|
172
|
+
reader — three installers' post-install probes, the integration test —
|
|
173
|
+
was written against ready/loading/unavailable, and
|
|
174
|
+
`LocalDetectorClient.isHealthy()` turns anything but 2xx into "send the
|
|
175
|
+
frame to the VLM"."""
|
|
176
|
+
for pinned, expected in [
|
|
177
|
+
("ready", "ready"),
|
|
178
|
+
("loading", "loading"),
|
|
179
|
+
("idle", "unavailable"),
|
|
180
|
+
("absent", "unavailable"),
|
|
181
|
+
("unavailable", "unavailable"),
|
|
182
|
+
]:
|
|
183
|
+
with self.subTest(pinned=pinned):
|
|
184
|
+
self.assertEqual(DetectorLoader._top_level_state(pinned), expected)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
unittest.main()
|
|
@@ -16,16 +16,25 @@ import unittest
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
18
|
from trillboards_detector.labels import (
|
|
19
|
+
BUILTIN_VOCABULARIES,
|
|
19
20
|
COCO91_CLASS_NAMES,
|
|
20
21
|
EXPECTED_LOGIT_SLOTS,
|
|
21
|
-
|
|
22
|
+
RFDETR_MEDIUM,
|
|
23
|
+
ClassIndex,
|
|
22
24
|
)
|
|
23
25
|
from trillboards_detector.modelpath import (
|
|
24
26
|
DEFAULT_MODEL_NAME,
|
|
25
27
|
resolve_data_dir,
|
|
26
28
|
resolve_model_path,
|
|
29
|
+
resolve_ram_budget_mb,
|
|
27
30
|
)
|
|
28
31
|
|
|
32
|
+
#: The single-model index this file's vocabulary assertions are about. It used
|
|
33
|
+
#: to be a module-level constant (`KNOWN_CLASS_NAMES`), which is exactly what
|
|
34
|
+
#: made a specialist inexpressible; the resolution rules it tests did not
|
|
35
|
+
#: change, only where the vocabulary comes from.
|
|
36
|
+
COCO_INDEX = ClassIndex([RFDETR_MEDIUM])
|
|
37
|
+
|
|
29
38
|
|
|
30
39
|
class TestModelPath(unittest.TestCase):
|
|
31
40
|
def test_home_wins(self):
|
|
@@ -94,15 +103,51 @@ class TestLabelMap(unittest.TestCase):
|
|
|
94
103
|
self.assertEqual(len(names), len(set(names)))
|
|
95
104
|
|
|
96
105
|
def test_requested_classes_fold_case_underscores_and_hyphens(self):
|
|
97
|
-
wanted, unknown =
|
|
106
|
+
wanted, unknown = COCO_INDEX.resolve(["Person", "CELL_PHONE", "dining-table"])
|
|
98
107
|
self.assertEqual(wanted, {"person", "cell phone", "dining table"})
|
|
99
108
|
self.assertEqual(unknown, [])
|
|
100
109
|
|
|
101
110
|
def test_an_unknown_class_is_returned_never_silently_dropped(self):
|
|
102
|
-
wanted, unknown =
|
|
111
|
+
wanted, unknown = COCO_INDEX.resolve(["person", "shoplifter"])
|
|
103
112
|
self.assertEqual(wanted, {"person"})
|
|
104
113
|
self.assertEqual(unknown, ["shoplifter"])
|
|
105
114
|
|
|
115
|
+
def test_every_committed_class_name_is_already_canonical(self):
|
|
116
|
+
"""`_to_wire` puts the DECLARED name on the wire and the filter matches
|
|
117
|
+
the CANONICAL one, so a vocabulary declaring `cell_phone` would emit a
|
|
118
|
+
label the caller's own request had just been folded away from — a class
|
|
119
|
+
that silently matches nothing on a box where everything looks loaded.
|
|
120
|
+
`ModelVocabulary.__post_init__` refuses it at import; this pins that the
|
|
121
|
+
shipped vocabularies satisfy it rather than merely that the check
|
|
122
|
+
exists."""
|
|
123
|
+
for vocabulary in BUILTIN_VOCABULARIES:
|
|
124
|
+
index = ClassIndex([vocabulary])
|
|
125
|
+
for name in vocabulary.class_names:
|
|
126
|
+
with self.subTest(model=vocabulary.model_name, name=name):
|
|
127
|
+
self.assertEqual(index.resolve([name]), ({name}, []))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TestRamBudgetEnv(unittest.TestCase):
|
|
131
|
+
"""The portfolio budget's env override. A bad value must not be ignored.
|
|
132
|
+
|
|
133
|
+
Silently falling back to the default would leave an operator convinced they
|
|
134
|
+
had raised a limit that never moved — an operation reporting success while
|
|
135
|
+
doing nothing, which is the failure shape this tree keeps catching.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def test_absent_means_take_the_committed_default(self):
|
|
139
|
+
self.assertIsNone(resolve_ram_budget_mb({}))
|
|
140
|
+
self.assertIsNone(resolve_ram_budget_mb({"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB": " "}))
|
|
141
|
+
|
|
142
|
+
def test_an_integer_is_read_as_mib(self):
|
|
143
|
+
self.assertEqual(resolve_ram_budget_mb({"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB": "2048"}), 2048)
|
|
144
|
+
|
|
145
|
+
def test_a_malformed_value_raises_rather_than_defaulting(self):
|
|
146
|
+
for bad in ("1.5gb", "2048MB", "-1", "0", "lots"):
|
|
147
|
+
with self.subTest(bad=bad):
|
|
148
|
+
with self.assertRaises(ValueError):
|
|
149
|
+
resolve_ram_budget_mb({"TRILLBOARDS_DETECTOR_RAM_BUDGET_MB": bad})
|
|
150
|
+
|
|
106
151
|
|
|
107
152
|
if __name__ == "__main__":
|
|
108
153
|
unittest.main()
|