@trillboards/edge-sdk 0.2.85 → 0.2.89
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/bench/count_quantization.py +174 -0
- package/deploy/detector/bench/quantization-control.json +52 -0
- package/deploy/detector/bench/run_candidate.py +933 -0
- package/deploy/detector/bench/verify_yolo_decode.py +132 -0
- 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
|
@@ -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
|
+
}
|