@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,257 @@
1
+ """Model lifecycle: load it, prove it, watch for the OTA, and say which.
2
+
3
+ WHY LOADING IS A BACKGROUND STATE MACHINE AND NOT A STARTUP STEP
4
+ ---------------------------------------------------------------
5
+ Three constraints, none optional:
6
+
7
+ 1. THE ARTIFACT MAY NOT BE THERE YET. It is OTA'd, so on a fresh install the
8
+ supervisor starts this service before edge-core's `ModelManager` has fetched
9
+ anything. A service that exits when the file is missing gets restarted
10
+ forever by launchd/systemd/Task Scheduler — the crash-loop each of those
11
+ definitions has a ThrottleInterval for. It must START and report UNHEALTHY.
12
+
13
+ 2. THE ARTIFACT ARRIVES LATER, WITHOUT ANYONE RESTARTING US. `ModelManager`
14
+ writes `.tmp` then renames. Nothing tells this process. So the file is
15
+ polled, and a size/mtime change reloads the session — the same problem
16
+ `gemmaModelOta.ts` solves by restarting `litert-lm serve` on `modelUpdated`,
17
+ solved here without needing the connector to reach in.
18
+
19
+ 3. HEALTH MUST REFLECT A LOAD THAT ACTUALLY HAPPENED. This codebase has been
20
+ burned twice by the opposite: `/v1/models` answers 200 without ever building
21
+ an engine, so a UTF-8 BOM in litert-lm's config.json 500'd every frame on
22
+ every Windows connector while the installer's health check AND its
23
+ CPU-fallback re-check both passed. `docs/runbooks/cctv-connector-runtime-edge-cases.md`
24
+ is a catalogue of the same shape. So the load runs a REAL forward pass on a
25
+ synthetic frame before declaring ready, and `/health` reports that outcome
26
+ rather than re-deriving anything — which is also how `/health` stays cheap
27
+ and inference-free.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import io
33
+ import threading
34
+ import time
35
+ from pathlib import Path
36
+
37
+ from .errors import DetectorNotReady
38
+ from .session import DetectionBatch, ModelContractError, OnnxDetector
39
+
40
+ STATE_LOADING = "loading"
41
+ STATE_READY = "ready"
42
+ STATE_UNAVAILABLE = "unavailable"
43
+
44
+ DEFAULT_POLL_INTERVAL_S = 30.0
45
+
46
+
47
+ def _warmup_jpeg() -> bytes:
48
+ """A tiny real JPEG. Small enough to be free, real enough to decode.
49
+
50
+ Mirrors `setup-litertlm.sh`'s inline probe image, for the same reason it
51
+ exists there: a capability is established by ATTEMPTING THE OPERATION, and
52
+ a file that merely exists proves nothing.
53
+ """
54
+ from PIL import Image
55
+
56
+ buf = io.BytesIO()
57
+ Image.new("RGB", (64, 48), (128, 128, 128)).save(buf, format="JPEG", quality=70)
58
+ return buf.getvalue()
59
+
60
+
61
+ class DetectorLoader:
62
+ """Owns the session and the one word that describes it."""
63
+
64
+ def __init__(
65
+ self,
66
+ model_path: Path,
67
+ model_name: str,
68
+ intra_op_threads: "int | None" = None,
69
+ poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
70
+ log=print,
71
+ ):
72
+ self.model_path = model_path
73
+ self.model_name = model_name
74
+ self._intra_op_threads = intra_op_threads
75
+ self._poll_interval_s = poll_interval_s
76
+ self._log = log
77
+
78
+ self._lock = threading.Lock()
79
+ self._detector: "OnnxDetector | None" = None
80
+ self._state = STATE_LOADING
81
+ self._reason: "str | None" = None
82
+ self._stamp: "tuple[int, int] | None" = None
83
+ self._warmup_ms: "float | None" = None
84
+ self._loaded_at: "float | None" = None
85
+
86
+ self._degenerate_dropped = 0
87
+ self._unnamed_class_dropped = 0
88
+ self._detect_count = 0
89
+ self._detect_failures = 0
90
+
91
+ self._stop = threading.Event()
92
+ self._thread = threading.Thread(target=self._run, name="detector-loader", daemon=True)
93
+
94
+ def start(self) -> None:
95
+ self._thread.start()
96
+
97
+ def stop(self) -> None:
98
+ self._stop.set()
99
+
100
+ def join(self, timeout: "float | None" = None) -> None:
101
+ self._thread.join(timeout)
102
+
103
+ # ── what the server reads ────────────────────────────────────────────────
104
+
105
+ @property
106
+ def state(self) -> str:
107
+ with self._lock:
108
+ return self._state
109
+
110
+ @property
111
+ def reason(self) -> "str | None":
112
+ with self._lock:
113
+ return self._reason
114
+
115
+ def info(self) -> dict:
116
+ with self._lock:
117
+ d = self._detector
118
+ payload = {
119
+ "state": self._state,
120
+ "model": self.model_name,
121
+ "model_path": str(self.model_path),
122
+ "detect_count": self._detect_count,
123
+ "detect_failures": self._detect_failures,
124
+ # Published because a drop nobody can see is the failure this
125
+ # whole stack keeps repeating. Both are normally 0.
126
+ "degenerate_boxes_dropped": self._degenerate_dropped,
127
+ "unnamed_class_dropped": self._unnamed_class_dropped,
128
+ }
129
+ if self._reason:
130
+ payload["reason"] = self._reason
131
+ if self._warmup_ms is not None:
132
+ payload["warmup_inference_ms"] = round(self._warmup_ms, 1)
133
+ if self._loaded_at is not None:
134
+ payload["loaded_at"] = self._loaded_at
135
+ if d is not None:
136
+ payload["input"] = f"{d.spec.width}x{d.spec.height}"
137
+ payload["classes"] = d.spec.num_classes
138
+ payload["providers"] = d.spec.providers
139
+ return payload
140
+
141
+ def detect(self, jpeg: bytes, min_confidence: float, wanted: "set[str] | None") -> DetectionBatch:
142
+ with self._lock:
143
+ detector, reason = self._detector, self._reason
144
+ if detector is None:
145
+ raise DetectorNotReady(reason or "model is not loaded")
146
+ try:
147
+ batch = detector.detect(jpeg, min_confidence, wanted)
148
+ except Exception:
149
+ with self._lock:
150
+ self._detect_failures += 1
151
+ raise
152
+ with self._lock:
153
+ self._detect_count += 1
154
+ self._degenerate_dropped += batch.degenerate_dropped
155
+ self._unnamed_class_dropped += batch.unnamed_class_dropped
156
+ if batch.degenerate_dropped or batch.unnamed_class_dropped:
157
+ self._log(
158
+ f"[detector] dropped {batch.degenerate_dropped} zero-extent box(es) and "
159
+ f"{batch.unnamed_class_dropped} unnamed-class detection(s) from one frame"
160
+ )
161
+ return batch
162
+
163
+ # ── the loop ─────────────────────────────────────────────────────────────
164
+
165
+ def _run(self) -> None:
166
+ while not self._stop.is_set():
167
+ try:
168
+ self._tick()
169
+ except Exception as err: # never let the watcher die
170
+ self._set(STATE_UNAVAILABLE, f"loader crashed: {err}")
171
+ self._stop.wait(self._poll_interval_s)
172
+
173
+ def _tick(self) -> None:
174
+ stamp = self._file_stamp()
175
+ if stamp is None:
176
+ if self._state != STATE_UNAVAILABLE or self._detector is not None:
177
+ self._log(f"[detector] model artifact not present at {self.model_path}")
178
+ self._drop(
179
+ f"model artifact not found at {self.model_path}; it arrives over the air "
180
+ "and this service will pick it up within "
181
+ f"{int(self._poll_interval_s)}s of it landing"
182
+ )
183
+ return
184
+
185
+ with self._lock:
186
+ unchanged = self._detector is not None and self._stamp == stamp
187
+ if unchanged:
188
+ return
189
+
190
+ if self._detector is not None:
191
+ self._log(f"[detector] artifact changed on disk — reloading {self.model_path}")
192
+ self._load(stamp)
193
+
194
+ def _load(self, stamp: "tuple[int, int]") -> None:
195
+ self._set(STATE_LOADING, None)
196
+ try:
197
+ detector = OnnxDetector(self.model_path, self.model_name, self._intra_op_threads)
198
+ except ModelContractError as err:
199
+ self._drop(f"artifact loaded but does not match the detector contract: {err}")
200
+ return
201
+ except Exception as err:
202
+ self._drop(f"could not load {self.model_path}: {err}")
203
+ return
204
+
205
+ # PROVE IT. A session that constructs is not a session that infers.
206
+ try:
207
+ started = time.perf_counter()
208
+ detector.detect(_warmup_jpeg(), 0.99, None)
209
+ warmup_ms = (time.perf_counter() - started) * 1000.0
210
+ except Exception as err:
211
+ self._drop(f"artifact loaded but a probe inference failed: {err}")
212
+ return
213
+
214
+ with self._lock:
215
+ self._detector = detector
216
+ self._stamp = stamp
217
+ self._state = STATE_READY
218
+ self._reason = None
219
+ self._warmup_ms = warmup_ms
220
+ self._loaded_at = time.time()
221
+ self._log(
222
+ f"[detector] ready — {self.model_name} at {self.model_path} "
223
+ f"({detector.spec.width}x{detector.spec.height}, {detector.spec.num_classes} classes, "
224
+ f"probe {warmup_ms:.0f} ms, providers {detector.spec.providers})"
225
+ )
226
+
227
+ def _file_stamp(self) -> "tuple[int, int] | None":
228
+ try:
229
+ st = self.model_path.stat()
230
+ except OSError:
231
+ return None
232
+ if st.st_size == 0:
233
+ return None
234
+ return (st.st_size, st.st_mtime_ns)
235
+
236
+ def _drop(self, reason: str) -> None:
237
+ with self._lock:
238
+ self._detector = None
239
+ self._stamp = None
240
+ self._warmup_ms = None
241
+ self._loaded_at = None
242
+ self._state = STATE_UNAVAILABLE
243
+ self._reason = reason
244
+
245
+ def _set(self, state: str, reason: "str | None") -> None:
246
+ with self._lock:
247
+ self._state = state
248
+ self._reason = reason
249
+
250
+
251
+ __all__ = [
252
+ "DetectorLoader",
253
+ "DetectorNotReady",
254
+ "STATE_LOADING",
255
+ "STATE_READY",
256
+ "STATE_UNAVAILABLE",
257
+ ]
@@ -0,0 +1,74 @@
1
+ """Where the detector artifact lives — ONE canonical home, on every platform.
2
+
3
+ THE ARTIFACT IS OTA'd, NEVER BUNDLED. `LocalDetectorClient.ts` records why: the
4
+ macOS code-only build is hard-capped at `MAX_SLIM_PKG_BYTES` = 250 MiB
5
+ (`build-macos-pkg.sh`) and the code slice is already ~207 MB, so staging a
6
+ ~128 MiB ONNX into the payload makes the build `die`. It arrives the way Gemma's
7
+ weights do — through edge-core's `ModelManager`, atomic `.tmp` + rename.
8
+
9
+ So this module has exactly one job: name the path `ModelManager` writes to, in a
10
+ form both the Python service and the Node connector derive rather than repeat.
11
+ It deliberately mirrors `gemmaModelOta.ts`'s
12
+ `resolveLiteRtLmStoreModelPath` — same shape, one directory per model name, a
13
+ fixed file name inside it:
14
+
15
+ ${TRILLBOARDS_DATA_DIR:-~/.trillboards}/models/<model-name>/model.onnx
16
+
17
+ ONE DIRECTORY PER MODEL NAME, NOT PER VERSION — deliberately. The model OTA
18
+ already orphans 752 MiB per update because the cleaner's `includes('xnnpack')`
19
+ misses the `mldrift_*_cache.bin` family
20
+ (`docs/runbooks/cctv-connector-runtime-edge-cases.md`), and there is no GC for
21
+ abandoned model dirs anywhere on this path. A version-named directory would add
22
+ a second, larger leak — 128 MiB per release on a box that has no disk-space
23
+ preflight either. `ModelManager` rewrites this one file in place, so an update
24
+ reclaims the old bytes by definition.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from pathlib import Path
31
+
32
+ #: The artifact this service is built around. `docs/runbooks/cctv-detector-tier.md`
33
+ #: §6: RF-DETR Medium, fp32 ONNX, NO quantisation — INT8 loses 31.7 points of
34
+ #: recall silently AND is slower, and `.export(quantization='int8')` is a silent
35
+ #: no-op that writes an all-float file anyway.
36
+ DEFAULT_MODEL_NAME = "rfdetr_medium"
37
+
38
+ #: Fixed file name inside the per-model directory. Matches litert-lm's
39
+ #: `models/<id>/model.litertlm` convention so an operator reading one store can
40
+ #: read the other.
41
+ MODEL_FILE_NAME = "model.onnx"
42
+
43
+
44
+ def resolve_data_dir(env: "os._Environ[str] | dict[str, str] | None" = None) -> Path:
45
+ """The connector's data dir, matching `config.ts`'s `DEFAULTS.dataDir`.
46
+
47
+ `path.join(process.env.HOME || process.env.USERPROFILE || '/tmp',
48
+ '.trillboards')` — the same three-step fallback, in the same order, because
49
+ a service that resolves a DIFFERENT directory from the process that
50
+ downloads the model is a detector that is permanently unhealthy on a machine
51
+ whose artifact is present.
52
+ """
53
+ e = os.environ if env is None else env
54
+ override = (e.get("TRILLBOARDS_DATA_DIR") or "").strip()
55
+ if override:
56
+ return Path(override)
57
+ home = (e.get("HOME") or "").strip() or (e.get("USERPROFILE") or "").strip() or "/tmp"
58
+ return Path(home) / ".trillboards"
59
+
60
+
61
+ def resolve_model_path(
62
+ model_name: str = DEFAULT_MODEL_NAME,
63
+ env: "os._Environ[str] | dict[str, str] | None" = None,
64
+ ) -> Path:
65
+ """Absolute path to the ONNX artifact.
66
+
67
+ `TRILLBOARDS_DETECTOR_MODEL` overrides it outright — that is the escape
68
+ hatch for a bench run, and the one the integration test uses.
69
+ """
70
+ e = os.environ if env is None else env
71
+ override = (e.get("TRILLBOARDS_DETECTOR_MODEL") or "").strip()
72
+ if override:
73
+ return Path(override)
74
+ return resolve_data_dir(e) / "models" / model_name / MODEL_FILE_NAME