1063 lines
47 KiB
Python
1063 lines
47 KiB
Python
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)
|