@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.
Files changed (53) 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 +591 -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/EdgeAgent.js +1 -1
  33. package/dist/LocalDetectorClient.d.ts +92 -0
  34. package/dist/LocalDetectorClient.d.ts.map +1 -0
  35. package/dist/LocalDetectorClient.js +241 -0
  36. package/dist/LocalDetectorClient.js.map +1 -0
  37. package/dist/SensingProfileClient.d.ts +25 -22
  38. package/dist/SensingProfileClient.d.ts.map +1 -1
  39. package/dist/SensingProfileClient.js.map +1 -1
  40. package/dist/diagnose.d.ts +1 -1
  41. package/dist/diagnose.js +1 -1
  42. package/dist/index.d.ts +4 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +17 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/localInferenceSchema.d.ts +11 -2
  47. package/dist/localInferenceSchema.d.ts.map +1 -1
  48. package/dist/localInferenceSchema.js.map +1 -1
  49. package/dist/zoneContainment.d.ts +148 -0
  50. package/dist/zoneContainment.d.ts.map +1 -0
  51. package/dist/zoneContainment.js +172 -0
  52. package/dist/zoneContainment.js.map +1 -0
  53. 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