source: centred INT8 calibration; keep the pre-2026-10-08 converter for the IRs that kept it
Browse files- source/README.md +14 -5
- source/archive/convert_lib-before-2026-10-08.py +170 -0
- source/build-camstack-models.py +61 -13
- source/convert_lib.py +45 -16
source/README.md
CHANGED
|
@@ -80,11 +80,20 @@ python build-camstack-models.py --family yolo26 --calib-dir ./val2017
|
|
| 80 |
|
| 81 |
The recipe takes the first N images in file-name order: 128 for YOLO26, 300
|
| 82 |
for YOLOv9, as recorded in each IR's `rt_info`. Each image is letterboxed to
|
| 83 |
-
the model's input size with grey (114) padding and scaled to [0, 1],
|
| 84 |
-
CamStack feeds the model at runtime. `--calib-samples` overrides N. An
|
| 85 |
-
not record WHICH images calibrated it, so a different set gives an
|
| 86 |
-
INT8 model, not a bit-identical one. Without calibration images the
|
| 87 |
-
are skipped and the run says so.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
## Licences
|
| 90 |
|
|
|
|
| 80 |
|
| 81 |
The recipe takes the first N images in file-name order: 128 for YOLO26, 300
|
| 82 |
for YOLOv9, as recorded in each IR's `rt_info`. Each image is letterboxed to
|
| 83 |
+
the model's input size, CENTRED with grey (114) padding, and scaled to [0, 1],
|
| 84 |
+
the way CamStack feeds the model at runtime. `--calib-samples` overrides N. An
|
| 85 |
+
IR does not record WHICH images calibrated it, so a different set gives an
|
| 86 |
+
equivalent INT8 model, not a bit-identical one. Without calibration images the
|
| 87 |
+
INT8 files are skipped and the run says so.
|
| 88 |
+
|
| 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, so they still carry the top-left
|
| 93 |
+
calibration. `build-camstack-models.py` lists which is which. The kept ones are
|
| 94 |
+
reproduced by [`archive/convert_lib-before-2026-10-08.py`](archive/convert_lib-before-2026-10-08.py),
|
| 95 |
+
which rebuilds the hosted `yolov9m-640` INT8 IR bit for bit. The replaced old
|
| 96 |
+
IRs are under `archive/2026-10-08-int8-topleft-calibration/` at the mirror root.
|
| 97 |
|
| 98 |
## Licences
|
| 99 |
|
source/archive/convert_lib-before-2026-10-08.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
def quantize_openvino_int8(
|
| 68 |
+
onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
|
| 69 |
+
) -> None:
|
| 70 |
+
import cv2
|
| 71 |
+
import numpy as np
|
| 72 |
+
import openvino as ov
|
| 73 |
+
import nncf
|
| 74 |
+
|
| 75 |
+
calib = Path(calib_dir)
|
| 76 |
+
images = sorted(
|
| 77 |
+
p for p in calib.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png"}
|
| 78 |
+
)[:max_samples]
|
| 79 |
+
if not images:
|
| 80 |
+
raise ValueError(f"no calibration images in {calib_dir}")
|
| 81 |
+
|
| 82 |
+
def preprocess(path: Path):
|
| 83 |
+
img = cv2.imread(str(path))
|
| 84 |
+
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
| 85 |
+
h, w = img.shape[:2]
|
| 86 |
+
scale = imgsz / max(h, w)
|
| 87 |
+
nh, nw = int(round(h * scale)), int(round(w * scale))
|
| 88 |
+
resized = cv2.resize(img, (nw, nh))
|
| 89 |
+
canvas = np.full((imgsz, imgsz, 3), 114, dtype=np.uint8)
|
| 90 |
+
canvas[:nh, :nw] = resized
|
| 91 |
+
chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0
|
| 92 |
+
return chw[np.newaxis, ...]
|
| 93 |
+
|
| 94 |
+
dataset = nncf.Dataset(images, lambda p: preprocess(p))
|
| 95 |
+
ov_model = ov.convert_model(onnx_path)
|
| 96 |
+
quantized = nncf.quantize(ov_model, dataset, subset_size=len(images))
|
| 97 |
+
Path(out_xml).parent.mkdir(parents=True, exist_ok=True)
|
| 98 |
+
ov.save_model(quantized, out_xml)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def export_coreml_from_onnx(
|
| 102 |
+
onnx_path: str,
|
| 103 |
+
out_pkg: str,
|
| 104 |
+
imgsz: int,
|
| 105 |
+
*,
|
| 106 |
+
input_shape: "tuple[int, ...] | None" = None,
|
| 107 |
+
fp32: bool = False,
|
| 108 |
+
) -> None:
|
| 109 |
+
"""ONNX -> CoreML mlpackage (darwin only).
|
| 110 |
+
|
| 111 |
+
Modern coremltools (>=6) dropped the ONNX frontend entirely — ``ct.convert``
|
| 112 |
+
only accepts TensorFlow / PyTorch / MIL sources, so feeding it an ``.onnx``
|
| 113 |
+
raises "Unable to determine the type of the model". We bridge through
|
| 114 |
+
PyTorch: ``onnx2torch`` rebuilds the ONNX graph as an ``nn.Module``, we trace
|
| 115 |
+
it with a representative image input, then coremltools converts the
|
| 116 |
+
TorchScript. Image models are NCHW ``(1, 3, imgsz, imgsz)``.
|
| 117 |
+
|
| 118 |
+
``input_shape`` overrides that square-NCHW assumption for models whose input
|
| 119 |
+
is neither square nor channels-first — the plate recognizer is NHWC
|
| 120 |
+
``(1, 64, 128, 3)``. Without it the trace runs on a wrongly-shaped tensor and
|
| 121 |
+
either throws deep inside the graph or, worse, produces a package whose
|
| 122 |
+
declared input silently disagrees with the real one. ``imgsz`` stays the
|
| 123 |
+
positional contract for every existing caller.
|
| 124 |
+
|
| 125 |
+
``fp32`` keeps full precision instead of coremltools' default fp16 weights.
|
| 126 |
+
Detectors and embedders absorb fp16 — a box moves a fraction of a pixel, a
|
| 127 |
+
cosine moves 5e-5 — but a RECOGNIZER cannot: its output is an argmax plus a
|
| 128 |
+
confidence threshold, so a small shift in one softmax flips a stored
|
| 129 |
+
character or silences a real read. Measured on the plate recognizer, fp16
|
| 130 |
+
moved the weakest-character probability by up to 0.16 and changed the label
|
| 131 |
+
on a ground-truthed plate. The default stays fp16 so nothing already
|
| 132 |
+
published moves.
|
| 133 |
+
|
| 134 |
+
Dynamic-shape models (e.g. YOLO) emit ``Shape`` ops that coremltools' Torch
|
| 135 |
+
converter cannot lower ("Converter is not implemented … 'Shape'"). We first
|
| 136 |
+
run onnx-simplifier with the input shape pinned, which constant-folds those
|
| 137 |
+
Shape ops away, so the traced graph is static and convertible.
|
| 138 |
+
"""
|
| 139 |
+
import coremltools as ct
|
| 140 |
+
import onnx
|
| 141 |
+
import torch
|
| 142 |
+
from onnx2torch import convert as onnx_to_torch
|
| 143 |
+
from onnxsim import simplify
|
| 144 |
+
|
| 145 |
+
shape = list(input_shape) if input_shape is not None else [1, 3, imgsz, imgsz]
|
| 146 |
+
onnx_model = onnx.load(onnx_path)
|
| 147 |
+
input_name = onnx_model.graph.input[0].name
|
| 148 |
+
simplified, ok = simplify(onnx_model, overwrite_input_shapes={input_name: shape})
|
| 149 |
+
if not ok:
|
| 150 |
+
simplified = onnx_model # fall back to the original if simplify bailed
|
| 151 |
+
|
| 152 |
+
model = onnx_to_torch(simplified).eval()
|
| 153 |
+
example = torch.rand(*shape)
|
| 154 |
+
with torch.no_grad():
|
| 155 |
+
traced = torch.jit.trace(model, example, strict=False)
|
| 156 |
+
|
| 157 |
+
mlmodel = ct.convert(
|
| 158 |
+
traced,
|
| 159 |
+
source="pytorch",
|
| 160 |
+
inputs=[ct.TensorType(name="input", shape=example.shape)],
|
| 161 |
+
minimum_deployment_target=ct.target.iOS16,
|
| 162 |
+
**({"compute_precision": ct.precision.FLOAT32} if fp32 else {}),
|
| 163 |
+
)
|
| 164 |
+
Path(out_pkg).parent.mkdir(parents=True, exist_ok=True)
|
| 165 |
+
mlmodel.save(out_pkg)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def validate_coreml(pkg_path: str) -> None:
|
| 169 |
+
import coremltools as ct
|
| 170 |
+
ct.models.MLModel(pkg_path) # load; raises if malformed
|
source/build-camstack-models.py
CHANGED
|
@@ -28,6 +28,11 @@ Usage:
|
|
| 28 |
python build-camstack-models.py --model yolo26n --imgsz 416 \
|
| 29 |
--openvino fp16,int8 --calib-samples 128
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
Outputs land in `--out` (default ./exported-models) under the MIRROR's paths,
|
| 32 |
e.g. exported-models/objectDetection/yolo26/openvino/camstack-yolo26n-320-int8.xml.
|
| 33 |
|
|
@@ -38,17 +43,33 @@ Formats:
|
|
| 38 |
openvino convert_lib.export_openvino: ov.convert_model(ONNX) + ov.save_model
|
| 39 |
(compress_to_fp16 per precision) — never the Ultralytics OV exporter
|
| 40 |
int8 convert_lib.quantize_openvino_int8: NNCF post-training quantisation
|
| 41 |
-
of the ONNX, calibrated on the first N images (sorted by
|
| 42 |
-
of --calib-dir
|
|
|
|
| 43 |
|
| 44 |
INT8 calibration: the hosted INT8 IRs record subset_size 128 (YOLO26) and 300
|
| 45 |
(YOLOv9), drawn from COCO; an IR does not record WHICH images. The documented
|
| 46 |
set is COCO val2017: unpack http://images.cocodataset.org/zips/val2017.zip and
|
| 47 |
point --calib-dir at it — the recipe's N is taken in file-name order. Images are letterboxed to
|
| 48 |
-
the recipe's input size with grey (114) padding and scaled to [0, 1],
|
| 49 |
-
as the CamStack runtime feeds the model
|
|
|
|
| 50 |
equivalent but not bit-identical IR (NNCF picks ranges from the data).
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
Toolchain: each recipe names the Ultralytics / torch / coremltools / OpenVINO
|
| 53 |
versions its hosted files record. Other versions produce the same model with a
|
| 54 |
possibly different graph layout; the script warns when they differ.
|
|
@@ -128,7 +149,8 @@ class Recipe:
|
|
| 128 |
imgsz: int
|
| 129 |
toolchain: Toolchain
|
| 130 |
# The PyTorch checkpoint: an Ultralytics asset id (`yolo26n` -> yolo26n.pt,
|
| 131 |
-
# auto-downloaded by Ultralytics)
|
|
|
|
| 132 |
checkpoint: str
|
| 133 |
onnx: Optional[OnnxExport]
|
| 134 |
coreml: Optional[CoremlExport]
|
|
@@ -304,6 +326,8 @@ def place(src: Path, dest: Path) -> Path:
|
|
| 304 |
|
| 305 |
|
| 306 |
def checkpoint_path(recipe: Recipe) -> str:
|
|
|
|
|
|
|
| 307 |
return f'{recipe.checkpoint}.pt' # Ultralytics downloads its own assets
|
| 308 |
|
| 309 |
|
|
@@ -468,13 +492,18 @@ def run_step(report: Report, label: str, step: Callable[[], object]) -> object:
|
|
| 468 |
|
| 469 |
|
| 470 |
def build(recipe: Recipe, formats: set[str], out: Path, calib_dir: Path,
|
| 471 |
-
calib_samples: Optional[int], verify_coreml: bool, report: Report
|
|
|
|
| 472 |
print('\n' + '=' * 70 + f'\n{recipe.name} ({recipe.folder}, {recipe.imgsz}×{recipe.imgsz})\n' + '=' * 70)
|
| 473 |
if recipe.note:
|
| 474 |
print(f' {recipe.note}')
|
| 475 |
warn_toolchain(recipe)
|
| 476 |
onnx_path: Optional[Path] = None
|
| 477 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
result = run_step(report, f'{recipe.name} onnx', lambda: build_onnx(recipe, out))
|
| 479 |
onnx_path = result if isinstance(result, Path) else None
|
| 480 |
if 'coreml' in formats and recipe.coreml is not None:
|
|
@@ -496,12 +525,23 @@ def adhoc_recipe(args: argparse.Namespace) -> Recipe:
|
|
| 496 |
bad = [p for p in precisions if p not in ('fp32', 'fp16', 'int8')]
|
| 497 |
if bad:
|
| 498 |
sys.exit(f'--openvino accepts fp32, fp16, int8; got {bad}')
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
return Recipe(
|
| 501 |
name=name, folder=args.folder, imgsz=args.imgsz,
|
| 502 |
-
toolchain=Toolchain('any', 'any', 'any', 'any'), checkpoint=
|
| 503 |
onnx=OnnxExport(dynamic=args.dynamic, opset=args.opset),
|
| 504 |
-
|
|
|
|
|
|
|
|
|
|
| 505 |
openvino=tuple(OpenvinoOutput(f'-{p}', p) for p in precisions),
|
| 506 |
calib_samples=args.calib_samples or 300,
|
| 507 |
)
|
|
@@ -572,8 +612,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
| 572 |
p.add_argument('--verify-coreml', action='store_true',
|
| 573 |
help='predict N=1 and N=4 on each flexible-batch CoreML package (in a child process)')
|
| 574 |
p.add_argument('--calib-samples', type=int, help="override the recipe's INT8 calibration image count")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
adhoc = p.add_argument_group('ad-hoc build (not a hosted file)')
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
adhoc.add_argument('--imgsz', type=int, default=640)
|
| 578 |
adhoc.add_argument('--name', help='output stem (default camstack-<model>-<imgsz>)')
|
| 579 |
adhoc.add_argument('--folder', default='custom')
|
|
@@ -584,7 +632,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
| 584 |
|
| 585 |
|
| 586 |
def select(args: argparse.Namespace) -> list[Recipe]:
|
| 587 |
-
if args.model:
|
| 588 |
return [adhoc_recipe(args)]
|
| 589 |
by_name = {r.name: r for r in RECIPES}
|
| 590 |
unknown = [v for v in (args.variant or []) if v not in by_name]
|
|
@@ -625,7 +673,7 @@ def main(argv: list[str]) -> int:
|
|
| 625 |
report = Report()
|
| 626 |
for r in recipes:
|
| 627 |
build(r, formats, args.out, args.calib_dir, args.calib_samples,
|
| 628 |
-
args.verify_coreml, report)
|
| 629 |
print('\nDone.' + (f' Failed: {report.failed}.' if report.failed else '')
|
| 630 |
+ (f' Skipped: {report.skipped}.' if report.skipped else ''))
|
| 631 |
return 1 if report.failed else 0
|
|
|
|
| 28 |
python build-camstack-models.py --model yolo26n --imgsz 416 \
|
| 29 |
--openvino fp16,int8 --calib-samples 128
|
| 30 |
|
| 31 |
+
# A fine-tuned local checkpoint, INT8-calibrated on your own frames (a
|
| 32 |
+
# folder of images, or an extracted retrain export: its frames/ are used):
|
| 33 |
+
python build-camstack-models.py --checkpoint runs/train/weights/best.pt \
|
| 34 |
+
--imgsz 640 --openvino fp16,int8 --calib-dir ./my-export --name custom-yolov9m-640
|
| 35 |
+
|
| 36 |
Outputs land in `--out` (default ./exported-models) under the MIRROR's paths,
|
| 37 |
e.g. exported-models/objectDetection/yolo26/openvino/camstack-yolo26n-320-int8.xml.
|
| 38 |
|
|
|
|
| 43 |
openvino convert_lib.export_openvino: ov.convert_model(ONNX) + ov.save_model
|
| 44 |
(compress_to_fp16 per precision) — never the Ultralytics OV exporter
|
| 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
|
| 52 |
set is COCO val2017: unpack http://images.cocodataset.org/zips/val2017.zip and
|
| 53 |
point --calib-dir at it — the recipe's N is taken in file-name order. Images are letterboxed to
|
| 54 |
+
the recipe's input size, CENTRED with grey (114) padding, and scaled to [0, 1],
|
| 55 |
+
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 calibration each hosted INT8 IR carries (measured, D739 holdout +
|
| 60 |
+
COCO; docs/benchmarks/2026-10-08-int8-recalibration.md). All were built from
|
| 61 |
+
the HOSTED ONNX with OpenVINO 2026.2.1 + NNCF 3.2.0 on the first N COCO val2017
|
| 62 |
+
images (reproduce with `--reuse-onnx`, the mirror's .onnx placed under --out):
|
| 63 |
+
centred (this script, since 2026-10-08):
|
| 64 |
+
yolov9{t,s,m}-320, yolov9s-640, yolo26n-320, yolo26{n,m,l,x} @640,
|
| 65 |
+
yolo26{n,s,m,l}-256
|
| 66 |
+
top-left (convert_lib as it was before 2026-10-08 — kept: re-quantising them
|
| 67 |
+
centred measured WORSE, beyond noise):
|
| 68 |
+
yolov9m-640, yolov9t-640, yolov9c-320, yolov9c-640, yolo26s @640,
|
| 69 |
+
yolo26{s,m,l}-320
|
| 70 |
+
The top-left padding (`canvas[:nh, :nw]`, sizes by round()) reproduces the
|
| 71 |
+
hosted yolov9m-640 IR bit for bit.
|
| 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
|
| 75 |
possibly different graph layout; the script warns when they differ.
|
|
|
|
| 149 |
imgsz: int
|
| 150 |
toolchain: Toolchain
|
| 151 |
# The PyTorch checkpoint: an Ultralytics asset id (`yolo26n` -> yolo26n.pt,
|
| 152 |
+
# auto-downloaded by Ultralytics), or — `--checkpoint` — the absolute path
|
| 153 |
+
# of a local `.pt` (a fine-tune), used verbatim.
|
| 154 |
checkpoint: str
|
| 155 |
onnx: Optional[OnnxExport]
|
| 156 |
coreml: Optional[CoremlExport]
|
|
|
|
| 326 |
|
| 327 |
|
| 328 |
def checkpoint_path(recipe: Recipe) -> str:
|
| 329 |
+
if recipe.checkpoint.endswith('.pt'):
|
| 330 |
+
return recipe.checkpoint # a local checkpoint (--checkpoint), verbatim
|
| 331 |
return f'{recipe.checkpoint}.pt' # Ultralytics downloads its own assets
|
| 332 |
|
| 333 |
|
|
|
|
| 492 |
|
| 493 |
|
| 494 |
def build(recipe: Recipe, formats: set[str], out: Path, calib_dir: Path,
|
| 495 |
+
calib_samples: Optional[int], verify_coreml: bool, report: Report,
|
| 496 |
+
reuse_onnx: bool = False) -> None:
|
| 497 |
print('\n' + '=' * 70 + f'\n{recipe.name} ({recipe.folder}, {recipe.imgsz}×{recipe.imgsz})\n' + '=' * 70)
|
| 498 |
if recipe.note:
|
| 499 |
print(f' {recipe.note}')
|
| 500 |
warn_toolchain(recipe)
|
| 501 |
onnx_path: Optional[Path] = None
|
| 502 |
+
existing_onnx = out / recipe.folder / 'onnx' / f'{recipe.name}.onnx'
|
| 503 |
+
if reuse_onnx and recipe.onnx and existing_onnx.is_file():
|
| 504 |
+
print(f' → ONNX: reusing {existing_onnx} (--reuse-onnx)')
|
| 505 |
+
onnx_path = existing_onnx
|
| 506 |
+
elif formats & {'onnx', 'openvino'} and recipe.onnx:
|
| 507 |
result = run_step(report, f'{recipe.name} onnx', lambda: build_onnx(recipe, out))
|
| 508 |
onnx_path = result if isinstance(result, Path) else None
|
| 509 |
if 'coreml' in formats and recipe.coreml is not None:
|
|
|
|
| 525 |
bad = [p for p in precisions if p not in ('fp32', 'fp16', 'int8')]
|
| 526 |
if bad:
|
| 527 |
sys.exit(f'--openvino accepts fp32, fp16, int8; got {bad}')
|
| 528 |
+
if args.checkpoint is not None:
|
| 529 |
+
local = Path(args.checkpoint).expanduser().resolve()
|
| 530 |
+
if not local.is_file() or local.suffix != '.pt':
|
| 531 |
+
sys.exit(f'--checkpoint {args.checkpoint}: not a .pt file')
|
| 532 |
+
checkpoint, stem = str(local), local.stem
|
| 533 |
+
else:
|
| 534 |
+
checkpoint, stem = args.model, args.model
|
| 535 |
+
name = args.name or f'camstack-{stem}-{args.imgsz}'
|
| 536 |
+
coreml_dynamic = args.dynamic and args.checkpoint is None
|
| 537 |
return Recipe(
|
| 538 |
name=name, folder=args.folder, imgsz=args.imgsz,
|
| 539 |
+
toolchain=Toolchain('any', 'any', 'any', 'any'), checkpoint=checkpoint,
|
| 540 |
onnx=OnnxExport(dynamic=args.dynamic, opset=args.opset),
|
| 541 |
+
# A local checkpoint's architecture is not in its name, so the D742
|
| 542 |
+
# check cannot tell a YOLO26 fine-tune: its CoreML export is always
|
| 543 |
+
# static, the shape every architecture loads.
|
| 544 |
+
coreml=CoremlExport(dynamic=coreml_dynamic, batch=16 if coreml_dynamic else 1, half=True),
|
| 545 |
openvino=tuple(OpenvinoOutput(f'-{p}', p) for p in precisions),
|
| 546 |
calib_samples=args.calib_samples or 300,
|
| 547 |
)
|
|
|
|
| 612 |
p.add_argument('--verify-coreml', action='store_true',
|
| 613 |
help='predict N=1 and N=4 on each flexible-batch CoreML package (in a child process)')
|
| 614 |
p.add_argument('--calib-samples', type=int, help="override the recipe's INT8 calibration image count")
|
| 615 |
+
p.add_argument('--reuse-onnx', action='store_true',
|
| 616 |
+
help='an ONNX already at its mirror path under --out is used as is, not re-exported: '
|
| 617 |
+
'download the hosted ONNX there to re-quantise an IR from the exact graph the '
|
| 618 |
+
'mirror serves (no Ultralytics needed)')
|
| 619 |
adhoc = p.add_argument_group('ad-hoc build (not a hosted file)')
|
| 620 |
+
source = adhoc.add_mutually_exclusive_group()
|
| 621 |
+
source.add_argument('--model', help='Ultralytics id, e.g. yolo26n, yolov9m, yolo26s-seg')
|
| 622 |
+
source.add_argument('--checkpoint',
|
| 623 |
+
help='a local .pt (e.g. a fine-tune), exported exactly like --model; '
|
| 624 |
+
'pair with --calib-dir <your frames> for its INT8 IR')
|
| 625 |
adhoc.add_argument('--imgsz', type=int, default=640)
|
| 626 |
adhoc.add_argument('--name', help='output stem (default camstack-<model>-<imgsz>)')
|
| 627 |
adhoc.add_argument('--folder', default='custom')
|
|
|
|
| 632 |
|
| 633 |
|
| 634 |
def select(args: argparse.Namespace) -> list[Recipe]:
|
| 635 |
+
if args.model or args.checkpoint:
|
| 636 |
return [adhoc_recipe(args)]
|
| 637 |
by_name = {r.name: r for r in RECIPES}
|
| 638 |
unknown = [v for v in (args.variant or []) if v not in by_name]
|
|
|
|
| 673 |
report = Report()
|
| 674 |
for r in recipes:
|
| 675 |
build(r, formats, args.out, args.calib_dir, args.calib_samples,
|
| 676 |
+
args.verify_coreml, report, reuse_onnx=args.reuse_onnx)
|
| 677 |
print('\nDone.' + (f' Failed: {report.failed}.' if report.failed else '')
|
| 678 |
+ (f' Skipped: {report.skipped}.' if report.skipped else ''))
|
| 679 |
return 1 if report.failed else 0
|
source/convert_lib.py
CHANGED
|
@@ -64,32 +64,61 @@ def validate_openvino_ir(xml_path: str) -> None:
|
|
| 64 |
raise ValueError(f"IR {xml_path} has an empty-named tensor")
|
| 65 |
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
def quantize_openvino_int8(
|
| 68 |
onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
|
| 69 |
) -> None:
|
| 70 |
import cv2
|
| 71 |
-
import numpy as np
|
| 72 |
import openvino as ov
|
| 73 |
import nncf
|
| 74 |
|
| 75 |
-
|
| 76 |
-
images = sorted(
|
| 77 |
-
p for p in calib.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png"}
|
| 78 |
-
)[:max_samples]
|
| 79 |
-
if not images:
|
| 80 |
-
raise ValueError(f"no calibration images in {calib_dir}")
|
| 81 |
|
| 82 |
def preprocess(path: Path):
|
| 83 |
img = cv2.imread(str(path))
|
| 84 |
-
|
| 85 |
-
h, w = img.shape[:2]
|
| 86 |
-
scale = imgsz / max(h, w)
|
| 87 |
-
nh, nw = int(round(h * scale)), int(round(w * scale))
|
| 88 |
-
resized = cv2.resize(img, (nw, nh))
|
| 89 |
-
canvas = np.full((imgsz, imgsz, 3), 114, dtype=np.uint8)
|
| 90 |
-
canvas[:nh, :nw] = resized
|
| 91 |
-
chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0
|
| 92 |
-
return chw[np.newaxis, ...]
|
| 93 |
|
| 94 |
dataset = nncf.Dataset(images, lambda p: preprocess(p))
|
| 95 |
ov_model = ov.convert_model(onnx_path)
|
|
|
|
| 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)
|