Spaces:
Running
Running
Fix close-up selfie detection: pad margin so face-fills-frame photos are found (0 faces -> crude paste was the no-hair/skin bug)
f0e4246 | import os | |
| import sys | |
| import glob | |
| import cv2 | |
| import cv2.data | |
| import numpy as np | |
| def _ensure_cuda_dlls(): | |
| """ | |
| Windows: make CUDA 12 + cuDNN 9 DLLs discoverable so onnxruntime-gpu can load | |
| CUDAExecutionProvider — otherwise it silently falls back to CPU (LoadLibrary | |
| error 126). cuDNN 9 ships its CUDA-12 DLLs in a versioned subfolder | |
| (…\\CUDNN\\vX\\bin\\12.x) that isn't on PATH by default. We auto-discover the | |
| CUDA-toolkit bin + the cuDNN 12.x bin and prepend them to PATH. | |
| No-op on Linux (the HF Docker/CUDA base image already puts CUDA on the path). | |
| """ | |
| if sys.platform != "win32": | |
| return | |
| pf = os.environ.get("ProgramFiles", r"C:\Program Files") | |
| patterns = [ | |
| os.path.join(pf, "NVIDIA GPU Computing Toolkit", "CUDA", "v12*", "bin"), | |
| os.path.join(pf, "NVIDIA", "CUDNN", "v*", "bin", "12*"), # cuDNN9 for CUDA12 | |
| os.path.join(pf, "NVIDIA", "CUDNN", "v*", "bin"), | |
| ] | |
| added = set() | |
| for pat in patterns: | |
| for d in sorted(glob.glob(pat), reverse=True): # newest version first | |
| if os.path.isdir(d) and d not in added: | |
| added.add(d) | |
| os.environ["PATH"] = d + os.pathsep + os.environ.get("PATH", "") | |
| try: | |
| os.add_dll_directory(d) | |
| except Exception: | |
| pass | |
| _ensure_cuda_dlls() | |
| try: | |
| import onnxruntime as ort | |
| _ORT_PROVIDERS = ( | |
| ["CUDAExecutionProvider", "CPUExecutionProvider"] | |
| if "CUDAExecutionProvider" in ort.get_available_providers() | |
| else ["CPUExecutionProvider"] | |
| ) | |
| except ImportError: | |
| _ORT_PROVIDERS = ["CPUExecutionProvider"] | |
| _face_cascade = None | |
| _insightface_app = None | |
| _insightface_failed = False # True only after a real load error (not "not downloaded yet") | |
| def _get_cascade(): | |
| global _face_cascade | |
| if _face_cascade is None: | |
| _face_cascade = cv2.CascadeClassifier( | |
| cv2.data.haarcascades + "haarcascade_frontalface_default.xml" | |
| ) | |
| return _face_cascade | |
| def _buffalo_ready() -> bool: | |
| """Return True if buffalo_l model files are already on disk.""" | |
| path = os.path.expanduser("~/.insightface/models/buffalo_l") | |
| return os.path.isdir(path) and len(os.listdir(path)) > 0 | |
| def _get_insightface(): | |
| global _insightface_app, _insightface_failed | |
| if _insightface_app is not None: | |
| return _insightface_app # already loaded | |
| if _insightface_failed: | |
| return None # previously errored — don't retry | |
| # NOTE: we no longer short-circuit when buffalo_l is missing. FaceAnalysis() | |
| # auto-downloads the model on first use, so this works on a fresh machine | |
| # (previously it returned None forever and silently fell back to a crude | |
| # Haar-cascade paste — no real swap, no head swap, no glasses). | |
| try: | |
| import insightface | |
| if not _buffalo_ready(): | |
| print("[detector] buffalo_l not found — downloading (~300 MB, one-time)…") | |
| _insightface_app = insightface.app.FaceAnalysis( | |
| name="buffalo_l", | |
| providers=_ORT_PROVIDERS, | |
| ) | |
| _insightface_app.prepare(ctx_id=0, det_size=(640, 640)) | |
| print("[detector] InsightFace buffalo_l loaded OK") | |
| except Exception as e: | |
| print(f"[detector] InsightFace load failed: {e}") | |
| _insightface_failed = True | |
| _insightface_app = None | |
| return _insightface_app | |
| def align_face_upright(image: np.ndarray, max_roll: float = 3.0) -> np.ndarray: | |
| """ | |
| De-roll a (possibly tilted) photo so the face's eyes are horizontal. | |
| Why: InsightFace's detector misses faces rolled more than ~15-20 deg, so a | |
| tilted selfie silently falls back to a crude paste. We: | |
| 1. try to detect the face; if that fails (heavy tilt), brute-force rotate | |
| the image through candidate angles until a face is found; | |
| 2. measure the eye-line angle from the 5 keypoints and rotate the whole | |
| image so the eyes are level. | |
| The swapper then aligns this upright source to the target's pose as usual. | |
| Returns the original image unchanged if no face can be found. | |
| """ | |
| import math | |
| app = _get_insightface() | |
| if app is None: | |
| return image | |
| def _eye_angle(face): | |
| le, re = face.kps[0], face.kps[1] # left eye, right eye | |
| return math.degrees(math.atan2(float(re[1] - le[1]), float(re[0] - le[0]))) | |
| def _largest(faces): | |
| return max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1])) | |
| h, w = image.shape[:2] | |
| cx, cy = w / 2.0, h / 2.0 | |
| faces = app.get(image) | |
| base_angle = 0.0 | |
| if not faces: | |
| # Heavy tilt: rotate the image to bring the face into the detector's range. | |
| for ang in (15, -15, 25, -25, 35, -35, 45, -45, 10, -10, 20, -20): | |
| M = cv2.getRotationMatrix2D((cx, cy), ang, 1.0) | |
| rot = cv2.warpAffine(image, M, (w, h), borderValue=(255, 255, 255)) | |
| f = app.get(rot) | |
| if f: | |
| faces = f | |
| base_angle = ang | |
| break | |
| if not faces: | |
| return image | |
| roll = _eye_angle(_largest(faces)) # residual roll after base rotation | |
| total = base_angle + roll | |
| if abs(total) < max_roll: | |
| return image # already upright enough | |
| M = cv2.getRotationMatrix2D((cx, cy), total, 1.0) | |
| return cv2.warpAffine(image, M, (w, h), borderValue=(255, 255, 255)) | |
| def _clahe_enhance(image: np.ndarray) -> np.ndarray: | |
| """ | |
| Boost contrast with CLAHE on the L channel so face detectors can find | |
| faces in poorly-lit, backlit, or underexposed photos. | |
| """ | |
| lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) | |
| l, a, b = cv2.split(lab) | |
| clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) | |
| lab = cv2.merge([clahe.apply(l), a, b]) | |
| return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) | |
| def _detect_insightface(image: np.ndarray) -> list: | |
| app = _get_insightface() | |
| if app is None: | |
| return [] | |
| try: | |
| faces = app.get(image) | |
| if not faces: | |
| return [] | |
| bboxes = [] | |
| for face in faces: | |
| x1, y1, x2, y2 = face.bbox.astype(int) | |
| x1, y1 = max(0, x1), max(0, y1) | |
| x2, y2 = min(image.shape[1], x2), min(image.shape[0], y2) | |
| if (x2 - x1) >= 40 and (y2 - y1) >= 40: | |
| bboxes.append((x1, y1, x2, y2)) | |
| return bboxes | |
| except Exception: | |
| return [] | |
| def detect_faces(image: np.ndarray) -> list: | |
| if image is None or image.size == 0: | |
| return [] | |
| # First attempt on the original image. | |
| result = _detect_insightface(image) | |
| if result: | |
| return result | |
| # Low-light / poor-contrast retry: CLAHE enhances the luminance channel | |
| # before re-running InsightFace and the Haar fallback. Handles phone photos | |
| # taken in dim rooms, harsh backlit shots, and heavily shadowed faces. | |
| enhanced = _clahe_enhance(image) | |
| result = _detect_insightface(enhanced) | |
| if result: | |
| return result | |
| # Final fallback: Haar cascade (works on both original and CLAHE image). | |
| gray = cv2.cvtColor(enhanced, cv2.COLOR_BGR2GRAY) | |
| cascade = _get_cascade() | |
| detections = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(40, 40)) | |
| if len(detections) == 0: | |
| return [] | |
| return [(x, y, x + w, y + h) for (x, y, w, h) in detections] | |
| def get_insightface_faces(image: np.ndarray): | |
| app = _get_insightface() | |
| if app is None: | |
| return [] | |
| try: | |
| return app.get(image) | |
| except Exception: | |
| return [] | |
| def pad_until_detectable(image: np.ndarray, fracs=(0.3, 0.5, 0.8)) -> np.ndarray: | |
| """ | |
| Make a close-up face detectable. | |
| A selfie where the face fills the whole frame (no margin) defeats the | |
| RetinaFace detector inside InsightFace — it returns 0 faces, so the swap | |
| silently falls back to a crude paste (no real swap, no hair/skin/neck). We | |
| pad a replicated margin around the image until a face is found; the extra | |
| border gives the detector the context it needs. | |
| Safe for the SOURCE image, which is only used to READ the face landmarks — | |
| the output is always drawn on the target canvas, so the border never shows. | |
| Returns the padded image, or the original if a face is already detectable | |
| (or none can be found at any padding). | |
| """ | |
| app = _get_insightface() | |
| if app is None: | |
| return image | |
| try: | |
| if app.get(image): | |
| return image # already detectable — no change | |
| for frac in fracs: | |
| p = int(max(image.shape[:2]) * frac) | |
| padded = cv2.copyMakeBorder(image, p, p, p, p, cv2.BORDER_REPLICATE) | |
| if app.get(padded): | |
| return padded | |
| except Exception: | |
| pass | |
| return image | |