Files

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)