@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.
- 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/AudienceAnalyzeClient.d.ts +6 -0
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -1
- package/dist/AudienceAnalyzeClient.js +14 -0
- package/dist/AudienceAnalyzeClient.js.map +1 -1
- 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/localSensing.d.ts.map +1 -1
- package/dist/localSensing.js +6 -0
- package/dist/localSensing.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,133 @@
|
|
|
1
|
+
# `trillboards-detector` — the on-device object-detector service
|
|
2
|
+
|
|
3
|
+
RF-DETR Medium, fp32 ONNX, served over loopback on **127.0.0.1:9380**. It is the
|
|
4
|
+
cascade gate that sits in front of the on-device VLM: at the ANALYZE cadence it
|
|
5
|
+
removes ~84.7% of 5,645 ms VLM calls for ~2.1% of events.
|
|
6
|
+
|
|
7
|
+
The decision record — which model, why not the alternatives, and every number
|
|
8
|
+
above — is [`docs/runbooks/cctv-detector-tier.md`](../../../../../docs/runbooks/cctv-detector-tier.md).
|
|
9
|
+
Read it before changing anything about the model. **Do not quantise**: INT8 loses
|
|
10
|
+
31.7 points of recall *silently* and is slower than fp32.
|
|
11
|
+
|
|
12
|
+
## The wire
|
|
13
|
+
|
|
14
|
+
The consumer is
|
|
15
|
+
[`src/LocalDetectorClient.ts`](../../src/LocalDetectorClient.ts). It shipped
|
|
16
|
+
first and **its contract is frozen** — this service accommodates it, never the
|
|
17
|
+
other way round.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
POST /detect { image_b64, classes?, min_confidence }
|
|
21
|
+
200 { detections: [{ label, confidence, box: {x,y,width,height} }],
|
|
22
|
+
inference_ms, model }
|
|
23
|
+
400 the request cannot be acted on (bad base64, empty body,
|
|
24
|
+
min_confidence outside [0,1], every requested class unknown)
|
|
25
|
+
413 body over 32 MiB
|
|
26
|
+
500 inference failed, or a detection could not be put on the wire
|
|
27
|
+
503 no usable model — NEVER a 200 with an empty array
|
|
28
|
+
|
|
29
|
+
GET /health 200 when a load completed AND a probe inference succeeded
|
|
30
|
+
503 otherwise, with the reason. Runs no inference.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
* `label` is the class **name**, never an index.
|
|
34
|
+
* `box` is in **pixels of the submitted frame**, not normalised.
|
|
35
|
+
* `classes` filters at the **service**, so the wire stays small. Absent means
|
|
36
|
+
every class; an empty array is a 400, never a synonym for "all".
|
|
37
|
+
|
|
38
|
+
Three properties are load-bearing rather than stylistic:
|
|
39
|
+
|
|
40
|
+
**Down is never empty.** A missing model is 503 and a failed inference is 500.
|
|
41
|
+
Neither may become `200 []`. A cascade that reads "detector broken" as "venue
|
|
42
|
+
quiet" suppresses every alert while every health surface stays green.
|
|
43
|
+
|
|
44
|
+
**We refuse what the client would refuse.** `build_detect_response` re-checks
|
|
45
|
+
every detection against the client's own fifteen rules and answers 500 rather
|
|
46
|
+
than emitting a payload the client will reject — because a rejected payload
|
|
47
|
+
costs the whole frame *and* is indistinguishable, from the client's side, from
|
|
48
|
+
the service being down.
|
|
49
|
+
|
|
50
|
+
**Health reports a load that actually happened.** `GET /v1/models` on litert-lm
|
|
51
|
+
answers 200 without ever building an engine, which is how a UTF-8 BOM in its
|
|
52
|
+
config 500'd every frame on every Windows connector while the installer's health
|
|
53
|
+
check *and* its CPU-fallback re-check both passed. So this loader builds the
|
|
54
|
+
session, runs a real probe inference, and `/health` reports that outcome.
|
|
55
|
+
|
|
56
|
+
## Where it runs
|
|
57
|
+
|
|
58
|
+
Its own `uv tool`, so its own venv; its own OS-supervised service; Node never
|
|
59
|
+
spawns it.
|
|
60
|
+
|
|
61
|
+
| platform | supervisor | provisioned by |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| macOS | `deploy/macos/com.trillboards.detector.plist` (LaunchAgent) | `deploy/macos/scripts/setup-detector.sh` |
|
|
64
|
+
| Linux | `deploy/systemd/trillboards-detector.service` | `deploy/systemd/setup-detector.sh` |
|
|
65
|
+
| Windows | Scheduled Task `TrillboardsDetector` | `deploy/windows/setup-detector.ps1` |
|
|
66
|
+
|
|
67
|
+
All three are **best effort**. Without Gemma the connector cannot sense at all,
|
|
68
|
+
so that provisioner may abort an install; this one only makes sensing cheaper,
|
|
69
|
+
and a cost optimisation must never block a customer pairing a camera.
|
|
70
|
+
|
|
71
|
+
## The model is OTA'd, never bundled
|
|
72
|
+
|
|
73
|
+
`~/.trillboards/models/rfdetr_medium/model.onnx`, or wherever
|
|
74
|
+
`${TRILLBOARDS_DATA_DIR:-$HOME/.trillboards}` resolves — the same derivation
|
|
75
|
+
`config.ts`'s `DEFAULTS.dataDir` uses, because a service that resolves a
|
|
76
|
+
different directory from the process that downloads the model is permanently
|
|
77
|
+
unhealthy on a box whose artifact is sitting right there.
|
|
78
|
+
|
|
79
|
+
The macOS code-only build is capped at `MAX_SLIM_PKG_BYTES` = 250 MiB and the
|
|
80
|
+
code slice is already ~207 MB, so a ~128 MiB ONNX would make the build `die`.
|
|
81
|
+
It arrives through edge-core's `ModelManager`, the same channel Gemma's weights
|
|
82
|
+
use. The service **starts without it**, reports 503 with the reason, and picks
|
|
83
|
+
the artifact up within one poll interval of it landing — no restart.
|
|
84
|
+
|
|
85
|
+
One directory per model name and a fixed file name inside it, so an update
|
|
86
|
+
reclaims the old bytes. There is no GC for abandoned model dirs anywhere on this
|
|
87
|
+
path and the existing OTA already orphans 752 MiB per update; a version-named
|
|
88
|
+
directory would add to that.
|
|
89
|
+
|
|
90
|
+
## Tests
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# The wire contract. Stdlib only — no model, no onnxruntime, no pip install.
|
|
94
|
+
python3 -m unittest discover -s . -p "test_*.py" -v
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Runs in `guards-fast`. **Everything except the post-processing cases needs
|
|
98
|
+
nothing but the standard library** — those are the only ones that touch numpy,
|
|
99
|
+
and they skip themselves with a stated reason when it is absent. That invariant
|
|
100
|
+
is the durable claim; the counts (45 of 60 on 2026-08-24) are a snapshot and go
|
|
101
|
+
stale the next time a case is added, so derive them by running it rather than
|
|
102
|
+
reading them here.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# The real client against the real service, over loopback.
|
|
106
|
+
cd ../.. && TRILLBOARDS_DETECTOR_MODEL=/path/to/rfdetr-medium.onnx \
|
|
107
|
+
npx jest src/__tests__/localDetectorService.integration.test.ts
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Skips — loudly, with the reason — when the Python tool or the artifact is
|
|
111
|
+
absent. It is the only thing that can catch a two-language disagreement, and it
|
|
112
|
+
earned its place immediately: it found that the client sends every request
|
|
113
|
+
`Transfer-Encoding: chunked` (it sets no `Content-Length`) while `http.server`
|
|
114
|
+
does not decode chunked bodies, so the service was answering 400 to every real
|
|
115
|
+
call.
|
|
116
|
+
|
|
117
|
+
## The transcription control
|
|
118
|
+
|
|
119
|
+
`session.py` transcribes four steps from the `rfdetr` package — the
|
|
120
|
+
antialias-free square resize, ImageNet normalisation, `_select_topk`, and
|
|
121
|
+
`_gather_and_scale_boxes` — because that package pulls ~3 GB of torch and is not
|
|
122
|
+
going on a shop PC. A transcription can drift, so:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
uv venv /tmp/rfdetr-ref --python 3.12
|
|
126
|
+
uv pip install --python /tmp/rfdetr-ref/bin/python rfdetr onnx onnxruntime pillow
|
|
127
|
+
/tmp/rfdetr-ref/bin/python bench/verify_against_reference.py \
|
|
128
|
+
--onnx /path/to/rfdetr-medium.onnx --image ../windows/e2e/fixtures/person.jpg
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Runs both pipelines on one image and fails on any disagreement. A documented
|
|
132
|
+
manual run, not a CI step — it needs a checkpoint download and ~3 GB of
|
|
133
|
+
dependencies.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The control: this service's pipeline vs. the `rfdetr` package, same image.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. `session.py` TRANSCRIBES four steps from `rfdetr` — the
|
|
5
|
+
antialias-free square resize, the ImageNet normalisation, `_select_topk`, and
|
|
6
|
+
`_gather_and_scale_boxes` — because shipping torch + torchvision +
|
|
7
|
+
transformers (~3 GB) to a shop PC to run a 128 MiB graph is not an option. A
|
|
8
|
+
transcription can drift from its source silently: every one of those four steps
|
|
9
|
+
has a plausible wrong version that still returns boxes, and boxes that are
|
|
10
|
+
plausible and wrong are exactly what `docs/runbooks/cctv-detector-tier.md` §5
|
|
11
|
+
was written about.
|
|
12
|
+
|
|
13
|
+
So this runs both and fails on any disagreement. It is the same instinct as §5
|
|
14
|
+
rule 5 — *validate the rig before believing a surprise* — applied to the rig
|
|
15
|
+
itself.
|
|
16
|
+
|
|
17
|
+
NOT A CI STEP, deliberately: it needs the `rfdetr` package and its ~3 GB of
|
|
18
|
+
dependencies plus a checkpoint download. It is a documented manual run, the same
|
|
19
|
+
shape as the live half of `scripts/proposals/endpoint-matrix.js`.
|
|
20
|
+
|
|
21
|
+
uv venv /tmp/rfdetr-ref --python 3.12
|
|
22
|
+
uv pip install --python /tmp/rfdetr-ref/bin/python rfdetr onnx onnxruntime pillow
|
|
23
|
+
/tmp/rfdetr-ref/bin/python -m pip install -e <this dir>
|
|
24
|
+
/tmp/rfdetr-ref/bin/python bench/verify_against_reference.py \\
|
|
25
|
+
--onnx <exported>.onnx --image ../windows/e2e/fixtures/person.jpg
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
35
|
+
|
|
36
|
+
from trillboards_detector.session import OnnxDetector # noqa: E402
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> int:
|
|
40
|
+
ap = argparse.ArgumentParser()
|
|
41
|
+
ap.add_argument("--onnx", required=True)
|
|
42
|
+
ap.add_argument("--image", required=True)
|
|
43
|
+
ap.add_argument("--threshold", type=float, default=0.25)
|
|
44
|
+
# Tolerances. The exported fp32 graph is numerically identical to the `.pth`
|
|
45
|
+
# to ~1e-6 per the runbook, so the only real slack is the resize kernel:
|
|
46
|
+
# Pillow's BILINEAR and torchvision's antialias=False agree closely but not
|
|
47
|
+
# bit-for-bit, which moves a confidence in the third decimal and a box edge
|
|
48
|
+
# by well under a pixel.
|
|
49
|
+
ap.add_argument("--conf-tol", type=float, default=0.02)
|
|
50
|
+
ap.add_argument("--box-tol-px", type=float, default=2.0)
|
|
51
|
+
args = ap.parse_args()
|
|
52
|
+
|
|
53
|
+
from PIL import Image
|
|
54
|
+
from rfdetr import RFDETRMedium
|
|
55
|
+
|
|
56
|
+
image_path = Path(args.image)
|
|
57
|
+
with Image.open(image_path) as im:
|
|
58
|
+
width, height = im.size
|
|
59
|
+
print(f"image: {image_path} ({width}x{height})")
|
|
60
|
+
|
|
61
|
+
# ── the reference ────────────────────────────────────────────────────────
|
|
62
|
+
reference = RFDETRMedium()
|
|
63
|
+
ref = reference.predict(str(image_path), threshold=args.threshold)
|
|
64
|
+
ref_rows = sorted(
|
|
65
|
+
(
|
|
66
|
+
str(name),
|
|
67
|
+
round(float(conf), 4),
|
|
68
|
+
tuple(round(float(v), 2) for v in box),
|
|
69
|
+
)
|
|
70
|
+
for name, conf, box in zip(
|
|
71
|
+
ref.data.get("class_name", [""] * len(ref.confidence)),
|
|
72
|
+
ref.confidence,
|
|
73
|
+
ref.xyxy,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# ── this service ─────────────────────────────────────────────────────────
|
|
78
|
+
detector = OnnxDetector(Path(args.onnx), "rfdetr_medium")
|
|
79
|
+
batch = detector.detect(image_path.read_bytes(), args.threshold, None)
|
|
80
|
+
ours_rows = sorted(
|
|
81
|
+
(d.label, round(d.confidence, 4), (round(d.x, 2), round(d.y, 2), round(d.x + d.width, 2), round(d.y + d.height, 2)))
|
|
82
|
+
for d in batch.detections
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
print(f"\nreference ({len(ref_rows)}):")
|
|
86
|
+
for r in ref_rows:
|
|
87
|
+
print(" ", r)
|
|
88
|
+
print(f"\nthis service ({len(ours_rows)}): [inference {batch.inference_ms:.1f} ms]")
|
|
89
|
+
for r in ours_rows:
|
|
90
|
+
print(" ", r)
|
|
91
|
+
|
|
92
|
+
problems: list[str] = []
|
|
93
|
+
if len(ref_rows) != len(ours_rows):
|
|
94
|
+
problems.append(f"detection COUNT differs: reference {len(ref_rows)} vs ours {len(ours_rows)}")
|
|
95
|
+
else:
|
|
96
|
+
for i, (a, b) in enumerate(zip(ref_rows, ours_rows)):
|
|
97
|
+
if a[0] != b[0]:
|
|
98
|
+
problems.append(f"row {i}: label {a[0]!r} vs {b[0]!r}")
|
|
99
|
+
continue
|
|
100
|
+
if abs(a[1] - b[1]) > args.conf_tol:
|
|
101
|
+
problems.append(f"row {i} ({a[0]}): confidence {a[1]} vs {b[1]}")
|
|
102
|
+
for axis, (u, v) in enumerate(zip(a[2], b[2])):
|
|
103
|
+
if abs(u - v) > args.box_tol_px:
|
|
104
|
+
problems.append(f"row {i} ({a[0]}): box[{axis}] {u} vs {v}")
|
|
105
|
+
|
|
106
|
+
if problems:
|
|
107
|
+
print("\nDISAGREEMENT — the transcription in session.py has drifted:")
|
|
108
|
+
for p in problems:
|
|
109
|
+
print(" -", p)
|
|
110
|
+
return 1
|
|
111
|
+
|
|
112
|
+
print(
|
|
113
|
+
f"\nAGREE: {len(ref_rows)} detection(s), labels identical, "
|
|
114
|
+
f"confidence within {args.conf_tol}, boxes within {args.box_tol_px}px."
|
|
115
|
+
)
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "trillboards-detector"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "On-device object-detector service for the Trillboards BYO-CCTV connector (loopback HTTP, ONNX Runtime)."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
|
|
7
|
+
# PINNED, for the same reason litert-lm is pinned, and the reason is written out
|
|
8
|
+
# in `deploy/macos/scripts/setup-litertlm.sh`: an UNPINNED `uv tool install`
|
|
9
|
+
# installs whatever is current on the day a customer happens to reinstall, and
|
|
10
|
+
# the thing that then changes is not a changelog entry — it is the number this
|
|
11
|
+
# whole placement decision was made on. `docs/runbooks/cctv-detector-tier.md`
|
|
12
|
+
# picks RF-DETR Medium over the on-device VLM on a measured ~63 ms, and ORT's
|
|
13
|
+
# CPU execution provider is where that ms lives: kernel fusion, thread
|
|
14
|
+
# heuristics and SIMD dispatch all move between minor releases. litert-lm's
|
|
15
|
+
# accelerator behaviour moved twice with no release of ours and reproduced on no
|
|
16
|
+
# dev box; the pin is what stops that shape repeating here.
|
|
17
|
+
#
|
|
18
|
+
# These are the versions the service was measured against — see
|
|
19
|
+
# `docs/runbooks/cctv-detector-tier.md` §Runtime. A bump is a re-measurement,
|
|
20
|
+
# not a version-number edit.
|
|
21
|
+
dependencies = [
|
|
22
|
+
"onnxruntime==1.29.0",
|
|
23
|
+
"numpy==2.5.2",
|
|
24
|
+
"pillow==12.3.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
trillboards-detector = "trillboards_detector.cli:main"
|
|
29
|
+
|
|
30
|
+
# HATCHLING, NOT SETUPTOOLS, AND THAT IS A CORRECTNESS CHOICE.
|
|
31
|
+
#
|
|
32
|
+
# setuptools stages the package into a `build/lib/` directory and copies from
|
|
33
|
+
# THERE on later builds. A stale `build/` therefore silently shadows the real
|
|
34
|
+
# source: measured here on 2026-08-23, `uv tool install --force` reported
|
|
35
|
+
# "Installed 1 executable" and installed the PREVIOUS revision of server.py,
|
|
36
|
+
# with no warning anywhere. On a customer machine that is the whole failure
|
|
37
|
+
# shape this codebase keeps hitting — an operation that reports success while
|
|
38
|
+
# doing nothing — and it would ship an old detector behind a green install.
|
|
39
|
+
#
|
|
40
|
+
# Hatchling builds from the source tree with no intermediate staging directory
|
|
41
|
+
# and writes no `.egg-info`, so the staleness has nowhere to live. `.gitignore`
|
|
42
|
+
# and the two installer staging excludes cover the leftovers of the old
|
|
43
|
+
# backend; this makes new ones impossible.
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["hatchling"]
|
|
46
|
+
build-backend = "hatchling.build"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["trillboards_detector"]
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The artifact's home, and the label map's invariants. Stdlib only.
|
|
2
|
+
|
|
3
|
+
`resolve_data_dir` has to agree with `config.ts`'s `DEFAULTS.dataDir` exactly:
|
|
4
|
+
|
|
5
|
+
path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.trillboards')
|
|
6
|
+
|
|
7
|
+
If the Python service and the Node process that downloads the model disagree
|
|
8
|
+
about that directory, the detector is permanently unhealthy on a machine whose
|
|
9
|
+
artifact is sitting right there — and `/health` would report the truth about a
|
|
10
|
+
path nobody else uses.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import unittest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from trillboards_detector.labels import (
|
|
19
|
+
COCO91_CLASS_NAMES,
|
|
20
|
+
EXPECTED_LOGIT_SLOTS,
|
|
21
|
+
resolve_requested_classes,
|
|
22
|
+
)
|
|
23
|
+
from trillboards_detector.modelpath import (
|
|
24
|
+
DEFAULT_MODEL_NAME,
|
|
25
|
+
resolve_data_dir,
|
|
26
|
+
resolve_model_path,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestModelPath(unittest.TestCase):
|
|
31
|
+
def test_home_wins(self):
|
|
32
|
+
env = {"HOME": "/Users/x", "USERPROFILE": r"C:\Users\y"}
|
|
33
|
+
self.assertEqual(resolve_data_dir(env), Path("/Users/x/.trillboards"))
|
|
34
|
+
|
|
35
|
+
def test_userprofile_is_the_windows_fallback(self):
|
|
36
|
+
self.assertEqual(
|
|
37
|
+
resolve_data_dir({"USERPROFILE": r"C:\Users\y"}), Path(r"C:\Users\y") / ".trillboards"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def test_tmp_is_the_last_resort_exactly_as_in_config_ts(self):
|
|
41
|
+
self.assertEqual(resolve_data_dir({}), Path("/tmp/.trillboards"))
|
|
42
|
+
|
|
43
|
+
def test_an_empty_home_falls_through_rather_than_resolving_to_dot_trillboards(self):
|
|
44
|
+
"""`''` is falsy in JS too, so `config.ts` skips it. A Python `.get`
|
|
45
|
+
returning `''` would otherwise produce a RELATIVE `.trillboards`, i.e. a
|
|
46
|
+
different directory per working directory."""
|
|
47
|
+
self.assertEqual(resolve_data_dir({"HOME": "", "USERPROFILE": ""}), Path("/tmp/.trillboards"))
|
|
48
|
+
|
|
49
|
+
def test_the_default_layout_is_one_directory_per_model_name(self):
|
|
50
|
+
p = resolve_model_path(DEFAULT_MODEL_NAME, {"HOME": "/h"})
|
|
51
|
+
self.assertEqual(p, Path("/h/.trillboards/models/rfdetr_medium/model.onnx"))
|
|
52
|
+
|
|
53
|
+
def test_a_fixed_file_name_means_an_update_reclaims_the_old_bytes(self):
|
|
54
|
+
"""No version in the path, deliberately. The model OTA already orphans
|
|
55
|
+
752 MiB per update and nothing GCs abandoned model dirs; a
|
|
56
|
+
version-named directory would add 128 MiB per release on a box with no
|
|
57
|
+
disk-space preflight anywhere on this path."""
|
|
58
|
+
a = resolve_model_path("rfdetr_medium", {"HOME": "/h"})
|
|
59
|
+
b = resolve_model_path("rfdetr_medium", {"HOME": "/h"})
|
|
60
|
+
self.assertEqual(a, b)
|
|
61
|
+
|
|
62
|
+
def test_the_explicit_override_wins_over_everything(self):
|
|
63
|
+
env = {"HOME": "/h", "TRILLBOARDS_DETECTOR_MODEL": "/bench/rfdetr.onnx"}
|
|
64
|
+
self.assertEqual(resolve_model_path(env=env), Path("/bench/rfdetr.onnx"))
|
|
65
|
+
|
|
66
|
+
def test_the_data_dir_override_relocates_the_whole_store(self):
|
|
67
|
+
env = {"HOME": "/h", "TRILLBOARDS_DATA_DIR": "/var/lib/trillboards"}
|
|
68
|
+
self.assertEqual(
|
|
69
|
+
resolve_model_path("rfdetr_medium", env),
|
|
70
|
+
Path("/var/lib/trillboards/models/rfdetr_medium/model.onnx"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestLabelMap(unittest.TestCase):
|
|
75
|
+
def test_person_is_slot_1_which_is_what_the_eval_scored(self):
|
|
76
|
+
"""`detector-detections/rfdetr-medium.json` records
|
|
77
|
+
`class_selected: person, class_index: 1, class_confirmed_live: true`.
|
|
78
|
+
Every accuracy number in the runbook is a claim about THAT index."""
|
|
79
|
+
self.assertEqual(COCO91_CLASS_NAMES[1], "person")
|
|
80
|
+
|
|
81
|
+
def test_the_map_fits_inside_the_declared_logit_width(self):
|
|
82
|
+
self.assertLess(max(COCO91_CLASS_NAMES), EXPECTED_LOGIT_SLOTS)
|
|
83
|
+
self.assertEqual(EXPECTED_LOGIT_SLOTS, 91)
|
|
84
|
+
|
|
85
|
+
def test_it_is_the_80_named_coco_categories_with_the_gaps_intact(self):
|
|
86
|
+
"""80 names across a 91-wide axis. The eleven gaps are real — they are
|
|
87
|
+
why an unnamed slot has to be dropped rather than guessed."""
|
|
88
|
+
self.assertEqual(len(COCO91_CLASS_NAMES), 80)
|
|
89
|
+
for gap in (0, 12, 26, 29, 30, 45, 66, 68, 69, 71, 83):
|
|
90
|
+
self.assertNotIn(gap, COCO91_CLASS_NAMES)
|
|
91
|
+
|
|
92
|
+
def test_names_are_unique_so_a_filter_cannot_be_ambiguous(self):
|
|
93
|
+
names = list(COCO91_CLASS_NAMES.values())
|
|
94
|
+
self.assertEqual(len(names), len(set(names)))
|
|
95
|
+
|
|
96
|
+
def test_requested_classes_fold_case_underscores_and_hyphens(self):
|
|
97
|
+
wanted, unknown = resolve_requested_classes(["Person", "CELL_PHONE", "dining-table"])
|
|
98
|
+
self.assertEqual(wanted, {"person", "cell phone", "dining table"})
|
|
99
|
+
self.assertEqual(unknown, [])
|
|
100
|
+
|
|
101
|
+
def test_an_unknown_class_is_returned_never_silently_dropped(self):
|
|
102
|
+
wanted, unknown = resolve_requested_classes(["person", "shoplifter"])
|
|
103
|
+
self.assertEqual(wanted, {"person"})
|
|
104
|
+
self.assertEqual(unknown, ["shoplifter"])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
unittest.main()
|