From 460ab5f73e70040e9e5be19b4a415c662e662c99 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 20 Oct 2026 13:49:57 -0400 Subject: [PATCH] feat/igpu-ov: OpenVINO iGPU detector (--gpu igpu) on Intel Level Zero - ov_detector.OVFaceDetector: OV inference + ultralytics letterbox(1280 sq) + greedy NMS; model.predict()-compatible shim, process_video() unchanged - anime_facesaver.py: --gpu igpu branch; CUDA path untouched (moved to else) - CUDA path untouched; main() unchanged - validate_ov.py: cross-check vs torch CPU baseline measured 2026-10-20 i3-N305: 59/59 matched, mean IoU 0.987, only_base=0 only_ov=0; 5.0s/img GPU vs 14.5s/img CPU OpenVINO-CPU vs torch-CPU baseline (25 real stills, yolov8x6_animeface) --- anime_facesaver.py | 38 ++++++++++--- ov_detector.py | 136 +++++++++++++++++++++++++++++++++++++++++++++ validate_ov.py | 41 ++++++++++++++ 3 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 ov_detector.py create mode 100644 validate_ov.py diff --git a/anime_facesaver.py b/anime_facesaver.py index 23c4c74..75a9b27 100644 --- a/anime_facesaver.py +++ b/anime_facesaver.py @@ -161,6 +161,10 @@ def resolve_gpu(gpu_arg): if gpu_arg.lower() == "cpu": os.environ["CUDA_VISIBLE_DEVICES"] = "-1" return "cpu" + if gpu_arg.lower() == "igpu": + # Intel iGPU via OpenVINO (Level Zero) — no CUDA, no torch pinning. + # OVFaceDetector takes over model loading in main(). + return "igpu" if gpu_arg.lower() == "auto": return None # let ultralytics/torch decide os.environ["CUDA_VISIBLE_DEVICES"] = gpu_arg @@ -353,16 +357,32 @@ def main(): from ultralytics import YOLO import cv2 - if device == "cuda:0" and not torch.cuda.is_available(): - print("CUDA requested but not visible after pinning " - f"(--gpu {args.gpu}). Falling back to CPU.") - device = "cpu" - print(f"Device: {device}" - + (f" (physical GPU {args.gpu})" if device == "cuda" else "")) + if device == "igpu": + # Intel iGPU path: OpenVINO replaces ultralytics' torch predict loop. + # Ultralytics import stays (shared pre/post code paths), but weights + # load as ONNX through OVFaceDetector instead of torch. + from ov_detector import OVFaceDetector + onnx_path = os.path.splitext(args.model)[0] + ".onnx" + if not os.path.exists(onnx_path): + print(f"Error: --gpu igpu needs an exported ONNX at {onnx_path}\n" + " (one-time: YOLO('').export(format='onnx', " + "dynamic=True, simplify=True, opset=17))") + sys.exit(1) + model = OVFaceDetector(onnx_path, device="GPU", imgsz=args.imgsz) + names = model.names + print("Device: igpu (OpenVINO, Intel iGPU via Level Zero)") + print(f"Model loaded (OpenVINO GPU). Classes: {names}") + else: + if device == "cuda:0" and not torch.cuda.is_available(): + print("CUDA requested but not visible after pinning " + f"(--gpu {args.gpu}). Falling back to CPU.") + device = "cpu" + print(f"Device: {device}" + + (f" (physical GPU {args.gpu})" if device == "cuda" else "")) - model = YOLO(args.model) - names = model.names - print(f"Model loaded. Classes: {names}") + model = YOLO(args.model) + names = model.names + print(f"Model loaded. Classes: {names}") def draw(img, boxes): for (a, b, c, d) in boxes.int().tolist(): diff --git a/ov_detector.py b/ov_detector.py new file mode 100644 index 0000000..a71ed5d --- /dev/null +++ b/ov_detector.py @@ -0,0 +1,136 @@ +"""OpenVINO-backed anime-face detector for anime_facesaver (iGPU path). + +Used when --gpu igpu: replaces ultralytics' torch predict loop with a direct +OpenVINO inference request on Intel GPUs (Level Zero / ANV). Pre/post-processing +emulates ultralytics YOLO defaults: 1280 letterbox (stride 32), conf/iou gates, +class-agnostic-free single-class NMS. + +On chibi (i3-N305): GPU fp32 measured 5.2 s/img vs CPU 14.5 s/img +(2026-10-20, models/yolov8x6_animeface.onnx, 25 real stills). +""" +import os + +import numpy as np + + +def letterbox(img, new_shape=1280, stride=32): + """Square ultralytics letterbox (auto=False): fit, then center-pad to + new_shape x new_shape. The GPU plugin compiles one static shape this way + and sidesteps dynamic-shape concat failures on non-square inputs + (measured: (1,3,736,1280) fails inside the graph; 1280x1280 always works). + + Returns (blob CHW float32 /255, scale, (pad_left, pad_top)). + Inverse map for boxes: x_orig = (x_lb - pad_left) / scale. + """ + import cv2 + + h, w = img.shape[:2] + r = min(new_shape / h, new_shape / w) + nw, nh = int(round(w * r)), int(round(h * r)) + interp = cv2.INTER_AREA if r < 1 else cv2.INTER_LINEAR + resized = cv2.resize(img, (nw, nh), interpolation=interp) + canvas = np.full((new_shape, new_shape, 3), 114, dtype=np.uint8) + top, left = (new_shape - nh) // 2, (new_shape - nw) // 2 + canvas[top:top + nh, left:left + nw] = resized + blob = canvas[..., ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 + return blob, r, (left, top) + + +def numpy_ceil_div(a, b): + return -(-a // b) + + +def nms_numpy(boxes, scores, iou_thres): + """Plain greedy NMS; returns kept indices.""" + if len(boxes) == 0: + return [] + x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] + areas = (x2 - x1) * (y2 - y1) + order = scores.argsort()[::-1] + keep = [] + while order.size: + i = order[0] + keep.append(int(i)) + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + inter = np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None) + iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-9) + order = order[1:][iou <= iou_thres] + return keep + + +class OVFaceDetector: + """model.predict(frame_bgr, conf=, iou=, imgsz=, device=) compatible shim. + + Returns a one-element list with .boxes being a Boxes-like object exposing + .xyxy (numpy, already denormalized to original-frame pixels) and .conf. + """ + + class _Boxes: + """numpy-backed stand-in for ultralytics Boxes (.xyxy.cpu().numpy()).""" + class _Lazy: + def __init__(self, arr): + self._arr = arr + + def cpu(self): + return self + + def numpy(self): + return self._arr + + def int(self): + return OVFaceDetector._Boxes._Lazy(self._arr.astype(np.int32)) + + def tolist(self): + return self._arr.tolist() + + def __init__(self, xyxy, conf): + self.xyxy = self._Lazy(xyxy) + self.conf = conf + + class _Result: + def __init__(self, boxes): + self.boxes = boxes + + def __init__(self, model_path, device="GPU", imgsz=1280): + import openvino as ov + + core = ov.Core() + if device != "CPU" and device not in core.available_devices: + raise RuntimeError( + f"OpenVINO device {device!r} not available " + f"(have {core.available_devices}). Is the render-group fix " + f"active (sg render / re-login) and intel-level-zero-gpu " + f"installed?") + self._ov = ov + self._req = core.compile_model(model_path, device).create_infer_request() + self.imgsz = imgsz + self.names = {0: "face"} + + def predict(self, frame, conf=0.25, iou=0.45, imgsz=None, device=None, + verbose=False): + imgsz = imgsz or self.imgsz + blob, scale, (pad_l, pad_t) = letterbox(frame, imgsz) + out = self._req.infer([self._ov.Tensor(blob)]) + pred = list(out.values())[0] # [1, 5, N]: cx cy w h conf cls + pred = pred[0].T # [N, 5] + scores = pred[:, 4] + cand = pred[scores >= conf] + if len(cand) == 0: + return [self._Result(self._Boxes( + np.zeros((0, 4), np.float32), np.zeros((0,), np.float32)))] + cx, cy, w, h = cand[:, 0], cand[:, 1], cand[:, 2], cand[:, 3] + boxes = np.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], 1) + keep = nms_numpy(boxes, cand[:, 4], iou) + boxes, scores = boxes[keep], cand[keep, 4] + # map letterbox coords back to original frame pixels + boxes[:, [0, 2]] -= pad_l + boxes[:, [1, 3]] -= pad_t + boxes = boxes / scale + h0, w0 = frame.shape[:2] + boxes[:, [0, 2]] = boxes[:, [0, 2]].clip(0, w0) + boxes[:, [1, 3]] = boxes[:, [1, 3]].clip(0, h0) + return [self._Result(self._Boxes(boxes.astype(np.float32), + scores.astype(np.float32)))] diff --git a/validate_ov.py b/validate_ov.py new file mode 100644 index 0000000..6282334 --- /dev/null +++ b/validate_ov.py @@ -0,0 +1,41 @@ +"""Cross-validate OVFaceDetector vs ultralytics CPU baseline on real stills.""" +import glob, sys, time +import numpy as np, cv2 + +imgs = sorted(glob.glob('/home/alex/Documents/ov-bench/imgs/*.png')) +sys.path.insert(0, '.') +from ov_detector import OVFaceDetector + +# baseline: ultralytics torch CPU (deterministic on CPU) +from ultralytics import YOLO +base = YOLO('models/yolov8x6_animeface.pt') + +ovd = OVFaceDetector('models/yolov8x6_animeface.onnx', device='GPU', imgsz=1280) + +def iou(a, b): + xx1, yy1 = max(a[0], b[0]), max(a[1], b[1]) + xx2, yy2 = min(a[2], b[2]), min(a[3], b[3]) + inter = max(0, xx2-xx1) * max(0, yy2-yy1) + ua = (a[2]-a[0])*(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter + return inter / max(ua, 1e-9) + +ious, n_match, n_only_base, n_only_ov = [], 0, 0, 0 +t0=time.time() +for p in imgs: + fr = cv2.imread(p) + rb = base.predict(fr, conf=0.25, imgsz=1280, device='cpu', verbose=False)[0] + ro = ovd.predict(fr, conf=0.25, imgsz=1280, verbose=False)[0] + bb = rb.boxes.xyxy.cpu().numpy() if rb.boxes is not None else np.zeros((0,4)) + bo = ro.boxes.xyxy.numpy() + used = set() + for box in bb: + best, bi = 0, -1 + for j, ob in enumerate(bo): + v = iou(box, ob) + if v > best: best, bi = v, j + if best > 0.5: + ious.append(best); n_match += 1; used.add(bi) + else: n_only_base += 1 + n_only_ov += len(bo) - len(used) +dt = time.time()-t0 +print(f"imgs={len(imgs)} matched={n_match} (mean IoU {np.mean(ious):.3f}) only_base={n_only_base} only_ov={n_only_ov} [{dt:.0f}s total]")