Spaces:
Sleeping
Sleeping
File size: 14,822 Bytes
a0bbf06 3684fcc a0bbf06 3684fcc a0bbf06 3684fcc a0bbf06 3684fcc a0bbf06 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 | """
MorphGuard Face Morph Generator
===============================
Implements multi-tiered facial landmark extraction, Delaunay triangulation,
piecewise affine triangle warping, and seamless Poisson gradient blending.
"""
import os
import cv2
import math
import uuid
import logging
import numpy as np
from typing import Tuple, List, Dict, Optional
try:
from scipy.spatial import Delaunay
from scipy.spatial.distance import euclidean
SCIPY_AVAILABLE = True
except ImportError:
Delaunay = None
euclidean = None
SCIPY_AVAILABLE = False
logger = logging.getLogger("MorphGenerator")
class FaceMorphGenerator:
"""
Robust Face Morph Generator supporting:
- 68-point Dlib landmark extraction (if available)
- Resilient OpenCV Haar Cascade face + eye fallback
- Eye alignment & horizon rotation normalization
- Delaunay piecewise affine triangle warping
- Seamless Poisson cloning (cv2.seamlessClone)
"""
def __init__(self, predictor_path: Optional[str] = None):
self.face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
self.eye_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_eye.xml')
self.dlib_available = False
self.dlib_detector = None
self.dlib_predictor = None
# Check for dlib
try:
import dlib
if predictor_path is None:
candidates = [
"models/shape_predictor_68_face_landmarks.dat",
"pSp/shape_predictor_68_face_landmarks.dat",
os.path.join(os.getcwd(), "models/shape_predictor_68_face_landmarks.dat")
]
for c in candidates:
if os.path.exists(c):
predictor_path = c
break
if predictor_path and os.path.exists(predictor_path):
self.dlib_detector = dlib.get_frontal_face_detector()
self.dlib_predictor = dlib.shape_predictor(predictor_path)
self.dlib_available = True
logger.info("FaceMorphGenerator: Dlib 68-point landmark predictor active.")
except Exception as e:
logger.info(f"FaceMorphGenerator: Dlib not available ({e}), using OpenCV cascade fallback.")
def detect_face_box(self, img: np.ndarray) -> Optional[Tuple[int, int, int, int]]:
"""Detect the most prominent frontal face bounding box (x, y, w, h)."""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
faces = self.face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=4, minSize=(80, 80))
if len(faces) == 0:
return None
# Return largest face
return max(faces, key=lambda f: f[2] * f[3])
def get_landmarks(self, img: np.ndarray, face_box: Optional[Tuple[int, int, int, int]] = None) -> Optional[np.ndarray]:
"""
Extract facial landmarks.
Returns array of shape (N, 2) with points (x, y), including boundary/border anchors.
"""
h, w = img.shape[:2]
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img
if face_box is None:
face_box = self.detect_face_box(img)
points = []
# 1. Try Dlib if active
if self.dlib_available and self.dlib_detector and self.dlib_predictor:
try:
import dlib
if face_box is not None:
x, y, fw, fh = face_box
rect = dlib.rectangle(int(x), int(y), int(x + fw), int(y + fh))
else:
dets = self.dlib_detector(gray, 1)
rect = dets[0] if len(dets) > 0 else None
if rect is not None:
shape = self.dlib_predictor(gray, rect)
for i in range(68):
points.append([shape.part(i).x, shape.part(i).y])
except Exception as e:
logger.warning(f"Dlib landmark detection failed: {e}")
# 2. Fallback to OpenCV cascade geometric points
if len(points) == 0:
if face_box is None:
# If no face detected, generate centered oval synthetic anchors
face_box = (int(w * 0.2), int(h * 0.2), int(w * 0.6), int(h * 0.6))
x, y, fw, fh = face_box
face_roi_gray = gray[y:y+fh, x:x+fw]
# Detect eyes within face ROI
eyes = self.eye_cascade.detectMultiScale(face_roi_gray, scaleFactor=1.1, minNeighbors=3, minSize=(20, 20))
if len(eyes) >= 2:
eyes = sorted(eyes, key=lambda e: e[2] * e[3], reverse=True)[:2]
if eyes[0][0] > eyes[1][0]:
eyes[0], eyes[1] = eyes[1], eyes[0]
left_eye = (x + eyes[0][0] + eyes[0][2]//2, y + eyes[0][1] + eyes[0][3]//2)
right_eye = (x + eyes[1][0] + eyes[1][2]//2, y + eyes[1][1] + eyes[1][3]//2)
else:
left_eye = (int(x + fw * 0.35), int(y + fh * 0.40))
right_eye = (int(x + fw * 0.65), int(y + fh * 0.40))
nose_tip = (int(x + fw * 0.50), int(y + fh * 0.60))
mouth_left = (int(x + fw * 0.35), int(y + fh * 0.75))
mouth_right = (int(x + fw * 0.65), int(y + fh * 0.75))
mouth_center = (int(x + fw * 0.50), int(y + fh * 0.78))
chin = (int(x + fw * 0.50), int(y + fh * 0.96))
forehead = (int(x + fw * 0.50), int(y + fh * 0.10))
# Jawline / perimeter contour points
contour_pts = [
(x, int(y + fh * 0.3)), (x, int(y + fh * 0.6)), (int(x + fw * 0.2), int(y + fh * 0.85)),
chin,
(int(x + fw * 0.8), int(y + fh * 0.85)), (x + fw, int(y + fh * 0.6)), (x + fw, int(y + fh * 0.3))
]
feature_points = [
left_eye, right_eye, nose_tip, mouth_left, mouth_right, mouth_center, forehead
] + contour_pts
for pt in feature_points:
points.append([float(pt[0]), float(pt[1])])
# 3. Add canvas frame boundary anchors to avoid mesh collapse at borders
border_anchors = [
[0, 0], [w // 2, 0], [w - 1, 0],
[0, h // 2], [w - 1, h // 2],
[0, h - 1], [w // 2, h - 1], [w - 1, h - 1]
]
points.extend(border_anchors)
return np.array(points, dtype=np.float32)
@staticmethod
def align_and_scale_images(img1: np.ndarray, img2: np.ndarray, target_size: Tuple[int, int] = (600, 600)) -> Tuple[np.ndarray, np.ndarray]:
"""Normalize both donor images to the same standard resolution."""
tw, th = target_size
scaled1 = cv2.resize(img1, (tw, th), interpolation=cv2.INTER_AREA)
scaled2 = cv2.resize(img2, (tw, th), interpolation=cv2.INTER_AREA)
return scaled1, scaled2
@staticmethod
def _apply_affine_transform(src: np.ndarray, src_tri: np.ndarray, dst_tri: np.ndarray, size: Tuple[int, int]) -> np.ndarray:
"""Calculate and apply affine transform to warp a triangular patch."""
warp_mat = cv2.getAffineTransform(np.float32(src_tri), np.float32(dst_tri))
dst = cv2.warpAffine(src, warp_mat, (size[0], size[1]), None, flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT_101)
return dst
def _morph_triangle(self, img1: np.ndarray, img2: np.ndarray, img_morph: np.ndarray,
t1: np.ndarray, t2: np.ndarray, t_morph: np.ndarray, alpha: float):
"""Piecewise warp a single Delaunay triangle from img1 and img2 into img_morph."""
# Find bounding rectangle for each triangle
r1 = cv2.boundingRect(np.float32([t1]))
r2 = cv2.boundingRect(np.float32([t2]))
r_morph = cv2.boundingRect(np.float32([t_morph]))
# Offset points by top left of bounding box
t1_rect = []
t2_rect = []
t_morph_rect = []
for i in range(3):
t_morph_rect.append(((t_morph[i][0] - r_morph[0]), (t_morph[i][1] - r_morph[1])))
t1_rect.append(((t1[i][0] - r1[0]), (t1[i][1] - r1[1])))
t2_rect.append(((t2[i][0] - r2[0]), (t2[i][1] - r2[1])))
# Create mask for morphed triangle inside bounding box
mask = np.zeros((r_morph[3], r_morph[2], 3), dtype=np.float32)
cv2.fillConvexPoly(mask, np.int32(t_morph_rect), (1.0, 1.0, 1.0), 16, 0)
# Crop input images to bounding rects
img1_rect = img1[r1[1]:r1[1] + r1[3], r1[0]:r1[0] + r1[2]]
img2_rect = img2[r2[1]:r2[1] + r2[3], r2[0]:r2[0] + r2[2]]
size = (r_morph[2], r_morph[3])
warp_img1 = self._apply_affine_transform(img1_rect, t1_rect, t_morph_rect, size)
warp_img2 = self._apply_affine_transform(img2_rect, t2_rect, t_morph_rect, size)
# Alpha blend warped patches
img_rect = (1.0 - alpha) * warp_img1 + alpha * warp_img2
# Copy triangular region to morphed output
img_morph[r_morph[1]:r_morph[1]+r_morph[3], r_morph[0]:r_morph[0]+r_morph[2]] = \
img_morph[r_morph[1]:r_morph[1]+r_morph[3], r_morph[0]:r_morph[0]+r_morph[2]] * (1 - mask) + img_rect * mask
@staticmethod
def _compute_subdiv_triangles(rect: Tuple[int, int, int, int], points: np.ndarray) -> List[List[int]]:
"""Compute Delaunay triangles using cv2.Subdiv2D when scipy is absent."""
rx, ry, rw, rh = rect
subdiv = cv2.Subdiv2D((rx, ry, rw, rh))
# Insert points bounded slightly inside rect to avoid out-of-range bounds errors
valid_indices = []
for idx, p in enumerate(points):
px = float(np.clip(p[0], rx + 1, rx + rw - 2))
py = float(np.clip(p[1], ry + 1, ry + rh - 2))
try:
subdiv.insert((px, py))
valid_indices.append(idx)
except Exception:
continue
triangle_list = subdiv.getTriangleList()
simplices = []
for t in triangle_list:
pts = [(t[0], t[1]), (t[2], t[3]), (t[4], t[5])]
# Check if all triangle points fall inside the image canvas
if all(rx <= pt[0] < rx + rw and ry <= pt[1] < ry + rh for pt in pts):
# Map back to nearest point index
try:
idx1 = int(np.argmin(np.sum((points - pts[0])**2, axis=1)))
idx2 = int(np.argmin(np.sum((points - pts[1])**2, axis=1)))
idx3 = int(np.argmin(np.sum((points - pts[2])**2, axis=1)))
if idx1 != idx2 and idx2 != idx3 and idx1 != idx3:
simplices.append([idx1, idx2, idx3])
except Exception:
continue
return simplices
def generate_morph(self, img1: np.ndarray, img2: np.ndarray, alpha: float = 0.5, mode: str = "seamless") -> np.ndarray:
"""
Generate a morphed composite face from img1 and img2.
Args:
img1: Donor image 1 (BGR uint8)
img2: Donor image 2 (BGR uint8)
alpha: Blend ratio in [0.0, 1.0]. 0.0 = pure img1, 1.0 = pure img2.
mode: "delaunay" (piecewise affine only) or "seamless" (Poisson gradient fusion).
Returns:
morphed: Resulting composite image (BGR uint8).
"""
alpha = float(np.clip(alpha, 0.01, 0.99))
h, w = 600, 600
img1, img2 = self.align_and_scale_images(img1, img2, (w, h))
# Extract landmarks
points1 = self.get_landmarks(img1)
points2 = self.get_landmarks(img2)
if points1 is None or points2 is None:
raise ValueError("Failed to extract facial landmarks from one or both donor images.")
# Ensure equal count
min_pts = min(len(points1), len(points2))
points1 = points1[:min_pts]
points2 = points2[:min_pts]
# Compute intermediate landmark positions
points_morph = (1.0 - alpha) * points1 + alpha * points2
# Delaunay triangulation on intermediate points
if SCIPY_AVAILABLE and Delaunay is not None:
tri = Delaunay(points_morph)
simplices = tri.simplices
else:
# Fallback if scipy is absent: compute triangles using Subdiv2D
simplices = self._compute_subdiv_triangles((0, 0, w, h), points_morph)
# Prepare float images for precision blending
img1_float = np.float32(img1)
img2_float = np.float32(img2)
img_morph_float = np.zeros((h, w, 3), dtype=np.float32)
if len(simplices) > 0:
# Warp each triangle
for simplex in simplices:
x, y, z = simplex
t1 = [points1[x], points1[y], points1[z]]
t2 = [points2[x], points2[y], points2[z]]
t_morph = [points_morph[x], points_morph[y], points_morph[z]]
self._morph_triangle(img1_float, img2_float, img_morph_float, t1, t2, t_morph, alpha)
delaunay_result = np.uint8(np.clip(img_morph_float, 0, 255))
else:
# Simple weighted cross-dissolve fallback
delaunay_result = np.uint8(np.clip((1.0 - alpha) * img1_float + alpha * img2_float, 0, 255))
if mode == "seamless":
# Apply Poisson seamless cloning into donor canvas to remove seam artifacts
try:
# Compute convex hull of facial points (excluding outer boundary corners)
facial_points = points_morph[:-8] if len(points_morph) > 8 else points_morph
hull = cv2.convexHull(np.int32(facial_points))
mask = np.zeros((h, w), dtype=np.uint8)
cv2.fillConvexPoly(mask, hull, 255)
# Erode mask slightly to avoid perimeter edge bleed
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
mask = cv2.erode(mask, kernel, iterations=2)
# Center for seamless clone
M = cv2.moments(mask)
if M["m00"] > 0:
center = (int(M["m10"] / M["m00"]), int(M["m01"] / M["m00"]))
# Clone onto primary donor background
target_canvas = img1 if alpha <= 0.5 else img2
seamless_result = cv2.seamlessClone(delaunay_result, target_canvas, mask, center, cv2.NORMAL_CLONE)
return seamless_result
except Exception as e:
logger.warning(f"Seamless cloning encountered issue: {e}. Falling back to Delaunay composite.")
return delaunay_result
|