File size: 7,291 Bytes
bc56020 | 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 | """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")
def quantize_openvino_int8(
onnx_path: str, out_xml: str, calib_dir: str, imgsz: int, max_samples: int = 300
) -> None:
import cv2
import numpy as np
import openvino as ov
import nncf
calib = Path(calib_dir)
images = sorted(
p for p in calib.iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png"}
)[:max_samples]
if not images:
raise ValueError(f"no calibration images in {calib_dir}")
def preprocess(path: Path):
img = cv2.imread(str(path))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
h, w = img.shape[:2]
scale = imgsz / max(h, w)
nh, nw = int(round(h * scale)), int(round(w * scale))
resized = cv2.resize(img, (nw, nh))
canvas = np.full((imgsz, imgsz, 3), 114, dtype=np.uint8)
canvas[:nh, :nw] = resized
chw = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0
return chw[np.newaxis, ...]
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
|