保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。 worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。 验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
118 lines
5.2 KiB
Python
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
|