"""Interface 52: upright face axis, then native-resolution reference framing.""" from __future__ import annotations from functools import lru_cache from io import BytesIO import json from pathlib import Path import cv2 import numpy as np from PIL import Image, ImageOps PROFILE_PATH = Path(__file__).parent / "reference/framing_profile.json" def decode_portrait(raw): """Respect phone EXIF orientation before detecting or transforming points.""" try: with Image.open(BytesIO(raw)) as image: return cv2.cvtColor(np.array(ImageOps.exif_transpose(image).convert("RGB")), cv2.COLOR_RGB2BGR) except Exception: return None @lru_cache(maxsize=1) def load_profile(): return json.loads(PROFILE_PATH.read_text(encoding="utf-8")) def transform_points(points, matrix): return np.asarray(points, np.float64) @ matrix[:, :2].T + matrix[:, 2] def _rotation(landmarks, width, height): xy = landmarks[:, :2].astype(np.float64) * [width, height] delta = xy[152] - xy[9] length = float(np.linalg.norm(delta)) if not np.isfinite(xy).all() or length < 2 or delta[1] <= 0: raise ValueError("9号点与152号点无效,无法扶正人脸") down = delta / length rotation = np.array([[down[1], -down[0]], [down[0], down[1]]], np.float64) return xy, rotation, length def _matrix(rotation, source_anchor, target_anchor): return np.column_stack((rotation, np.asarray(target_anchor) - rotation @ source_anchor)) def crop_fits(matrix, source_size, output_size): w, h = output_size corners = transform_points([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]], cv2.invertAffineTransform(matrix)) sw, sh = source_size return bool((corners[:, 0] >= -1e-7).all() and (corners[:, 0] <= sw-1+1e-7).all() and (corners[:, 1] >= -1e-7).all() and (corners[:, 1] <= sh-1+1e-7).all()) def _inscribed_frame(rotation, source_size, aspect=None): """Largest centred valid rectangle: no generated background or upscaling.""" sw, sh = source_size ratio = sw / sh if aspect is None else aspect source_center = np.array([(sw-1)/2, (sh-1)/2]) low, high = 2.0, min(float(sh), sw/ratio) for _ in range(48): h = (low+high)/2 w = h*ratio matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2]) if crop_fits(matrix, source_size, (w, h)): low = h else: high = h if aspect == .75: h = max(4, int(low+1e-7)//4*4) w = h//4*3 else: h, w = max(2, int(low+1e-7)), max(2, int(low*ratio+1e-7)) matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2]) if not crop_fits(matrix, source_size, (w, h)): raise ValueError("图片边缘空间不足,无法得到有效扶正画面") return matrix, (w, h) def _tall_portrait_frame(xy, rotation, source_size, profile): """Largest native 3:4 frame, positioned to retain the entire face.""" _, size = _inscribed_frame(rotation, source_size, aspect=.75) half = (np.array(size, np.float64)-1)/2 extent = np.abs(rotation.T) @ half low, high = extent, np.array(source_size)-1-extent polygon = [np.array(p) for p in ((low[0], low[1]), (high[0], low[1]), (high[0], high[1]), (low[0], high[1]))] face = xy[:468] @ rotation.T # The crop centre must satisfy both the source boundaries and the face # bounds. Clip the feasible polygon in double precision to avoid padding. face_low, face_high = face.max(axis=0)-half, face.min(axis=0)+half for axis in range(2): for normal, bound in ((rotation[axis], face_high[axis]), (-rotation[axis], -face_low[axis])): clipped = [] for start, end in zip(polygon, polygon[1:]+polygon[:1]): a, b = float(normal @ start-bound-1e-8), float(normal @ end-bound-1e-8) if (a <= 0) != (b <= 0): clipped.append(start+(end-start)*(a/(a-b))) if b <= 0: clipped.append(end) polygon = clipped if not polygon: raise ValueError("3:4裁切窗口无法保留完整人脸") target = np.array([profile["brow_x_ratio"]*size[0], profile["brow_y_ratio"]*size[1]]) preferred = xy[9]-rotation.T @ (target-half) # Euclidean projection keeps the brow as close as possible to its target. candidates = [] if (preferred >= low-1e-8).all() and (preferred <= high+1e-8).all(): upright = rotation @ preferred if (upright >= face_low-1e-8).all() and (upright <= face_high+1e-8).all(): candidates.append(preferred) for start, end in zip(polygon, polygon[1:]+polygon[:1]): edge = end-start length2 = float(edge @ edge) t = np.clip(float((preferred-start) @ edge)/length2, 0, 1) if length2 > 1e-16 else 0 candidates.append(start+t*edge) centre = min(candidates, key=lambda p: float(np.sum((p-preferred)**2))) matrix = _matrix(rotation, centre, half) if not crop_fits(matrix, source_size, size): raise ValueError("3:4裁切窗口超出原图有效范围") return matrix, size def _apply(photo, landmarks, matrix, size): h, w = photo.shape[:2] ow, oh = size moved = np.asarray(landmarks, np.float64).copy() moved[:, :2] = transform_points(landmarks[:, :2]*[w, h], matrix) / [ow, oh] # MediaPipe Z is measured in normalized image-width units. moved[:, 2] *= w/ow result = cv2.warpAffine(photo, matrix, size, flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0)) return result, moved def calibrate_reference(photo, landmarks): """First straighten the actual reference; derive ratios from its final frame.""" h, w = photo.shape[:2] _xy, rotation, length = _rotation(landmarks, w, h) matrix, size = _inscribed_frame(rotation, (w, h), aspect=.75) image, moved = _apply(photo, landmarks, matrix, size) ow, oh = size b, c = moved[[9, 152], :2]*[ow, oh] profile = { "version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4", "source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg", "reference_image_url": "/static/framing_reference.png", "reference_size": {"width": ow, "height": oh}, "source_size": {"width": w, "height": h}, "brow_9": {"x": float(b[0]), "y": float(b[1])}, "chin_152": {"x": float(c[0]), "y": float(c[1])}, "face_axis_length_px": length, "brow_x_ratio": float(b[0]/ow), "brow_y_ratio": float(b[1]/oh), "reference_width_per_face_length": ow/length, "reference_height_per_face_length": oh/length, "width_per_face_length": 2.5, "height_per_face_length": 3.0/.9, "rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))), "axis_x_error_px": float(abs(b[0]-c[0])), "source_to_reference": matrix.tolist(), "landmark_source": "source_landmarks_transformed_without_redetection", } return image, moved, profile def normalize_portrait(photo, landmarks, profile=None): profile = load_profile() if profile is None else profile h, w = photo.shape[:2] xy, rotation, length = _rotation(landmarks, w, h) # Round to an exact 3:4 frame without changing pixel scale. target_h = max(4, round(profile["height_per_face_length"]*length/4)*4) target_w = target_h//4*3 matrix = _matrix(rotation, xy[9], [profile["brow_x_ratio"]*target_w, profile["brow_y_ratio"]*target_h]) cropped = crop_fits(matrix, (w, h), (target_w, target_h)) aspect_cropped = False aspect_skip_reason = None if cropped: size = (target_w, target_h) elif h*3 > w*4: try: matrix, size = _tall_portrait_frame(xy, rotation, (w, h), profile) aspect_cropped = True except ValueError as ex: aspect_skip_reason = str(ex) matrix, size = _inscribed_frame(rotation, (w, h)) else: matrix, size = _inscribed_frame(rotation, (w, h)) image, moved = _apply(photo, landmarks, matrix, size) ow, oh = size axis = moved[[9, 152], :2]*[ow, oh] if not ((axis[:, 0] >= 0).all() and (axis[:, 0] < ow).all() and (axis[:, 1] >= 0).all() and (axis[:, 1] < oh).all()): raise ValueError("人脸过于贴近边缘,扶正后无法保留9号点与152号点") metadata = { "enabled": True, "standard_crop": cropped, "aspect_crop": aspect_cropped, "aspect_crop_skip_reason": aspect_skip_reason, "status": "reference_crop" if cropped else ("aspect_crop" if aspect_cropped else "rotation_only"), "skip_reason": None if cropped else "insufficient_space", "message": ("已扶正并按参考图裁切" if cropped else "原图偏长,已扶正并裁成3:4,保留完整人脸" if aspect_cropped else "3:4窗口无法完整保留人脸,已仅扶正" if aspect_skip_reason else "空间不足,仅扶正,未按参考比例裁切"), "rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))), "source_size": {"width": w, "height": h}, "output_size": {"width": ow, "height": oh}, "requested_crop_size": {"width": target_w, "height": target_h}, "face_axis_length_px": length, "height_per_face_length": profile["height_per_face_length"], "width_per_face_length": profile["height_per_face_length"]*.75, "source_to_processed": matrix.tolist(), "processed_to_source": cv2.invertAffineTransform(matrix).tolist(), "axis_x_error_px": float(abs(axis[0, 0]-axis[1, 0])), "profile_version": profile["version"], "reference_image_url": profile["reference_image_url"], } return image, moved, metadata