fix(发际线): 修正不对称刘海导致的模板叠加倾斜

按 MediaPipe 左右对应额头点协调模板延伸距离,保留人脸姿态与头部轮廓钳制,统一预览白线和生发遮罩的几何。补充对齐回归测试并更新贴图顺序测试及实现说明。

验证:13 项相关测试通过;两张用户图片完成前后对比,新图男性 7 型接口与生发图均成功返回。
This commit is contained in:
xsl
2026-11-01 15:14:29 +08:00
parent 8554850d55
commit a3fddbbc34
6 changed files with 145 additions and 6 deletions
+1
View File
@@ -74,6 +74,7 @@
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
返回 `results[].image_base64` + `grown_image_base64`。
- 模板对齐:左右刘海边界高低不同时,按 MediaPipe 左右对应额头点协调沿人脸向上方向的延伸距离,避免把 M 型等设计模板拉歪;保留人脸姿态,之后仍做头部轮廓钳制。预览白线与生发黑线/遮罩共用修正后的 502 点几何,不改变真实发际线测量算法。
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal 7=wave。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线(`generate_grow_results_swap`,复用接口12 final 的 `_V2_FINAL_DEFAULTS`,长边默认压到 `REDRAW_MAX_SIDE`=1024 再放大回原尺寸);female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。可选参数 `flux_model`(切换 Flux unet,.gguf 自动配 UnetLoaderGGUF + 配套 qwen CLIP)、`redraw_max_side`(0=不缩图)。
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
+7
View File
@@ -20,6 +20,13 @@ MP_TOP_ANCHORS: list[int] = [
]
N_ANCHORS = len(MP_TOP_ANCHORS) # 17
# Anatomical left/right pairs, in MediaPipe indices. The anchor list is
# NOT mirror-ordered at its ends: 127 pairs with 356, 234 with 454.
MP_TOP_ANCHOR_PAIRS = [
(127, 356), (234, 454), (162, 389), (21, 251),
(54, 284), (103, 332), (67, 297), (109, 338),
]
# Vertex ID layout in the extended array of length 468 + 2*N_ANCHORS.
# [0 .. 468) : MediaPipe canonical landmarks
# [468 .. 468+N) : middle row (between MP boundary and hairline)
+40
View File
@@ -124,6 +124,46 @@ def build_hairline_mask(parse_map: np.ndarray) -> np.ndarray:
return build_hair_mask(parse_map)
def align_hairline_for_template(
landmarks_norm: np.ndarray,
hairline_norm: np.ndarray,
valid: np.ndarray,
image_shape: tuple[int, int],
) -> np.ndarray:
"""Keep a designed template aligned with the face rather than the fringe.
Uneven bangs can make the detected hair boundary much higher on one
side, shearing an otherwise balanced template. Average the upward
extension of each anatomical anchor pair, measured in image pixels.
Keep the face anchors and the perpendicular component unchanged so
facial roll/perspective remain visible; do not level the curve in the
image. Missing ray hits retain their fallback and never influence a
valid partner. Call silhouette clamping afterwards to bound the result.
This is for template placement only, not measurement of the real hairline.
"""
h, w = image_shape
scale = np.array([w, h], dtype=np.float64)
up = face_up_vector(landmarks_norm) * scale
length = np.linalg.norm(up)
if length < 1e-9:
return hairline_norm.copy()
up /= length
anchors = landmarks_norm[C.MP_TOP_ANCHORS, :2] * scale
points = hairline_norm * scale
distances = (points - anchors) @ up
indices = {mp_idx: i for i, mp_idx in enumerate(C.MP_TOP_ANCHORS)}
for left, right in C.MP_TOP_ANCHOR_PAIRS:
i, j = indices[left], indices[right]
if not (valid[i] and valid[j]):
continue
mean_distance = (distances[i] + distances[j]) / 2.0
for k in (i, j):
points[k] += (mean_distance - distances[k]) * up
return (points / scale).astype(np.float32)
def smooth_hairline(
hairline_norm: np.ndarray,
valid: np.ndarray,
+9 -4
View File
@@ -15,7 +15,8 @@ from . import comfyui
from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
from .hairline_2d import (
smooth_hairline, sample_hairline_clamped,
smooth_hairline, sample_hairline, align_hairline_for_template,
clamp_hairline_to_silhouette,
)
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
@@ -180,17 +181,21 @@ def extract_502(image_bgr: np.ndarray):
def extract_context(image_bgr: np.ndarray):
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制:
左右刘海高低不同时,协调对应锚点的延伸距离,避免把设计模板拉歪;
保留人脸本身的倾斜。预览白线与生发黑线/遮罩共用同一套 points。
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。
fallback 点不会再跑出头部轮廓。
"""
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
return None
parse_map = get_parser().parse(rgb)
hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
hairline_2d, valid = sample_hairline(landmarks, parse_map)
hairline_2d = align_hairline_for_template(landmarks, hairline_2d, valid, parse_map.shape)
hairline_2d = clamp_hairline_to_silhouette(hairline_2d, parse_map)
hairline_2d = smooth_hairline(hairline_2d, valid)
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
middle_3d = build_middle_row(landmarks, hairline_3d)
+6 -2
View File
@@ -27,5 +27,9 @@ def test_mp_to_obj_anchor_mapping():
def test_texture_map():
m = get_texture_map()
assert [k for k, _ in m["female"]] == ["ellipse", "flower", "heart", "straight", "wave"]
assert [k for k, _ in m["male"]] == ["ellipse", "inverse_arc", "m", "straight"]
assert [k for k, _ in m["female"]] == [
"ellipse", "flower", "heart", "straight", "wave", "bigflower", "clasicalflower",
]
assert [k for k, _ in m["male"]] == [
"ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal", "wave",
]
+82
View File
@@ -0,0 +1,82 @@
"""Template placement must follow face pose, not unequal left/right bangs."""
import numpy as np
import pytest
from hairline import constants as C
from hairline.hairline_2d import (
align_hairline_for_template,
clamp_hairline_to_silhouette,
)
def _scene(angle=0.0, shape=(600, 900)):
h, w = shape
scale = np.array([w, h])
lm = np.zeros((468, 3), np.float32)
xy = np.full((468, 2), [450.0, 300.0])
for i, (left, right) in enumerate(C.MP_TOP_ANCHOR_PAIRS):
xy[left] = [300.0 + i * 12, 210.0 - i * 8]
xy[right] = [600.0 - i * 12, 210.0 - i * 8]
xy[10] = [450, 150]
xy[152] = [450, 450]
theta = np.deg2rad(angle)
rotation = np.array([[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]])
xy = (xy - [450, 300]) @ rotation.T + [450, 300]
lm[:, :2] = xy / scale
up = rotation @ [0.0, -1.0]
anchors = lm[C.MP_TOP_ANCHORS, :2] * scale
distances = np.full(C.N_ANCHORS, 50.0)
indices = {k: i for i, k in enumerate(C.MP_TOP_ANCHORS)}
distances[indices[67]] = 80.0
distances[indices[297]] = 20.0
hairline = ((anchors + distances[:, None] * up) / scale).astype(np.float32)
return lm, hairline, np.ones(C.N_ANCHORS, bool), anchors, up
@pytest.mark.parametrize("angle,shape", [(0, (600, 900)), (23, (600, 900)),
(-17, (1200, 900))])
def test_uneven_bangs_do_not_shear_template(angle, shape):
lm, raw, valid, anchors, up = _scene(angle, shape)
raw_copy, lm_copy = raw.copy(), lm.copy()
aligned = align_hairline_for_template(lm, raw, valid, shape)
displacement = aligned * shape[::-1] - anchors
# Every anchor extends 50 px along the rotated face axis, even when
# one side's hair boundary is 60 px higher than its counterpart.
np.testing.assert_allclose(displacement, np.tile(50 * up, (C.N_ANCHORS, 1)), atol=1e-4)
np.testing.assert_array_equal(raw, raw_copy)
np.testing.assert_array_equal(lm, lm_copy)
def test_anatomical_pairs_are_not_reversed_anchor_order():
shape = (600, 900)
lm, raw, valid, anchors, up = _scene(shape=shape)
# Endpoints of MP_TOP_ANCHORS are not mirror-ordered.
distances = {127: 10, 356: 30, 234: 60, 454: 100}
for mp_idx, distance in distances.items():
i = C.MP_TOP_ANCHORS.index(mp_idx)
raw[i] = (anchors[i] + distance * up) / shape[::-1]
aligned = align_hairline_for_template(lm, raw, valid, shape)
actual = (aligned * shape[::-1] - anchors) @ up
for mp_idx, expected in [(127, 20), (356, 20), (234, 80), (454, 80)]:
assert actual[C.MP_TOP_ANCHORS.index(mp_idx)] == pytest.approx(expected, abs=1e-4)
def test_missing_ray_hit_does_not_pull_valid_partner():
shape = (600, 900)
lm, raw, valid, _anchors, _up = _scene(shape=shape)
left, right = C.MP_TOP_ANCHORS.index(67), C.MP_TOP_ANCHORS.index(297)
valid[right] = False
raw[right, 1] = -0.5 # An extrapolated fallback outside the head.
aligned = align_hairline_for_template(lm, raw, valid, shape)
np.testing.assert_allclose(aligned[[left, right]], raw[[left, right]], atol=1e-7)
def test_template_alignment_still_allows_silhouette_clamping():
shape = (600, 900)
lm, raw, valid, _anchors, _up = _scene(shape=shape)
aligned = align_hairline_for_template(lm, raw, valid, shape)
parse_map = np.zeros(shape, np.int32)
parse_map[140:450, 250:650] = C.PARSE_SKIN
clamped = clamp_hairline_to_silhouette(aligned, parse_map)
assert np.min(clamped[:, 1] * shape[0]) >= 142 - 1e-4