"""接口52设计高度:上庭 = (中庭 + 下庭) / 2;不改变接口2/5的几何。""" from __future__ import annotations import cv2 import numpy as np from .mask import mask_from_curve def height_target(landmarks, width, height, reference=None): """Use unrounded measurement coordinates; fall back to the same MP indices. Distances use image Y, matching face_analysis.measure's court heights. No rounded centimetre values or existing-hairline estimates enter the rule. """ scale = np.array([width, height], dtype=np.float64) if reference is None: brow, nose, chin = landmarks[[9, 94, 152], :2] * scale source = "hairline_landmarks" px_per_cm = None else: brow = np.asarray(reference["brow_center"], dtype=np.float64) nose = np.asarray(reference["nose_bottom"], dtype=np.float64) chin = np.asarray(reference["chin_tip"], dtype=np.float64) source = "face_measure" px_per_cm = reference.get("px_per_cm") if not np.isfinite([brow, nose, chin]).all() or not brow[1] < nose[1] < chin[1]: raise ValueError("眉心、鼻翼下缘与下巴坐标异常,无法计算目标上庭高度") middle, lower = float(nose[1] - brow[1]), float(chin[1] - nose[1]) upper = (middle + lower) / 2.0 target_y = float(brow[1] - upper) if not (0 <= brow[0] < width and 1 <= target_y < height - 1): raise ValueError("按中下庭均值计算的目标发际线超出图片范围,请使用完整正面照") def pt(p): return {"x": float(p[0]), "y": float(p[1])} return { "name": "court_average", "formula": "上庭 = (中庭 + 下庭) / 2", "measurement_source": source, "brow_center": pt(brow), "nose_bottom": pt(nose), "chin_tip": pt(chin), "middle_height_px": middle, "lower_height_px": lower, "target_upper_height_px": upper, "target_upper_height_cm": upper / px_per_cm if px_per_cm else None, "target_center": {"x": float(brow[0]), "y": target_y}, } def clean_curve(overlay): """Keep the white design line; discard opaque black texture-border pixels.""" out = np.zeros_like(overlay, dtype=np.float32) out[:, :, :3] = 255 out[:, :, 3] = overlay[:, :, 3] * (overlay[:, :, :3].mean(axis=2) > 128) return out def curve_center(overlay, center_x): """Alpha-weighted centre of the line at the facial midline, in image pixels.""" h, w = overlay.shape[:2] column = int(round(center_x)) if not 0 <= column < w: raise ValueError("面部中轴超出图片范围") alpha = overlay[:, column, 3].astype(np.float64) alpha[alpha <= 1] = 0 if not alpha.any(): # Rasterized endpoints may leave a 1px gap, but distant sideburns # must never be silently substituted for a missing central curve. columns = np.flatnonzero((overlay[:, :, 3] > 1).any(axis=0)) if not columns.size or np.min(np.abs(columns - column)) > 2: raise ValueError("发际线模板在面部中轴处没有有效曲线") column = int(columns[np.argmin(np.abs(columns - column))]) alpha = overlay[:, column, 3].astype(np.float64) alpha[alpha <= 1] = 0 return {"x": float(center_x), "y": float(np.dot(np.arange(h), alpha) / alpha.sum())} def place_curve(overlay, target, landmarks): """Rigidly shift a rendered template along face-up to meet the target Y. Keeping the curve rigid preserves roll and the template's shape. Pixel Y remains the height convention, even for a rolled face. A short correction compensates for the curve's slope as the local-axis shift also changes X. """ curve = clean_curve(overlay) h, w = curve.shape[:2] center_x, target_y = target["x"], target["y"] before = curve_center(curve, center_x) up = (landmarks[10, :2] - landmarks[152, :2]) * [w, h] if abs(up[1]) < 1e-6: raise ValueError("无法确定人脸向上方向") x_per_y = float(np.clip(up[0] / up[1], -1.0, 1.0)) dy = target_y - before["y"] moved = curve for _ in range(8): transform = np.array([[1, 0, dy * x_per_y], [0, 1, dy]], np.float32) moved = cv2.warpAffine(curve, transform, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=0) center = curve_center(moved, center_x) residual = target_y - center["y"] if abs(residual) <= 0.15: break dy += residual if abs(center["y"] - target_y) > 1.0: raise ValueError("发际线中心无法对齐目标高度,请检查照片姿态与裁剪范围") return moved, { "old_center": before, "new_center": center, "shift_x_px": float(dy * x_per_y), "shift_y_px": float(dy), "target_error_px": float(abs(center["y"] - target_y)), } def inpaint_from_curve(photo, landmarks, parse_map, overlay): """The preview line is also the exact boundary used for the black line/mask.""" alpha = np.clip(overlay[:, :, 3:4] / 255.0, 0, 1) marked = np.clip(photo * (1.0 - alpha), 0, 255).astype(np.uint8) curve = (overlay[:, :, 3] > 40).astype(np.uint8) * 255 mask = mask_from_curve(curve, landmarks, parse_map) return marked, mask