source: D798 INT8 recipe (Detect head in float, MIXED, full bias correction); archive the 2026-10-08 convert_lib
Browse files- source/README.md +38 -5
- source/archive/convert_lib-before-2026-10-09.py +199 -0
- source/build-camstack-models.py +19 -13
- source/convert_lib.py +60 -1
source/README.md
CHANGED
|
@@ -89,11 +89,44 @@ INT8 files are skipped and the run says so.
|
|
| 89 |
Until 2026-10-08 the calibration padded TOP-LEFT. On that date every INT8 IR
|
| 90 |
was re-quantised centred from the hosted ONNX (`--reuse-onnx`, OpenVINO
|
| 91 |
2026.2.1 + NNCF 3.2.0) and measured against the old one. 13 replaced the old
|
| 92 |
-
IR
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
## Licences
|
| 99 |
|
|
|
|
| 89 |
Until 2026-10-08 the calibration padded TOP-LEFT. On that date every INT8 IR
|
| 90 |
was re-quantised centred from the hosted ONNX (`--reuse-onnx`, OpenVINO
|
| 91 |
2026.2.1 + NNCF 3.2.0) and measured against the old one. 13 replaced the old
|
| 92 |
+
IR, 8 measured worse and were kept. The replaced IRs are under
|
| 93 |
+
`archive/2026-10-08-int8-topleft-calibration/` at the mirror root.
|
| 94 |
+
[`archive/convert_lib-before-2026-10-08.py`](archive/convert_lib-before-2026-10-08.py)
|
| 95 |
+
reproduces the top-left ones; it rebuilds the hosted `yolov9m-640` INT8 IR bit
|
| 96 |
+
for bit.
|
| 97 |
+
|
| 98 |
+
### INT8 recipe (since 2026-10-09)
|
| 99 |
+
|
| 100 |
+
`convert_lib.quantize_openvino_int8` keeps the YOLO Detect head in float.
|
| 101 |
+
|
| 102 |
+
- **YOLOv9:** the head's own ops stay float: the DFL softmax and the box
|
| 103 |
+
decode (anchor ± distance, ×stride, class sigmoid). The conv branches stay
|
| 104 |
+
INT8.
|
| 105 |
+
- **YOLO26:** the whole end-to-end head stays float.
|
| 106 |
+
- **Every model:** NNCF preset MIXED and full bias correction.
|
| 107 |
+
|
| 108 |
+
This follows the Ultralytics OpenVINO INT8 exporter and NNCF's YOLO26 example.
|
| 109 |
+
The previous recipe quantised the decode, which put box coordinates on a
|
| 110 |
+
256-level grid.
|
| 111 |
+
|
| 112 |
+
**Result:**
|
| 113 |
+
|
| 114 |
+
- On all 8 YOLOv9 IRs, mAP50-95 went from 0.01–0.18 below float to within noise
|
| 115 |
+
of it, at NPU latency within 1 %.
|
| 116 |
+
- 14 IRs were replaced in place: every `yolov9*-int8`, `yolo26{n,s,l}-int8`,
|
| 117 |
+
`yolo26n-320-int8` and `yolo26{n,s}-256-int8`.
|
| 118 |
+
- The previous files are under `archive/2026-10-09-int8-before-head-float-recipe/`
|
| 119 |
+
at the mirror root.
|
| 120 |
+
|
| 121 |
+
Seven YOLO26 IRs were kept, because the float head cost 11–22 % latency on the
|
| 122 |
+
Intel iGPU:
|
| 123 |
+
|
| 124 |
+
- `yolo26{m,x}-int8` and `yolo26{m,l}-256-int8` (centred) are reproduced by
|
| 125 |
+
[`archive/convert_lib-before-2026-10-09.py`](archive/convert_lib-before-2026-10-09.py);
|
| 126 |
+
- `yolo26{s,m,l}-320-int8` (top-left) are reproduced by
|
| 127 |
+
`archive/convert_lib-before-2026-10-08.py`.
|
| 128 |
+
|
| 129 |
+
`build-camstack-models.py` lists which IR carries which recipe.
|
| 130 |
|
| 131 |
## Licences
|
| 132 |
|
source/archive/convert_lib-before-2026-10-09.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared model converter: ONNX -> OpenVINO IR (fp16/int8/CoreML) + validation.
|
| 2 |
+
|
| 3 |
+
Used by both the runtime model-convert cap (convert.py) and the offline
|
| 4 |
+
scripts/build-camstack-models.py. Conversion ALWAYS starts from ONNX via
|
| 5 |
+
ov.convert_model (never the Ultralytics OV exporter, which emits unnamed
|
| 6 |
+
tensors that break the runtime's named-output postprocess)."""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import os
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# OpenVINO sizes the pool that COMPILES a model to the machine's core count and
|
| 12 |
+
# TBB never gives those workers back. A container cgroup counts THREADS, and
|
| 13 |
+
# this one has been measured refusing forks against `pids.max=2048` — so a
|
| 14 |
+
# conversion running beside the live pools must not spend `nproc` of the budget
|
| 15 |
+
# just to validate an IR. Same knob, same env var and same default (4) as
|
| 16 |
+
# OV_COMPILATION_NUM_THREADS in
|
| 17 |
+
# packages/addon-pipeline/python/inference_pool.py, which carries the
|
| 18 |
+
# measurement; this process is short-lived, so the saving here is a spike, not
|
| 19 |
+
# the 214-thread accumulation that motivated the bound. 0 disables it.
|
| 20 |
+
_OV_COMPILATION_NUM_THREADS: int = 4
|
| 21 |
+
try:
|
| 22 |
+
_OV_COMPILATION_NUM_THREADS = int(
|
| 23 |
+
os.environ.get("CAMSTACK_OV_COMPILATION_THREADS", "4")
|
| 24 |
+
)
|
| 25 |
+
except ValueError:
|
| 26 |
+
_OV_COMPILATION_NUM_THREADS = 4
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def export_openvino(onnx_path: str, out_xml: str, *, fp16: bool) -> None:
|
| 30 |
+
import openvino as ov
|
| 31 |
+
ov_model = ov.convert_model(onnx_path)
|
| 32 |
+
Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
ov.save_model(ov_model, out_xml, compress_to_fp16=fp16)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def validate_openvino_ir(xml_path: str) -> None:
|
| 37 |
+
"""Compile the IR and assert every input/output tensor carries a name.
|
| 38 |
+
This is the exact defect (unnamed tensors) that broke 14/26 catalog IRs."""
|
| 39 |
+
import openvino as ov
|
| 40 |
+
core = ov.Core()
|
| 41 |
+
model = core.read_model(xml_path)
|
| 42 |
+
# Bound the compilation pool (see _OV_COMPILATION_NUM_THREADS). A plugin
|
| 43 |
+
# REJECTS a property it does not know, and this compile is the VALIDATION —
|
| 44 |
+
# it must never fail for a reason that has nothing to do with the IR, so a
|
| 45 |
+
# refused property falls back to the unbounded compile rather than
|
| 46 |
+
# reporting a bad model.
|
| 47 |
+
if _OV_COMPILATION_NUM_THREADS > 0:
|
| 48 |
+
try:
|
| 49 |
+
compiled = core.compile_model(
|
| 50 |
+
model,
|
| 51 |
+
"CPU",
|
| 52 |
+
{"COMPILATION_NUM_THREADS": _OV_COMPILATION_NUM_THREADS},
|
| 53 |
+
)
|
| 54 |
+
except Exception:
|
| 55 |
+
compiled = core.compile_model(model, "CPU")
|
| 56 |
+
else:
|
| 57 |
+
compiled = core.compile_model(model, "CPU")
|
| 58 |
+
for port in list(compiled.inputs) + list(compiled.outputs):
|
| 59 |
+
try:
|
| 60 |
+
name = port.get_any_name()
|
| 61 |
+
except Exception as exc: # ov raises when a tensor has no names
|
| 62 |
+
raise ValueError(f"IR {xml_path} has an unnamed tensor: {exc}") from exc
|
| 63 |
+
if not name:
|
| 64 |
+
raise ValueError(f"IR {xml_path} has an empty-named tensor")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
CALIBRATION_SUFFIXES = {".jpg", ".jpeg", ".png"}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def calibration_images(calib_dir: str, max_samples: int) -> "list[Path]":
|
| 71 |
+
"""The first ``max_samples`` calibration images of ``calib_dir``, in path order.
|
| 72 |
+
|
| 73 |
+
A flat folder (COCO val2017) is read exactly as before: file-name order. A
|
| 74 |
+
folder is walked recursively, so the operator's own frames work as they
|
| 75 |
+
come. The hub's annotated export (a folder holding ``annotations.json``)
|
| 76 |
+
calibrates on its ``frames/`` only: its ``crops/`` are classifier inputs,
|
| 77 |
+
not what a root detector is fed.
|
| 78 |
+
"""
|
| 79 |
+
root = Path(calib_dir)
|
| 80 |
+
if not root.is_dir():
|
| 81 |
+
raise ValueError(f"calibration folder {calib_dir} does not exist")
|
| 82 |
+
base = root / "frames" if (root / "annotations.json").is_file() else root
|
| 83 |
+
images = sorted(
|
| 84 |
+
(p for p in base.rglob("*") if p.is_file() and p.suffix.lower() in CALIBRATION_SUFFIXES),
|
| 85 |
+
key=lambda p: p.relative_to(base).as_posix(),
|
| 86 |
+
)[:max_samples]
|
| 87 |
+
if not images:
|
| 88 |
+
raise ValueError(f"no calibration images in {calib_dir}")
|
| 89 |
+
return images
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def letterbox_for_calibration(rgb, imgsz: int):
|
| 93 |
+
"""``rgb`` (HxWx3 uint8) → ``[1,3,imgsz,imgsz]`` float32 in [0,1], letterboxed
|
| 94 |
+
as the inference pool does (``inference_pool.letterbox_image``): scale by the
|
| 95 |
+
long side, truncate, CENTRE with grey 114 padding."""
|
| 96 |
+
import cv2
|
| 97 |
+
import numpy as np
|
| 98 |
+
|
| 99 |
+
h, w = rgb.shape[:2]
|
| 100 |
+
scale = imgsz / max(h, w)
|
| 101 |
+
nw, nh = int(w * scale), int(h * scale)
|
| 102 |
+
pad_x, pad_y = (imgsz - nw) // 2, (imgsz - nh) // 2
|
| 103 |
+
resized = rgb if (nw, nh) == (w, h) else cv2.resize(rgb, (nw, nh), interpolation=cv2.INTER_LINEAR)
|
| 104 |
+
canvas = np.full((imgsz, imgsz, 3), 114, dtype=np.uint8)
|
| 105 |
+
canvas[pad_y : pad_y + nh, pad_x : pad_x + nw] = resized
|
| 106 |
+
chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0
|
| 107 |
+
return chw[np.newaxis, ...]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def quantize_openvino_int8(
|
| 111 |
+
onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
|
| 112 |
+
) -> None:
|
| 113 |
+
import cv2
|
| 114 |
+
import openvino as ov
|
| 115 |
+
import nncf
|
| 116 |
+
|
| 117 |
+
images = calibration_images(calib_dir, max_samples)
|
| 118 |
+
|
| 119 |
+
def preprocess(path: Path):
|
| 120 |
+
img = cv2.imread(str(path))
|
| 121 |
+
return letterbox_for_calibration(cv2.cvtColor(img, cv2.COLOR_BGR2RGB), imgsz)
|
| 122 |
+
|
| 123 |
+
dataset = nncf.Dataset(images, lambda p: preprocess(p))
|
| 124 |
+
ov_model = ov.convert_model(onnx_path)
|
| 125 |
+
quantized = nncf.quantize(ov_model, dataset, subset_size=len(images))
|
| 126 |
+
Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
|
| 127 |
+
ov.save_model(quantized, out_xml)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def export_coreml_from_onnx(
|
| 131 |
+
onnx_path: str,
|
| 132 |
+
out_pkg: str,
|
| 133 |
+
imgsz: int,
|
| 134 |
+
*,
|
| 135 |
+
input_shape: "tuple[int, ...] | None" = None,
|
| 136 |
+
fp32: bool = False,
|
| 137 |
+
) -> None:
|
| 138 |
+
"""ONNX -> CoreML mlpackage (darwin only).
|
| 139 |
+
|
| 140 |
+
Modern coremltools (>=6) dropped the ONNX frontend entirely — ``ct.convert``
|
| 141 |
+
only accepts TensorFlow / PyTorch / MIL sources, so feeding it an ``.onnx``
|
| 142 |
+
raises "Unable to determine the type of the model". We bridge through
|
| 143 |
+
PyTorch: ``onnx2torch`` rebuilds the ONNX graph as an ``nn.Module``, we trace
|
| 144 |
+
it with a representative image input, then coremltools converts the
|
| 145 |
+
TorchScript. Image models are NCHW ``(1, 3, imgsz, imgsz)``.
|
| 146 |
+
|
| 147 |
+
``input_shape`` overrides that square-NCHW assumption for models whose input
|
| 148 |
+
is neither square nor channels-first — the plate recognizer is NHWC
|
| 149 |
+
``(1, 64, 128, 3)``. Without it the trace runs on a wrongly-shaped tensor and
|
| 150 |
+
either throws deep inside the graph or, worse, produces a package whose
|
| 151 |
+
declared input silently disagrees with the real one. ``imgsz`` stays the
|
| 152 |
+
positional contract for every existing caller.
|
| 153 |
+
|
| 154 |
+
``fp32`` keeps full precision instead of coremltools' default fp16 weights.
|
| 155 |
+
Detectors and embedders absorb fp16 — a box moves a fraction of a pixel, a
|
| 156 |
+
cosine moves 5e-5 — but a RECOGNIZER cannot: its output is an argmax plus a
|
| 157 |
+
confidence threshold, so a small shift in one softmax flips a stored
|
| 158 |
+
character or silences a real read. Measured on the plate recognizer, fp16
|
| 159 |
+
moved the weakest-character probability by up to 0.16 and changed the label
|
| 160 |
+
on a ground-truthed plate. The default stays fp16 so nothing already
|
| 161 |
+
published moves.
|
| 162 |
+
|
| 163 |
+
Dynamic-shape models (e.g. YOLO) emit ``Shape`` ops that coremltools' Torch
|
| 164 |
+
converter cannot lower ("Converter is not implemented … 'Shape'"). We first
|
| 165 |
+
run onnx-simplifier with the input shape pinned, which constant-folds those
|
| 166 |
+
Shape ops away, so the traced graph is static and convertible.
|
| 167 |
+
"""
|
| 168 |
+
import coremltools as ct
|
| 169 |
+
import onnx
|
| 170 |
+
import torch
|
| 171 |
+
from onnx2torch import convert as onnx_to_torch
|
| 172 |
+
from onnxsim import simplify
|
| 173 |
+
|
| 174 |
+
shape = list(input_shape) if input_shape is not None else [1, 3, imgsz, imgsz]
|
| 175 |
+
onnx_model = onnx.load(onnx_path)
|
| 176 |
+
input_name = onnx_model.graph.input[0].name
|
| 177 |
+
simplified, ok = simplify(onnx_model, overwrite_input_shapes={input_name: shape})
|
| 178 |
+
if not ok:
|
| 179 |
+
simplified = onnx_model # fall back to the original if simplify bailed
|
| 180 |
+
|
| 181 |
+
model = onnx_to_torch(simplified).eval()
|
| 182 |
+
example = torch.rand(*shape)
|
| 183 |
+
with torch.no_grad():
|
| 184 |
+
traced = torch.jit.trace(model, example, strict=False)
|
| 185 |
+
|
| 186 |
+
mlmodel = ct.convert(
|
| 187 |
+
traced,
|
| 188 |
+
source="pytorch",
|
| 189 |
+
inputs=[ct.TensorType(name="input", shape=example.shape)],
|
| 190 |
+
minimum_deployment_target=ct.target.iOS16,
|
| 191 |
+
**({"compute_precision": ct.precision.FLOAT32} if fp32 else {}),
|
| 192 |
+
)
|
| 193 |
+
Path(out_pkg).parent.mkdir(parents=True, exist_ok=True)
|
| 194 |
+
mlmodel.save(out_pkg)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def validate_coreml(pkg_path: str) -> None:
|
| 198 |
+
import coremltools as ct
|
| 199 |
+
ct.models.MLModel(pkg_path) # load; raises if malformed
|
source/build-camstack-models.py
CHANGED
|
@@ -45,7 +45,10 @@ Formats:
|
|
| 45 |
int8 convert_lib.quantize_openvino_int8: NNCF post-training quantisation
|
| 46 |
of the ONNX, calibrated on the first N images (sorted by path,
|
| 47 |
recursively; an export folder's frames/ only) of --calib-dir
|
| 48 |
-
(default $CAMSTACK_CALIB_DIR or ./calibration-images)
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
INT8 calibration: the hosted INT8 IRs record subset_size 128 (YOLO26) and 300
|
| 51 |
(YOLOv9), drawn from COCO; an IR does not record WHICH images. The documented
|
|
@@ -56,19 +59,22 @@ exactly as the CamStack runtime feeds the model (the padding sat top-left until
|
|
| 56 |
2026-10-08, which the runtime never does). A different calibration set yields an
|
| 57 |
equivalent but not bit-identical IR (NNCF picks ranges from the data).
|
| 58 |
|
| 59 |
-
Which
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
yolov9{t,s,m}-320,
|
| 65 |
-
yolo26{n,s
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
yolo26{s,m,l}-320
|
| 70 |
-
The
|
| 71 |
-
|
| 72 |
|
| 73 |
Toolchain: each recipe names the Ultralytics / torch / coremltools / OpenVINO
|
| 74 |
versions its hosted files record. Other versions produce the same model with a
|
|
|
|
| 45 |
int8 convert_lib.quantize_openvino_int8: NNCF post-training quantisation
|
| 46 |
of the ONNX, calibrated on the first N images (sorted by path,
|
| 47 |
recursively; an export folder's frames/ only) of --calib-dir
|
| 48 |
+
(default $CAMSTACK_CALIB_DIR or ./calibration-images). Recipe (D798):
|
| 49 |
+
preset MIXED, full bias correction, the Detect head in float —
|
| 50 |
+
its own ops (DFL + box decode) for a YOLOv9 head, all of it for
|
| 51 |
+
YOLO26's end-to-end head; every conv before it is INT8
|
| 52 |
|
| 53 |
INT8 calibration: the hosted INT8 IRs record subset_size 128 (YOLO26) and 300
|
| 54 |
(YOLOv9), drawn from COCO; an IR does not record WHICH images. The documented
|
|
|
|
| 59 |
2026-10-08, which the runtime never does). A different calibration set yields an
|
| 60 |
equivalent but not bit-identical IR (NNCF picks ranges from the data).
|
| 61 |
|
| 62 |
+
Which recipe each hosted INT8 IR carries. All were built from the HOSTED ONNX
|
| 63 |
+
with OpenVINO 2026.2.1 + NNCF 3.2.0 on the first N COCO val2017 images
|
| 64 |
+
(reproduce with `--reuse-onnx`, the mirror's .onnx placed under --out):
|
| 65 |
+
D798 recipe (this script, since 2026-10-09 — MIXED, full bias correction,
|
| 66 |
+
the Detect head in float; docs/benchmarks/2026-10-09-int8-recipe.md):
|
| 67 |
+
yolov9{t,s,m,c}-{320,640}, yolo26{n,s,l} @640, yolo26n-320,
|
| 68 |
+
yolo26{n,s}-256
|
| 69 |
+
NNCF defaults, centred calibration (convert_lib 2026-10-08 → 2026-10-09,
|
| 70 |
+
the mirror's source/archive/convert_lib-before-2026-10-09.py — kept: the D798
|
| 71 |
+
build was more accurate but 11-22 % slower on the Intel iGPU):
|
| 72 |
+
yolo26{m,x} @640, yolo26{m,l}-256
|
| 73 |
+
NNCF defaults, top-left calibration (convert_lib before 2026-10-08,
|
| 74 |
+
source/archive/convert_lib-before-2026-10-08.py — kept, same reason):
|
| 75 |
yolo26{s,m,l}-320
|
| 76 |
+
The D798 build reproduces every replaced IR's .bin bit for bit
|
| 77 |
+
(docs/benchmarks/2026-10-09-int8-recipe.md).
|
| 78 |
|
| 79 |
Toolchain: each recipe names the Ultralytics / torch / coremltools / OpenVINO
|
| 80 |
versions its hosted files record. Other versions produce the same model with a
|
source/convert_lib.py
CHANGED
|
@@ -6,7 +6,9 @@ ov.convert_model (never the Ultralytics OV exporter, which emits unnamed
|
|
| 6 |
tensors that break the runtime's named-output postprocess)."""
|
| 7 |
from __future__ import annotations
|
| 8 |
import os
|
|
|
|
| 9 |
from pathlib import Path
|
|
|
|
| 10 |
|
| 11 |
# OpenVINO sizes the pool that COMPILES a model to the machine's core count and
|
| 12 |
# TBB never gives those workers back. A container cgroup counts THREADS, and
|
|
@@ -107,9 +109,55 @@ def letterbox_for_calibration(rgb, imgsz: int):
|
|
| 107 |
return chw[np.newaxis, ...]
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def quantize_openvino_int8(
|
| 111 |
onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
|
| 112 |
) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
import cv2
|
| 114 |
import openvino as ov
|
| 115 |
import nncf
|
|
@@ -122,7 +170,18 @@ def quantize_openvino_int8(
|
|
| 122 |
|
| 123 |
dataset = nncf.Dataset(images, lambda p: preprocess(p))
|
| 124 |
ov_model = ov.convert_model(onnx_path)
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
|
| 127 |
ov.save_model(quantized, out_xml)
|
| 128 |
|
|
|
|
| 6 |
tensors that break the runtime's named-output postprocess)."""
|
| 7 |
from __future__ import annotations
|
| 8 |
import os
|
| 9 |
+
import re
|
| 10 |
from pathlib import Path
|
| 11 |
+
from typing import Iterable, Optional
|
| 12 |
|
| 13 |
# OpenVINO sizes the pool that COMPILES a model to the machine's core count and
|
| 14 |
# TBB never gives those workers back. A container cgroup counts THREADS, and
|
|
|
|
| 109 |
return chw[np.newaxis, ...]
|
| 110 |
|
| 111 |
|
| 112 |
+
_HEAD_MODULE = re.compile(r"^(/model\.\d+)/")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def detect_head_prefix(ops: "Iterable[tuple[str, str]]") -> "Optional[str]":
|
| 116 |
+
"""The Ultralytics Detect module (``/model.22`` for YOLOv9, ``/model.23``
|
| 117 |
+
for YOLO26): the module holding the graph's LAST Sigmoid, which is the
|
| 118 |
+
class-score sigmoid (the Ultralytics exporter finds its head the same way).
|
| 119 |
+
``ops`` is ``(friendly_name, type_name)`` in graph order. None when the
|
| 120 |
+
model is not an Ultralytics export — it is then quantised whole."""
|
| 121 |
+
last = None
|
| 122 |
+
for name, kind in ops:
|
| 123 |
+
if kind == "Sigmoid":
|
| 124 |
+
last = name
|
| 125 |
+
match = _HEAD_MODULE.match(last) if last else None
|
| 126 |
+
return match.group(1) if match else None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def is_end_to_end_head(names: "Iterable[str]", head: str) -> bool:
|
| 130 |
+
"""An NMS-free (end-to-end) head — YOLO26's — runs its ``one2one_*`` branches."""
|
| 131 |
+
return any(n.startswith(f"{head}/one2one") for n in names)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def int8_ignored_patterns(head: str, end_to_end: bool) -> "list[str]":
|
| 135 |
+
"""NNCF ignored-scope patterns for the Detect head ``head``.
|
| 136 |
+
|
| 137 |
+
- DFL head (YOLOv9): the head's OWN ops stay float — the DFL and the box
|
| 138 |
+
decode (concat/split, anchor ± distance, ×stride, the class sigmoid). The
|
| 139 |
+
conv branches (``cv2.*``/``cv3.*``) stay INT8.
|
| 140 |
+
- End-to-end head (YOLO26): the whole head stays float, its ``one2one``
|
| 141 |
+
convs included, as NNCF's YOLO26 example keeps it; measured better than
|
| 142 |
+
the decode alone on YOLO26 (docs/benchmarks/2026-10-09-int8-recipe.md).
|
| 143 |
+
|
| 144 |
+
NNCF matches with ``re.match`` — a prefix match — so the own-op pattern is
|
| 145 |
+
anchored with ``$`` and every pattern ends at a module boundary."""
|
| 146 |
+
h = re.escape(head)
|
| 147 |
+
if end_to_end:
|
| 148 |
+
return [rf"{h}/"]
|
| 149 |
+
return [rf"{h}/[^/]+$", rf"{h}/dfl/"]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
def quantize_openvino_int8(
|
| 153 |
onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
|
| 154 |
) -> None:
|
| 155 |
+
"""NNCF post-training INT8. For an Ultralytics detector (D798): preset MIXED
|
| 156 |
+
(asymmetric activations), full bias correction, and the Detect head in
|
| 157 |
+
float — its own ops for a DFL head, all of it for an end-to-end one
|
| 158 |
+
(`int8_ignored_patterns`). Any other model keeps NNCF's defaults. Measured in
|
| 159 |
+
docs/benchmarks/2026-10-09-int8-recipe.md: the decode in INT8 was where
|
| 160 |
+
the hosted IRs lost their box precision."""
|
| 161 |
import cv2
|
| 162 |
import openvino as ov
|
| 163 |
import nncf
|
|
|
|
| 170 |
|
| 171 |
dataset = nncf.Dataset(images, lambda p: preprocess(p))
|
| 172 |
ov_model = ov.convert_model(onnx_path)
|
| 173 |
+
ops = [(op.get_friendly_name(), op.get_type_name()) for op in ov_model.get_ordered_ops()]
|
| 174 |
+
head = detect_head_prefix(ops)
|
| 175 |
+
options: dict = {}
|
| 176 |
+
if head:
|
| 177 |
+
end_to_end = is_end_to_end_head((name for name, _ in ops), head)
|
| 178 |
+
options = {
|
| 179 |
+
"preset": nncf.QuantizationPreset.MIXED,
|
| 180 |
+
"fast_bias_correction": False,
|
| 181 |
+
"ignored_scope": nncf.IgnoredScope(patterns=int8_ignored_patterns(head, end_to_end), validate=False),
|
| 182 |
+
}
|
| 183 |
+
# Not an Ultralytics detector: NNCF defaults, as before D798 (unmeasured otherwise).
|
| 184 |
+
quantized = nncf.quantize(ov_model, dataset, subset_size=len(images), **options)
|
| 185 |
Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
|
| 186 |
ov.save_model(quantized, out_xml)
|
| 187 |
|