File size: 8,608 Bytes
2991ff3 | 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 | """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
from pathlib import Path
# 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, ...]
def quantize_openvino_int8(
onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
) -> None:
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)
quantized = nncf.quantize(ov_model, dataset, subset_size=len(images))
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
|