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