File size: 9,699 Bytes
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 | #!/usr/bin/env python3
"""
Build the camstack-* model set with batch-flexible CoreML packages.
The pipeline's Python pool currently does N predicts iterated internally
on a list-of-dicts (saves session setup, but each frame still costs ~9ms
warm). A CoreML model exported with a flexible batch dim accepts a single
stacked tensor of shape `(N, 3, H, W)` β one ANE dispatch per batch,
amortising every fixed cost.
This script produces (per model in MODELS):
exported-models/camstack-<id>.onnx β ONNX with dynamic batch axis
exported-models/camstack-<id>.mlpackage β CoreML with RangeDim batch (1..16)
exported-models/camstack-<id>_openvino_model/ β OpenVINO IR with dynamic batch
The dynamic axis is SAFE to export and must not be pinned here: the same
artifact is loaded on CPU, iGPU, NPU and ANE nodes, so an export cannot know
which target it will meet. The loader does β `_static_batch_required` /
`_pin_batch_for_device` in `packages/addon-pipeline/python/inference_pool.py`
reshape the graph to a static batch of 1 for the one target that refuses dynamic
dims (the Intel NPU) and hand it over untouched everywhere else (D202). Pinning
it here would instead delete the flexible axis the CoreML batched-ANE dispatch
path depends on.
Usage:
python scripts/build-camstack-models.py # from the CamStack repository
python build-camstack-models.py # from the mirror's source/ folder
Environment: the packages in convert_lib's requirements.txt plus `ultralytics`
(which also fetches the upstream `.pt` checkpoints). INT8 needs calibration
images in CAMSTACK_CALIB_DIR (default ./calibration-images).
"""
import os
import shutil
import sys
from pathlib import Path
# convert_lib.py is the converter Model Studio runs at runtime. In the CamStack
# repository it lives in packages/addon-model-studio/python/; the published copy
# of this script (the `source/` folder of the camstack/camstack-models mirror)
# ships it as a sibling file. A sibling wins, so the SAME file runs in both places.
_HERE = Path(__file__).resolve().parent
_CONVERT_LIB_DIRS = (
_HERE,
_HERE.parent / 'packages' / 'addon-model-studio' / 'python',
)
_CONVERT_LIB_DIR = next(
(d for d in _CONVERT_LIB_DIRS if (d / 'convert_lib.py').is_file()), None
)
if _CONVERT_LIB_DIR is None:
sys.exit(
'convert_lib.py not found next to this script or in '
'packages/addon-model-studio/python/'
)
sys.path.insert(0, str(_CONVERT_LIB_DIR))
from convert_lib import export_openvino, quantize_openvino_int8, validate_openvino_ir
# --- Config ------------------------------------------------------------
OUTPUT_DIR = Path("exported-models")
MODELS = [
# (ultralytics-id, exported-name, image-size)
("yolov9t", "camstack-yolov9t", 640),
("yolov9s", "camstack-yolov9s", 640),
("yolo26n", "camstack-yolo26n", 640),
]
# Directory of calibration images (e.g. a COCO val subset) for INT8. INT8 is
# only produced when this dir exists + has images; otherwise it's skipped with
# a note. Override via CAMSTACK_CALIB_DIR.
CALIB_DIR = Path(os.environ.get("CAMSTACK_CALIB_DIR", "calibration-images"))
BATCH_RANGE_LOWER = 1
BATCH_RANGE_UPPER = 16
BATCH_DEFAULT = 1
# --- Helpers -----------------------------------------------------------
def get_size_mb(path: Path) -> float:
if path.is_file():
return path.stat().st_size / (1024 * 1024)
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) / (1024 * 1024)
def ensure_dir(p: Path) -> Path:
p.mkdir(parents=True, exist_ok=True)
return p
# --- Step 1: YOLO -> ONNX (dynamic batch) ------------------------------
def export_onnx(ultra_id: str, out_path: Path, imgsz: int) -> Path:
"""Export the Ultralytics .pt as ONNX with `dynamic=True`.
`dynamic=True` produces an ONNX with `images` axis 0 marked dynamic
(batch) so coremltools.convert can set RangeDim on it.
"""
from ultralytics import YOLO
print(f" β loading {ultra_id}.pt (auto-download if missing)")
model = YOLO(f"{ultra_id}.pt")
print(f" β exporting ONNX (imgsz={imgsz}, dynamic batch)")
onnx_src = model.export(
format="onnx",
imgsz=imgsz,
dynamic=True,
simplify=True,
opset=17,
)
if not onnx_src:
raise RuntimeError(f"ONNX export returned no path for {ultra_id}")
onnx_src = Path(onnx_src)
if onnx_src != out_path:
shutil.copy2(onnx_src, out_path)
print(f" β {out_path.name} ({get_size_mb(out_path):.1f} MB)")
return out_path
# --- Step 2: ONNX -> CoreML (RangeDim batch) ---------------------------
def export_coreml(ultra_id: str, out_path: Path, imgsz: int) -> Path:
"""Export the Ultralytics .pt as a CoreML mlpackage with flexible batch.
Ultralytics' CoreML exporter handles the torch.jit.trace + coremltools
pipeline internally. With `dynamic=True` + `batch=N`, it produces a
model that accepts shape `(B, 3, imgsz, imgsz)` with B β [1..N].
`half=True` sets compute precision to FLOAT16 β required for ANE.
"""
from ultralytics import YOLO
print(f" β loading {ultra_id}.pt for CoreML export")
model = YOLO(f"{ultra_id}.pt")
print(
f" β exporting CoreML mlpackage (imgsz={imgsz}, dynamic batch up to {BATCH_RANGE_UPPER}, half=FP16)"
)
src = model.export(
format="coreml",
imgsz=imgsz,
dynamic=True,
batch=BATCH_RANGE_UPPER,
half=True,
nms=False,
)
if not src:
raise RuntimeError(f"CoreML export returned no path for {ultra_id}")
src = Path(src)
if out_path.exists():
shutil.rmtree(out_path)
if src.is_dir():
shutil.copytree(src, out_path)
else:
out_path.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, out_path / src.name)
print(f" β {out_path.name} ({get_size_mb(out_path):.1f} MB)")
return out_path
# --- Step 3: ONNX -> OpenVINO (dynamic batch IR) -----------------------
# export_openvino, quantize_openvino_int8, validate_openvino_ir are imported
# from convert_lib.py (shared with the runtime; located above).
# --- Step 4: Verification ---------------------------------------------
def verify_coreml_batch(mlpackage_path: Path, imgsz: int) -> None:
"""Predict with N=1 then N=4 to confirm batch flexibility works."""
import coremltools as ct
import numpy as np
print(f" β loading {mlpackage_path.name} for verification")
model = ct.models.MLModel(str(mlpackage_path))
# Find input name
spec = model.get_spec()
input_name = spec.description.input[0].name
for batch in (1, 4):
x = np.random.rand(batch, 3, imgsz, imgsz).astype(np.float32)
out = model.predict({input_name: x})
out_keys = list(out.keys())
out_shape = out[out_keys[0]].shape if hasattr(out[out_keys[0]], "shape") else "?"
print(f" β batch={batch} β output[{out_keys[0]}].shape={out_shape}")
# --- Main --------------------------------------------------------------
def main() -> None:
output = ensure_dir(OUTPUT_DIR)
print(f"Output: {output.resolve()}")
for ultra_id, name, imgsz in MODELS:
print()
print("=" * 70)
print(f"Building {name} (source: {ultra_id}.pt, imgsz={imgsz}Γ{imgsz})")
print("=" * 70)
onnx_path = output / f"{name}.onnx"
coreml_path = output / f"{name}.mlpackage"
try:
export_onnx(ultra_id, onnx_path, imgsz)
except Exception as e:
print(f" β ONNX export failed: {e}")
continue
try:
export_coreml(ultra_id, coreml_path, imgsz)
except Exception as e:
print(f" β CoreML conversion failed: {e}")
# OpenVINO IR variants β all converted from the ONNX via ov.convert_model
# (NOT the Ultralytics openvino exporter). fp32 baseline + fp16 (GPU/NPU).
try:
out_xml_fp32 = output / f"{name}.xml"
export_openvino(str(onnx_path), str(out_xml_fp32), fp16=False)
validate_openvino_ir(str(out_xml_fp32))
out_xml_fp16 = output / f"{name}-fp16.xml"
export_openvino(str(onnx_path), str(out_xml_fp16), fp16=True)
validate_openvino_ir(str(out_xml_fp16))
except Exception as e:
print(f" β OpenVINO conversion failed: {e}")
# INT8 β only when calibration images are present.
if CALIB_DIR.is_dir() and any(CALIB_DIR.iterdir()):
try:
out_xml_int8 = output / f"{name}-int8.xml"
quantize_openvino_int8(
str(onnx_path), str(out_xml_int8), str(CALIB_DIR), imgsz
)
validate_openvino_ir(str(out_xml_int8))
except Exception as e:
print(f" β OpenVINO INT8 quantization failed: {e}")
else:
print(f" β INT8 skipped β no calibration images in {CALIB_DIR} (set CAMSTACK_CALIB_DIR)")
if coreml_path.exists():
try:
verify_coreml_batch(coreml_path, imgsz)
except Exception as e:
print(f" β CoreML verification failed: {e}")
# --- Summary ---
print()
print("=" * 70)
print("Build complete. Files:")
print("=" * 70)
total = 0.0
for f in sorted(output.iterdir()):
size = get_size_mb(f)
total += size
print(f" {f.name:50s} {size:8.1f} MB")
print(" " + "β" * 60)
print(f" {'Total':50s} {total:8.1f} MB")
print()
print("Upload to HF camstack/camstack-models when ready.")
if __name__ == "__main__":
sys.exit(main())
|