from __future__ import annotations from dataclasses import dataclass, field from io import BytesIO import math from typing import Any import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageFilter try: import cv2 # type: ignore except Exception: # pragma: no cover - fallback if opencv is not installed yet cv2 = None # type: ignore try: import mediapipe as mp # type: ignore except Exception: # pragma: no cover - mediapipe is optional; OpenCV fallback remains available mp = None # type: ignore _FACE_OVAL = [10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379, 378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109, 10] _LIPS_OUTER = [61, 146, 91, 181, 84, 17, 314, 405, 321, 375, 291, 409, 270, 269, 267, 0, 37, 39, 40, 185, 61] _LIPS_INNER = [78, 95, 88, 178, 87, 14, 317, 402, 318, 324, 308, 415, 310, 311, 312, 13, 82, 81, 80, 191, 78] _NOSE_RIDGE = [168, 6, 197, 195, 5, 4, 1, 19, 94, 2] _NOSE_WIDTH = [98, 64, 240, 1, 460, 294, 327] _LEFT_EYE = [33, 160, 158, 133, 153, 144, 33] _RIGHT_EYE = [362, 385, 387, 263, 373, 380, 362] @dataclass class FaceGeometry: detected: bool face_center: tuple[float, float] face_width: float face_height: float nose_point: tuple[float, float] mouth_center: tuple[float, float] mouth_width: float chin_point: tuple[float, float] box: tuple[int, int, int, int] | None = None detection_method: str = "approximation" landmarks: dict[int, tuple[float, float]] = field(default_factory=dict) @property def landmark_count(self) -> int: return len(self.landmarks) def _clamp_float(value: Any, default: float = 0.0, low: float = -1.0, high: float = 1.0) -> float: try: v = float(value) except Exception: v = default if math.isnan(v) or math.isinf(v): return default return max(low, min(high, v)) def _safe_hex_color(value: Any) -> str | None: if not isinstance(value, str): return None raw = value.strip() if raw.startswith("#"): raw = raw[1:] if len(raw) != 6: return None try: int(raw, 16) except Exception: return None return f"#{raw.upper()}" def _safe_point_list(value: Any) -> list[tuple[float, float]]: if not isinstance(value, list): return [] points: list[tuple[float, float]] = [] for item in value[:1400]: if not isinstance(item, (list, tuple)) or len(item) < 2: continue try: x = float(item[0]) y = float(item[1]) except Exception: continue if math.isnan(x) or math.isnan(y) or math.isinf(x) or math.isinf(y): continue points.append((max(0.0, min(1.0, x)), max(0.0, min(1.0, y)))) return points def _hex_to_rgb(value: str | None, default: tuple[int, int, int]) -> tuple[int, int, int]: safe = _safe_hex_color(value) if safe is None: return default raw = safe[1:] return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16) def _params(payload: dict[str, Any] | None) -> dict[str, float | bool | str | None]: payload = payload or {} return { "global_strength": _clamp_float(payload.get("global_strength"), 0.62, 0.2, 1.45), "lip_volume": _clamp_float(payload.get("lip_volume")), "upper_lip": _clamp_float(payload.get("upper_lip")), "lower_lip": _clamp_float(payload.get("lower_lip")), "nose_width": _clamp_float(payload.get("nose_width")), "nose_tip": _clamp_float(payload.get("nose_tip")), "chin_projection": _clamp_float(payload.get("chin_projection")), "chin_width": _clamp_float(payload.get("chin_width")), "jawline": _clamp_float(payload.get("jawline")), "cheekbones": _clamp_float(payload.get("cheekbones")), "eye_color": _safe_hex_color(payload.get("eye_color")), "eye_color_strength": _clamp_float(payload.get("eye_color_strength"), 0.0, 0.0, 1.0), "eye_iris_size": _clamp_float(payload.get("eye_iris_size"), 0.72, 0.40, 1.25), "eye_paint_points": _safe_point_list(payload.get("eye_paint_points")), "eye_paint_radius": _clamp_float(payload.get("eye_paint_radius"), 0.018, 0.004, 0.080), "lip_tint": _safe_hex_color(payload.get("lip_tint")), "lip_tint_strength": _clamp_float(payload.get("lip_tint_strength"), 0.0, 0.0, 1.0), "cheek_blush": _clamp_float(payload.get("cheek_blush"), 0.0, 0.0, 1.0), "brow_darkness": _clamp_float(payload.get("brow_darkness"), 0.0, 0.0, 1.0), "brow_lift": _clamp_float(payload.get("brow_lift"), 0.0, -1.0, 1.0), "skin_smooth": _clamp_float(payload.get("skin_smooth"), 0.0, 0.0, 1.0), "show_guides": bool(payload.get("show_guides", False)), } def _load_image(source_bytes: bytes) -> Image.Image: image = Image.open(BytesIO(source_bytes)) image = ImageOps.exif_transpose(image) return image.convert("RGB") def _resize_for_processing(image: Image.Image, max_side: int = 1800) -> Image.Image: w, h = image.size longest = max(w, h) if longest <= max_side: return image scale = max_side / float(longest) return image.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS) def _distance(a: tuple[float, float], b: tuple[float, float]) -> float: return math.hypot(a[0] - b[0], a[1] - b[1]) def _guess_geometry(width: int, height: int) -> FaceGeometry: cx = width * 0.5 cy = height * 0.46 face_width = max(80.0, width * 0.38) face_height = max(120.0, height * 0.56) return FaceGeometry( detected=False, face_center=(cx, cy), face_width=face_width, face_height=face_height, nose_point=(cx, cy + face_height * 0.03), mouth_center=(cx, cy + face_height * 0.22), mouth_width=face_width * 0.34, chin_point=(cx, cy + face_height * 0.44), box=None, detection_method="approximation", ) def _normal_point(payload: dict[str, Any], key: str, width: int, height: int, default: tuple[float, float]) -> tuple[float, float]: raw = payload.get(key) try: if isinstance(raw, (list, tuple)) and len(raw) >= 2: x = float(raw[0]) y = float(raw[1]) elif isinstance(raw, dict): x = float(raw.get("x")) y = float(raw.get("y")) else: return default # Coordinates are normalized 0..1 from Flutter. if -0.25 <= x <= 1.25 and -0.25 <= y <= 1.25: return (max(0.0, min(1.0, x)) * width, max(0.0, min(1.0, y)) * height) # Fallback: allow pixel coordinates, because future-us will forget and send pixels someday. return (max(0.0, min(float(width), x)), max(0.0, min(float(height), y))) except Exception: return default def _manual_geometry_from_payload(payload: dict[str, Any] | None, width: int, height: int) -> FaceGeometry | None: payload = payload or {} raw = payload.get("manual_landmarks") if not isinstance(raw, dict) or not raw: return None defaults = { "left_eye": (width * 0.40, height * 0.42), "right_eye": (width * 0.60, height * 0.42), "nose": (width * 0.50, height * 0.54), "mouth": (width * 0.50, height * 0.66), "chin": (width * 0.50, height * 0.80), "face_top": (width * 0.50, height * 0.25), "face_left": (width * 0.31, height * 0.55), "face_right": (width * 0.69, height * 0.55), } left_eye = _normal_point(raw, "left_eye", width, height, defaults["left_eye"]) right_eye = _normal_point(raw, "right_eye", width, height, defaults["right_eye"]) nose = _normal_point(raw, "nose", width, height, defaults["nose"]) mouth = _normal_point(raw, "mouth", width, height, defaults["mouth"]) chin = _normal_point(raw, "chin", width, height, defaults["chin"]) face_top = _normal_point(raw, "face_top", width, height, defaults["face_top"]) face_left = _normal_point(raw, "face_left", width, height, defaults["face_left"]) face_right = _normal_point(raw, "face_right", width, height, defaults["face_right"]) face_width = max(60.0, _distance(face_left, face_right), _distance(left_eye, right_eye) * 2.25) face_height = max(90.0, _distance(face_top, chin)) face_center = ((face_left[0] + face_right[0]) / 2.0, (face_top[1] + chin[1]) / 2.0) mouth_width = max(24.0, _distance(left_eye, right_eye) * 0.42, face_width * 0.28) # Populate a small subset of MediaPipe-like indices so existing drawing and warping code can reuse them. landmarks: dict[int, tuple[float, float]] = { 33: left_eye, 133: (left_eye[0] + _distance(left_eye, right_eye) * 0.10, left_eye[1]), 362: (right_eye[0] - _distance(left_eye, right_eye) * 0.10, right_eye[1]), 263: right_eye, 1: nose, 4: nose, 98: (nose[0] - face_width * 0.055, nose[1] + face_height * 0.035), 327: (nose[0] + face_width * 0.055, nose[1] + face_height * 0.035), 13: (mouth[0], mouth[1] - face_height * 0.020), 14: (mouth[0], mouth[1] + face_height * 0.025), 0: (mouth[0], mouth[1] - face_height * 0.040), 17: (mouth[0], mouth[1] + face_height * 0.045), 61: (mouth[0] - mouth_width / 2.0, mouth[1]), 291: (mouth[0] + mouth_width / 2.0, mouth[1]), 152: chin, 10: face_top, 234: face_left, 454: face_right, 172: (face_left[0] + face_width * 0.08, chin[1] - face_height * 0.18), 397: (face_right[0] - face_width * 0.08, chin[1] - face_height * 0.18), } min_x = int(max(0, min(p[0] for p in landmarks.values()))) max_x = int(min(width - 1, max(p[0] for p in landmarks.values()))) min_y = int(max(0, min(p[1] for p in landmarks.values()))) max_y = int(min(height - 1, max(p[1] for p in landmarks.values()))) return FaceGeometry( detected=True, face_center=face_center, face_width=face_width, face_height=face_height, nose_point=nose, mouth_center=mouth, mouth_width=mouth_width, chin_point=chin, box=(min_x, min_y, max(1, max_x - min_x), max(1, max_y - min_y)), detection_method="manual_landmarks", landmarks=landmarks, ) def _lm(geom: FaceGeometry, idx: int, default: tuple[float, float] | None = None) -> tuple[float, float]: if idx in geom.landmarks: return geom.landmarks[idx] if default is not None: return default return geom.face_center def _avg_points(points: list[tuple[float, float]]) -> tuple[float, float]: if not points: return (0.0, 0.0) return (sum(p[0] for p in points) / len(points), sum(p[1] for p in points) / len(points)) def _geometry_from_landmarks( landmarks: dict[int, tuple[float, float]], image_width: int, image_height: int, ) -> FaceGeometry | None: if not landmarks: return None w, h = image_width, image_height xs = [p[0] for p in landmarks.values()] ys = [p[1] for p in landmarks.values()] min_x = max(0, int(min(xs))) min_y = max(0, int(min(ys))) max_x = min(w - 1, int(max(xs))) max_y = min(h - 1, int(max(ys))) left_cheek = landmarks.get(234, (min_x, (min_y + max_y) / 2)) right_cheek = landmarks.get(454, (max_x, (min_y + max_y) / 2)) top_face = landmarks.get(10, ((min_x + max_x) / 2, min_y)) chin = landmarks.get(152, ((min_x + max_x) / 2, max_y)) nose_candidates = [landmarks[i] for i in [1, 4, 5, 195] if i in landmarks] nose = _avg_points(nose_candidates) if nose_candidates else ((min_x + max_x) / 2, (min_y + max_y) / 2) lip_candidates = [landmarks[i] for i in [13, 14, 0, 17, 61, 291] if i in landmarks] mouth_center = _avg_points(lip_candidates) if lip_candidates else ((min_x + max_x) / 2, min_y + (max_y - min_y) * 0.68) mouth_width = _distance( landmarks.get(61, (mouth_center[0] - 30, mouth_center[1])), landmarks.get(291, (mouth_center[0] + 30, mouth_center[1])), ) face_width = max(40.0, _distance(left_cheek, right_cheek), float(max_x - min_x) * 0.78) face_height = max(70.0, _distance(top_face, chin), float(max_y - min_y) * 0.86) face_center = ((left_cheek[0] + right_cheek[0]) / 2, (top_face[1] + chin[1]) / 2) return FaceGeometry( detected=True, face_center=face_center, face_width=face_width, face_height=face_height, nose_point=nose, mouth_center=mouth_center, mouth_width=max(24.0, mouth_width), chin_point=chin, box=(min_x, min_y, max(1, max_x - min_x), max(1, max_y - min_y)), detection_method="mediapipe_face_mesh", landmarks=landmarks, ) def _padded_crop_box(box: tuple[int, int, int, int], image_width: int, image_height: int, pad: float = 0.42) -> tuple[int, int, int, int]: x, y, bw, bh = box cx = x + bw / 2 cy = y + bh / 2 # Give MediaPipe more forehead/chin context than Haar provides. Haar rectangles are allergic to anatomy. new_w = bw * (1.0 + pad * 2.0) new_h = bh * (1.0 + pad * 2.25) x1 = max(0, int(cx - new_w / 2)) y1 = max(0, int(cy - new_h / 2)) x2 = min(image_width, int(cx + new_w / 2)) y2 = min(image_height, int(cy + new_h / 2)) if x2 <= x1 + 20 or y2 <= y1 + 20: return (0, 0, image_width, image_height) return (x1, y1, x2, y2) def _detect_face_mesh(image: Image.Image, crop_box: tuple[int, int, int, int] | None = None) -> FaceGeometry | None: """Try MediaPipe Face Mesh on the full image or on a padded crop. V3.4.1: retrying on a crop matters because MediaPipe can miss faces in portraits with lots of hair/background even when OpenCV Haar finds the rough face box. """ if mp is None: return None original_w, original_h = image.size x0 = 0 y0 = 0 target = image if crop_box is not None: # crop_box may be Haar-style (x, y, width, height) or PIL-style (x1, y1, x2, y2). if len(crop_box) == 4: x, y, a, b = crop_box # If a/b look like width/height, convert to x2/y2. if a <= original_w and b <= original_h and (x + a) <= original_w and (y + b) <= original_h: x1, y1, x2, y2 = _padded_crop_box((int(x), int(y), int(a), int(b)), original_w, original_h) else: x1, y1, x2, y2 = int(x), int(y), int(a), int(b) else: x1, y1, x2, y2 = 0, 0, original_w, original_h x0, y0 = x1, y1 target = image.crop((x1, y1, x2, y2)) try: # Try a couple of detection thresholds. MediaPipe is powerful, but still occasionally behaves like a locked door. for confidence in (0.35, 0.22, 0.12): arr = np.ascontiguousarray(np.array(target.convert("RGB"))) arr.flags.writeable = False face_mesh = mp.solutions.face_mesh.FaceMesh( static_image_mode=True, max_num_faces=1, refine_landmarks=True, min_detection_confidence=confidence, ) try: result = face_mesh.process(arr) finally: face_mesh.close() if not result.multi_face_landmarks: continue raw_landmarks = result.multi_face_landmarks[0].landmark crop_w, crop_h = target.size landmarks: dict[int, tuple[float, float]] = {} for idx, point in enumerate(raw_landmarks): landmarks[idx] = (float(point.x * crop_w + x0), float(point.y * crop_h + y0)) geom = _geometry_from_landmarks(landmarks, original_w, original_h) if geom is not None: return geom return None except Exception: return None def _detect_face_haar(image: Image.Image) -> FaceGeometry | None: if cv2 is None: return None try: arr = np.array(image) gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY) cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml" cascade = cv2.CascadeClassifier(cascade_path) faces = cascade.detectMultiScale(gray, scaleFactor=1.08, minNeighbors=5, minSize=(80, 80)) if faces is None or len(faces) == 0: return None x, y, fw, fh = max(faces, key=lambda item: int(item[2]) * int(item[3])) cx = float(x + fw / 2) cy = float(y + fh / 2) face_width = float(max(40, fw)) face_height = float(max(60, fh)) return FaceGeometry( detected=True, face_center=(cx, cy), face_width=face_width, face_height=face_height, nose_point=(cx, y + fh * 0.52), mouth_center=(cx, y + fh * 0.70), mouth_width=face_width * 0.34, chin_point=(cx, y + fh * 0.90), box=(int(x), int(y), int(fw), int(fh)), detection_method="opencv_haar", ) except Exception: return None def _detect_face(image: Image.Image, payload: dict[str, Any] | None = None) -> FaceGeometry: manual = _manual_geometry_from_payload(payload, image.size[0], image.size[1]) if manual is not None: return manual # V3.4.1: try full-image MediaPipe, then use Haar only as a crop finder for a second MediaPipe attempt. # This is less dumb than giving up at the first miss and pretending a rectangle is anatomy. mesh = _detect_face_mesh(image) if mesh is not None: return mesh haar = _detect_face_haar(image) if haar is not None: if haar.box is not None: mesh_from_crop = _detect_face_mesh(image, haar.box) if mesh_from_crop is not None: return mesh_from_crop return haar return _guess_geometry(*image.size) def _ellipse_weight(x: np.ndarray, y: np.ndarray, center: tuple[float, float], rx: float, ry: float) -> np.ndarray: rx = max(1.0, float(rx)) ry = max(1.0, float(ry)) nx = (x - center[0]) / rx ny = (y - center[1]) / ry d = nx * nx + ny * ny soft = np.clip(1.0 - d, 0.0, 1.0) return soft * soft * (3.0 - 2.0 * soft) def _local_scale(sx: np.ndarray, sy: np.ndarray, center: tuple[float, float], rx: float, ry: float, value: float, amount: float) -> tuple[np.ndarray, np.ndarray]: if abs(value) <= 0.001: return sx, sy weight = _ellipse_weight(sx, sy, center, rx, ry) factor = np.maximum(0.45, 1.0 + value * amount * weight) sx = center[0] + (sx - center[0]) / factor sy = center[1] + (sy - center[1]) / factor return sx, sy def _warp_image(image: Image.Image, geom: FaceGeometry, payload: dict[str, Any]) -> Image.Image: p = _params(payload) arr = np.array(image) h, w = arr.shape[:2] yy, xx = np.indices((h, w), dtype=np.float32) sx = xx.copy() sy = yy.copy() k = float(p["global_strength"]) lip_volume = float(p["lip_volume"]) * k upper_lip = float(p["upper_lip"]) * k lower_lip = float(p["lower_lip"]) * k nose_width = float(p["nose_width"]) * k nose_tip = float(p["nose_tip"]) * k chin_projection = float(p["chin_projection"]) * k chin_width = float(p["chin_width"]) * k jawline = float(p["jawline"]) * k cheekbones = float(p["cheekbones"]) * k cx, cy = geom.face_center fw = geom.face_width fh = geom.face_height # MediaPipe-specific anchors when available. Fallbacks keep OpenCV/approximation alive, because apparently code must survive reality. left_jaw = _lm(geom, 172, (cx - fw * 0.38, cy + fh * 0.22)) right_jaw = _lm(geom, 397, (cx + fw * 0.38, cy + fh * 0.22)) left_cheek = _lm(geom, 234, (cx - fw * 0.36, cy - fh * 0.02)) right_cheek = _lm(geom, 454, (cx + fw * 0.36, cy - fh * 0.02)) nose_left = _lm(geom, 98, (geom.nose_point[0] - fw * 0.065, geom.nose_point[1])) nose_right = _lm(geom, 327, (geom.nose_point[0] + fw * 0.065, geom.nose_point[1])) upper_lip_center = _avg_points([_lm(geom, i, geom.mouth_center) for i in [13, 0, 12]]) lower_lip_center = _avg_points([_lm(geom, i, geom.mouth_center) for i in [14, 17, 15]]) mouth_left = _lm(geom, 61, (geom.mouth_center[0] - geom.mouth_width / 2, geom.mouth_center[1])) mouth_right = _lm(geom, 291, (geom.mouth_center[0] + geom.mouth_width / 2, geom.mouth_center[1])) mouth_width = max(20.0, _distance(mouth_left, mouth_right), geom.mouth_width) nose_actual_width = max(18.0, _distance(nose_left, nose_right), fw * 0.10) # Jawline / face oval: translate source coordinates outward on lower side zones, which visually slims the lower face. if abs(jawline) > 0.001: for side, center in [(-1.0, left_jaw), (1.0, right_jaw)]: weight = _ellipse_weight(sx, sy, center, fw * 0.28, fh * 0.33) sx += (side * jawline * fw * 0.155 * weight).astype(np.float32) # A very soft global lower-face correction so the two side zones blend instead of looking stapled on. dy = (sy - (cy + fh * 0.08)) / max(1.0, fh * 0.58) dx = (sx - cx) / max(1.0, fw * 0.62) lower_mask = sy > (cy - fh * 0.04) weight = np.clip(1.0 - (dx * dx + dy * dy), 0.0, 1.0) * lower_mask side = np.where(sx < cx, -1.0, 1.0).astype(np.float32) sx += (side * jawline * fw * 0.055 * weight * weight).astype(np.float32) if abs(cheekbones) > 0.001: for side, center in [(-1.0, left_cheek), (1.0, right_cheek)]: sx, sy = _local_scale(sx, sy, center, fw * 0.24, fh * 0.18, cheekbones, 0.24) # Tiny outward pull to make cheekbone changes more visible without bloating the whole face. weight = _ellipse_weight(sx, sy, center, fw * 0.24, fh * 0.18) sx -= (side * cheekbones * fw * 0.030 * weight).astype(np.float32) if abs(chin_projection) > 0.001: center = geom.chin_point weight = _ellipse_weight(sx, sy, center, fw * 0.34, fh * 0.24) sy -= (chin_projection * fh * 0.115 * weight).astype(np.float32) if abs(chin_width) > 0.001: center = (geom.chin_point[0], geom.chin_point[1] - fh * 0.02) # Positive = narrower chin, so use negative scale in source map. sx, sy = _local_scale(sx, sy, center, fw * 0.30, fh * 0.22, -chin_width, 0.34) if abs(lip_volume) > 0.001: center = geom.mouth_center sx, sy = _local_scale(sx, sy, center, mouth_width * 1.38, fh * 0.105, lip_volume, 0.40) if abs(upper_lip) > 0.001: sx, sy = _local_scale(sx, sy, upper_lip_center, mouth_width * 1.15, fh * 0.062, upper_lip, 0.50) if abs(lower_lip) > 0.001: sx, sy = _local_scale(sx, sy, lower_lip_center, mouth_width * 1.20, fh * 0.070, lower_lip, 0.48) if abs(nose_width) > 0.001: center = geom.nose_point weight = _ellipse_weight(sx, sy, center, nose_actual_width * 2.10, fh * 0.245) factor = np.maximum(0.46, 1.0 - nose_width * 0.46 * weight) sx = center[0] + (sx - center[0]) / factor # keep vertical movement modest, otherwise the nose looks like it melted, the traditional curse of cheap filters. sy = center[1] + (sy - center[1]) / np.maximum(0.78, 1.0 - nose_width * 0.08 * weight) if abs(nose_tip) > 0.001: center = _lm(geom, 1, (geom.nose_point[0], geom.nose_point[1] + fh * 0.045)) weight = _ellipse_weight(sx, sy, center, nose_actual_width * 1.85, fh * 0.135) sy -= (nose_tip * fh * 0.088 * weight).astype(np.float32) sx = np.clip(sx, 0, w - 1).astype(np.float32) sy = np.clip(sy, 0, h - 1).astype(np.float32) if cv2 is not None: out = cv2.remap(arr, sx, sy, interpolation=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) else: out = arr[np.rint(sy).astype(np.int32), np.rint(sx).astype(np.int32)] return Image.fromarray(out) def _mask_from_polygon(size: tuple[int, int], points: list[tuple[float, float]], blur: float = 0.0) -> Image.Image: mask = Image.new("L", size, 0) if len(points) >= 3: draw = ImageDraw.Draw(mask) draw.polygon([(float(x), float(y)) for x, y in points], fill=255) if blur > 0: mask = mask.filter(ImageFilter.GaussianBlur(blur)) return mask def _mask_from_ellipse(size: tuple[int, int], center: tuple[float, float], rx: float, ry: float, blur: float = 0.0) -> Image.Image: mask = Image.new("L", size, 0) draw = ImageDraw.Draw(mask) cx, cy = center draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=255) if blur > 0: mask = mask.filter(ImageFilter.GaussianBlur(blur)) return mask def _apply_tint(base: Image.Image, mask: Image.Image, color: tuple[int, int, int], strength: float, preserve_luma: bool = True) -> Image.Image: strength = max(0.0, min(1.0, float(strength))) if strength <= 0.001: return base arr = np.asarray(base.convert("RGB"), dtype=np.float32) m = np.asarray(mask, dtype=np.float32) / 255.0 if m.max() <= 0: return base m = (m * strength)[..., None] target = np.zeros_like(arr) target[..., 0] = color[0] target[..., 1] = color[1] target[..., 2] = color[2] if preserve_luma: luma = (arr[..., 0] * 0.299 + arr[..., 1] * 0.587 + arr[..., 2] * 0.114) / 255.0 target = target * (0.45 + luma[..., None] * 0.85) out = arr * (1.0 - m) + target * m return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)) def _darken_region(base: Image.Image, mask: Image.Image, strength: float) -> Image.Image: strength = max(0.0, min(1.0, float(strength))) if strength <= 0.001: return base arr = np.asarray(base.convert("RGB"), dtype=np.float32) m = (np.asarray(mask, dtype=np.float32) / 255.0 * strength * 0.65)[..., None] out = arr * (1.0 - m) return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)) def _skin_smooth(base: Image.Image, geom: FaceGeometry, strength: float) -> Image.Image: strength = max(0.0, min(1.0, float(strength))) if strength <= 0.001: return base w, h = base.size cx, cy = geom.face_center fw, fh = geom.face_width, geom.face_height if geom.landmarks: points = [geom.landmarks[i] for i in _FACE_OVAL if i in geom.landmarks] mask = _mask_from_polygon((w, h), points, blur=max(6.0, fw * 0.025)) else: mask = _mask_from_ellipse((w, h), (cx, cy), fw * 0.50, fh * 0.53, blur=max(6.0, fw * 0.025)) # Protect eyes and lips a little; smoothing those is how faces become wax museum inventory. protect = Image.new("L", (w, h), 0) pd = ImageDraw.Draw(protect) left_eye = _lm(geom, 468, _lm(geom, 33, (cx - fw * 0.20, cy - fh * 0.15))) right_eye = _lm(geom, 473, _lm(geom, 263, (cx + fw * 0.20, cy - fh * 0.15))) for pt in [left_eye, right_eye, geom.mouth_center]: px, py = pt pd.ellipse([px - fw * 0.11, py - fh * 0.055, px + fw * 0.11, py + fh * 0.055], fill=210) protect = protect.filter(ImageFilter.GaussianBlur(max(3.0, fw * 0.015))) mask_arr = np.asarray(mask, dtype=np.float32) protect_arr = np.asarray(protect, dtype=np.float32) mask = Image.fromarray(np.clip(mask_arr * (1.0 - protect_arr / 255.0), 0, 255).astype(np.uint8)) blur_radius = max(1.5, fw * (0.010 + strength * 0.018)) blurred = base.filter(ImageFilter.GaussianBlur(blur_radius)) alpha = Image.fromarray(np.clip(np.asarray(mask, dtype=np.float32) * (0.16 + strength * 0.42), 0, 255).astype(np.uint8)) return Image.composite(blurred, base, alpha) def _eye_centers(geom: FaceGeometry) -> list[tuple[float, float]]: # Refined MediaPipe iris landmarks when available. left_iris = [geom.landmarks[i] for i in [468, 469, 470, 471, 472] if i in geom.landmarks] right_iris = [geom.landmarks[i] for i in [473, 474, 475, 476, 477] if i in geom.landmarks] if len(left_iris) >= 2 and len(right_iris) >= 2: return [_avg_points(left_iris), _avg_points(right_iris)] left_eye = [geom.landmarks[i] for i in [33, 160, 158, 133, 153, 144] if i in geom.landmarks] right_eye = [geom.landmarks[i] for i in [362, 385, 387, 263, 373, 380] if i in geom.landmarks] if len(left_eye) >= 2 and len(right_eye) >= 2: return [_avg_points(left_eye), _avg_points(right_eye)] cx, cy = geom.face_center fw, fh = geom.face_width, geom.face_height return [ _lm(geom, 33, (cx - fw * 0.19, cy - fh * 0.14)), _lm(geom, 263, (cx + fw * 0.19, cy - fh * 0.14)), ] def _target_hsv_from_rgb(color: tuple[int, int, int]) -> tuple[float, float, float]: # OpenCV uses HSV hue in 0..179. If cv2 is unavailable, fall back to blue-ish arithmetic elsewhere. if cv2 is None: return (100.0, 180.0, 180.0) pixel = np.array([[[color[0], color[1], color[2]]]], dtype=np.uint8) hsv = cv2.cvtColor(pixel, cv2.COLOR_RGB2HSV)[0, 0] return float(hsv[0]), float(hsv[1]), float(hsv[2]) def _smooth01(x: np.ndarray, edge0: float, edge1: float) -> np.ndarray: if abs(edge1 - edge0) < 1e-6: return np.clip(x, 0.0, 1.0) t = np.clip((x - edge0) / (edge1 - edge0), 0.0, 1.0) return t * t * (3.0 - 2.0 * t) def _apply_eye_color(base: Image.Image, geom: FaceGeometry, color_hex: str | None, strength: float, iris_size: float = 0.72, paint_points: list[tuple[float, float]] | None = None, paint_radius: float = 0.018) -> Image.Image: """Recolor iris using a constrained iris mask instead of a broad cosmetic tint. V3.10.1: previous versions used an estimated oval and could leak onto sclera or lower eyelid. This version uses a smaller manual iris-size control plus a local connected-component mask around each eye center. The centers are the manual eye landmarks when manual mode is active, so the user can place them directly on the iris. """ strength = max(0.0, min(1.0, float(strength))) iris_size = max(0.40, min(1.25, float(iris_size))) if not color_hex or strength <= 0.001: return base w, h = base.size color = _hex_to_rgb(color_hex, (90, 145, 175)) fw, fh = geom.face_width, geom.face_height eyes = _eye_centers(geom) if len(eyes) < 2: return base eye_distance = max(20.0, _distance(eyes[0], eyes[1])) # Conservative by default. The user can increase the mask size if the iris is large. # Most previous leakage came from a mask that was brave enough to color eyelids. iris_rx_base = max(3.2, min(fw * 0.043, eye_distance * 0.075)) iris_rx = iris_rx_base * iris_size iris_ry = max(2.7, iris_rx * 0.74) arr = np.asarray(base.convert("RGB"), dtype=np.uint8) arr_f = arr.astype(np.float32) / 255.0 r, g, b = arr_f[..., 0], arr_f[..., 1], arr_f[..., 2] luma = r * 0.299 + g * 0.587 + b * 0.114 maxc = arr_f.max(axis=2) minc = arr_f.min(axis=2) sat_approx = (maxc - minc) / np.maximum(maxc, 1e-6) if cv2 is not None: hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV).astype(np.float32) target_h, target_s, _target_v = _target_hsv_from_rgb(color) else: hsv = None target_h, target_s = 100.0, 205.0 yy, xx = np.mgrid[0:h, 0:w] alpha_total = np.zeros((h, w), dtype=np.float32) pupil_total = np.zeros((h, w), dtype=np.float32) # Strong protections. If these are too weak, the sclera becomes a glowing aquarium. sclera_like = (luma > 0.42) & (sat_approx < 0.32) highlight_like = luma > 0.78 # Typical skin around eyelids: red channel dominates, green above blue, medium luma. skin_like = (r > 0.26) & ((r - b) > 0.075) & ((g - b) > 0.035) & (luma > 0.20) # V3.10.1: if the user paints the iris manually, trust that mask. Earlier # versions still ran the brush through skin/sclera heuristics, which produced # a miserable dotted aquarium instead of an iris recolor. Here the brush is a # real soft mask. It preserves only obvious catchlights and deep pupil shadows. paint_points = paint_points or [] if paint_points: radius_px = max(2.0, min(w, h) * max(0.004, min(0.085, float(paint_radius)))) if cv2 is not None: mask_u8 = np.zeros((h, w), dtype=np.uint8) coords: list[tuple[int, int]] = [] for nx, ny in paint_points: px = int(round(max(0.0, min(1.0, float(nx))) * (w - 1))) py = int(round(max(0.0, min(1.0, float(ny))) * (h - 1))) coords.append((px, py)) cv2.circle(mask_u8, (px, py), int(round(radius_px)), 255, thickness=-1, lineType=cv2.LINE_AA) # Connect nearby brush samples into a continuous stroke, but never connect # the left eye to the right eye, because apparently that needed saying. max_link = max(radius_px * 3.2, min(w, h) * 0.035) for (x1, y1), (x2, y2) in zip(coords, coords[1:]): if ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5 <= max_link: cv2.line(mask_u8, (x1, y1), (x2, y2), 255, thickness=max(2, int(round(radius_px * 2.0))), lineType=cv2.LINE_AA) # Smooth edge, fill pinholes. This is a mask, not digital confetti. kernel = max(1, int(round(radius_px * 0.35))) if kernel > 1: k = np.ones((kernel, kernel), dtype=np.uint8) mask_u8 = cv2.morphologyEx(mask_u8, cv2.MORPH_CLOSE, k) manual_alpha = cv2.GaussianBlur(mask_u8.astype(np.float32) / 255.0, (0, 0), sigmaX=max(0.6, radius_px * 0.23)) else: manual_alpha = np.zeros((h, w), dtype=np.float32) for nx, ny in paint_points: cx = nx * w cy = ny * h e = ((xx - cx) / radius_px) ** 2 + ((yy - cy) / radius_px) ** 2 brush = 1.0 - _smooth01(e, 0.48, 1.0) manual_alpha = np.maximum(manual_alpha, brush.astype(np.float32)) # Only protect the obvious things. If the user paints the iris, the app must # color the iris, not hold a seminar on edge detection. pupil_like = luma < 0.075 catchlight_like = luma > 0.86 protect = np.ones((h, w), dtype=np.float32) protect *= np.where(catchlight_like, 0.06, 1.0) protect *= np.where(pupil_like, 0.34, 1.0) alpha_total = np.clip(manual_alpha * protect * min(1.0, strength * 1.35), 0.0, 1.0) pupil_total = np.where(pupil_like | catchlight_like, manual_alpha, 0.0).astype(np.float32) else: # Automatic iris mask. Useful for clean photos, less useful when the eyelid # decides to join the color party. for cx, cy in eyes: e = ((xx - cx) / iris_rx) ** 2 + ((yy - cy) / iris_ry) ** 2 inside = e <= 1.18 if not np.any(inside): continue # Allow dark/colored pixels in a tiny ellipse around the manual iris center. # This includes the pupil so connected-components can grow through it, then the # pupil is protected again before blending. allowed = inside & (~sclera_like) & (~skin_like) & (~highlight_like) & (luma < 0.72) if cv2 is not None: # Connected component around the seed prevents coloring separate eyelid pixels # that happen to be inside the oval but are not connected to the iris/pupil. seed = (((xx - cx) / max(1.0, iris_rx * 0.34)) ** 2 + ((yy - cy) / max(1.0, iris_ry * 0.34)) ** 2) <= 1.0 cc_input = allowed.astype(np.uint8) num, labels = cv2.connectedComponents(cc_input, connectivity=8) seed_labels = np.unique(labels[seed & (labels > 0)]) if len(seed_labels) == 0: component = allowed.astype(np.float32) else: component = np.isin(labels, seed_labels).astype(np.float32) else: component = allowed.astype(np.float32) radial = 1.0 - _smooth01(e, 0.68, 1.18) radial = np.clip(radial, 0.0, 1.0) # Pupil protection. Keep central darkness, but not as a giant artificial dot. pupil = 1.0 - _smooth01( ((xx - cx) / max(1.0, iris_rx * 0.28)) ** 2 + ((yy - cy) / max(1.0, iris_ry * 0.28)) ** 2, 0.50, 1.05, ) # Extra protection for pixels that are likely sclera or eyelid, even if component # selection is fooled by makeup/shadow. Software, because apparently eyes are hard. protect = np.ones((h, w), dtype=np.float32) protect *= np.where(sclera_like, 0.04, 1.0) protect *= np.where(skin_like, 0.06, 1.0) protect *= np.where(highlight_like, 0.10, 1.0) protect *= (1.0 - pupil * 0.78) iris_texture_weight = np.clip((1.0 - _smooth01(luma, 0.55, 0.80)) * 0.78 + _smooth01(sat_approx, 0.06, 0.25) * 0.35, 0.0, 1.0) local_alpha = component * radial * protect * (0.40 + iris_texture_weight * 0.60) local_alpha *= min(1.0, strength * 1.18) alpha_total = np.maximum(alpha_total, local_alpha.astype(np.float32)) pupil_total = np.maximum(pupil_total, pupil.astype(np.float32) * component) if alpha_total.max() <= 0.001: return base if cv2 is not None: alpha_total = cv2.GaussianBlur(alpha_total, (0, 0), sigmaX=max(0.18, iris_rx * 0.025)) alpha_total = np.clip(alpha_total, 0.0, 1.0) if cv2 is not None and hsv is not None: original_h = hsv[..., 0].copy() delta = ((target_h - original_h + 90.0) % 180.0) - 90.0 # Strong hue change inside mask, but the mask itself is now tight. hsv[..., 0] = (original_h + delta * np.clip(alpha_total * 1.22, 0.0, 1.0)) % 180.0 target_sat = max(112.0, target_s * (0.92 + 0.16 * strength)) hsv[..., 1] = hsv[..., 1] * (1.0 - alpha_total * 0.88) + target_sat * (alpha_total * 0.88) # Slight lift for dark brown eyes so color is visible without coloring the sclera. hsv[..., 2] = np.clip(hsv[..., 2] * (1.0 + alpha_total * 0.07) + alpha_total * 8.0, 0, 255) recolored = cv2.cvtColor(np.clip(hsv, 0, 255).astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) out = arr.astype(np.float32) * (1.0 - alpha_total[..., None]) + recolored * alpha_total[..., None] out_img = Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)) else: mask = Image.fromarray(np.clip(alpha_total * 255.0, 0, 255).astype(np.uint8)) out_img = _apply_tint(base, mask, color, strength * 0.88, preserve_luma=True) # Restore pupil/catchlight contrast inside the tight mask. pupil_mask = Image.fromarray(np.clip(pupil_total * 255.0, 0, 255).astype(np.uint8)).filter( ImageFilter.GaussianBlur(max(0.18, iris_rx * 0.020)) ) out_img = _darken_region(out_img, pupil_mask, 0.38) return out_img def _apply_lip_tint(base: Image.Image, geom: FaceGeometry, color_hex: str | None, strength: float) -> Image.Image: strength = max(0.0, min(1.0, float(strength))) if not color_hex or strength <= 0.001: return base w, h = base.size color = _hex_to_rgb(color_hex, (178, 82, 102)) if geom.landmarks: points = [geom.landmarks[i] for i in _LIPS_OUTER if i in geom.landmarks] mask = _mask_from_polygon((w, h), points, blur=max(2.0, geom.face_width * 0.010)) else: mask = _mask_from_ellipse((w, h), geom.mouth_center, geom.mouth_width * 0.62, geom.face_height * 0.055, blur=max(2.0, geom.face_width * 0.010)) return _apply_tint(base, mask, color, strength * 0.72, preserve_luma=True) def _apply_cheek_blush(base: Image.Image, geom: FaceGeometry, strength: float) -> Image.Image: strength = max(0.0, min(1.0, float(strength))) if strength <= 0.001: return base w, h = base.size cx, cy = geom.face_center fw, fh = geom.face_width, geom.face_height left = _lm(geom, 234, (cx - fw * 0.28, cy + fh * 0.05)) right = _lm(geom, 454, (cx + fw * 0.28, cy + fh * 0.05)) out = base for center in [left, right]: mask = _mask_from_ellipse((w, h), center, fw * 0.16, fh * 0.095, blur=max(8.0, fw * 0.040)) out = _apply_tint(out, mask, (210, 92, 118), strength * 0.36, preserve_luma=True) return out def _apply_brows(base: Image.Image, geom: FaceGeometry, darkness: float, lift: float) -> Image.Image: darkness = max(0.0, min(1.0, float(darkness))) lift = max(-1.0, min(1.0, float(lift))) if darkness <= 0.001 and abs(lift) <= 0.001: return base w, h = base.size fw, fh = geom.face_width, geom.face_height left_eye, right_eye = _eye_centers(geom) out = base.copy() brow_mask = Image.new("L", (w, h), 0) draw = ImageDraw.Draw(brow_mask) y_shift = -lift * fh * 0.040 for side, eye in [(-1.0, left_eye), (1.0, right_eye)]: ex, ey = eye x1 = ex - fw * 0.135 x2 = ex + fw * 0.135 y = ey - fh * 0.085 + y_shift # Curved-ish brow made from a thick polyline. Not follicular art, mercifully. p1 = (x1, y + fh * 0.018) p2 = (ex - side * fw * 0.020, y - fh * 0.010) p3 = (x2, y + fh * 0.010) draw.line([p1, p2, p3], fill=int(255 * max(darkness, abs(lift) * 0.35)), width=max(3, int(fw * 0.022)), joint="curve") brow_mask = brow_mask.filter(ImageFilter.GaussianBlur(max(1.0, fw * 0.008))) if darkness > 0.001: out = _darken_region(out, brow_mask, darkness * 0.78) return out def _apply_beauty_effects(image: Image.Image, geom: FaceGeometry, payload: dict[str, Any]) -> Image.Image: p = _params(payload) out = image out = _skin_smooth(out, geom, float(p["skin_smooth"])) out = _apply_cheek_blush(out, geom, float(p["cheek_blush"])) out = _apply_lip_tint(out, geom, p["lip_tint"] if isinstance(p["lip_tint"], str) else None, float(p["lip_tint_strength"])) out = _apply_eye_color(out, geom, p["eye_color"] if isinstance(p["eye_color"], str) else None, float(p["eye_color_strength"]), float(p["eye_iris_size"]), p["eye_paint_points"] if isinstance(p.get("eye_paint_points"), list) else [], float(p["eye_paint_radius"])) out = _apply_brows(out, geom, float(p["brow_darkness"]), float(p["brow_lift"])) return out def _draw_polyline(draw: ImageDraw.ImageDraw, geom: FaceGeometry, indices: list[int], color: tuple[int, int, int, int], width: int) -> None: points = [geom.landmarks[i] for i in indices if i in geom.landmarks] if len(points) >= 2: draw.line(points, fill=color, width=width, joint="curve") def _draw_guides(image: Image.Image, geom: FaceGeometry) -> Image.Image: out = image.copy() draw = ImageDraw.Draw(out, "RGBA") cx, cy = geom.face_center fw = geom.face_width fh = geom.face_height accent = (255, 40, 96, 225) cyan = (0, 180, 255, 215) white = (255, 255, 255, 220) green = (70, 255, 155, 210) yellow = (255, 230, 90, 220) line_w = max(2, int(fw * 0.006)) if geom.landmarks: _draw_polyline(draw, geom, _FACE_OVAL, white, max(2, line_w + 1)) _draw_polyline(draw, geom, _LIPS_OUTER, cyan, line_w) _draw_polyline(draw, geom, _LIPS_INNER, cyan, line_w) _draw_polyline(draw, geom, _NOSE_RIDGE, accent, line_w) _draw_polyline(draw, geom, _NOSE_WIDTH, accent, line_w) _draw_polyline(draw, geom, _LEFT_EYE, green, line_w) _draw_polyline(draw, geom, _RIGHT_EYE, green, line_w) # Key control points used by the transformation. Not all 478 points, because that would make the patient look like a QR code. for idx, color in [ (1, accent), (4, accent), (98, yellow), (327, yellow), (13, cyan), (14, cyan), (61, cyan), (291, cyan), (152, accent), (172, white), (397, white), (234, green), (454, green), ]: if idx in geom.landmarks: px, py = geom.landmarks[idx] r = max(3, int(fw * 0.010)) draw.ellipse([px - r, py - r, px + r, py + r], fill=color, outline=(0, 0, 0, 190), width=1) else: if geom.box: x, y, bw, bh = geom.box draw.rectangle([x, y, x + bw, y + bh], outline=accent, width=max(2, int(fw * 0.01))) draw.ellipse([cx - fw * 0.50, cy - fh * 0.50, cx + fw * 0.50, cy + fh * 0.50], outline=white, width=max(2, int(fw * 0.01))) draw.line([cx, cy - fh * 0.55, cx, cy + fh * 0.55], fill=accent, width=max(2, int(fw * 0.008))) draw.line([cx - fw * 0.44, cy - fh * 0.15, cx + fw * 0.44, cy - fh * 0.15], fill=cyan, width=max(2, int(fw * 0.006))) for point, radius, color in [ (geom.nose_point, fw * 0.025, accent), (geom.mouth_center, fw * 0.030, cyan), (geom.chin_point, fw * 0.030, accent), ]: px, py = point draw.ellipse([px - radius, py - radius, px + radius, py + radius], outline=color, width=max(2, int(fw * 0.008))) return out def _draw_watermark(image: Image.Image, geom: FaceGeometry, transformed: bool) -> Image.Image: out = image.copy() draw = ImageDraw.Draw(out, "RGBA") w, h = out.size scale = max(w, h) / 1400.0 bar_h = int(max(70, 96 * scale)) draw.rectangle([0, 0, w, bar_h], fill=(0, 0, 0, 122)) title = "Simulation backend V3.10.1 - non contractuelle" if transformed else "Repères visage backend V3.10.1" if geom.detection_method == "mediapipe_face_mesh": subtitle = f"Face Mesh détecté: {geom.landmark_count} points" elif geom.detection_method == "manual_landmarks": subtitle = "Repères manuels utilisés" elif geom.detected: subtitle = f"Visage détecté: {geom.detection_method}" else: subtitle = "Repères approximatifs: visage non détecté de façon fiable" try: font_title = ImageFont.truetype("arial.ttf", int(24 * scale)) font_sub = ImageFont.truetype("arial.ttf", int(18 * scale)) except Exception: font_title = ImageFont.load_default() font_sub = ImageFont.load_default() draw.text((int(16 * scale), int(12 * scale)), title, fill=(255, 255, 255, 235), font=font_title) draw.text((int(16 * scale), int(50 * scale)), subtitle, fill=(255, 255, 255, 210), font=font_sub) return out def _meta(geom: FaceGeometry, image: Image.Image) -> dict[str, Any]: return { "detected": geom.detected, "method": geom.detection_method, "landmark_count": geom.landmark_count, "width": image.size[0], "height": image.size[1], } def render_face_transform(source_bytes: bytes, payload: dict[str, Any] | None = None, *, preview: bool = False) -> tuple[bytes, dict[str, Any]]: params = _params(payload) image = _resize_for_processing(_load_image(source_bytes)) geom = _detect_face(image, payload) transformed = _warp_image(image, geom, payload or {}) transformed = _apply_beauty_effects(transformed, geom, payload or {}) if params["show_guides"] or preview: transformed = _draw_guides(transformed, geom) transformed = _draw_watermark(transformed, geom, transformed=True) out = BytesIO() transformed.save(out, format="PNG", optimize=True) return out.getvalue(), _meta(geom, transformed) def render_face_guides(source_bytes: bytes, payload: dict[str, Any] | None = None) -> tuple[bytes, dict[str, Any]]: image = _resize_for_processing(_load_image(source_bytes)) geom = _detect_face(image, payload) guided = _draw_watermark(_draw_guides(image, geom), geom, transformed=False) out = BytesIO() guided.save(out, format="PNG", optimize=True) return out.getvalue(), _meta(geom, guided)