@trillboards/edge-sdk 0.2.74 → 0.2.76
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 +133 -0
- package/deploy/detector/bench/verify_against_reference.py +120 -0
- package/deploy/detector/pyproject.toml +49 -0
- package/deploy/detector/tests/__init__.py +0 -0
- package/deploy/detector/tests/test_modelpath.py +108 -0
- package/deploy/detector/tests/test_server.py +577 -0
- package/deploy/detector/tests/test_session.py +242 -0
- package/deploy/detector/trillboards_detector/__init__.py +8 -0
- package/deploy/detector/trillboards_detector/__main__.py +4 -0
- package/deploy/detector/trillboards_detector/cli.py +141 -0
- package/deploy/detector/trillboards_detector/errors.py +16 -0
- package/deploy/detector/trillboards_detector/labels.py +107 -0
- package/deploy/detector/trillboards_detector/loader.py +257 -0
- package/deploy/detector/trillboards_detector/modelpath.py +74 -0
- package/deploy/detector/trillboards_detector/server.py +460 -0
- package/deploy/detector/trillboards_detector/session.py +354 -0
- package/deploy/macos/build-macos-pkg.sh +48 -0
- package/deploy/macos/com.trillboards.detector.plist +61 -0
- package/deploy/macos/scripts/ensure-detector.sh +85 -0
- package/deploy/macos/scripts/postinstall +28 -0
- package/deploy/macos/scripts/setup-detector.sh +185 -0
- package/deploy/systemd/install.sh +35 -0
- package/deploy/systemd/setup-detector.sh +130 -0
- package/deploy/systemd/trillboards-detector.service +54 -0
- package/deploy/windows/build-windows.ps1 +38 -0
- package/deploy/windows/installer.nsi +24 -1
- package/deploy/windows/register-detector-task.ps1 +161 -0
- package/deploy/windows/register-task.ps1 +27 -0
- package/deploy/windows/setup-detector.ps1 +176 -0
- package/deploy/windows/stop-existing.ps1 +1 -0
- package/deploy/windows/trillboards-edge.cmd +25 -0
- package/dist/EdgeAgent.js +1 -1
- package/dist/LocalDetectorClient.d.ts +92 -0
- package/dist/LocalDetectorClient.d.ts.map +1 -0
- package/dist/LocalDetectorClient.js +241 -0
- package/dist/LocalDetectorClient.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/zoneContainment.d.ts +148 -0
- package/dist/zoneContainment.d.ts.map +1 -0
- package/dist/zoneContainment.js +172 -0
- package/dist/zoneContainment.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Post-processing, against hand-built tensors. No model, no ORT session.
|
|
2
|
+
|
|
3
|
+
These are the arithmetic the measured 98.8/98.8 depends on: the top-k selection
|
|
4
|
+
rule, the cxcywh -> pixel conversion, the class-slot lookup. A transcription bug
|
|
5
|
+
in any of them produces detections that are entirely plausible and entirely
|
|
6
|
+
wrong — the shape `docs/runbooks/cctv-detector-tier.md` §5 rule 2 records, where
|
|
7
|
+
a class-index fallback scored BUSES as people for a whole run.
|
|
8
|
+
|
|
9
|
+
Skipped rather than failed without numpy: the wire contract in `test_server.py`
|
|
10
|
+
needs no third-party package at all and is the half that must always run.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import unittest
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from trillboards_detector.session import (
|
|
21
|
+
ModelContractError,
|
|
22
|
+
_read_graph_spec,
|
|
23
|
+
_sigmoid,
|
|
24
|
+
postprocess,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
HAVE_NUMPY = True
|
|
28
|
+
except ImportError: # pragma: no cover - exercised only on a bare interpreter
|
|
29
|
+
HAVE_NUMPY = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _logits(pairs, num_queries=3, num_classes=91):
|
|
33
|
+
"""Build a logit tensor whose sigmoid gives EXACTLY the requested scores."""
|
|
34
|
+
out = np.full((1, num_queries, num_classes), -30.0, dtype=np.float32)
|
|
35
|
+
for (q, c), score in pairs.items():
|
|
36
|
+
out[0, q, c] = np.log(score / (1.0 - score))
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _boxes(rows, num_queries=3):
|
|
41
|
+
out = np.zeros((1, num_queries, 4), dtype=np.float32)
|
|
42
|
+
for q, box in rows.items():
|
|
43
|
+
out[0, q] = box
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
|
|
48
|
+
class TestPostprocess(unittest.TestCase):
|
|
49
|
+
def test_cxcywh_normalised_becomes_pixels_of_the_submitted_frame(self):
|
|
50
|
+
# Centre of a 640x480 frame, half its width and height.
|
|
51
|
+
batch = postprocess(
|
|
52
|
+
_boxes({0: (0.5, 0.5, 0.5, 0.5)}),
|
|
53
|
+
_logits({(0, 1): 0.9}),
|
|
54
|
+
frame_w=640,
|
|
55
|
+
frame_h=480,
|
|
56
|
+
min_confidence=0.25,
|
|
57
|
+
)
|
|
58
|
+
self.assertEqual(len(batch.detections), 1)
|
|
59
|
+
d = batch.detections[0]
|
|
60
|
+
self.assertEqual(d.label, "person")
|
|
61
|
+
self.assertAlmostEqual(d.confidence, 0.9, places=5)
|
|
62
|
+
self.assertAlmostEqual(d.x, 160.0, places=3)
|
|
63
|
+
self.assertAlmostEqual(d.y, 120.0, places=3)
|
|
64
|
+
self.assertAlmostEqual(d.width, 320.0, places=3)
|
|
65
|
+
self.assertAlmostEqual(d.height, 240.0, places=3)
|
|
66
|
+
|
|
67
|
+
def test_a_box_running_off_the_frame_is_clamped_to_it(self):
|
|
68
|
+
batch = postprocess(
|
|
69
|
+
_boxes({0: (0.0, 0.0, 0.5, 0.5)}), # centred on the top-left corner
|
|
70
|
+
_logits({(0, 1): 0.9}),
|
|
71
|
+
frame_w=100,
|
|
72
|
+
frame_h=100,
|
|
73
|
+
min_confidence=0.25,
|
|
74
|
+
)
|
|
75
|
+
d = batch.detections[0]
|
|
76
|
+
self.assertEqual((d.x, d.y), (0.0, 0.0))
|
|
77
|
+
self.assertAlmostEqual(d.width, 25.0, places=3)
|
|
78
|
+
self.assertAlmostEqual(d.height, 25.0, places=3)
|
|
79
|
+
|
|
80
|
+
def test_class_slot_1_is_person_and_slot_3_is_car(self):
|
|
81
|
+
batch = postprocess(
|
|
82
|
+
_boxes({0: (0.5, 0.5, 0.2, 0.2), 1: (0.3, 0.3, 0.2, 0.2)}),
|
|
83
|
+
_logits({(0, 1): 0.9, (1, 3): 0.8}),
|
|
84
|
+
frame_w=200,
|
|
85
|
+
frame_h=200,
|
|
86
|
+
min_confidence=0.25,
|
|
87
|
+
)
|
|
88
|
+
self.assertEqual(sorted(d.label for d in batch.detections), ["car", "person"])
|
|
89
|
+
|
|
90
|
+
def test_below_threshold_is_dropped_strictly_greater_than(self):
|
|
91
|
+
"""`PostProcess` filters `scores > threshold`. Exactly on the threshold
|
|
92
|
+
is OUT, and that has to match: the operating point in the runbook was
|
|
93
|
+
chosen against this comparison, not a >=."""
|
|
94
|
+
args = (_boxes({0: (0.5, 0.5, 0.2, 0.2)}), _logits({(0, 1): 0.5}), 100, 100)
|
|
95
|
+
self.assertEqual(len(postprocess(*args, min_confidence=0.4).detections), 1)
|
|
96
|
+
self.assertEqual(len(postprocess(*args, min_confidence=0.5).detections), 0)
|
|
97
|
+
|
|
98
|
+
def test_an_unnamed_class_slot_is_dropped_and_counted_never_labelled(self):
|
|
99
|
+
"""Eleven of the ninety-one slots are COCO id gaps. Slot 12 is one.
|
|
100
|
+
|
|
101
|
+
Naming it anything would be exactly the fabrication the runbook forbids,
|
|
102
|
+
and dropping it silently is the failure this whole stack keeps hitting.
|
|
103
|
+
So: dropped, and counted where `/health` publishes it.
|
|
104
|
+
"""
|
|
105
|
+
batch = postprocess(
|
|
106
|
+
_boxes({0: (0.5, 0.5, 0.2, 0.2)}),
|
|
107
|
+
_logits({(0, 12): 0.9}),
|
|
108
|
+
frame_w=100,
|
|
109
|
+
frame_h=100,
|
|
110
|
+
min_confidence=0.25,
|
|
111
|
+
)
|
|
112
|
+
self.assertEqual(batch.detections, [])
|
|
113
|
+
self.assertEqual(batch.unnamed_class_dropped, 1)
|
|
114
|
+
|
|
115
|
+
def test_a_zero_extent_box_is_dropped_and_counted(self):
|
|
116
|
+
"""A box with no width covers no pixels and would make the CLIENT reject
|
|
117
|
+
the entire frame's result. Dropping it costs one artifact; emitting it
|
|
118
|
+
costs every other detection in the frame."""
|
|
119
|
+
batch = postprocess(
|
|
120
|
+
_boxes({0: (0.5, 0.5, 0.0, 0.4), 1: (0.5, 0.5, 0.4, 0.4)}),
|
|
121
|
+
_logits({(0, 1): 0.9, (1, 1): 0.8}),
|
|
122
|
+
frame_w=100,
|
|
123
|
+
frame_h=100,
|
|
124
|
+
min_confidence=0.25,
|
|
125
|
+
)
|
|
126
|
+
self.assertEqual(len(batch.detections), 1)
|
|
127
|
+
self.assertEqual(batch.degenerate_dropped, 1)
|
|
128
|
+
|
|
129
|
+
def test_the_class_filter_runs_at_the_service(self):
|
|
130
|
+
args = (
|
|
131
|
+
_boxes({0: (0.5, 0.5, 0.2, 0.2), 1: (0.3, 0.3, 0.2, 0.2)}),
|
|
132
|
+
_logits({(0, 1): 0.9, (1, 3): 0.8}),
|
|
133
|
+
100,
|
|
134
|
+
100,
|
|
135
|
+
0.25,
|
|
136
|
+
)
|
|
137
|
+
self.assertEqual([d.label for d in postprocess(*args, wanted={"car"}).detections], ["car"])
|
|
138
|
+
self.assertEqual(len(postprocess(*args, wanted=None).detections), 2)
|
|
139
|
+
|
|
140
|
+
def test_selection_keeps_the_top_300_pairs_before_thresholding(self):
|
|
141
|
+
"""`num_select=300` is applied FIRST, exactly as upstream does.
|
|
142
|
+
|
|
143
|
+
Thresholding first would return rows the measured arm never saw on a
|
|
144
|
+
busy frame — a difference invisible on an empty one and decisive on a
|
|
145
|
+
crowded one.
|
|
146
|
+
"""
|
|
147
|
+
# 400 queries each with one class above the threshold: only 300 survive.
|
|
148
|
+
scores = {(q, 1): 0.9 for q in range(400)}
|
|
149
|
+
batch = postprocess(
|
|
150
|
+
_boxes({q: (0.5, 0.5, 0.2, 0.2) for q in range(400)}, num_queries=400),
|
|
151
|
+
_logits(scores, num_queries=400),
|
|
152
|
+
frame_w=100,
|
|
153
|
+
frame_h=100,
|
|
154
|
+
min_confidence=0.25,
|
|
155
|
+
)
|
|
156
|
+
self.assertEqual(len(batch.detections), 300)
|
|
157
|
+
|
|
158
|
+
def test_confidences_are_always_inside_the_unit_interval(self):
|
|
159
|
+
rng = np.random.default_rng(7)
|
|
160
|
+
logits = rng.normal(0.0, 40.0, size=(1, 300, 91)).astype(np.float32)
|
|
161
|
+
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)
|
|
163
|
+
for d in batch.detections:
|
|
164
|
+
self.assertGreaterEqual(d.confidence, 0.0)
|
|
165
|
+
self.assertLessEqual(d.confidence, 1.0)
|
|
166
|
+
|
|
167
|
+
def test_sigmoid_does_not_overflow_on_a_large_negative_logit(self):
|
|
168
|
+
out = _sigmoid(np.array([-800.0, 800.0, 0.0], dtype=np.float32))
|
|
169
|
+
self.assertTrue(np.all(np.isfinite(out)))
|
|
170
|
+
self.assertAlmostEqual(float(out[2]), 0.5, places=6)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _FakeIo:
|
|
174
|
+
def __init__(self, name, shape):
|
|
175
|
+
self.name = name
|
|
176
|
+
self.shape = shape
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _FakeSession:
|
|
180
|
+
def __init__(self, inputs, outputs):
|
|
181
|
+
self._inputs = inputs
|
|
182
|
+
self._outputs = outputs
|
|
183
|
+
|
|
184
|
+
def get_inputs(self):
|
|
185
|
+
return self._inputs
|
|
186
|
+
|
|
187
|
+
def get_outputs(self):
|
|
188
|
+
return self._outputs
|
|
189
|
+
|
|
190
|
+
def get_providers(self):
|
|
191
|
+
return ["CPUExecutionProvider"]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@unittest.skipUnless(HAVE_NUMPY, "numpy is not installed")
|
|
195
|
+
class TestGraphContract(unittest.TestCase):
|
|
196
|
+
"""A graph we cannot label must take the service UNHEALTHY, not relabel boxes.
|
|
197
|
+
|
|
198
|
+
An off-by-one on the class axis renames every object in the venue and every
|
|
199
|
+
number stays plausible — the `yolov8_nano` failure with the sign flipped:
|
|
200
|
+
instead of a model offered and never loadable, a model loaded and never
|
|
201
|
+
right.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
GOOD_IN = [_FakeIo("input", [1, 3, 576, 576])]
|
|
205
|
+
GOOD_OUT = [_FakeIo("dets", [1, 300, 4]), _FakeIo("labels", [1, 300, 91])]
|
|
206
|
+
|
|
207
|
+
def test_accepts_the_shipped_graph(self):
|
|
208
|
+
spec = _read_graph_spec(_FakeSession(self.GOOD_IN, self.GOOD_OUT))
|
|
209
|
+
self.assertEqual((spec.width, spec.height), (576, 576))
|
|
210
|
+
self.assertEqual(spec.num_classes, 91)
|
|
211
|
+
self.assertEqual(spec.input_name, "input")
|
|
212
|
+
|
|
213
|
+
def test_refuses_a_class_axis_the_label_map_does_not_describe(self):
|
|
214
|
+
out = [_FakeIo("dets", [1, 300, 4]), _FakeIo("labels", [1, 300, 80])]
|
|
215
|
+
with self.assertRaisesRegex(ModelContractError, "80 class slots"):
|
|
216
|
+
_read_graph_spec(_FakeSession(self.GOOD_IN, out))
|
|
217
|
+
|
|
218
|
+
def test_refuses_a_dynamic_resolution(self):
|
|
219
|
+
"""imgsz is one of the two numbers a detector arm is meaningless
|
|
220
|
+
without (§5 rule 1). A dynamic axis means it is not recoverable from
|
|
221
|
+
the artifact at all."""
|
|
222
|
+
dyn = [_FakeIo("input", [1, 3, "height", "width"])]
|
|
223
|
+
with self.assertRaisesRegex(ModelContractError, "fixed input resolution"):
|
|
224
|
+
_read_graph_spec(_FakeSession(dyn, self.GOOD_OUT))
|
|
225
|
+
|
|
226
|
+
def test_refuses_unexpected_output_names(self):
|
|
227
|
+
out = [_FakeIo("boxes", [1, 300, 4]), _FakeIo("scores", [1, 300, 91])]
|
|
228
|
+
with self.assertRaisesRegex(ModelContractError, "dets"):
|
|
229
|
+
_read_graph_spec(_FakeSession(self.GOOD_IN, out))
|
|
230
|
+
|
|
231
|
+
def test_refuses_a_multi_input_graph(self):
|
|
232
|
+
"""An open-vocabulary model would carry a text input. Per the runbook
|
|
233
|
+
those do not survive ONNX export at all — one exported artifact is one
|
|
234
|
+
class list, permanently — so a second input means this is not the model
|
|
235
|
+
the label map belongs to."""
|
|
236
|
+
two = [_FakeIo("images", [1, 3, 576, 576]), _FakeIo("text", [1, 512])]
|
|
237
|
+
with self.assertRaisesRegex(ModelContractError, "one graph input"):
|
|
238
|
+
_read_graph_spec(_FakeSession(two, self.GOOD_OUT))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
unittest.main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""On-device object detector for the Trillboards BYO-CCTV connector.
|
|
2
|
+
|
|
3
|
+
Its own uv tool, its own venv, its own OS-supervised loopback service on
|
|
4
|
+
127.0.0.1:9380 — see `LocalDetectorClient.ts` for why all three, and
|
|
5
|
+
`docs/runbooks/cctv-detector-tier.md` for what it runs and why that model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""`trillboards-detector serve` — the entrypoint the OS supervisors run.
|
|
2
|
+
|
|
3
|
+
NOTHING SPAWNS THIS FROM NODE. `LocalDetectorClient` is an HTTP client and
|
|
4
|
+
nothing else: a launchd LaunchAgent, a systemd unit and a Windows Scheduled Task
|
|
5
|
+
own the process, exactly as they do for `litert-lm serve`. That is deliberate —
|
|
6
|
+
the connector must be able to come back from a crash without also being
|
|
7
|
+
responsible for a Python runtime it did not install.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import logging
|
|
14
|
+
import logging.handlers
|
|
15
|
+
import signal
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .loader import DEFAULT_POLL_INTERVAL_S, DetectorLoader
|
|
21
|
+
from .modelpath import DEFAULT_MODEL_NAME, resolve_model_path
|
|
22
|
+
from .server import DEFAULT_HOST, DEFAULT_PORT, serve
|
|
23
|
+
|
|
24
|
+
LOG = logging.getLogger("trillboards_detector")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _configure_logging(log_file: "str | None", verbose: bool) -> None:
|
|
28
|
+
"""stderr always; a file only when asked.
|
|
29
|
+
|
|
30
|
+
THE FILE OPTION EXISTS FOR WINDOWS AND ONLY FOR WINDOWS. A Windows Scheduled
|
|
31
|
+
Task has no stdout, so `litert-lm serve`'s own log is discarded on that
|
|
32
|
+
platform and nowhere else — which is why the 2026-08-19 windows-latest
|
|
33
|
+
failure is STILL undiagnosed (`docs/runbooks/cctv-local-inference-hardware-floor.md`
|
|
34
|
+
§Windows: "the one process that knows why wrote its explanation to a handle
|
|
35
|
+
nobody was holding"). litert-lm cannot be fixed from here — it takes no
|
|
36
|
+
--log-file and wrapping it in cmd.exe breaks the stop path's image-name
|
|
37
|
+
filter. This service is OURS, so it simply writes the file itself.
|
|
38
|
+
|
|
39
|
+
ROTATED, because macOS has no log rotation for this connector at all
|
|
40
|
+
(`docs/runbooks/cctv-connector-runtime-edge-cases.md`) and an always-on
|
|
41
|
+
service on a box nobody visits must not be able to fill a disk that has no
|
|
42
|
+
space preflight anywhere on the model path.
|
|
43
|
+
"""
|
|
44
|
+
LOG.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
45
|
+
fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
|
|
46
|
+
|
|
47
|
+
stream = logging.StreamHandler(sys.stderr)
|
|
48
|
+
stream.setFormatter(fmt)
|
|
49
|
+
LOG.addHandler(stream)
|
|
50
|
+
|
|
51
|
+
if log_file:
|
|
52
|
+
path = Path(log_file)
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
rotating = logging.handlers.RotatingFileHandler(
|
|
55
|
+
path, maxBytes=8 * 1024 * 1024, backupCount=2, encoding="utf-8"
|
|
56
|
+
)
|
|
57
|
+
rotating.setFormatter(fmt)
|
|
58
|
+
LOG.addHandler(rotating)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
62
|
+
p = argparse.ArgumentParser(prog="trillboards-detector")
|
|
63
|
+
sub = p.add_subparsers(dest="command")
|
|
64
|
+
s = sub.add_parser("serve", help="run the loopback detector service")
|
|
65
|
+
s.add_argument("--host", default=DEFAULT_HOST)
|
|
66
|
+
s.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
67
|
+
s.add_argument("--model", default=None, help="path to the ONNX artifact (overrides the default location)")
|
|
68
|
+
s.add_argument("--model-name", default=DEFAULT_MODEL_NAME)
|
|
69
|
+
s.add_argument(
|
|
70
|
+
"--threads",
|
|
71
|
+
type=int,
|
|
72
|
+
default=None,
|
|
73
|
+
help=(
|
|
74
|
+
"ORT intra-op threads. LEAVE UNSET. cctv-detector-tier.md §2 measured "
|
|
75
|
+
"threads as non-monotonic — 16 (99.9 ms) is slower than 6 (78-83) and "
|
|
76
|
+
"slower than ORT's own auto heuristic (63.9) — so 'use all cores' is "
|
|
77
|
+
"wrong and this exists only to reproduce a bench arm."
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
s.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL_S)
|
|
81
|
+
s.add_argument("--log-file", default=None, help="also write to this file (Windows has no task stdout)")
|
|
82
|
+
s.add_argument("--verbose", action="store_true")
|
|
83
|
+
return p
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
87
|
+
args = build_parser().parse_args(argv)
|
|
88
|
+
if args.command != "serve":
|
|
89
|
+
build_parser().print_help()
|
|
90
|
+
return 2
|
|
91
|
+
|
|
92
|
+
_configure_logging(args.log_file, args.verbose)
|
|
93
|
+
|
|
94
|
+
model_path = Path(args.model) if args.model else resolve_model_path(args.model_name)
|
|
95
|
+
LOG.info("[detector] starting on %s:%s, model %s", args.host, args.port, model_path)
|
|
96
|
+
|
|
97
|
+
loader = DetectorLoader(
|
|
98
|
+
model_path=model_path,
|
|
99
|
+
model_name=args.model_name,
|
|
100
|
+
intra_op_threads=args.threads,
|
|
101
|
+
poll_interval_s=args.poll_interval,
|
|
102
|
+
log=LOG.info,
|
|
103
|
+
)
|
|
104
|
+
loader.start()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
server = serve(loader, args.host, args.port, LOG.info)
|
|
108
|
+
except OSError as err:
|
|
109
|
+
# A bind failure is FATAL and must stay fatal. `register-litertlm-task.ps1`
|
|
110
|
+
# has the incident this mirrors: a serve left alive keeps ANSWERING, so an
|
|
111
|
+
# upgrade installs the new binary, fails to bind, and leaves the OLD one
|
|
112
|
+
# serving while every health probe passes. Exiting lets the supervisor's
|
|
113
|
+
# restart policy do its job instead of running two half-services.
|
|
114
|
+
LOG.error("[detector] could not bind %s:%s — %s", args.host, args.port, err)
|
|
115
|
+
loader.stop()
|
|
116
|
+
return 1
|
|
117
|
+
|
|
118
|
+
stop = threading.Event()
|
|
119
|
+
|
|
120
|
+
def _shutdown(signum, _frame):
|
|
121
|
+
LOG.info("[detector] signal %s — shutting down", signum)
|
|
122
|
+
stop.set()
|
|
123
|
+
|
|
124
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
125
|
+
try:
|
|
126
|
+
signal.signal(sig, _shutdown)
|
|
127
|
+
except (ValueError, OSError): # not available on every platform/thread
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
LOG.info("[detector] listening on http://%s:%s", args.host, args.port)
|
|
131
|
+
try:
|
|
132
|
+
stop.wait()
|
|
133
|
+
except KeyboardInterrupt:
|
|
134
|
+
pass
|
|
135
|
+
server.shutdown()
|
|
136
|
+
loader.stop()
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__": # pragma: no cover
|
|
141
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Errors shared by the wire layer and the model layer.
|
|
2
|
+
|
|
3
|
+
Its own module, with NO third-party imports, so `server.py` stays importable —
|
|
4
|
+
and therefore unit-testable against a stub — on a machine with no onnxruntime,
|
|
5
|
+
no numpy and no Pillow. The wire contract is the load-bearing half of this
|
|
6
|
+
service; it must not need a 128 MiB model or a 50 MB runtime to be tested.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DetectorNotReady(RuntimeError):
|
|
11
|
+
"""No usable session. The server turns this into a 503, never into `[]`.
|
|
12
|
+
|
|
13
|
+
The distinction is the entire reason `LocalDetectorClient` exists: a cascade
|
|
14
|
+
that reads "detector down" as "nobody there" suppresses every alert while
|
|
15
|
+
every health surface stays green.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The class vocabulary — COMMITTED, never inferred from the artifact.
|
|
2
|
+
|
|
3
|
+
WHY THIS FILE IS A CONSTANT AND NOT A LOOKUP
|
|
4
|
+
--------------------------------------------
|
|
5
|
+
The exported RF-DETR graph carries NO class metadata: its
|
|
6
|
+
``custom_metadata_map`` is empty and its only outputs are ``dets`` and
|
|
7
|
+
``labels``. So there is nothing on disk to read the names from, and the
|
|
8
|
+
alternative to committing them is guessing an index — which
|
|
9
|
+
``docs/runbooks/cctv-detector-tier.md`` §5 rule 2 forbids by name, because a
|
|
10
|
+
fallback to index 0 once scored BUSES as people for an entire eval run and the
|
|
11
|
+
resulting table looked entirely plausible.
|
|
12
|
+
|
|
13
|
+
This map is not hand-typed. It is what the `rfdetr` package itself computes for
|
|
14
|
+
a pretrained COCO checkpoint, at `rfdetr/detr.py`::
|
|
15
|
+
|
|
16
|
+
_class_id_to_name = {coco_id: model_class_names[i]
|
|
17
|
+
for i, coco_id in enumerate(COCO_CLASSES) if i < n}
|
|
18
|
+
|
|
19
|
+
`COCO_CLASSES` is the SPARSE list of 80 COCO category ids and
|
|
20
|
+
`COCO_CLASS_NAMES` the 80 flat names. The logit axis is 91 wide because COCO's
|
|
21
|
+
category ids run 1..90 WITH GAPS — so eleven of the ninety-one slots name
|
|
22
|
+
nothing at all, and slot 0 is background.
|
|
23
|
+
|
|
24
|
+
THE ELEVEN UNNAMED SLOTS ARE DROPPED, NOT LABELLED.
|
|
25
|
+
A detection whose argmax lands on an unnamed slot cannot be given a name
|
|
26
|
+
without inventing one, and inventing one is precisely the failure above. It is
|
|
27
|
+
dropped and COUNTED (`unnamed_class_drops` on /health), because a drop nobody
|
|
28
|
+
can see is the other failure this codebase keeps hitting — an operation that
|
|
29
|
+
reports success while doing nothing.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from types import MappingProxyType
|
|
35
|
+
|
|
36
|
+
# Slot index (the position on the model's 91-wide logit axis) -> class name.
|
|
37
|
+
# Slot index IS the COCO category id here; the gaps are real and deliberate.
|
|
38
|
+
COCO91_CLASS_NAMES: "MappingProxyType[int, str]" = MappingProxyType({
|
|
39
|
+
1: "person", 2: "bicycle", 3: "car", 4: "motorcycle", 5: "airplane",
|
|
40
|
+
6: "bus", 7: "train", 8: "truck", 9: "boat", 10: "traffic light",
|
|
41
|
+
11: "fire hydrant", 13: "stop sign", 14: "parking meter", 15: "bench",
|
|
42
|
+
16: "bird", 17: "cat", 18: "dog", 19: "horse", 20: "sheep", 21: "cow",
|
|
43
|
+
22: "elephant", 23: "bear", 24: "zebra", 25: "giraffe", 27: "backpack",
|
|
44
|
+
28: "umbrella", 31: "handbag", 32: "tie", 33: "suitcase", 34: "frisbee",
|
|
45
|
+
35: "skis", 36: "snowboard", 37: "sports ball", 38: "kite",
|
|
46
|
+
39: "baseball bat", 40: "baseball glove", 41: "skateboard",
|
|
47
|
+
42: "surfboard", 43: "tennis racket", 44: "bottle", 46: "wine glass",
|
|
48
|
+
47: "cup", 48: "fork", 49: "knife", 50: "spoon", 51: "bowl", 52: "banana",
|
|
49
|
+
53: "apple", 54: "sandwich", 55: "orange", 56: "broccoli", 57: "carrot",
|
|
50
|
+
58: "hot dog", 59: "pizza", 60: "donut", 61: "cake", 62: "chair",
|
|
51
|
+
63: "couch", 64: "potted plant", 65: "bed", 67: "dining table",
|
|
52
|
+
70: "toilet", 72: "tv", 73: "laptop", 74: "mouse", 75: "remote",
|
|
53
|
+
76: "keyboard", 77: "cell phone", 78: "microwave", 79: "oven",
|
|
54
|
+
80: "toaster", 81: "sink", 82: "refrigerator", 84: "book", 85: "clock",
|
|
55
|
+
86: "vase", 87: "scissors", 88: "teddy bear", 89: "hair drier",
|
|
56
|
+
90: "toothbrush",
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
# 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 the service UNHEALTHY
|
|
61
|
+
# rather than relabelling every box by a constant offset. That is the whole
|
|
62
|
+
# point: an off-by-one on this axis is invisible downstream, it just renames
|
|
63
|
+
# every object in the venue, and the resulting numbers stay plausible.
|
|
64
|
+
EXPECTED_LOGIT_SLOTS = 91
|
|
65
|
+
|
|
66
|
+
# The one label the cascade actually gates on. Asserted at import so a careless
|
|
67
|
+
# edit to the map above fails at start rather than at the first empty venue.
|
|
68
|
+
PERSON_SLOT = 1
|
|
69
|
+
assert COCO91_CLASS_NAMES[PERSON_SLOT] == "person"
|
|
70
|
+
assert max(COCO91_CLASS_NAMES) < EXPECTED_LOGIT_SLOTS
|
|
71
|
+
|
|
72
|
+
#: Canonical names, for validating a caller's `classes` filter.
|
|
73
|
+
KNOWN_CLASS_NAMES = frozenset(COCO91_CLASS_NAMES.values())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_class_name(name: str) -> str:
|
|
77
|
+
"""Fold a requested class name to its canonical form.
|
|
78
|
+
|
|
79
|
+
`sensing_profiles.classes` is written by an LLM from a COCO vocabulary, so
|
|
80
|
+
it arrives as prose-ish text: 'Person', 'cell_phone', 'dining-table'. The
|
|
81
|
+
canonical name is what goes back on the wire regardless, so the connector
|
|
82
|
+
and the eval harness always see one spelling.
|
|
83
|
+
"""
|
|
84
|
+
return name.strip().lower().replace("_", " ").replace("-", " ")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_CANONICAL_BY_NORMALIZED = {normalize_class_name(v): v for v in COCO91_CLASS_NAMES.values()}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_requested_classes(requested: list[str]) -> tuple[set[str], list[str]]:
|
|
91
|
+
"""Map caller-supplied class names onto canonical ones.
|
|
92
|
+
|
|
93
|
+
Returns ``(canonical_names, unknown_inputs)``. The caller decides what an
|
|
94
|
+
unknown means; this function never silently discards one, because a profile
|
|
95
|
+
naming a class the model cannot emit is exactly the `yolov8_nano` shape —
|
|
96
|
+
offered to profile authors everywhere, loadable nowhere, and ~0% for six
|
|
97
|
+
months while every surface looked fine.
|
|
98
|
+
"""
|
|
99
|
+
canonical: set[str] = set()
|
|
100
|
+
unknown: list[str] = []
|
|
101
|
+
for raw in requested:
|
|
102
|
+
hit = _CANONICAL_BY_NORMALIZED.get(normalize_class_name(raw))
|
|
103
|
+
if hit is None:
|
|
104
|
+
unknown.append(raw)
|
|
105
|
+
else:
|
|
106
|
+
canonical.add(hit)
|
|
107
|
+
return canonical, unknown
|