@trillboards/edge-sdk 0.2.74 → 0.2.78
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 +591 -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/SensingProfileClient.d.ts +25 -22
- package/dist/SensingProfileClient.d.ts.map +1 -1
- package/dist/SensingProfileClient.js.map +1 -1
- 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/localInferenceSchema.d.ts +11 -2
- package/dist/localInferenceSchema.d.ts.map +1 -1
- package/dist/localInferenceSchema.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 +2 -2
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""The ONNX session, and the pre/post-processing that must match the reference.
|
|
2
|
+
|
|
3
|
+
WHY THE PROCESSING IS TRANSCRIBED RATHER THAN IMPORTED
|
|
4
|
+
-----------------------------------------------------
|
|
5
|
+
The score in `docs/runbooks/cctv-detector-tier.md` (98.8/98.8 pooled, 2.1% of
|
|
6
|
+
events lost to buy 84.7% of VLM calls) was produced by the `rfdetr` package's
|
|
7
|
+
own `predict()`. That package pulls torch + torchvision + transformers — about
|
|
8
|
+
3 GB — which is not going on a shop PC to run a 128 MiB graph. So the four steps
|
|
9
|
+
that decide whether the shipped service reproduces the measured number are
|
|
10
|
+
transcribed here from the exact call sites, each named in the function that
|
|
11
|
+
implements it:
|
|
12
|
+
|
|
13
|
+
resize `rfdetr/detr.py` F.resize(t, [576, 576], antialias=False)
|
|
14
|
+
normalize `rfdetr/detr.py` F.normalize(t, [0.485,0.456,0.406], [0.229,0.224,0.225])
|
|
15
|
+
select `rfdetr/models/postprocess.py::PostProcess._select_topk`
|
|
16
|
+
boxes `rfdetr/models/postprocess.py::PostProcess._gather_and_scale_boxes`
|
|
17
|
+
|
|
18
|
+
`antialias=False` is not a detail to tidy up: the upstream comment says it
|
|
19
|
+
matches the antialias-free bilinear resize Albumentations uses in training, and
|
|
20
|
+
changing it changes the pixels the model was scored on. Likewise the resize is a
|
|
21
|
+
STRETCH to a square, not a letterbox — aspect ratio is not preserved, and
|
|
22
|
+
"fixing" that silently re-scores every box.
|
|
23
|
+
|
|
24
|
+
A transcription can drift from its source. `bench/verify_against_reference.py`
|
|
25
|
+
is the control: it runs this pipeline and the `rfdetr` package on the same image
|
|
26
|
+
and fails on any disagreement.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import io
|
|
32
|
+
import math
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
import onnxruntime as ort
|
|
40
|
+
from PIL import Image
|
|
41
|
+
|
|
42
|
+
from .labels import COCO91_CLASS_NAMES, EXPECTED_LOGIT_SLOTS
|
|
43
|
+
|
|
44
|
+
#: `rfdetr/detr.py` — ImageNet statistics, applied AFTER scaling to [0,1].
|
|
45
|
+
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(3, 1, 1)
|
|
46
|
+
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1)
|
|
47
|
+
|
|
48
|
+
#: `PostProcess.__init__(num_select=300)`. The graph emits 300 queries x 91
|
|
49
|
+
#: classes = 27,300 (query, class) pairs and the reference keeps the best 300 of
|
|
50
|
+
#: them BEFORE thresholding. Reproduced exactly: thresholding first would return
|
|
51
|
+
#: rows the measured arm never saw on a busy frame.
|
|
52
|
+
NUM_SELECT = 300
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Detection:
|
|
57
|
+
"""One wire-shaped detection. Pixel box, canonical label, [0,1] confidence."""
|
|
58
|
+
|
|
59
|
+
label: str
|
|
60
|
+
confidence: float
|
|
61
|
+
x: float
|
|
62
|
+
y: float
|
|
63
|
+
width: float
|
|
64
|
+
height: float
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class DetectionBatch:
|
|
69
|
+
detections: list[Detection]
|
|
70
|
+
#: The ORT forward pass alone, in ms — directly comparable to the ~63 ms in
|
|
71
|
+
#: `docs/runbooks/cctv-detector-tier.md`. Decode and post-processing are
|
|
72
|
+
#: reported separately (`total_ms`) so a slow JPEG decode cannot hide inside
|
|
73
|
+
#: a number the runbook will later be checked against.
|
|
74
|
+
inference_ms: float = 0.0
|
|
75
|
+
total_ms: float = 0.0
|
|
76
|
+
#: Boxes that survived the threshold but cover no pixels after clamping to
|
|
77
|
+
#: the frame. Counted, never silently swallowed — see `_to_wire`.
|
|
78
|
+
degenerate_dropped: int = 0
|
|
79
|
+
#: Detections whose argmax landed on one of the eleven COCO id gaps.
|
|
80
|
+
unnamed_class_dropped: int = 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class GraphSpec:
|
|
85
|
+
"""What the loaded artifact actually is, read off the graph, never assumed."""
|
|
86
|
+
|
|
87
|
+
input_name: str
|
|
88
|
+
box_output: str
|
|
89
|
+
logit_output: str
|
|
90
|
+
height: int
|
|
91
|
+
width: int
|
|
92
|
+
num_queries: int
|
|
93
|
+
num_classes: int
|
|
94
|
+
providers: list[str] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ModelContractError(RuntimeError):
|
|
98
|
+
"""The artifact loaded, but it is not the graph this service can label."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def preprocess(jpeg: bytes, width: int, height: int) -> tuple[np.ndarray, int, int]:
|
|
102
|
+
"""JPEG bytes -> NCHW float32, plus the SUBMITTED frame's pixel size.
|
|
103
|
+
|
|
104
|
+
The submitted size is what boxes are scaled back to, so it is returned here
|
|
105
|
+
rather than re-read later: the frame is decoded exactly once.
|
|
106
|
+
"""
|
|
107
|
+
with Image.open(io.BytesIO(jpeg)) as im:
|
|
108
|
+
rgb = im.convert("RGB")
|
|
109
|
+
src_w, src_h = rgb.size
|
|
110
|
+
# BILINEAR without a prefilter == torchvision's antialias=False path.
|
|
111
|
+
# `Image.BILINEAR` in Pillow does not low-pass on downscale, which is
|
|
112
|
+
# the behaviour being matched. Do not "improve" this to LANCZOS.
|
|
113
|
+
resized = rgb.resize((width, height), resample=Image.BILINEAR)
|
|
114
|
+
arr = np.asarray(resized, dtype=np.float32)
|
|
115
|
+
|
|
116
|
+
chw = arr.transpose(2, 0, 1) / np.float32(255.0)
|
|
117
|
+
chw -= IMAGENET_MEAN
|
|
118
|
+
chw /= IMAGENET_STD
|
|
119
|
+
return chw[None, ...], src_w, src_h
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def postprocess(
|
|
123
|
+
boxes_cxcywh: np.ndarray,
|
|
124
|
+
logits: np.ndarray,
|
|
125
|
+
frame_w: int,
|
|
126
|
+
frame_h: int,
|
|
127
|
+
min_confidence: float,
|
|
128
|
+
wanted: "set[str] | None" = None,
|
|
129
|
+
) -> DetectionBatch:
|
|
130
|
+
"""Raw graph outputs -> wire detections, in the submitted frame's pixels.
|
|
131
|
+
|
|
132
|
+
Mirrors `PostProcess._select_topk` and `_gather_and_scale_boxes`.
|
|
133
|
+
|
|
134
|
+
`wanted` filters HERE, at the service, because the alternative is shipping
|
|
135
|
+
every box across loopback to throw most away — `LocalDetectorClient`'s own
|
|
136
|
+
reasoning, and the reason `classes` rides the wire at all.
|
|
137
|
+
"""
|
|
138
|
+
# (1, Q, C) -> (Q, C); the graph is batch-1 by construction.
|
|
139
|
+
prob = _sigmoid(logits[0])
|
|
140
|
+
flat = prob.reshape(-1)
|
|
141
|
+
n_classes = prob.shape[1]
|
|
142
|
+
|
|
143
|
+
num_to_select = min(NUM_SELECT, flat.size)
|
|
144
|
+
# torch's `argsort(descending=True, stable=True)`: descending score, ties
|
|
145
|
+
# broken by ascending flattened index. Negating and sorting ascending-stable
|
|
146
|
+
# is the same order; `np.argsort(...)[::-1]` is NOT (it reverses ties too).
|
|
147
|
+
order = np.argsort(-flat, kind="stable")[:num_to_select]
|
|
148
|
+
scores = flat[order]
|
|
149
|
+
query_idx = order // n_classes
|
|
150
|
+
class_idx = order % n_classes
|
|
151
|
+
|
|
152
|
+
keep = scores > min_confidence
|
|
153
|
+
scores = scores[keep]
|
|
154
|
+
query_idx = query_idx[keep]
|
|
155
|
+
class_idx = class_idx[keep]
|
|
156
|
+
|
|
157
|
+
sel = boxes_cxcywh[0][query_idx]
|
|
158
|
+
cx, cy, w, h = sel[:, 0], sel[:, 1], sel[:, 2], sel[:, 3]
|
|
159
|
+
x0 = np.clip((cx - 0.5 * w) * frame_w, 0.0, frame_w)
|
|
160
|
+
y0 = np.clip((cy - 0.5 * h) * frame_h, 0.0, frame_h)
|
|
161
|
+
x1 = np.clip((cx + 0.5 * w) * frame_w, 0.0, frame_w)
|
|
162
|
+
y1 = np.clip((cy + 0.5 * h) * frame_h, 0.0, frame_h)
|
|
163
|
+
|
|
164
|
+
return _to_wire(scores, class_idx, x0, y0, x1, y1, wanted)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _to_wire(
|
|
168
|
+
scores: np.ndarray,
|
|
169
|
+
class_idx: np.ndarray,
|
|
170
|
+
x0: np.ndarray,
|
|
171
|
+
y0: np.ndarray,
|
|
172
|
+
x1: np.ndarray,
|
|
173
|
+
y1: np.ndarray,
|
|
174
|
+
wanted: "set[str] | None",
|
|
175
|
+
) -> DetectionBatch:
|
|
176
|
+
"""Assemble detections, dropping only what CANNOT be put on the wire.
|
|
177
|
+
|
|
178
|
+
Two drops, and each is counted rather than swallowed:
|
|
179
|
+
|
|
180
|
+
* an UNNAMED CLASS SLOT. Eleven of the ninety-one slots are COCO id gaps
|
|
181
|
+
with no name. `docs/runbooks/cctv-detector-tier.md` §5 rule 2 forbids
|
|
182
|
+
guessing; a detection with no nameable class is dropped.
|
|
183
|
+
* a ZERO-EXTENT BOX. A box clamped flush against a frame edge can have zero
|
|
184
|
+
width or height, and `LocalDetectorClient` REJECTS THE ENTIRE PAYLOAD on
|
|
185
|
+
one — correctly, since it cannot tell a schema-drifted service from a
|
|
186
|
+
quiet venue. Emitting one would turn a numerical edge into the total loss
|
|
187
|
+
of a frame's result, so it is dropped here instead.
|
|
188
|
+
|
|
189
|
+
Neither is the "skip a malformed entry" defect the client warns about. That
|
|
190
|
+
is about a SERVICE that has stopped speaking the contract, where an
|
|
191
|
+
undercount is indistinguishable from an emptier frame. These two are the
|
|
192
|
+
producer declining to emit a box that describes no pixels and a class that
|
|
193
|
+
has no name — and both increment a counter `/health` publishes, so the
|
|
194
|
+
failure can report itself.
|
|
195
|
+
"""
|
|
196
|
+
out: list[Detection] = []
|
|
197
|
+
degenerate = 0
|
|
198
|
+
unnamed = 0
|
|
199
|
+
|
|
200
|
+
for i in range(scores.shape[0]):
|
|
201
|
+
name = COCO91_CLASS_NAMES.get(int(class_idx[i]))
|
|
202
|
+
if name is None:
|
|
203
|
+
unnamed += 1
|
|
204
|
+
continue
|
|
205
|
+
if wanted is not None and name not in wanted:
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
left, top = float(x0[i]), float(y0[i])
|
|
209
|
+
width, height = float(x1[i]) - left, float(y1[i]) - top
|
|
210
|
+
if not (width > 0.0 and height > 0.0):
|
|
211
|
+
degenerate += 1
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
conf = float(scores[i])
|
|
215
|
+
if not math.isfinite(conf):
|
|
216
|
+
# A non-finite confidence is unrepresentable on the wire and the
|
|
217
|
+
# client refuses the whole payload on one. sigmoid cannot produce
|
|
218
|
+
# it, so reaching here means the graph did — which is a load-bearing
|
|
219
|
+
# surprise, not a row to drop quietly.
|
|
220
|
+
raise ModelContractError(f"model produced a non-finite confidence at index {i}")
|
|
221
|
+
# sigmoid is (0,1); clamp only guards float64 round-trip at the edges.
|
|
222
|
+
out.append(Detection(name, min(1.0, max(0.0, conf)), left, top, width, height))
|
|
223
|
+
|
|
224
|
+
return DetectionBatch(out, degenerate_dropped=degenerate, unnamed_class_dropped=unnamed)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _sigmoid(x: np.ndarray) -> np.ndarray:
|
|
228
|
+
# Branch on sign so neither exp() overflows; np.exp(710) is inf.
|
|
229
|
+
pos = x >= 0
|
|
230
|
+
out = np.empty_like(x, dtype=np.float64)
|
|
231
|
+
out[pos] = 1.0 / (1.0 + np.exp(-x[pos].astype(np.float64)))
|
|
232
|
+
e = np.exp(x[~pos].astype(np.float64))
|
|
233
|
+
out[~pos] = e / (1.0 + e)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class OnnxDetector:
|
|
238
|
+
"""One ORT session, one inference at a time.
|
|
239
|
+
|
|
240
|
+
SERIALISED ON PURPOSE. `docs/runbooks/cctv-detector-tier.md` §2 measures
|
|
241
|
+
threads as NON-MONOTONIC — 16 threads (99.9 ms) is slower than 6 (78-83) and
|
|
242
|
+
slower than ORT's own auto heuristic (63.9) — so "use all cores" is wrong,
|
|
243
|
+
and so is letting N concurrent HTTP requests each start an intra-op thread
|
|
244
|
+
pool's worth of work on the same box. The detector gates the ANALYZE
|
|
245
|
+
cadence, where §2 measured that nothing binds (hundreds of cameras clear at
|
|
246
|
+
every thread count, holding under 5x pessimisation), so a queue costs
|
|
247
|
+
nothing that was ever measured to matter.
|
|
248
|
+
|
|
249
|
+
Thread counts are therefore LEFT AT ORT'S DEFAULT unless an operator
|
|
250
|
+
overrides them. Pinning them is the mistake the same section names.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(self, model_path: Path, model_name: str, intra_op_threads: "int | None" = None):
|
|
254
|
+
self.model_path = model_path
|
|
255
|
+
self.model_name = model_name
|
|
256
|
+
self._lock = threading.Lock()
|
|
257
|
+
|
|
258
|
+
opts = ort.SessionOptions()
|
|
259
|
+
if intra_op_threads is not None:
|
|
260
|
+
opts.intra_op_num_threads = intra_op_threads
|
|
261
|
+
# Loopback service, one process, one model: log only what a fault needs.
|
|
262
|
+
opts.log_severity_level = 3
|
|
263
|
+
|
|
264
|
+
self._session = ort.InferenceSession(
|
|
265
|
+
str(model_path), sess_options=opts, providers=["CPUExecutionProvider"]
|
|
266
|
+
)
|
|
267
|
+
self.spec = _read_graph_spec(self._session)
|
|
268
|
+
|
|
269
|
+
def detect(
|
|
270
|
+
self,
|
|
271
|
+
jpeg: bytes,
|
|
272
|
+
min_confidence: float,
|
|
273
|
+
wanted: "set[str] | None" = None,
|
|
274
|
+
) -> DetectionBatch:
|
|
275
|
+
started = time.perf_counter()
|
|
276
|
+
blob, frame_w, frame_h = preprocess(jpeg, self.spec.width, self.spec.height)
|
|
277
|
+
|
|
278
|
+
with self._lock:
|
|
279
|
+
infer_started = time.perf_counter()
|
|
280
|
+
outputs = self._session.run(
|
|
281
|
+
[self.spec.box_output, self.spec.logit_output], {self.spec.input_name: blob}
|
|
282
|
+
)
|
|
283
|
+
infer_ms = (time.perf_counter() - infer_started) * 1000.0
|
|
284
|
+
|
|
285
|
+
batch = postprocess(outputs[0], outputs[1], frame_w, frame_h, min_confidence, wanted)
|
|
286
|
+
batch.inference_ms = infer_ms
|
|
287
|
+
batch.total_ms = (time.perf_counter() - started) * 1000.0
|
|
288
|
+
return batch
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _read_graph_spec(session: "ort.InferenceSession") -> GraphSpec:
|
|
292
|
+
"""Read the contract off the loaded graph, and REFUSE a graph we cannot label.
|
|
293
|
+
|
|
294
|
+
The exported artifact carries no class metadata, so the label map in
|
|
295
|
+
`labels.py` is the only thing naming its 91 slots. If the graph's class axis
|
|
296
|
+
is not 91 wide, that map does not describe it — and applying it anyway
|
|
297
|
+
renames every object in the venue while every number stays plausible. That
|
|
298
|
+
is the `yolov8_nano` failure with the sign flipped: instead of a model
|
|
299
|
+
offered and never loadable, a model loaded and never right.
|
|
300
|
+
|
|
301
|
+
So a mismatch raises, the service goes UNHEALTHY with the reason, and
|
|
302
|
+
nothing is emitted. The 2.1%-of-events figure the placement decision rests
|
|
303
|
+
on is a claim about ONE artifact; a different one has no score at all.
|
|
304
|
+
"""
|
|
305
|
+
inputs = session.get_inputs()
|
|
306
|
+
if len(inputs) != 1:
|
|
307
|
+
raise ModelContractError(
|
|
308
|
+
f"expected exactly one graph input, found {[i.name for i in inputs]}"
|
|
309
|
+
)
|
|
310
|
+
shape = inputs[0].shape
|
|
311
|
+
if len(shape) != 4:
|
|
312
|
+
raise ModelContractError(f"expected an NCHW input, found shape {shape}")
|
|
313
|
+
_, channels, height, width = shape
|
|
314
|
+
if channels != 3:
|
|
315
|
+
raise ModelContractError(f"expected a 3-channel input, found {channels}")
|
|
316
|
+
if not isinstance(height, int) or not isinstance(width, int):
|
|
317
|
+
raise ModelContractError(
|
|
318
|
+
f"expected a fixed input resolution, found {height}x{width}; a dynamic "
|
|
319
|
+
"axis means the resolution this model was scored at is not recoverable "
|
|
320
|
+
"from the artifact, and imgsz is one of the two numbers a detector "
|
|
321
|
+
"arm is meaningless without"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
outputs = session.get_outputs()
|
|
325
|
+
by_name = {o.name: o for o in outputs}
|
|
326
|
+
if "dets" not in by_name or "labels" not in by_name:
|
|
327
|
+
raise ModelContractError(
|
|
328
|
+
f"expected `dets` and `labels` outputs, found {sorted(by_name)}"
|
|
329
|
+
)
|
|
330
|
+
box_shape = by_name["dets"].shape
|
|
331
|
+
logit_shape = by_name["labels"].shape
|
|
332
|
+
if len(box_shape) != 3 or box_shape[2] != 4:
|
|
333
|
+
raise ModelContractError(f"`dets` is not (N, Q, 4): {box_shape}")
|
|
334
|
+
if len(logit_shape) != 3:
|
|
335
|
+
raise ModelContractError(f"`labels` is not (N, Q, C): {logit_shape}")
|
|
336
|
+
|
|
337
|
+
num_classes = logit_shape[2]
|
|
338
|
+
if num_classes != EXPECTED_LOGIT_SLOTS:
|
|
339
|
+
raise ModelContractError(
|
|
340
|
+
f"model has {num_classes} class slots; the committed label map describes "
|
|
341
|
+
f"{EXPECTED_LOGIT_SLOTS}. Refusing rather than labelling boxes by an "
|
|
342
|
+
"index this map does not name — see labels.py."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return GraphSpec(
|
|
346
|
+
input_name=inputs[0].name,
|
|
347
|
+
box_output="dets",
|
|
348
|
+
logit_output="labels",
|
|
349
|
+
height=int(height),
|
|
350
|
+
width=int(width),
|
|
351
|
+
num_queries=int(logit_shape[1]) if isinstance(logit_shape[1], int) else -1,
|
|
352
|
+
num_classes=int(num_classes),
|
|
353
|
+
providers=list(session.get_providers()),
|
|
354
|
+
)
|
|
@@ -379,6 +379,28 @@ if [ "${1:-}" = "auto-sense" ]; then
|
|
|
379
379
|
if { [ ! -f "$MODEL_MARKER" ] || [ ! -f "$SERVE_PLIST" ] || [ "$LOCAL_AI_HEALTHY" -ne 1 ]; } && [ -f "$SETUP" ]; then
|
|
380
380
|
nohup /bin/bash "$SETUP" >/dev/null 2>&1 &
|
|
381
381
|
fi
|
|
382
|
+
|
|
383
|
+
# The detector cascade, repaired on the same trigger. TWO signals, and the
|
|
384
|
+
# health one asks only whether the service ANSWERS: its model is OTA'd, so a
|
|
385
|
+
# correctly-installed detector reports 503 until the artifact lands, and
|
|
386
|
+
# treating that as broken would reinstall a working tool on every launch.
|
|
387
|
+
DETECTOR_PLIST="$HOME/Library/LaunchAgents/com.trillboards.detector.plist"
|
|
388
|
+
DETECTOR_SETUP="$ROOT/share/ensure-detector.sh"
|
|
389
|
+
# NO `|| echo 000` HERE. curl writes `000` through -w AND exits nonzero when
|
|
390
|
+
# it cannot connect, so a fallback appends a SECOND value and the variable
|
|
391
|
+
# becomes `000\n000` -- which equals neither `000` nor any HTTP code, so the
|
|
392
|
+
# equality test below silently stops firing and the repair path is dead on
|
|
393
|
+
# exactly the machines that need it. Measured 2026-08-24 against a closed
|
|
394
|
+
# port: `[000000]`. Caught by codex on this PR.
|
|
395
|
+
#
|
|
396
|
+
# Anything that is not a real HTTP status means "not answering": empty (curl
|
|
397
|
+
# missing entirely) and `000` (could not connect) both trigger the repair.
|
|
398
|
+
DETECTOR_CODE="$(/usr/bin/curl -s -o /dev/null -w '%{http_code}' --max-time 2 \
|
|
399
|
+
http://127.0.0.1:9380/health 2>/dev/null)"
|
|
400
|
+
if { [ ! -f "$DETECTOR_PLIST" ] || [ -z "$DETECTOR_CODE" ] || [ "$DETECTOR_CODE" = "000" ]; } \
|
|
401
|
+
&& [ -f "$DETECTOR_SETUP" ]; then
|
|
402
|
+
nohup /bin/bash "$DETECTOR_SETUP" >/dev/null 2>&1 &
|
|
403
|
+
fi
|
|
382
404
|
fi
|
|
383
405
|
|
|
384
406
|
exec "$HERE/node" "$ROOT/lib/edge-sdk/dist/cli.js" "$@"
|
|
@@ -404,7 +426,33 @@ cp "$PKG_ROOT/deploy/macos/scripts/setup-litertlm.sh" "$PAYLOAD/share/setup-lite
|
|
|
404
426
|
cp "$PKG_ROOT/deploy/litertlm/trillboards_litertlm_shim.py" "$PAYLOAD/share/trillboards_litertlm_shim.py"
|
|
405
427
|
cp "$PKG_ROOT/deploy/macos/scripts/ensure-litertlm.sh" "$PAYLOAD/share/ensure-litertlm.sh"
|
|
406
428
|
cp "$PKG_ROOT/deploy/macos/apply-update.sh" "$PAYLOAD/share/apply-update.sh"
|
|
429
|
+
|
|
430
|
+
# On-device OBJECT DETECTOR stack: the LaunchAgent template, the provisioner,
|
|
431
|
+
# the retry wrapper, and the Python package SOURCE that `uv tool install` builds
|
|
432
|
+
# into its own venv.
|
|
433
|
+
#
|
|
434
|
+
# THE SOURCE, NOT THE MODEL. The package is a few tens of KB; the 124 MB fp32
|
|
435
|
+
# ONNX it runs is OTA'd by edge-core's ModelManager, because the code-only build
|
|
436
|
+
# is capped at MAX_SLIM_PKG_BYTES = 250 MiB below and the code slice is already
|
|
437
|
+
# ~207 MB — staging the artifact here would `die` on every slim build. It is
|
|
438
|
+
# staged NEXT TO setup-detector.sh because that is where find_detector_source
|
|
439
|
+
# looks first, the same convention trillboards_litertlm_shim.py follows.
|
|
440
|
+
cp "$PKG_ROOT/deploy/macos/com.trillboards.detector.plist" "$PAYLOAD/share/com.trillboards.detector.plist"
|
|
441
|
+
cp "$PKG_ROOT/deploy/macos/scripts/setup-detector.sh" "$PAYLOAD/share/setup-detector.sh"
|
|
442
|
+
cp "$PKG_ROOT/deploy/macos/scripts/ensure-detector.sh" "$PAYLOAD/share/ensure-detector.sh"
|
|
443
|
+
mkdir -p "$PAYLOAD/share/detector"
|
|
444
|
+
# `build/`, `dist/` and `*.egg-info` are excluded for a REASON, not for tidiness:
|
|
445
|
+
# setuptools copies from `build/lib/` on a later build, so a stale one staged
|
|
446
|
+
# into the payload would install the PREVIOUS revision of the service while the
|
|
447
|
+
# installer reported success. The package uses hatchling precisely so it never
|
|
448
|
+
# creates one, and this exclude covers a leftover from an older backend or a
|
|
449
|
+
# hand-run build on a developer's tree.
|
|
450
|
+
rsync -a --exclude '__pycache__' --exclude '*.pyc' --exclude 'bench' \
|
|
451
|
+
--exclude 'build' --exclude 'dist' --exclude '*.egg-info' \
|
|
452
|
+
"$PKG_ROOT/deploy/detector/" "$PAYLOAD/share/detector/"
|
|
453
|
+
|
|
407
454
|
chmod +x "$PAYLOAD/share/setup-litertlm.sh" "$PAYLOAD/share/ensure-litertlm.sh" \
|
|
455
|
+
"$PAYLOAD/share/setup-detector.sh" "$PAYLOAD/share/ensure-detector.sh" \
|
|
408
456
|
"$PAYLOAD/share/apply-update.sh"
|
|
409
457
|
|
|
410
458
|
# ─── 7b. Stage the pre-bundled Gemma model (.litertlm) into the payload ───────
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<!--
|
|
4
|
+
Per-user LaunchAgent for the on-device OBJECT DETECTOR (RF-DETR Medium, fp32
|
|
5
|
+
ONNX) that gates the analyze cadence in front of the VLM.
|
|
6
|
+
|
|
7
|
+
A SECOND service, not a route on the first. `LocalDetectorClient.ts` records
|
|
8
|
+
the three reasons: `onnxruntime-node` is excluded by name from both installers,
|
|
9
|
+
`uv tool install` gives one isolated venv per tool name (and `--force` rebuilds
|
|
10
|
+
litert-lm's from scratch on any version drift, which would take a package added
|
|
11
|
+
into it with it), and a supervised loopback service is already a solved problem
|
|
12
|
+
here. Port 9380; litert-lm owns 9379 and is not touched.
|
|
13
|
+
|
|
14
|
+
This is a TEMPLATE: setup-detector.sh substitutes __DETECTOR__ (absolute path
|
|
15
|
+
to the uv-installed `trillboards-detector` binary) / __PORT__ / __LOG_DIR__ and
|
|
16
|
+
writes the result to ~/Library/LaunchAgents/com.trillboards.detector.plist,
|
|
17
|
+
then loads it via `launchctl bootstrap gui/$UID`.
|
|
18
|
+
|
|
19
|
+
KeepAlive=true + ThrottleInterval=30 exactly as com.trillboards.litertlm.plist.
|
|
20
|
+
ThrottleInterval is load-bearing here for a specific reason: the model is OTA'd,
|
|
21
|
+
so on a fresh install this service starts with NO artifact. It is designed to
|
|
22
|
+
stay up and answer /health with 503 rather than exit — but if it ever did exit
|
|
23
|
+
early, this is what keeps the failure to a slow retry instead of a hot loop.
|
|
24
|
+
-->
|
|
25
|
+
<plist version="1.0">
|
|
26
|
+
<dict>
|
|
27
|
+
<key>Label</key>
|
|
28
|
+
<string>com.trillboards.detector</string>
|
|
29
|
+
|
|
30
|
+
<key>ProgramArguments</key>
|
|
31
|
+
<array>
|
|
32
|
+
<string>__DETECTOR__</string>
|
|
33
|
+
<string>serve</string>
|
|
34
|
+
<string>--host</string>
|
|
35
|
+
<string>127.0.0.1</string>
|
|
36
|
+
<string>--port</string>
|
|
37
|
+
<string>__PORT__</string>
|
|
38
|
+
</array>
|
|
39
|
+
|
|
40
|
+
<key>RunAtLoad</key>
|
|
41
|
+
<true/>
|
|
42
|
+
<key>KeepAlive</key>
|
|
43
|
+
<true/>
|
|
44
|
+
<key>ThrottleInterval</key>
|
|
45
|
+
<integer>30</integer>
|
|
46
|
+
|
|
47
|
+
<key>ProcessType</key>
|
|
48
|
+
<string>Background</string>
|
|
49
|
+
|
|
50
|
+
<!--
|
|
51
|
+
Keep the service's own stdout. The Windows Scheduled Task cannot (a task has
|
|
52
|
+
no stdout), which is exactly why litert-lm's 2026-08-19 failure on
|
|
53
|
+
windows-latest is still undiagnosed — see the hardware-floor runbook. On
|
|
54
|
+
this platform there is no excuse for discarding it.
|
|
55
|
+
-->
|
|
56
|
+
<key>StandardOutPath</key>
|
|
57
|
+
<string>__LOG_DIR__/detector.out.log</string>
|
|
58
|
+
<key>StandardErrorPath</key>
|
|
59
|
+
<string>__LOG_DIR__/detector.err.log</string>
|
|
60
|
+
</dict>
|
|
61
|
+
</plist>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Keep the installer-provisioned detector service alive across transient
|
|
3
|
+
# failures. The inner setup script is idempotent and owns its own lock; this
|
|
4
|
+
# wrapper owns a second one so pairing and auto-sense launches collapse into a
|
|
5
|
+
# single retry loop. Mirrors ensure-litertlm.sh.
|
|
6
|
+
#
|
|
7
|
+
# THE HEALTH PREDICATE IS "IT ANSWERS", NOT "IT IS READY", AND THAT IS THE WHOLE
|
|
8
|
+
# DIFFERENCE FROM ensure-litertlm.sh.
|
|
9
|
+
#
|
|
10
|
+
# The detector's model is OTA'd, so a correctly-installed service reports 503
|
|
11
|
+
# for as long as the artifact has not landed — minutes on a fresh install,
|
|
12
|
+
# indefinitely on a box that cannot reach the CDN. Retrying `uv tool install`
|
|
13
|
+
# against that would be a permanent reinstall loop repairing something that is
|
|
14
|
+
# not broken, on a box whose disk has no space preflight anywhere on the model
|
|
15
|
+
# path. So: any HTTP answer means the service is up and this loop is done. The
|
|
16
|
+
# 503-to-200 transition is the loader's job, and it does it without a restart.
|
|
17
|
+
|
|
18
|
+
set -u
|
|
19
|
+
USER_HOME="${HOME:-$(eval echo "~$(id -un)")}"
|
|
20
|
+
ROOT="$USER_HOME/Library/Trillboards/edge"
|
|
21
|
+
SETUP="$ROOT/share/setup-detector.sh"
|
|
22
|
+
LOG_DIR="$USER_HOME/Library/Logs/Trillboards"
|
|
23
|
+
LOCK_DIR="$LOG_DIR/detector-ensure.lock"
|
|
24
|
+
HEALTH_URL="${TRILLBOARDS_DETECTOR_HEALTH_URL:-http://127.0.0.1:9380/health}"
|
|
25
|
+
MAX_DELAY_SECONDS="${TRILLBOARDS_DETECTOR_RETRY_MAX_SECONDS:-300}"
|
|
26
|
+
MAX_ATTEMPTS="${TRILLBOARDS_DETECTOR_MAX_ATTEMPTS:-5}"
|
|
27
|
+
|
|
28
|
+
mkdir -p "$LOG_DIR" 2>/dev/null || true
|
|
29
|
+
LOG="$LOG_DIR/detector-setup.log"
|
|
30
|
+
log() { printf '[trillboards ensure-detector] %s %s\n' "$(date '+%H:%M:%S')" "$*" >>"$LOG" 2>&1; }
|
|
31
|
+
|
|
32
|
+
# ANY status code is a live service. `curl --fail` would call the designed 503 a
|
|
33
|
+
# failure, which is precisely the reinstall loop described above.
|
|
34
|
+
detector_answers() {
|
|
35
|
+
_code="$(/usr/bin/curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$HEALTH_URL" 2>/dev/null)"
|
|
36
|
+
[ -n "$_code" ] && [ "$_code" != "000" ]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
40
|
+
LOCK_PID="$(cat "$LOCK_DIR/pid" 2>/dev/null || echo '')"
|
|
41
|
+
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
|
42
|
+
log "detector retry loop is already running (pid $LOCK_PID)"
|
|
43
|
+
exit 0
|
|
44
|
+
fi
|
|
45
|
+
rm -rf "$LOCK_DIR" 2>/dev/null || true
|
|
46
|
+
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
|
|
47
|
+
fi
|
|
48
|
+
printf '%s\n' "$$" >"$LOCK_DIR/pid" 2>/dev/null || true
|
|
49
|
+
trap 'rm -rf "$LOCK_DIR" 2>/dev/null || true' EXIT
|
|
50
|
+
trap 'exit 130' INT
|
|
51
|
+
trap 'exit 143' TERM
|
|
52
|
+
|
|
53
|
+
if [ ! -f "$SETUP" ]; then
|
|
54
|
+
log "setup script is missing at $SETUP"
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
if detector_answers; then
|
|
59
|
+
log "detector service was already answering"
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# BOUNDED, unlike ensure-litertlm.sh's unbounded loop. That one may retry
|
|
64
|
+
# forever because without Gemma the connector cannot sense at all. This one
|
|
65
|
+
# gates a cost optimisation: if it cannot be provisioned, every frame goes to
|
|
66
|
+
# the VLM exactly as it does today, and an infinite background loop buys nothing
|
|
67
|
+
# for a machine that will get another attempt at the next auto-sense anyway.
|
|
68
|
+
delay=30
|
|
69
|
+
attempt=0
|
|
70
|
+
while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do
|
|
71
|
+
attempt=$((attempt + 1))
|
|
72
|
+
log "detector provisioning attempt $attempt of $MAX_ATTEMPTS"
|
|
73
|
+
/bin/bash "$SETUP" >>"$LOG" 2>&1 || true
|
|
74
|
+
if detector_answers; then
|
|
75
|
+
log "detector service is answering"
|
|
76
|
+
exit 0
|
|
77
|
+
fi
|
|
78
|
+
log "detector is not answering; retrying in ${delay}s"
|
|
79
|
+
sleep "$delay"
|
|
80
|
+
delay=$((delay * 2))
|
|
81
|
+
if [ "$delay" -gt "$MAX_DELAY_SECONDS" ]; then delay="$MAX_DELAY_SECONDS"; fi
|
|
82
|
+
done
|
|
83
|
+
|
|
84
|
+
log "detector did not come up after $MAX_ATTEMPTS attempts; the VLM keeps handling every frame"
|
|
85
|
+
exit 0
|
|
@@ -114,6 +114,34 @@ if [ "$AI_RC" -ne 0 ]; then
|
|
|
114
114
|
fi
|
|
115
115
|
log "bundled local AI is installed and healthy"
|
|
116
116
|
|
|
117
|
+
# 3b. The on-device OBJECT DETECTOR service. BEST EFFORT, and deliberately not
|
|
118
|
+
# fatal the way the block above is.
|
|
119
|
+
#
|
|
120
|
+
# Gemma is fatal because without it the connector cannot sense at all. The
|
|
121
|
+
# detector is a CASCADE GATE in front of it: with the detector, ~84.7% of VLM
|
|
122
|
+
# calls are removed for ~2.1% of events; without it every frame simply goes to
|
|
123
|
+
# the VLM, which is what happens today. A cost optimisation must never be able
|
|
124
|
+
# to stop a customer pairing a camera.
|
|
125
|
+
#
|
|
126
|
+
# It also cannot be verified here the way Gemma is: its 124 MB ONNX is OTA'd
|
|
127
|
+
# (the code-only build is capped at 250 MiB and the code slice is already
|
|
128
|
+
# ~207 MB), so at this moment the artifact is legitimately absent and the
|
|
129
|
+
# service correctly reports itself unhealthy. setup-detector.sh checks that it
|
|
130
|
+
# ANSWERS and logs which of the two states it is in.
|
|
131
|
+
DETECTOR_SETUP="$EDGE_ROOT/share/setup-detector.sh"
|
|
132
|
+
if [ -x "$DETECTOR_SETUP" ]; then
|
|
133
|
+
log "provisioning the on-device object detector (best effort)"
|
|
134
|
+
if [ "$(id -u)" = "0" ] && [ -n "$OWNER" ] && [ "$OWNER" != "root" ] && [ -n "${LAUNCH_UID:-}" ]; then
|
|
135
|
+
/bin/launchctl asuser "$LAUNCH_UID" /usr/bin/sudo -u "$OWNER" \
|
|
136
|
+
/usr/bin/env HOME="$USER_HOME" \
|
|
137
|
+
/bin/bash "$DETECTOR_SETUP" || log "WARN: detector provisioning did not finish; the VLM keeps handling every frame"
|
|
138
|
+
else
|
|
139
|
+
/bin/bash "$DETECTOR_SETUP" || log "WARN: detector provisioning did not finish; the VLM keeps handling every frame"
|
|
140
|
+
fi
|
|
141
|
+
else
|
|
142
|
+
log "WARN: detector installer missing at $DETECTOR_SETUP; the VLM keeps handling every frame"
|
|
143
|
+
fi
|
|
144
|
+
|
|
117
145
|
# 4. Launch the real native setup application. It runs the canonical pairer as
|
|
118
146
|
# an invisible child process and renders its credential-free progress file,
|
|
119
147
|
# so customers never see Terminal or a command window. The app itself is
|