Author SHA1 Message Date
alex c4b623ec75 print scene-detection heads-up line before the silent CPU pass 2026-11-01 18:44:47 -05:00
alex 39a8e60242 feat: PyAV decode path for AV1 rips (AvFrameReader + scenedetect pyav backend); cv2 kept as fallback 2026-11-01 18:21:32 -05:00
alex c8c9aae171 chore: ignore kernel.errors.txt (transient iGPU kernel log) 2026-10-21 15:34:21 -04:00
alex c715542c18 chore: gitignore .venv-ov build venv and png artifacts (keep models/*.png) 2026-10-21 15:34:19 -04:00
alex 4b27db2e77 feat/igpu-ov: --probe-stride/--probe-max-per-scene — thin inference (not decode) on slow devices
Every-frame prediction is free on CUDA (~0.2s) and hopeless on the iGPU
(5.2s/img): a 100-frame faceless scene cost 8.7 min. stride>1 keeps
frame-perfect decode and probes every Nth frame, max M per scene, then
waits for the next scene start. Default stride=1 = original behavior.
Scheduler unit-tested (face-hit, faceless-exhaust, multi-scene).
2026-10-20 14:12:12 -04:00
alex 460ab5f73e 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)
2026-10-20 13:49:57 -04:00
4 changed files with 318 additions and 15 deletions
+4
View File
@@ -6,3 +6,7 @@ output/
preview/
*.pt
*.part
.venv-ov/
*.png
!models/*.png
kernel.errors.txt
+126 -4
View File
@@ -119,6 +119,22 @@ def parse_arguments():
"edges. 0 (default) allows cropped faces; set >0 to reject "
"faces touching the crop lines.",
)
parser.add_argument(
"--probe-stride",
type=int,
default=1,
help="Within a scene still needing a face, run detection every Nth "
"frame instead of every frame (1 = original behavior, free on "
"CUDA). For slow devices (iGPU ~5s/img) use e.g. 25: decode "
"stays frame-perfect, only the expensive inference is thinned.",
)
parser.add_argument(
"--probe-max-per-scene",
type=int,
default=4,
help="With --probe-stride>1, probes before giving up on a scene and "
"waiting for the next scene start (default 4).",
)
parser.add_argument(
"--scene-threshold",
type=float,
@@ -161,6 +177,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
@@ -232,6 +252,61 @@ def face_qualifies(box, frame_shape, min_width, min_height,
return True
class AvFrameReader:
"""Forward-only frame reader with a cv2.VideoCapture-compatible
surface (read/release). PyAV ships its own ffmpeg build with libdav1d:
the ffmpeg bundled inside opencv-python has no software AV1 decoder,
and Alder Lake-N has no AV1 hw decode, so BD AV1 rips (e.g. SavI0r)
decode 0 frames through cv2. scenedetect likewise prefers its pyav
backend once av is installed, fixing scene detection on the same files.
"""
def __init__(self, path):
import av
self._c = av.open(path)
self._vs = self._c.streams.video[0]
self._vs.thread_type = "AUTO"
self._iter = self._c.decode(self._vs)
@property
def frame_count(self):
vs = self._vs
if vs.frames:
return int(vs.frames)
# MKV headers often leave stream.frames unset; estimate from the
# container duration (it is this file's total decode work anyway).
dur = self._c.duration # microseconds, or None
if dur and vs.average_rate:
return int(float(dur) / 1e6 * float(vs.average_rate))
return 0
def read(self):
try:
frame = next(self._iter)
except StopIteration:
return False, None
return True, frame.to_ndarray(format="bgr24")
def release(self):
try:
self._c.close()
except Exception:
pass
def open_reader(video_path):
"""PyAV when available (AV1-capable), else plain cv2."""
import cv2
try:
return AvFrameReader(video_path)
except ImportError:
pass
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
return None
return cap
def process_video(video_path, output_dir, args, model, device, draw,
output_start=0):
from scenedetect import open_video, SceneManager, ContentDetector
@@ -240,6 +315,12 @@ def process_video(video_path, output_dir, args, model, device, draw,
try:
import cv2
try:
import av # noqa: F401
# scenedetect defaults to backend="opencv", which cannot decode
# AV1 in this env; pyav ships its own ffmpeg with libdav1d.
video = open_video(video_path, backend="pyav")
except ImportError:
video = open_video(video_path)
scene_manager = SceneManager()
scene_manager.add_detector(
@@ -249,14 +330,18 @@ def process_video(video_path, output_dir, args, model, device, draw,
f"{video_path}: {e}")
return output_start
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
cap = open_reader(video_path)
if cap is None:
print(f"Error opening video file {video_path}")
return output_start
frame_count = getattr(cap, "frame_count", 0)
if not frame_count and hasattr(cap, "get"):
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
try:
print(f"{video_name}: detecting scenes "
"(silent for several minutes on CPU decode)...", flush=True)
scene_manager.detect_scenes(video=video)
scene_list = scene_manager.get_scene_list()
scene_starts = {scene[0].get_frames() for scene in scene_list}
@@ -272,6 +357,17 @@ def process_video(video_path, output_dir, args, model, device, draw,
face_in_scene = False
frame_idx = 0
# Probe schedule. probe_stride=1 reproduces the original every-frame
# loop (right on CUDA, where predict costs ~0.2 s). On slow devices
# (iGPU ~5 s/img) predicting every frame is hopeless: decode is ~300 fps,
# inference 0.2/s. With stride>1 we decode through frames for free and
# predict only at scene starts, retrying every stride frames up to
# probe_max attempts per scene, then waiting for the next scene start.
probe_stride = getattr(args, "probe_stride", 1)
probe_max = getattr(args, "probe_max_per_scene", 4)
next_probe = 0
probes_used = 0
while True:
ret, frame = cap.read()
if not ret:
@@ -279,10 +375,20 @@ def process_video(video_path, output_dir, args, model, device, draw,
if frame_idx in scene_starts:
face_in_scene = False
# membership test on a set; pop matched starts to keep it cheap
# (scenes strictly increase, so set stays small either way)
next_probe = frame_idx
probes_used = 0
if face_in_scene:
frame_idx += 1
continue
if probe_stride > 1 and (frame_idx < next_probe
or probes_used >= probe_max):
frame_idx += 1
continue
if not face_in_scene:
probes_used += 1
next_probe = frame_idx + probe_stride
try:
results = model.predict(frame, conf=args.conf,
iou=args.iou, imgsz=args.imgsz,
@@ -353,6 +459,22 @@ def main():
from ultralytics import YOLO
import cv2
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('<pt>').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.")
+136
View File
@@ -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)))]
+41
View File
@@ -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]")