fix(发际线): 修正不对称刘海导致的模板叠加倾斜
按 MediaPipe 左右对应额头点协调模板延伸距离,保留人脸姿态与头部轮廓钳制,统一预览白线和生发遮罩的几何。补充对齐回归测试并更新贴图顺序测试及实现说明。 验证:13 项相关测试通过;两张用户图片完成前后对比,新图男性 7 型接口与生发图均成功返回。
This commit is contained in:
@@ -74,6 +74,7 @@
|
|||||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
|
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
|
||||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||||
返回 `results[].image_base64` + `grown_image_base64`。
|
返回 `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=不缩图)。
|
- `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 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
|
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.
|
# Vertex ID layout in the extended array of length 468 + 2*N_ANCHORS.
|
||||||
# [0 .. 468) : MediaPipe canonical landmarks
|
# [0 .. 468) : MediaPipe canonical landmarks
|
||||||
# [468 .. 468+N) : middle row (between MP boundary and hairline)
|
# [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)
|
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(
|
def smooth_hairline(
|
||||||
hairline_norm: np.ndarray,
|
hairline_norm: np.ndarray,
|
||||||
valid: np.ndarray,
|
valid: np.ndarray,
|
||||||
|
|||||||
+9
-4
@@ -15,7 +15,8 @@ from . import comfyui
|
|||||||
from .face_landmarks import FaceLandmarker
|
from .face_landmarks import FaceLandmarker
|
||||||
from .face_parsing import FaceParser
|
from .face_parsing import FaceParser
|
||||||
from .hairline_2d import (
|
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 .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 .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):
|
def extract_context(image_bgr: np.ndarray):
|
||||||
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
|
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
|
||||||
|
|
||||||
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
|
发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制:
|
||||||
|
左右刘海高低不同时,协调对应锚点的延伸距离,避免把设计模板拉歪;
|
||||||
|
保留人脸本身的倾斜。预览白线与生发黑线/遮罩共用同一套 points。
|
||||||
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
|
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
|
||||||
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
|
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
|
||||||
fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。
|
fallback 点不会再跑出头部轮廓。
|
||||||
"""
|
"""
|
||||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||||
landmarks = get_landmarker().detect(rgb)
|
landmarks = get_landmarker().detect(rgb)
|
||||||
if landmarks is None:
|
if landmarks is None:
|
||||||
return None
|
return None
|
||||||
parse_map = get_parser().parse(rgb)
|
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_2d = smooth_hairline(hairline_2d, valid)
|
||||||
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
||||||
middle_3d = build_middle_row(landmarks, hairline_3d)
|
middle_3d = build_middle_row(landmarks, hairline_3d)
|
||||||
|
|||||||
@@ -27,5 +27,9 @@ def test_mp_to_obj_anchor_mapping():
|
|||||||
|
|
||||||
def test_texture_map():
|
def test_texture_map():
|
||||||
m = get_texture_map()
|
m = get_texture_map()
|
||||||
assert [k for k, _ in m["female"]] == ["ellipse", "flower", "heart", "straight", "wave"]
|
assert [k for k, _ in m["female"]] == [
|
||||||
assert [k for k, _ in m["male"]] == ["ellipse", "inverse_arc", "m", "straight"]
|
"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