fix(发际线): 修正不对称刘海导致的模板叠加倾斜
按 MediaPipe 左右对应额头点协调模板延伸距离,保留人脸姿态与头部轮廓钳制,统一预览白线和生发遮罩的几何。补充对齐回归测试并更新贴图顺序测试及实现说明。 验证:13 项相关测试通过;两张用户图片完成前后对比,新图男性 7 型接口与生发图均成功返回。
This commit is contained in:
@@ -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 工作流
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user