@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.
@@ -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
+ }