Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| export_onnx.py | |
| - Export YOLOv8-(seg) 'best.pt' to ONNX | |
| - Assemble all required companion files into a deploy bundle: | |
| model.onnx, names.json, label_map.json, dataset.yaml, train.yaml, | |
| metrics.json, summary_eval.json, calibration_temp.json, val_predictions.json, | |
| postprocess_config.json, model_card.json, CHECKSUMS.sha256 | |
| - Optional: dynamic axes, simplify, half/fp16, quantize | |
| - Optional: ZIP the bundle | |
| Example: | |
| python ml/scripts/export_onnx.py \ | |
| --artifacts_dir artifacts/v2025YYYYMMDD-HHMMSS \ | |
| --out_dir deploy/v2025YYYYMMDD-HHMMSS \ | |
| --dynamic --simplify --opset 12 --zip | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import shutil | |
| import sys | |
| import hashlib | |
| from pathlib import Path | |
| from datetime import datetime | |
| from typing import Optional, List | |
| # --- Optional imports (used if available) --- | |
| try: | |
| import onnx | |
| except Exception: | |
| onnx = None | |
| try: | |
| from onnxruntime.quantization import quantize_dynamic, QuantType | |
| except Exception: | |
| quantize_dynamic, QuantType = None, None | |
| try: | |
| from ultralytics import YOLO | |
| except Exception as e: | |
| print("Ultralytics YOLOv8 required. Please `pip install ultralytics`.", file=sys.stderr) | |
| raise | |
| try: | |
| import yaml | |
| except Exception: | |
| print("Please `pip install pyyaml`.", file=sys.stderr) | |
| raise | |
| # ---------------------------- IO/Utils --------------------------------------- | |
| def read_json(p: Path): | |
| if p.exists(): | |
| return json.loads(p.read_text(encoding="utf-8")) | |
| return None | |
| def write_json(p: Path, obj): | |
| p.parent.mkdir(parents=True, exist_ok=True) | |
| p.write_text(json.dumps(obj, ensure_ascii=False, indent=2), encoding="utf-8") | |
| def read_yaml(p: Path): | |
| return yaml.safe_load(p.read_text(encoding="utf-8")) if p.exists() else None | |
| def copy_if_exists(src: Path, dst: Path): | |
| if src.exists(): | |
| dst.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(src, dst) | |
| def sha256_file(path: Path) -> str: | |
| h = hashlib.sha256() | |
| with path.open("rb") as f: | |
| for chunk in iter(lambda: f.read(1 << 16), b""): | |
| h.update(chunk) | |
| return h.hexdigest() | |
| def _glob_any(paths: List[Path], pattern: str) -> List[Path]: | |
| out = [] | |
| for p in paths: | |
| try: | |
| out.extend(list(p.glob(pattern))) | |
| except Exception: | |
| pass | |
| return out | |
| # ---------------------------- Names / Metadata -------------------------------- | |
| def build_names_json(artifacts_dir: Path) -> list: | |
| # Prefer names.json; otherwise, read from dataset.yaml | |
| names = read_json(artifacts_dir / "names.json") | |
| if names is None: | |
| ds = read_yaml(artifacts_dir / "dataset.yaml") or {} | |
| names = ds.get("names", []) | |
| # normalisieren auf Liste | |
| if isinstance(names, dict): | |
| # Keys can be strings like "0","1",... | |
| keys = [int(k) for k in names.keys()] if names else [] | |
| arr = [None] * (max(keys) + 1 if keys else 0) | |
| for k, v in names.items(): | |
| arr[int(k)] = v | |
| names = arr | |
| if not isinstance(names, list): | |
| names = list(names) if names is not None else [] | |
| names = [("" if n is None else str(n)) for n in names] | |
| return names | |
| # ---------------------------- ONNX Export/Verify/Quant ------------------------ | |
| def onnx_export( | |
| best_pt: Path, | |
| save_dir: Path, | |
| dynamic: bool, | |
| opset: int, | |
| simplify: bool, | |
| half: bool | |
| ) -> Path: | |
| """ | |
| Run Ultralytics export and copy the first found *.onnx to save_dir/model.onnx. | |
| Search robustly in: | |
| - model.trainer.save_dir | |
| - current working dir | |
| - directory of best_pt | |
| """ | |
| save_dir.mkdir(parents=True, exist_ok=True) | |
| # Sanity for opset | |
| if opset < 11: | |
| print("[WARN] opset < 11 is unusual; forcing 11.", file=sys.stderr) | |
| opset = 11 | |
| model = YOLO(str(best_pt)) | |
| try: | |
| model.export(format="onnx", dynamic=dynamic, opset=opset, simplify=simplify, half=half) | |
| except Exception as e: | |
| print(f"[ERROR] ONNX export failed: {e}", file=sys.stderr) | |
| raise | |
| # Search candidate locations | |
| candidates = [] | |
| try: | |
| candidates.append(Path(model.trainer.save_dir)) | |
| except Exception: | |
| pass | |
| candidates.extend([Path.cwd(), best_pt.parent]) | |
| onnx_files = _glob_any(candidates, "*.onnx") | |
| if not onnx_files: | |
| raise FileNotFoundError("No *.onnx found after export (trainer folder, CWD, artifacts).") | |
| out_onnx = save_dir / "model.onnx" | |
| shutil.copy2(onnx_files[0], out_onnx) | |
| return out_onnx | |
| def onnx_verify(onnx_path: Path): | |
| if onnx is None: | |
| print("[WARN] onnx not installed; skipping checker.") | |
| return | |
| try: | |
| m = onnx.load(str(onnx_path)) | |
| onnx.checker.check_model(m) | |
| print("[INFO] ONNX Model check OK.") | |
| except Exception as e: | |
| print(f"[WARN] ONNX checker warning/error: {e}", file=sys.stderr) | |
| def onnx_quantize_dynamic(in_path: Path, out_path: Path) -> Optional[Path]: | |
| if quantize_dynamic is None: | |
| print("[WARN] onnxruntime-quantization not available; skipping quantize.") | |
| return None | |
| try: | |
| quantize_dynamic( | |
| model_input=str(in_path), | |
| model_output=str(out_path), | |
| weight_type=QuantType.QInt8 | |
| ) | |
| return out_path | |
| except Exception as e: | |
| print(f"[WARN] Dynamic quantization failed: {e}", file=sys.stderr) | |
| return None | |
| # ---------------------------- Serving/Bundle Metadaten ------------------------ | |
| def compose_postprocess_config(artifacts_dir: Path) -> dict: | |
| # Default thresholds; can be overridden in serving | |
| cfg = { | |
| "confidence_threshold": 0.25, | |
| "iou_threshold": 0.50, | |
| "max_detections": 300, | |
| "calibration": None | |
| } | |
| cal = read_json(artifacts_dir / "calibration_temp.json") | |
| if cal and "temperature" in cal: | |
| cfg["calibration"] = { | |
| "method": cal.get("method", "temperature_scaling"), | |
| "temperature": float(cal["temperature"]) | |
| } | |
| return cfg | |
| def compose_model_card(artifacts_dir: Path, names: list, onnx_path: Path, onnx_q_path: Optional[Path]) -> dict: | |
| ds = read_yaml(artifacts_dir / "dataset.yaml") or {} | |
| metrics = read_json(artifacts_dir / "metrics.json") or {} | |
| evalsum = read_json(artifacts_dir / "summary_eval.json") or {} | |
| label_map = read_json(artifacts_dir / "label_map.json") or {} | |
| return { | |
| "model_card_version": "1.0", | |
| "created_utc": datetime.utcnow().isoformat() + "Z", | |
| "artifacts_dir": str(artifacts_dir.resolve()), | |
| "onnx": { | |
| "path": str(onnx_path.name), | |
| "quantized_path": (onnx_q_path.name if onnx_q_path else None) | |
| }, | |
| "dataset": { | |
| "path": ds.get("path"), | |
| "splits": { | |
| "train": ds.get("train"), | |
| "val": ds.get("val"), | |
| "test": ds.get("test") | |
| }, | |
| "classes": names | |
| }, | |
| "metrics": { | |
| "ultralytics": metrics, | |
| "evaluation": evalsum | |
| }, | |
| "mapping": { | |
| "label_map": label_map | |
| }, | |
| "notes": "YOLOv8-seg export; calibration via temperature scaling (if present)." | |
| } | |
| def build_checksums(out_dir: Path): | |
| lines = [] | |
| for p in sorted(out_dir.rglob("*")): | |
| if p.is_file(): | |
| if p.name.endswith(".zip"): # don't include the ZIP itself in checksums | |
| continue | |
| digest = sha256_file(p) | |
| rel = p.relative_to(out_dir) | |
| lines.append(f"{digest} {rel.as_posix()}") | |
| (out_dir / "CHECKSUMS.sha256").write_text("\n".join(lines) + "\n", encoding="utf-8") | |
| def make_zip(out_dir: Path): | |
| base = out_dir.parent / (out_dir.name + ".zip") | |
| if base.exists(): | |
| base.unlink() | |
| shutil.make_archive(str(base.with_suffix("")), "zip", root_dir=str(out_dir)) | |
| print(f"[INFO] ZIP: {base}") | |
| # ---------------------------- Main ------------------------------------------- | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Export YOLOv8 best.pt to ONNX and assemble deploy bundle.") | |
| ap.add_argument("--artifacts_dir", required=True, type=Path, help="Path to artifacts/<version>") | |
| ap.add_argument("--out_dir", required=True, type=Path, help="Deploy target directory (prefer empty/non-existent)") | |
| ap.add_argument("--opset", type=int, default=12) | |
| ap.add_argument("--dynamic", action="store_true") | |
| ap.add_argument("--simplify", action="store_true") | |
| ap.add_argument("--half", action="store_true", help="fp16 export (if supported)") | |
| ap.add_argument("--quantize", action="store_true", help="additional dynamic INT8 quantization") | |
| ap.add_argument("--zip", action="store_true", help="create ZIP of the deploy directory") | |
| args = ap.parse_args() | |
| artifacts_dir = args.artifacts_dir.resolve() | |
| out_dir = args.out_dir.resolve() | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| # Accept several common locations | |
| candidates = [ | |
| artifacts_dir / "best.pt", | |
| artifacts_dir / "weights" / "best.pt", | |
| ] | |
| best_pt = next((p for p in candidates if p.exists()), None) | |
| # As a last resort, search typical Ultralytics runs dirs | |
| if best_pt is None: | |
| from glob import glob | |
| runs = [] | |
| runs += glob(str(artifacts_dir / "weights" / "*.pt")) | |
| runs += glob(str(artifacts_dir.parent / "runs-seg" / "*" / "weights" / "best.pt")) | |
| if runs: | |
| best_pt = Path(sorted(runs)[-1]) | |
| if best_pt is None or not best_pt.exists(): | |
| print(f"[ERROR] best.pt not found in artifacts. Checked: {', '.join(map(str, candidates))}", file=sys.stderr) | |
| sys.exit(1) | |
| # 1) ONNX Export | |
| onnx_path = onnx_export( | |
| best_pt=best_pt, | |
| save_dir=out_dir, | |
| dynamic=bool(args.dynamic), | |
| opset=int(args.opset), | |
| simplify=bool(args.simplify), | |
| half=bool(args.half) | |
| ) | |
| onnx_verify(onnx_path) | |
| # 2) Optional: dynamic quantization | |
| onnx_q_path = None | |
| if args.quantize: | |
| q_path = out_dir / "model.int8.onnx" | |
| q = onnx_quantize_dynamic(onnx_path, q_path) | |
| if q: | |
| onnx_q_path = q | |
| # 3) Collect companion files | |
| names = build_names_json(artifacts_dir) | |
| write_json(out_dir / "names.json", names) | |
| copy_if_exists(artifacts_dir / "label_map.json", out_dir / "label_map.json") | |
| copy_if_exists(artifacts_dir / "dataset.yaml", out_dir / "dataset.yaml") | |
| copy_if_exists(artifacts_dir / "train.yaml", out_dir / "train.yaml") | |
| copy_if_exists(artifacts_dir / "metrics.json", out_dir / "metrics.json") | |
| copy_if_exists(artifacts_dir / "summary_eval.json", out_dir / "summary_eval.json") | |
| copy_if_exists(artifacts_dir / "val_predictions.json", out_dir / "val_predictions.json") | |
| copy_if_exists(artifacts_dir / "calibration_temp.json", out_dir / "calibration_temp.json") | |
| # 4) Postprocess-Config (Thresholds + Calibration) | |
| post_cfg = compose_postprocess_config(artifacts_dir) | |
| write_json(out_dir / "postprocess_config.json", post_cfg) | |
| # 5) Model Card | |
| model_card = compose_model_card(artifacts_dir, names, onnx_path, onnx_q_path) | |
| write_json(out_dir / "model_card.json", model_card) | |
| # 6) Checksums | |
| build_checksums(out_dir) | |
| # 7) ZIP (optional) | |
| if args.zip: | |
| make_zip(out_dir) | |
| print("\n=== Export complete ===") | |
| print(f"Deploy bundle: {out_dir}") | |
| if args.zip: | |
| print(f"ZIP: {out_dir.parent / (out_dir.name + '.zip')}") | |
| if __name__ == "__main__": | |
| main() | |