source: D798 INT8 recipe (Detect head in float, MIXED, full bias correction); archive the 2026-10-08 convert_lib
2991ff3 verified Download source/archive/convert_lib-before-2026-10-09.py from camstack/camstack-models: direct link, hf CLI and curl.
- Browser
- Download file 8.61 kB
-
https://huggingface.co/camstack/camstack-models/resolve/main/source/archive/convert_lib-before-2026-10-09.py
- Command line
-
hf download hf://camstack/camstack-models/source/archive/convert_lib-before-2026-10-09.py
-
curl -L -o convert_lib-before-2026-10-09.py https://huggingface.co/camstack/camstack-models/resolve/main/source/archive/convert_lib-before-2026-10-09.py
8.61 kB
| """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 | |