@trillboards/edge-sdk 0.2.73 → 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.
Files changed (54) hide show
  1. package/deploy/detector/README.md +133 -0
  2. package/deploy/detector/bench/verify_against_reference.py +120 -0
  3. package/deploy/detector/pyproject.toml +49 -0
  4. package/deploy/detector/tests/__init__.py +0 -0
  5. package/deploy/detector/tests/test_modelpath.py +108 -0
  6. package/deploy/detector/tests/test_server.py +577 -0
  7. package/deploy/detector/tests/test_session.py +242 -0
  8. package/deploy/detector/trillboards_detector/__init__.py +8 -0
  9. package/deploy/detector/trillboards_detector/__main__.py +4 -0
  10. package/deploy/detector/trillboards_detector/cli.py +141 -0
  11. package/deploy/detector/trillboards_detector/errors.py +16 -0
  12. package/deploy/detector/trillboards_detector/labels.py +107 -0
  13. package/deploy/detector/trillboards_detector/loader.py +257 -0
  14. package/deploy/detector/trillboards_detector/modelpath.py +74 -0
  15. package/deploy/detector/trillboards_detector/server.py +460 -0
  16. package/deploy/detector/trillboards_detector/session.py +354 -0
  17. package/deploy/macos/build-macos-pkg.sh +48 -0
  18. package/deploy/macos/com.trillboards.detector.plist +61 -0
  19. package/deploy/macos/scripts/ensure-detector.sh +85 -0
  20. package/deploy/macos/scripts/postinstall +28 -0
  21. package/deploy/macos/scripts/setup-detector.sh +185 -0
  22. package/deploy/systemd/install.sh +35 -0
  23. package/deploy/systemd/setup-detector.sh +130 -0
  24. package/deploy/systemd/trillboards-detector.service +54 -0
  25. package/deploy/windows/build-windows.ps1 +38 -0
  26. package/deploy/windows/installer.nsi +24 -1
  27. package/deploy/windows/register-detector-task.ps1 +161 -0
  28. package/deploy/windows/register-task.ps1 +27 -0
  29. package/deploy/windows/setup-detector.ps1 +176 -0
  30. package/deploy/windows/stop-existing.ps1 +1 -0
  31. package/deploy/windows/trillboards-edge.cmd +25 -0
  32. package/dist/AudienceAnalyzeClient.d.ts +6 -0
  33. package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
  34. package/dist/AudienceAnalyzeClient.js +14 -0
  35. package/dist/AudienceAnalyzeClient.js.map +1 -1
  36. package/dist/EdgeAgent.js +1 -1
  37. package/dist/LocalDetectorClient.d.ts +92 -0
  38. package/dist/LocalDetectorClient.d.ts.map +1 -0
  39. package/dist/LocalDetectorClient.js +241 -0
  40. package/dist/LocalDetectorClient.js.map +1 -0
  41. package/dist/diagnose.d.ts +1 -1
  42. package/dist/diagnose.js +1 -1
  43. package/dist/index.d.ts +4 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +17 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/localSensing.d.ts.map +1 -1
  48. package/dist/localSensing.js +6 -0
  49. package/dist/localSensing.js.map +1 -1
  50. package/dist/zoneContainment.d.ts +148 -0
  51. package/dist/zoneContainment.d.ts.map +1 -0
  52. package/dist/zoneContainment.js +172 -0
  53. package/dist/zoneContainment.js.map +1 -0
  54. 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,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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