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