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())