FaceSWAP / core /detector.py
aditya-rAj19's picture
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
Raw
History Blame Contribute Delete
8.89 kB
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