@trillboards/edge-sdk 0.2.85 → 0.2.91
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 +76 -7
- package/deploy/detector/bench/count_quantization.py +174 -0
- package/deploy/detector/bench/quantization-control.json +52 -0
- package/deploy/detector/bench/run_candidate.py +993 -0
- package/deploy/detector/bench/verify_detector_decode.py +574 -0
- package/deploy/detector/bench/verify_yolo_decode.py +135 -0
- package/deploy/detector/tests/test_decode.py +559 -0
- package/deploy/detector/tests/test_loader.py +10 -1
- package/deploy/detector/tests/test_registry.py +42 -4
- package/deploy/detector/tests/test_server.py +9 -1
- package/deploy/detector/tests/test_session.py +402 -1
- package/deploy/detector/trillboards_detector/decode.py +373 -0
- package/deploy/detector/trillboards_detector/labels.py +139 -0
- package/deploy/detector/trillboards_detector/loader.py +15 -1
- package/deploy/detector/trillboards_detector/registry.py +9 -0
- package/deploy/detector/trillboards_detector/session.py +233 -26
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/sensingCatchRate.d.ts +69 -5
- package/dist/sensingCatchRate.d.ts.map +1 -1
- package/dist/sensingCatchRate.js +41 -2
- package/dist/sensingCatchRate.js.map +1 -1
- package/package.json +1 -1
|
@@ -51,12 +51,44 @@ what is resident on that box right now.
|
|
|
51
51
|
|
|
52
52
|
```
|
|
53
53
|
labels.py one committed ModelVocabulary per model (slots -> class names,
|
|
54
|
-
logit width, measured RAM
|
|
54
|
+
logit width, measured RAM, and the VariantContract naming what
|
|
55
|
+
its ONNX output MEANS). ClassIndex is the UNION over them.
|
|
56
|
+
decode.py the three output layouts and the arithmetic each needs, plus the
|
|
57
|
+
only IoU/NMS in the Python tree. NUMPY ONLY — no ORT, no Pillow.
|
|
55
58
|
registry.py name -> session map, the class index, lazy load, LRU eviction
|
|
56
59
|
under a RAM budget, and the merge. STDLIB ONLY.
|
|
57
60
|
loader.py the poll loop + the real ORT session factory and probe frame.
|
|
58
61
|
```
|
|
59
62
|
|
|
63
|
+
### Output layouts
|
|
64
|
+
|
|
65
|
+
Three, and **which one an artifact uses is DECLARED, never sniffed**. The
|
|
66
|
+
declaration is a `VariantContract` on the model's vocabulary — the box-side copy
|
|
67
|
+
of that artifact's `(model_id, format)` row in `model_platform_variants`
|
|
68
|
+
(`output_layout`, `box_units`, `box_scale_to_pixels`) — and `_read_graph_spec`
|
|
69
|
+
CHECKS the loaded graph against it and refuses on disagreement.
|
|
70
|
+
|
|
71
|
+
| layout | shape | needs NMS | preprocessing |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| `rfdetr_dets_labels` | `dets [1,Q,4]` + `labels [1,Q,C]` | no (set prediction) | ImageNet-normalised **stretch** |
|
|
74
|
+
| `anchor_4_plus_nc` | `[1, 4+nc, A]`, scores already sigmoid | **yes** | Ultralytics **letterbox**, 114-grey, /255 |
|
|
75
|
+
| `end2end_n_6` | `[1, N, 6]` = x1,y1,x2,y2,score,cls | no (already suppressed) | Ultralytics **letterbox**, 114-grey, /255 |
|
|
76
|
+
|
|
77
|
+
Sniffing is forbidden because `[1, 300, 6]` and `[1, 6, 8400]` are both rank-3
|
|
78
|
+
with a 6 in them: a reader that gets one backwards reads x1,y1,x2,y2,score,cls
|
|
79
|
+
as four box channels and two classes, with no exception and a plausible table.
|
|
80
|
+
|
|
81
|
+
**`box_units` is a property of the FORMAT, not of the model.** Ultralytics wraps
|
|
82
|
+
a `task=detect`, `end2end=False` TFLite export in `_NormalizeCoords`, dividing
|
|
83
|
+
the box channels by (W, H). Measured across all 17 committed LiteRT twins: 16 of
|
|
84
|
+
17 have ONNX boxes in pixels 0..640 and LiteRT boxes in 0..1, agreeing to
|
|
85
|
+
0.00037 px after multiplying by 640, with class scores agreeing to 5.5e-06.
|
|
86
|
+
`queue_supermarket`, the one `end2end_n_6` twin, is pixels on both sides. Every
|
|
87
|
+
other check passes on such a pair — same shape, same dtype, both load, both run
|
|
88
|
+
— so a decoder that assumes pixels draws every box 640x too small in silence.
|
|
89
|
+
The two preprocessors are likewise NOT interchangeable and `/health` reports
|
|
90
|
+
which one ran, next to `output_layout`.
|
|
91
|
+
|
|
60
92
|
* **A class routes to the FIRST catalogue model that names it.** Adding a
|
|
61
93
|
specialist can never move an existing class off the model it was scored on —
|
|
62
94
|
`person` stays on `rfdetr_medium` even after a PPE model that also emits it.
|
|
@@ -181,12 +213,13 @@ python3 -m unittest discover -s . -p "test_*.py" -v
|
|
|
181
213
|
|
|
182
214
|
Runs in `guards-fast`. `test_registry.py` covers the whole residency policy —
|
|
183
215
|
routing, lazy load, eviction, and every refusal — with FAKE sessions, so it runs
|
|
184
|
-
there too. **
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
216
|
+
there too. **The wire contract and the residency policy need nothing but the
|
|
217
|
+
standard library**; `test_decode.py` needs numpy and nothing else (`decode.py`
|
|
218
|
+
imports no ORT, deliberately, so the arithmetic is exercisable on any runner
|
|
219
|
+
that can `import numpy`), and `test_session.py` needs onnxruntime too because
|
|
220
|
+
`session.py` imports it. Each skips itself with a stated reason. That layering
|
|
221
|
+
is the durable claim; any count is a snapshot and goes stale the next time a
|
|
222
|
+
case is added, so derive it by running rather than reading it here.
|
|
190
223
|
|
|
191
224
|
```bash
|
|
192
225
|
# The real client against the real service, over loopback.
|
|
@@ -218,3 +251,39 @@ uv pip install --python /tmp/rfdetr-ref/bin/python rfdetr onnx onnxruntime pillo
|
|
|
218
251
|
Runs both pipelines on one image and fails on any disagreement. A documented
|
|
219
252
|
manual run, not a CI step — it needs a checkpoint download and ~3 GB of
|
|
220
253
|
dependencies.
|
|
254
|
+
|
|
255
|
+
`bench/verify_detector_decode.py` is the same control for the two YOLO layouts,
|
|
256
|
+
against `ultralytics` itself, in three arms that each hold one variable:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
uv venv /tmp/yolo-ref --python 3.11
|
|
260
|
+
uv pip install --python /tmp/yolo-ref/bin/python ultralytics onnxruntime pillow ai-edge-litert
|
|
261
|
+
|
|
262
|
+
# decode: SAME raw tensor, our decode vs ultralytics NMS + scale_boxes.
|
|
263
|
+
/tmp/yolo-ref/bin/python bench/verify_detector_decode.py --arm decode \
|
|
264
|
+
--models-dir /path/to/onnx --frames-dir /path/to/frames --tolerance-px 0.0
|
|
265
|
+
|
|
266
|
+
# pipeline: the whole service path vs YOLO(...).predict, resize kernel held constant.
|
|
267
|
+
/tmp/yolo-ref/bin/python bench/verify_detector_decode.py --arm pipeline \
|
|
268
|
+
--models-dir /path/to/onnx --frames-dir /path/to/frames --tolerance-px 0.001
|
|
269
|
+
|
|
270
|
+
# litert: the same weights through both runtimes, in PIXELS, per the DECLARED scale.
|
|
271
|
+
/tmp/yolo-ref/bin/python bench/verify_detector_decode.py --arm litert \
|
|
272
|
+
--models-dir /path/to/onnx --frames-dir /path/to/frames \
|
|
273
|
+
--litert-manifest /path/to/litert/MANIFEST.json --tolerance-px 0.01
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Two traps this bench cost, both of them the instrument rather than the decode,
|
|
277
|
+
and both now closed in the file:
|
|
278
|
+
|
|
279
|
+
* **The reference must be the ONNX, not the `.pt`** — Ultralytics letterboxes a
|
|
280
|
+
checkpoint to 640x480 while the graph is fixed 640x640, so the two arms
|
|
281
|
+
disagree by up to 0.25 confidence in both directions and it reads as a decode
|
|
282
|
+
bug.
|
|
283
|
+
* **Hold the resize kernel constant too.** Pillow's BILINEAR low-passes on
|
|
284
|
+
downscale, cv2's `INTER_LINEAR` does not, and on one 1200x900 frame that moved
|
|
285
|
+
a class from 0.18 to 0.51 — 20 of 378 presence decisions apart. The
|
|
286
|
+
`pipeline` arm therefore letterboxes ONCE and feeds both sides those bytes,
|
|
287
|
+
and it feeds the reference the pixels that came back OUT of the JPEG rather
|
|
288
|
+
than the canvas that went in, because JPEG at quality 100 is still lossy
|
|
289
|
+
enough to move a borderline box.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The control for `docs/runbooks/cctv-detector-tier.md` §4: never trust an
|
|
3
|
+
int8 export FLAG, count the nodes it actually emitted.
|
|
4
|
+
|
|
5
|
+
WHY THIS EXISTS. An int8 export flag can fail SILENTLY or LOUDLY depending on
|
|
6
|
+
the library, and the difference is the argument NAME rather than the exporter:
|
|
7
|
+
RF-DETR documents `quantization` as *ignored when format=onnx* and writes an
|
|
8
|
+
all-FLOAT file without erroring, while ultralytics REJECTS that same spelling
|
|
9
|
+
and quantises only under `quantize`. A runbook sentence written against one
|
|
10
|
+
library reads as universal to the next reader — that is how the RF-DETR
|
|
11
|
+
sentence got applied to ultralytics in five separate briefs on 2026-08-24
|
|
12
|
+
before anyone measured it.
|
|
13
|
+
|
|
14
|
+
What survives both behaviours is this count. It reads only the emitted graph,
|
|
15
|
+
so it is indifferent to which library, which flag spelling, and which release
|
|
16
|
+
produced the file — the property it reports is the one the runbook actually
|
|
17
|
+
claims.
|
|
18
|
+
|
|
19
|
+
NOT A CI STEP, deliberately, and for the same reason as
|
|
20
|
+
`verify_against_reference.py` beside it: reproducing the quantised arm needs
|
|
21
|
+
ultralytics plus torch (~3 GB) and a checkpoint download. Counting an ONNX file
|
|
22
|
+
you already have needs only `onnx`. It is a documented manual run.
|
|
23
|
+
|
|
24
|
+
# Count any ONNX file you already have (needs only `onnx`):
|
|
25
|
+
python3 bench/count_quantization.py --onnx <exported>.onnx
|
|
26
|
+
|
|
27
|
+
# Reproduce the committed control from scratch:
|
|
28
|
+
uv venv /tmp/ultra --python 3.12
|
|
29
|
+
uv pip install --python /tmp/ultra/bin/python \\
|
|
30
|
+
'ultralytics==8.4.128' onnx onnxruntime onnxslim
|
|
31
|
+
/tmp/ultra/bin/python bench/count_quantization.py --reproduce-ultralytics
|
|
32
|
+
|
|
33
|
+
`--reproduce-ultralytics` re-derives all three rows of the committed result
|
|
34
|
+
(`bench/quantization-control.json`): the rejected spelling, the fp32 baseline,
|
|
35
|
+
and the genuinely quantised arm.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import re
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
# Ultralytics colourises the argument name inside its own SyntaxError, so the
|
|
46
|
+
# raw message carries ANSI escapes. Strip them: the committed control is a
|
|
47
|
+
# readable record that must compare equal to a fresh run's output.
|
|
48
|
+
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
49
|
+
|
|
50
|
+
# The control is pinned to the smallest ultralytics detection checkpoint so the
|
|
51
|
+
# arm is cheap to re-run. It is NOT a recommendation: §3 of the runbook rejects
|
|
52
|
+
# raw COCO YOLO as a gate on accuracy grounds, and rejects INT8 outright (it
|
|
53
|
+
# loses 31.7 points of recall SILENTLY and is slower than fp32). This model is
|
|
54
|
+
# here only because it is the smallest artifact that can demonstrate a REAL
|
|
55
|
+
# quantisation, against which a silent no-op is distinguishable.
|
|
56
|
+
CONTROL_MODEL = "yolo11n.pt"
|
|
57
|
+
CONTROL_ULTRALYTICS_VERSION = "8.4.128"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def count_onnx(path: Path) -> dict:
|
|
61
|
+
"""Node and initializer census of one ONNX file.
|
|
62
|
+
|
|
63
|
+
The two halves answer different questions and a real quantisation moves
|
|
64
|
+
BOTH: QDQ nodes prove the graph carries quantise/dequantise steps, and INT8
|
|
65
|
+
initializers prove the WEIGHTS were actually converted. A file with QDQ
|
|
66
|
+
nodes but no int8 weights is quantising activations only; a file with
|
|
67
|
+
neither is what a silently-ignored flag produces.
|
|
68
|
+
|
|
69
|
+
NON-FLOAT ALONE IS NOT THE PREDICATE, and this is the trap inside the trap:
|
|
70
|
+
a perfectly clean fp32 export still carries INT64 SHAPE CONSTANTS — the
|
|
71
|
+
committed yolo11n fp32 control has 15 of them against 181 FLOAT tensors. A
|
|
72
|
+
check that only counted "non-FLOAT initializers > 0" would call that file
|
|
73
|
+
quantised. `quantised` below therefore requires a QuantizeLinear node AND a
|
|
74
|
+
genuine INT8 weight tensor, and `initializers_by_dtype` is reported in full
|
|
75
|
+
so the next reader can see which dtypes were actually present rather than
|
|
76
|
+
trusting a single derived count.
|
|
77
|
+
"""
|
|
78
|
+
import onnx
|
|
79
|
+
from onnx import TensorProto
|
|
80
|
+
|
|
81
|
+
model = onnx.load(str(path), load_external_data=False)
|
|
82
|
+
ops: dict[str, int] = {}
|
|
83
|
+
for node in model.graph.node:
|
|
84
|
+
ops[node.op_type] = ops.get(node.op_type, 0) + 1
|
|
85
|
+
|
|
86
|
+
by_dtype: dict[str, int] = {}
|
|
87
|
+
for init in model.graph.initializer:
|
|
88
|
+
name = TensorProto.DataType.Name(init.data_type)
|
|
89
|
+
by_dtype[name] = by_dtype.get(name, 0) + 1
|
|
90
|
+
|
|
91
|
+
total_init = sum(by_dtype.values())
|
|
92
|
+
non_float = total_init - by_dtype.get("FLOAT", 0)
|
|
93
|
+
return {
|
|
94
|
+
"file": path.name,
|
|
95
|
+
"size_bytes": path.stat().st_size,
|
|
96
|
+
"size_mb": round(path.stat().st_size / 1e6, 1),
|
|
97
|
+
"quantize_linear": ops.get("QuantizeLinear", 0),
|
|
98
|
+
"dequantize_linear": ops.get("DequantizeLinear", 0),
|
|
99
|
+
"initializers_total": total_init,
|
|
100
|
+
"initializers_by_dtype": dict(sorted(by_dtype.items())),
|
|
101
|
+
"initializers_non_float": non_float,
|
|
102
|
+
"initializers_int8": by_dtype.get("INT8", 0),
|
|
103
|
+
# The predicate the runbook tells you to trust. Deliberately NOT
|
|
104
|
+
# `non_float > 0` — see the docstring: INT64 shape constants make that
|
|
105
|
+
# true on a clean fp32 file.
|
|
106
|
+
"quantised": ops.get("QuantizeLinear", 0) > 0 and by_dtype.get("INT8", 0) > 0,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def reproduce_ultralytics() -> dict:
|
|
111
|
+
"""Re-derive the committed three-row control."""
|
|
112
|
+
from ultralytics import YOLO
|
|
113
|
+
|
|
114
|
+
rows: dict[str, object] = {
|
|
115
|
+
"model": CONTROL_MODEL,
|
|
116
|
+
"ultralytics": CONTROL_ULTRALYTICS_VERSION,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Row 1 — the REJECTED spelling. RF-DETR ignores this silently; ultralytics
|
|
120
|
+
# raises. Same characters, opposite failure mode, which is the whole point.
|
|
121
|
+
try:
|
|
122
|
+
YOLO(CONTROL_MODEL).export(format="onnx", quantization="int8")
|
|
123
|
+
rows["rejected_spelling"] = {
|
|
124
|
+
"raised": False,
|
|
125
|
+
"note": "did NOT raise — ultralytics behaviour changed, update the runbook",
|
|
126
|
+
}
|
|
127
|
+
except Exception as exc: # noqa: BLE001 - the exception IS the measurement
|
|
128
|
+
rows["rejected_spelling"] = {
|
|
129
|
+
"raised": True,
|
|
130
|
+
"type": type(exc).__name__,
|
|
131
|
+
"message": ANSI.sub("", str(exc)).strip().splitlines()[0][:300],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Row 2 — fp32 baseline. Expect 0 QDQ nodes and 0 INT8 tensors. It is NOT
|
|
135
|
+
# expected to be free of non-FLOAT initializers: the INT64 shape constants
|
|
136
|
+
# are normal and are exactly why `quantised` does not key on that count.
|
|
137
|
+
fp32 = Path(YOLO(CONTROL_MODEL).export(format="onnx"))
|
|
138
|
+
rows["fp32"] = count_onnx(fp32)
|
|
139
|
+
|
|
140
|
+
# Row 3 — the correct spelling, which genuinely quantises. Ultralytics
|
|
141
|
+
# auto-fetches coco8 to calibrate.
|
|
142
|
+
int8 = Path(YOLO(CONTROL_MODEL).export(format="onnx", quantize="int8"))
|
|
143
|
+
rows["int8"] = count_onnx(int8)
|
|
144
|
+
return rows
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main() -> int:
|
|
148
|
+
ap = argparse.ArgumentParser()
|
|
149
|
+
ap.add_argument("--onnx", help="count one ONNX file already on disk")
|
|
150
|
+
ap.add_argument(
|
|
151
|
+
"--reproduce-ultralytics",
|
|
152
|
+
action="store_true",
|
|
153
|
+
help="re-derive the committed control (needs ultralytics + torch)",
|
|
154
|
+
)
|
|
155
|
+
ap.add_argument("--json-out", help="write the result to this path")
|
|
156
|
+
args = ap.parse_args()
|
|
157
|
+
|
|
158
|
+
if not args.onnx and not args.reproduce_ultralytics:
|
|
159
|
+
ap.error("pass --onnx <file> or --reproduce-ultralytics")
|
|
160
|
+
|
|
161
|
+
result = (
|
|
162
|
+
reproduce_ultralytics()
|
|
163
|
+
if args.reproduce_ultralytics
|
|
164
|
+
else count_onnx(Path(args.onnx))
|
|
165
|
+
)
|
|
166
|
+
text = json.dumps(result, indent=2)
|
|
167
|
+
print(text)
|
|
168
|
+
if args.json_out:
|
|
169
|
+
Path(args.json_out).write_text(text + "\n", encoding="utf-8")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "Committed control for docs/runbooks/cctv-detector-tier.md \u00a74. Regenerate with: bench/count_quantization.py --reproduce-ultralytics. Sizes are raw bytes; the MB/MiB split is why the first write-up of this control disagreed with itself (10.7 MB is 10.2 MiB).",
|
|
3
|
+
"measured_at": "2026-08-25",
|
|
4
|
+
"environment": {
|
|
5
|
+
"ultralytics": "8.4.128",
|
|
6
|
+
"onnx": "1.22.0",
|
|
7
|
+
"onnxslim": "0.1.96",
|
|
8
|
+
"onnx_opset": 18,
|
|
9
|
+
"torch": "2.13.0",
|
|
10
|
+
"python": "3.12.12",
|
|
11
|
+
"device": "CPU (Apple M4 Max)",
|
|
12
|
+
"calibration": "coco8.yaml (4 images; ultralytics warns >300 recommended)"
|
|
13
|
+
},
|
|
14
|
+
"model": "yolo11n.pt",
|
|
15
|
+
"rejected_spelling": {
|
|
16
|
+
"raised": true,
|
|
17
|
+
"type": "SyntaxError",
|
|
18
|
+
"message": "'quantization' is not a valid YOLO argument. Similar arguments are i.e. ['quantize']."
|
|
19
|
+
},
|
|
20
|
+
"fp32": {
|
|
21
|
+
"file": "yolo11n.onnx",
|
|
22
|
+
"size_bytes": 10741399,
|
|
23
|
+
"size_mb": 10.7,
|
|
24
|
+
"quantize_linear": 0,
|
|
25
|
+
"dequantize_linear": 0,
|
|
26
|
+
"initializers_total": 196,
|
|
27
|
+
"initializers_by_dtype": {
|
|
28
|
+
"FLOAT": 181,
|
|
29
|
+
"INT64": 15
|
|
30
|
+
},
|
|
31
|
+
"initializers_non_float": 15,
|
|
32
|
+
"initializers_int8": 0,
|
|
33
|
+
"quantised": false
|
|
34
|
+
},
|
|
35
|
+
"int8": {
|
|
36
|
+
"file": "yolo11n_int8.onnx",
|
|
37
|
+
"size_bytes": 3100688,
|
|
38
|
+
"size_mb": 3.1,
|
|
39
|
+
"quantize_linear": 174,
|
|
40
|
+
"dequantize_linear": 349,
|
|
41
|
+
"initializers_total": 894,
|
|
42
|
+
"initializers_by_dtype": {
|
|
43
|
+
"FLOAT": 355,
|
|
44
|
+
"INT32": 174,
|
|
45
|
+
"INT64": 15,
|
|
46
|
+
"INT8": 350
|
|
47
|
+
},
|
|
48
|
+
"initializers_non_float": 539,
|
|
49
|
+
"initializers_int8": 350,
|
|
50
|
+
"quantised": true
|
|
51
|
+
}
|
|
52
|
+
}
|