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