@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,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
|