Files
hair/hairline/height_rule.py
T
xsl 5b2b73f5d5 feat: 新增52号发际线接口并限制GPU worker来源IP
保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。

worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。

验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
2026-11-04 10:20:29 +08:00

118 lines
5.2 KiB
Python

"""接口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