feat: 新增52号发际线接口并限制GPU worker来源IP

保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。

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

验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
This commit is contained in:
xsl
2026-11-04 10:20:29 +08:00
parent a3fddbbc34
commit 5b2b73f5d5
35 changed files with 2826 additions and 26 deletions
+6 -6
View File
@@ -44,18 +44,18 @@ class FaceLandmarker:
self._detector = vision.FaceLandmarker.create_from_options(options)
self._mp = mp
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
"""Returns (468, 3) float32 of normalized x, y and relative z, or None.
def detect(self, image_rgb: np.ndarray, include_iris: bool = False) -> np.ndarray | None:
"""Returns (468, 3), or (478, 3) with iris, normalized float32 points.
The task model produces 478 landmarks (468 face + 10 iris); we return
only the first 468 to match the canonical FaceMesh topology used by
the SDK's OBJ file.
The task model produces 478 landmarks (468 face + 10 iris). By default
only the first 468 match the SDK's canonical FaceMesh topology;
include_iris=True retains all 478 for face-measurement calibration.
"""
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb)
result = self._detector.detect(mp_image)
if not result.face_landmarks:
return None
lm = result.face_landmarks[0][:468]
lm = result.face_landmarks[0] if include_iris else result.face_landmarks[0][:468]
arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
return arr
+218
View File
@@ -0,0 +1,218 @@
"""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
+117
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
{
"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": 786,
"height": 1048
},
"source_size": {
"width": 792,
"height": 1056
},
"brow_9": {
"x": 385.1878369059212,
"y": 360.65437126595765
},
"chin_152": {
"x": 385.18783690592113,
"y": 725.8251047982238
},
"face_axis_length_px": 365.1707335322661,
"brow_x_ratio": 0.4900608612034621,
"brow_y_ratio": 0.3441358504446161,
"reference_width_per_face_length": 2.152417835890317,
"reference_height_per_face_length": 2.8698904478537566,
"width_per_face_length": 2.5,
"height_per_face_length": 3.333333333333333,
"rotation_degrees": 0.2596248408989324,
"axis_x_error_px": 5.684341886080802e-14,
"source_to_reference": [
[
0.9999897336401403,
0.004531292786961665,
-5.386196599797756
],
[
-0.004531292786961665,
0.9999897336401403,
-2.202458197930582
]
],
"landmark_source": "source_landmarks_transformed_without_redetection"
}
+77 -2
View File
@@ -22,6 +22,7 @@ from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
from .marker_detect import detect_marker_hairline, path_to_curve_mask
from .height_rule import height_target, clean_curve, place_curve, inpaint_from_curve
import base64
import io
@@ -178,7 +179,7 @@ def extract_502(image_bgr: np.ndarray):
return ctx["points"], ctx["valid"]
def extract_context(image_bgr: np.ndarray):
def extract_context(image_bgr: np.ndarray, landmarks: np.ndarray | None = None):
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制:
@@ -189,7 +190,8 @@ def extract_context(image_bgr: np.ndarray):
fallback 点不会再跑出头部轮廓。
"""
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
return None
parse_map = get_parser().parse(rgb)
@@ -538,6 +540,79 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
return {"images": images, "best_centers": best_centers}
def generate_hairline_pngs_52(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None,
generate_grow_image: bool = True,
height_reference: dict | None = None,
compare_rules: bool = False,
precomputed_landmarks: np.ndarray | None = None):
"""接口52:每种 middle 曲线中点对齐到眉心上方 (中庭+下庭)/2。
只读取 middle 贴图,high/low 保留空值。生发直接复用移动后的预览曲线,
不再使用位置不同的黑模板。compare_rules 仅为调试页附带旧规则叠图。
接口2仍使用原有管线。
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
if not hair_styles:
raise ValueError("hair_styles 必填且不能为空")
ctx = extract_context(image_bgr, landmarks=precomputed_landmarks)
if ctx is None:
return None
h, w = image_bgr.shape[:2]
uv, ext_faces = load_ext_mesh()
rule = height_target(ctx["landmarks"], w, h, height_reference)
textures = get_texture_map()[gender]
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
# generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None
shared_grown = None
if generate_grow_image and not use_mask:
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
images, best_centers = [], None
for s in hair_styles: # s = 1-indexed 发型序号
key, mid_path = textures[s - 1]
old = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, load_texture_rgba(mid_path))
overlay, placement = place_curve(old, rule["target_center"], ctx["landmarks"])
overlays = {"middle": overlay, "high": None, "low": None}
if not generate_grow_image:
grown_png = None
elif not use_mask:
grown_png = shared_grown
else:
grown_png = None
try:
marked, mask = inpaint_from_curve(
image_bgr, ctx["landmarks"], ctx["parse_map"], overlay)
buf = io.BytesIO()
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
except Exception as ex: # 单张生发失败不丢弃设计曲线
logger.warning("接口52 生发失败 type=%s:%s", key, ex)
# Flux may round dimensions to a multiple of its latent stride.
# Restore the input size so the preview and generated photo align.
if grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
item = {"hairline_type": key, "order": s, "overlays": overlays,
"grown_png": grown_png, "height_placement": placement}
if compare_rules:
item["legacy_overlay"] = clean_curve(old)
images.append(item)
if best_centers is None:
center = placement["new_center"]
best_centers = {"middle": (round(center["x"]), round(center["y"])),
"high": None, "low": None}
guide_landmarks = {
str(index): {"x": float(ctx["landmarks"][index, 0] * w),
"y": float(ctx["landmarks"][index, 1] * h)}
for index in (9, 150, 152, 162, 251, 389)
}
return {"images": images, "best_centers": best_centers, "height_rule": rule,
"guide_landmarks": guide_landmarks}
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))