From a3fddbbc34c5b0562a4e46aa17ec938417d08f5e Mon Sep 17 00:00:00 2001 From: xsl Date: Sun, 1 Nov 2026 15:12:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=8F=91=E9=99=85=E7=BA=BF):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E4=B8=8D=E5=AF=B9=E7=A7=B0=E5=88=98=E6=B5=B7=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E6=A8=A1=E6=9D=BF=E5=8F=A0=E5=8A=A0=E5=80=BE?= =?UTF-8?q?=E6=96=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 MediaPipe 左右对应额头点协调模板延伸距离,保留人脸姿态与头部轮廓钳制,统一预览白线和生发遮罩的几何。补充对齐回归测试并更新贴图顺序测试及实现说明。 验证:13 项相关测试通过;两张用户图片完成前后对比,新图男性 7 型接口与生发图均成功返回。 --- docs/实现说明.md | 1 + hairline/constants.py | 7 +++ hairline/hairline_2d.py | 40 ++++++++++++++++ hairline/service.py | 13 +++-- tests/test_hairline.py | 8 +++- tests/test_hairline_alignment.py | 82 ++++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 tests/test_hairline_alignment.py diff --git a/docs/实现说明.md b/docs/实现说明.md index 74beff5..8803648 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -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 工作流 diff --git a/hairline/constants.py b/hairline/constants.py index 196ac4f..6a63e16 100644 --- a/hairline/constants.py +++ b/hairline/constants.py @@ -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) diff --git a/hairline/hairline_2d.py b/hairline/hairline_2d.py index e4235c4..92252ce 100644 --- a/hairline/hairline_2d.py +++ b/hairline/hairline_2d.py @@ -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, diff --git a/hairline/service.py b/hairline/service.py index 8cfb5e8..50cd7c0 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -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) diff --git a/tests/test_hairline.py b/tests/test_hairline.py index 0be9679..385ef5e 100644 --- a/tests/test_hairline.py +++ b/tests/test_hairline.py @@ -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", + ] diff --git a/tests/test_hairline_alignment.py b/tests/test_hairline_alignment.py new file mode 100644 index 0000000..e80e7ae --- /dev/null +++ b/tests/test_hairline_alignment.py @@ -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