camstack-models / source /build-camstack-models.py
apocaliss92's picture
Squash history: mirror holds only the models CamStack offers (2026-10-06)
fb3cc07
Raw History Blame
9.7 kB
#!/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())