""" env_config.py — Shared hardware environment detection and model optimization. Provides a single entry point for any DeepCamera skill to: 1. Detect available compute hardware (NVIDIA, AMD, Apple, Intel, CPU) 2. Auto-export models to the optimal inference format 3. Load cached optimized models with PyTorch fallback Usage: from lib.env_config import HardwareEnv env = HardwareEnv.detect() model, fmt = env.load_optimized("yolo26n") """ import json import os import platform import shutil import subprocess import sys import time from dataclasses import dataclass, field from pathlib import Path from typing import Optional def _log(msg: str): """Log to stderr.""" print(f"[env_config] {msg}", file=sys.stderr, flush=True) # ─── Backend definitions ──────────────────────────────────────────────────── @dataclass class BackendSpec: """Specification for a compute backend's optimized export.""" name: str # "cuda", "rocm", "mps", "intel", "cpu" export_format: str # ultralytics export format string model_suffix: str # file extension/dir to look for cached model half: bool = True # use FP16 extra_export_args: dict = field(default_factory=dict) compute_units: Optional[str] = None # CoreML compute units: "cpu_and_ne", "all", etc. BACKEND_SPECS = { "cuda": BackendSpec( name="cuda", export_format="engine", model_suffix=".engine", half=True, ), "rocm": BackendSpec( name="rocm", export_format="pytorch", # PyTorch + HIP — ultralytics ONNX doesn't support ROCMExecutionProvider model_suffix=".pt", half=False, ), "mps": BackendSpec( name="mps", export_format="onnx", model_suffix=".onnx", half=False, # ONNX Runtime handles precision internally # ONNX Runtime + CoreMLExecutionProvider bypasses the broken # MPSGraphExecutable MLIR pipeline on macOS 26.x while still # leveraging GPU/ANE via CoreML under the hood. ), "intel": BackendSpec( name="intel", export_format="openvino", model_suffix="_openvino_model", half=True, ), "cpu": BackendSpec( name="cpu", export_format="onnx", model_suffix=".onnx", half=False, ), } # ─── ONNX + CoreML EP wrapper ──────────────────────────────────────────────── # Provides an ultralytics-compatible model interface using onnxruntime directly # with CoreMLExecutionProvider for ~6ms inference on Apple Silicon (vs 21ms when # ultralytics defaults to CPUExecutionProvider). class _BoxResult: """Minimal replacement for ultralytics Boxes result.""" __slots__ = ('xyxy', 'conf', 'cls') def __init__(self, xyxy, conf, cls): self.xyxy = xyxy # [[x1,y1,x2,y2]] self.conf = conf # [conf] self.cls = cls # [cls_id] class _DetResult: """Minimal replacement for ultralytics Results.""" __slots__ = ('boxes',) def __init__(self, boxes: list): self.boxes = boxes class _OnnxCoreMLModel: """ONNX Runtime model with CoreML EP, compatible with ultralytics API. Supports: model(image_path_or_pil, conf=0.5, verbose=False) Returns: list of _DetResult with .boxes iterable of _BoxResult """ def __init__(self, session, class_names: dict): self.session = session self.names = class_names self._input_name = session.get_inputs()[0].name # Expected input shape: [1, 3, H, W] shape = session.get_inputs()[0].shape self._input_h = shape[2] if isinstance(shape[2], int) else 640 self._input_w = shape[3] if isinstance(shape[3], int) else 640 def __call__(self, source, conf: float = 0.25, verbose: bool = True, **kwargs): """Run inference on an image path or PIL Image. All models use onnx-community HuggingFace format: outputs[0] = logits [1, 300, 80] (raw, pre-sigmoid) outputs[1] = pred_boxes [1, 300, 4] (cx, cy, w, h normalized 0..1) """ import numpy as np from PIL import Image # Load image if isinstance(source, str): img = Image.open(source).convert("RGB") elif isinstance(source, Image.Image): img = source.convert("RGB") else: img = Image.fromarray(source).convert("RGB") orig_w, orig_h = img.size # Letterbox resize to input size scale = min(self._input_w / orig_w, self._input_h / orig_h) new_w, new_h = int(orig_w * scale), int(orig_h * scale) img_resized = img.resize((new_w, new_h), Image.BILINEAR) # Pad to input size (center) pad_x = (self._input_w - new_w) // 2 pad_y = (self._input_h - new_h) // 2 canvas = np.full((self._input_h, self._input_w, 3), 114, dtype=np.uint8) canvas[pad_y:pad_y + new_h, pad_x:pad_x + new_w] = np.array(img_resized) # HWC→CHW, normalize, add batch dim blob = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0 blob = np.expand_dims(blob, 0) # Run inference outputs = self.session.run(None, {self._input_name: blob}) logits = outputs[0][0] # [300, 80] raw class logits pred_boxes = outputs[1][0] # [300, 4] cx, cy, w, h (normalized 0..1) # Sigmoid → class probabilities probs = 1.0 / (1.0 + np.exp(-logits)) # Parse detections boxes = [] for i in range(len(pred_boxes)): cls_id = int(np.argmax(probs[i])) det_conf = float(probs[i][cls_id]) if det_conf < conf: continue # cx,cy,w,h (normalized) → x1,y1,x2,y2 (original image pixels) cx, cy, bw, bh = pred_boxes[i] px_cx = cx * self._input_w px_cy = cy * self._input_h px_w = bw * self._input_w px_h = bh * self._input_h x1 = max(0, min((px_cx - px_w / 2 - pad_x) / scale, orig_w)) y1 = max(0, min((px_cy - px_h / 2 - pad_y) / scale, orig_h)) x2 = max(0, min((px_cx + px_w / 2 - pad_x) / scale, orig_w)) y2 = max(0, min((px_cy + px_h / 2 - pad_y) / scale, orig_h)) boxes.append(_BoxResult( xyxy=np.array([[x1, y1, x2, y2]]), conf=np.array([det_conf]), cls=np.array([cls_id]), )) return [_DetResult(boxes)] # ─── Hardware detection ────────────────────────────────────────────────────── @dataclass class HardwareEnv: """Detected hardware environment with model optimization capabilities.""" backend: str = "cpu" # "cuda" | "rocm" | "mps" | "intel" | "cpu" device: str = "cpu" # torch device string export_format: str = "onnx" # optimal export format compute_units: str = "all" # CoreML compute units (Apple only) gpu_name: str = "" # human-readable GPU name gpu_memory_mb: int = 0 # GPU memory in MB driver_version: str = "" # GPU driver version framework_ok: bool = False # True if optimized runtime is importable detection_details: dict = field(default_factory=dict) # raw detection info # Timing (populated by export/load) export_ms: float = 0.0 load_ms: float = 0.0 @staticmethod def detect() -> "HardwareEnv": """Probe the system and return a populated HardwareEnv.""" env = HardwareEnv() # Try each backend in priority order if env._try_cuda(): pass elif env._try_rocm(): pass elif env._try_mps(): pass elif env._try_intel(): pass else: env._fallback_cpu() # Set export format and compute units from backend spec spec = BACKEND_SPECS.get(env.backend, BACKEND_SPECS["cpu"]) env.export_format = spec.export_format if spec.compute_units: env.compute_units = spec.compute_units # Check if optimized runtime is available env.framework_ok = env._check_framework() _log(f"Detected: backend={env.backend}, device={env.device}, " f"gpu={env.gpu_name or 'none'}, " f"format={env.export_format}, " f"framework_ok={env.framework_ok}") return env def _try_cuda(self) -> bool: """Detect NVIDIA GPU via nvidia-smi (with Windows path search) and WMI fallback.""" nvidia_smi = shutil.which("nvidia-smi") # Windows: check well-known paths if not on PATH if not nvidia_smi and platform.system() == "Windows": for candidate in [ Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe", Path(os.environ.get("WINDIR", r"C:\Windows")) / "System32" / "nvidia-smi.exe", ]: if candidate.is_file(): nvidia_smi = str(candidate) _log(f"Found nvidia-smi at {nvidia_smi}") break if nvidia_smi: try: result = subprocess.run( [nvidia_smi, "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader,nounits"], capture_output=True, text=True, timeout=10, ) if result.returncode == 0: line = result.stdout.strip().split("\n")[0] parts = [p.strip() for p in line.split(",")] if len(parts) >= 3: self.backend = "cuda" self.device = "cuda" self.gpu_name = parts[0] self.gpu_memory_mb = int(float(parts[1])) self.driver_version = parts[2] self.detection_details["nvidia_smi"] = line _log(f"NVIDIA GPU: {self.gpu_name} ({self.gpu_memory_mb}MB, driver {self.driver_version})") return True except (subprocess.TimeoutExpired, FileNotFoundError, ValueError) as e: _log(f"nvidia-smi probe failed: {e}") # Windows WMI fallback: detect NVIDIA GPU even without nvidia-smi on PATH if platform.system() == "Windows": return self._try_cuda_wmi() return False def _try_cuda_wmi(self) -> bool: """Windows-only: detect NVIDIA GPU via WMI (wmic).""" try: result = subprocess.run( ["wmic", "path", "win32_VideoController", "get", "Name,AdapterRAM,DriverVersion", "/format:csv"], capture_output=True, text=True, timeout=10, ) if result.returncode != 0: return False for line in result.stdout.strip().split("\n"): if "NVIDIA" in line.upper(): parts = [p.strip() for p in line.split(",")] # CSV format: Node,AdapterRAM,DriverVersion,Name if len(parts) >= 4: self.backend = "cuda" self.device = "cuda" self.gpu_name = parts[3] try: self.gpu_memory_mb = int(int(parts[1]) / (1024 * 1024)) except (ValueError, IndexError): pass self.driver_version = parts[2] if len(parts) > 2 else "" self.detection_details["wmi"] = line _log(f"NVIDIA GPU (WMI): {self.gpu_name} ({self.gpu_memory_mb}MB)") return True except (subprocess.TimeoutExpired, FileNotFoundError, ValueError) as e: _log(f"WMI probe failed: {e}") return False def _try_rocm(self) -> bool: """Detect AMD GPU via amd-smi (preferred) or rocm-smi.""" has_amd_smi = shutil.which("amd-smi") is not None has_rocm_smi = shutil.which("rocm-smi") is not None has_rocm_dir = Path("/opt/rocm").is_dir() if not (has_amd_smi or has_rocm_smi or has_rocm_dir): return False self.backend = "rocm" # ROCm exposes as CUDA in PyTorch — but only if PyTorch-ROCm is installed try: import torch if torch.cuda.is_available(): self.device = "cuda" else: self.device = "cpu" _log("PyTorch CUDA/ROCm not available, using CPU for PyTorch fallback") except ImportError: self.device = "cpu" # Strategy 1: amd-smi static --json (ROCm 6.3+/7.x, richest output) if has_amd_smi: try: result = subprocess.run( ["amd-smi", "static", "--json"], capture_output=True, text=True, timeout=10, ) if result.returncode == 0: import json as _json data = _json.loads(result.stdout) # amd-smi may return {"gpu_data": [...]} or a bare list gpu_list = data.get("gpu_data", data) if isinstance(data, dict) else data if isinstance(gpu_list, list) and len(gpu_list) > 0: # Pick GPU with most VRAM (discrete > iGPU) def _vram_mb(g): vram = g.get("vram", {}).get("size", {}) if isinstance(vram, dict): return int(vram.get("value", 0)) return 0 best_gpu = max(gpu_list, key=_vram_mb) best_idx = gpu_list.index(best_gpu) asic = best_gpu.get("asic", {}) vram = best_gpu.get("vram", {}).get("size", {}) self.gpu_name = asic.get("market_name", "AMD GPU") self.gpu_memory_mb = int(vram.get("value", 0)) if isinstance(vram, dict) else 0 self.detection_details["amd_smi"] = { "gpu_index": best_idx, "gfx_version": asic.get("target_graphics_version", ""), "total_gpus": len(gpu_list), } # Pin to discrete GPU if multiple GPUs present if len(gpu_list) > 1: os.environ["HIP_VISIBLE_DEVICES"] = str(best_idx) os.environ["ROCR_VISIBLE_DEVICES"] = str(best_idx) _log(f"Multi-GPU: pinned to GPU {best_idx} ({self.gpu_name})") except (subprocess.TimeoutExpired, FileNotFoundError, ValueError, Exception) as e: _log(f"amd-smi probe failed: {e}") # Strategy 2: rocm-smi fallback (legacy ROCm <6.3) if not self.gpu_name and has_rocm_smi: try: result = subprocess.run( ["rocm-smi", "--showproductname", "--csv"], capture_output=True, text=True, timeout=10, ) if result.returncode == 0: lines = result.stdout.strip().split("\n") if len(lines) > 1: self.gpu_name = lines[1].split(",")[0].strip() self.detection_details["rocm_smi"] = result.stdout.strip() except (subprocess.TimeoutExpired, FileNotFoundError): pass try: result = subprocess.run( ["rocm-smi", "--showmeminfo", "vram", "--csv"], capture_output=True, text=True, timeout=10, ) if result.returncode == 0: for line in result.stdout.strip().split("\n")[1:]: parts = line.split(",") if len(parts) >= 2: try: self.gpu_memory_mb = int(float(parts[0].strip()) / (1024 * 1024)) except ValueError: pass break except (subprocess.TimeoutExpired, FileNotFoundError): pass _log(f"AMD ROCm GPU: {self.gpu_name or 'detected'} ({self.gpu_memory_mb}MB)") return True def _try_mps(self) -> bool: """Detect Apple Silicon via uname + sysctl.""" if platform.system() != "Darwin" or platform.machine() != "arm64": return False self.backend = "mps" self.device = "mps" # Get chip name try: result = subprocess.run( ["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, timeout=5, ) if result.returncode == 0: self.gpu_name = result.stdout.strip() except (subprocess.TimeoutExpired, FileNotFoundError): self.gpu_name = "Apple Silicon" # Get total memory (shared with GPU on Apple Silicon) try: result = subprocess.run( ["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5, ) if result.returncode == 0: self.gpu_memory_mb = int(int(result.stdout.strip()) / (1024 * 1024)) except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): pass _log(f"Apple Silicon: {self.gpu_name} ({self.gpu_memory_mb}MB unified)") return True def _try_intel(self) -> bool: """Detect Intel OpenVINO-capable hardware.""" # Check for OpenVINO installation has_openvino = False try: import openvino # noqa: F401 has_openvino = True except ImportError: # Check for system install has_openvino = Path("/opt/intel/openvino").is_dir() if not has_openvino: # Check CPU flags for Intel-specific features (AVX-512, AMX) try: if platform.system() == "Linux": with open("/proc/cpuinfo") as f: cpuinfo = f.read() if "GenuineIntel" in cpuinfo: self.backend = "intel" self.device = "cpu" self.gpu_name = "Intel CPU" _log("Intel CPU detected (no OpenVINO installed)") return True except FileNotFoundError: pass return False self.backend = "intel" self.device = "cpu" # OpenVINO handles device selection internally self.gpu_name = "Intel (OpenVINO)" # Check for Intel GPU / NPU try: from openvino.runtime import Core core = Core() devices = core.available_devices self.detection_details["openvino_devices"] = devices if "GPU" in devices: self.gpu_name = "Intel GPU (OpenVINO)" if "NPU" in devices: self.gpu_name = "Intel NPU (OpenVINO)" _log(f"OpenVINO devices: {devices}") except Exception: pass _log(f"Intel: {self.gpu_name}") return True def _fallback_cpu(self): """CPU-only fallback.""" self.backend = "cpu" self.device = "cpu" self.gpu_name = "" # Report CPU info try: self.detection_details["cpu"] = platform.processor() or "unknown" except Exception: pass _log("No GPU detected, using CPU backend") def _check_rocm_runtime(self): """Verify onnxruntime has ROCm provider, not just CPU.""" import onnxruntime providers = onnxruntime.get_available_providers() if "ROCmExecutionProvider" in providers or "MIGraphXExecutionProvider" in providers: _log(f"onnxruntime ROCm providers: {providers}") return True _log(f"onnxruntime providers: {providers} — ROCmExecutionProvider not found") _log("Fix: pip uninstall onnxruntime && pip install onnxruntime-rocm") raise ImportError("ROCmExecutionProvider not available") def _check_mps_runtime(self): """Verify onnxruntime has CoreML provider for Apple GPU/ANE acceleration. ONNX Runtime + CoreMLExecutionProvider bypasses the broken MPSGraphExecutable MLIR pipeline (macOS 26.x) while still routing inference through CoreML to leverage GPU and Neural Engine. """ import onnxruntime providers = onnxruntime.get_available_providers() if "CoreMLExecutionProvider" in providers: _log(f"onnxruntime CoreML provider available: {providers}") return True _log(f"onnxruntime providers: {providers} — CoreMLExecutionProvider not found") _log("Fix: pip install onnxruntime (arm64 macOS wheel includes CoreML EP)") raise ImportError("CoreMLExecutionProvider not available") def _check_framework(self) -> bool: """Check if the optimized inference runtime is importable and compatible.""" checks = { "cuda": lambda: __import__("tensorrt"), "rocm": lambda: self._check_rocm_runtime(), "mps": lambda: self._check_mps_runtime(), "intel": lambda: __import__("openvino"), "cpu": lambda: __import__("onnxruntime"), } check = checks.get(self.backend) if not check: return False try: check() return True except ImportError: _log(f"Optimized runtime not installed for {self.backend}, " f"will use PyTorch fallback") return False # ─── Model export & loading ────────────────────────────────────────── def get_optimized_path(self, model_name: str) -> Path: """Get the expected path for the optimized model.""" spec = BACKEND_SPECS.get(self.backend, BACKEND_SPECS["cpu"]) return Path(f"{model_name}{spec.model_suffix}") def export_model(self, model, model_name: str) -> Optional[Path]: """Export PyTorch model to optimal format. Returns path or None.""" if not self.framework_ok: _log(f"Skipping export — {self.backend} runtime not available") return None spec = BACKEND_SPECS.get(self.backend, BACKEND_SPECS["cpu"]) optimized_path = self.get_optimized_path(model_name) # Already exported if optimized_path.exists(): _log(f"Cached model found: {optimized_path}") return optimized_path # Guard: numpy 2.x breaks coremltools PyTorch→MIL converter # (TypeError: only 0-dimensional arrays can be converted to Python scalars) if spec.export_format == "coreml": try: import numpy as np np_major = int(np.__version__.split('.')[0]) if np_major >= 2: _log(f"numpy {np.__version__} detected — CoreML export " f"requires numpy<2.0.0 (coremltools incompatibility)") _log("Fix: pip install 'numpy>=1.24,<2.0'") return None except Exception: pass # If numpy check fails, try export anyway try: _log(f"Exporting {model_name}.pt → {spec.export_format} " f"(one-time, may take 30-120s)...") t0 = time.perf_counter() export_kwargs = { "format": spec.export_format, "half": spec.half, } export_kwargs.update(spec.extra_export_args) exported = model.export(**export_kwargs) self.export_ms = (time.perf_counter() - t0) * 1000 exported_path = Path(exported) if exported_path.exists(): _log(f"Export complete: {exported_path} ({self.export_ms:.0f}ms)") return exported_path _log(f"Export returned {exported} but path not found") except Exception as e: _log(f"Export failed ({spec.export_format}): {e}") return None def _load_coreml_with_compute_units(self, model_path: str): """ Load a CoreML model via YOLO with specific compute_units. Monkey-patches coremltools.MLModel to inject compute_units (e.g. CPU_AND_NE for Neural Engine) since ultralytics doesn't expose this parameter. Patch is scoped and immediately restored. """ from ultralytics import YOLO # Map string config → coremltools enum _COMPUTE_UNIT_MAP = { "all": "ALL", "cpu_only": "CPU_ONLY", "cpu_and_gpu": "CPU_AND_GPU", "cpu_and_ne": "CPU_AND_NE", } ct_enum_name = _COMPUTE_UNIT_MAP.get(self.compute_units) if not ct_enum_name: _log(f"Unknown compute_units '{self.compute_units}', using default") return YOLO(model_path) try: import coremltools as ct target_units = getattr(ct.ComputeUnit, ct_enum_name, None) if target_units is None: _log(f"coremltools.ComputeUnit.{ct_enum_name} not available") return YOLO(model_path) # Temporarily patch MLModel to inject compute_units _OrigMLModel = ct.models.MLModel class _PatchedMLModel(_OrigMLModel): def __init__(self, *args, **kwargs): kwargs.setdefault('compute_units', target_units) super().__init__(*args, **kwargs) ct.models.MLModel = _PatchedMLModel try: model = YOLO(model_path) finally: ct.models.MLModel = _OrigMLModel # Always restore _log(f"CoreML model loaded with compute_units={ct_enum_name} " f"(Neural Engine preferred)") return model except ImportError: _log("coremltools not available, loading without compute_units") return YOLO(model_path) # ── ONNX model download from HuggingFace ────────────────────────── # Maps model base name → onnx-community HuggingFace repo _ONNX_HF_REPOS = { "yolo26n": "onnx-community/yolo26n-ONNX", "yolo26s": "onnx-community/yolo26s-ONNX", "yolo26m": "onnx-community/yolo26m-ONNX", "yolo26l": "onnx-community/yolo26l-ONNX", } def _download_onnx_from_hf(self, model_name: str, dest_path: Path) -> bool: """Download pre-built ONNX model from onnx-community on HuggingFace. Uses urllib (no extra dependencies). Downloads to dest_path. Returns True on success, False on failure. """ repo = self._ONNX_HF_REPOS.get(model_name) if not repo: _log(f"No HuggingFace repo for {model_name}") return False url = f"https://huggingface.co/{repo}/resolve/main/onnx/model.onnx" names_url = None # class names not available on HF, use bundled nano names _log(f"Downloading {model_name}.onnx from {repo}...") try: import urllib.request import shutil # Download ONNX model tmp_path = str(dest_path) + ".download" with urllib.request.urlopen(url) as resp, open(tmp_path, 'wb') as f: shutil.copyfileobj(resp, f) # Rename to final path Path(tmp_path).rename(dest_path) size_mb = dest_path.stat().st_size / 1e6 _log(f"Downloaded {model_name}.onnx ({size_mb:.1f} MB)") # Create class names JSON if missing (COCO 80 — same for all YOLO models) names_path = Path(str(dest_path).replace('.onnx', '_names.json')) if not names_path.exists(): # Try copying from nano (which is shipped in the repo) nano_names = dest_path.parent / "yolo26n_names.json" if nano_names.exists(): shutil.copy2(str(nano_names), str(names_path)) _log(f"Copied class names from yolo26n_names.json") else: # Generate default COCO names import json coco_names = {str(i): f"class_{i}" for i in range(80)} with open(str(names_path), 'w') as f: json.dump(coco_names, f) _log("Generated default class names") return True except Exception as e: _log(f"HuggingFace download failed: {e}") # Clean up partial download for p in [str(dest_path) + ".download", str(dest_path)]: try: Path(p).unlink(missing_ok=True) except Exception: pass return False def _load_onnx_coreml(self, onnx_path: str): """Load ONNX model with CoreMLExecutionProvider for fast GPU/ANE inference. Returns an OnnxCoreMLModel wrapper that is compatible with the ultralytics model(frame_path, conf=...) call pattern. """ import onnxruntime as ort providers = ['CoreMLExecutionProvider', 'CPUExecutionProvider'] session = ort.InferenceSession(onnx_path, providers=providers) active = session.get_providers() _log(f"ONNX+CoreML session: {active}") # Load class names from companion JSON (avoids torch/ultralytics dep) import json names_path = onnx_path.replace('.onnx', '_names.json') try: with open(names_path) as f: raw = json.load(f) # JSON keys are strings; convert to int-keyed dict class_names = {int(k): v for k, v in raw.items()} _log(f"Loaded {len(class_names)} class names from {Path(names_path).name}") except FileNotFoundError: # Fallback: try loading from .pt if JSON doesn't exist try: from ultralytics import YOLO pt_path = onnx_path.replace('.onnx', '.pt') pt_model = YOLO(pt_path) class_names = pt_model.names _log(f"Loaded class names from {Path(pt_path).name} (fallback)") except Exception: # Last resort: use COCO 80-class defaults _log("WARNING: No class names found, using generic labels") class_names = {i: f"class_{i}" for i in range(80)} return _OnnxCoreMLModel(session, class_names) def load_optimized(self, model_name: str, use_optimized: bool = True): """ Load the best available model for this hardware. Returns: (model, format_str) — the YOLO model and its format name """ t0 = time.perf_counter() if use_optimized and self.framework_ok: # Try loading from cache first (no export needed) optimized_path = self.get_optimized_path(model_name) if optimized_path.exists(): try: # MPS: use ONNX Runtime + CoreML EP for fast inference if self.backend == "mps": model = self._load_onnx_coreml(str(optimized_path)) else: from ultralytics import YOLO model = YOLO(str(optimized_path)) self.load_ms = (time.perf_counter() - t0) * 1000 _log(f"Loaded {self.export_format} model ({self.load_ms:.0f}ms)") return model, self.export_format except Exception as e: _log(f"Failed to load cached model: {e}") # Try downloading pre-built ONNX from HuggingFace (no torch needed) if self.export_format == "onnx" and self._download_onnx_from_hf(model_name, optimized_path): try: if self.backend == "mps": model = self._load_onnx_coreml(str(optimized_path)) else: from ultralytics import YOLO model = YOLO(str(optimized_path)) self.load_ms = (time.perf_counter() - t0) * 1000 _log(f"Loaded HuggingFace ONNX model ({self.load_ms:.0f}ms)") return model, self.export_format except Exception as e: _log(f"Failed to load HF-downloaded model: {e}") # Try exporting then loading from ultralytics import YOLO pt_model = YOLO(f"{model_name}.pt") exported = self.export_model(pt_model, model_name) if exported: try: # MPS: use ONNX Runtime + CoreML EP for fast inference if self.backend == "mps": model = self._load_onnx_coreml(str(exported)) else: from ultralytics import YOLO model = YOLO(str(exported)) self.load_ms = (time.perf_counter() - t0) * 1000 _log(f"Loaded freshly exported {self.export_format} model ({self.load_ms:.0f}ms)") return model, self.export_format except Exception as e: _log(f"Failed to load exported model: {e}") # Fallback: use the PT model we already loaded _log("Falling back to PyTorch model") fallback_device = self.device if fallback_device == "cuda": try: import torch if not torch.cuda.is_available(): fallback_device = "cpu" _log("torch.cuda not available, falling back to CPU") except ImportError: fallback_device = "cpu" pt_model.to(fallback_device) self.device = fallback_device self.load_ms = (time.perf_counter() - t0) * 1000 return pt_model, "pytorch" # No optimization requested or framework missing from ultralytics import YOLO model = YOLO(f"{model_name}.pt") fallback_device = self.device if fallback_device == "cuda": try: import torch if not torch.cuda.is_available(): fallback_device = "cpu" _log("torch.cuda not available, falling back to CPU") except ImportError: fallback_device = "cpu" model.to(fallback_device) self.device = fallback_device self.load_ms = (time.perf_counter() - t0) * 1000 return model, "pytorch" def to_dict(self) -> dict: """Serialize environment info for JSON output.""" d = { "backend": self.backend, "device": self.device, "export_format": self.export_format, "gpu_name": self.gpu_name, "gpu_memory_mb": self.gpu_memory_mb, "driver_version": self.driver_version, "framework_ok": self.framework_ok, "export_ms": round(self.export_ms, 1), "load_ms": round(self.load_ms, 1), } if self.backend == "mps": d["compute_units"] = self.compute_units return d # ─── CLI: run standalone for diagnostics ───────────────────────────────────── if __name__ == "__main__": env = HardwareEnv.detect() print(json.dumps(env.to_dict(), indent=2))