From 4291f125d4b40d9c068d5f78d343da46118635dd Mon Sep 17 00:00:00 2001 From: xsl Date: Wed, 22 Jul 2026 23:36:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=8E=A5=E5=8F=A32):=20=E5=8F=91=E9=99=85?= =?UTF-8?q?=E7=BA=BF=E9=92=B3=E5=88=B6=E5=88=B0=E5=A4=B4=E9=83=A8=E8=BD=AE?= =?UTF-8?q?=E5=BB=93=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=9F=AD=E5=8F=91/?= =?UTF-8?q?=E5=85=89=E5=A4=B4=E7=85=A7=E7=89=87=E5=8F=91=E9=99=85=E7=BA=BF?= =?UTF-8?q?=E8=B4=B4=E5=88=B0=E5=A4=B4=E9=83=A8=E5=A4=96=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 短发/剃光头照片(如男性椭圆发际线)中间锚点射线检测命中不到 hair 像素时, sample_hairline 的 fallback 会用固定 0.18 归一化偏移把点顶到头部轮廓外的背景, 在有效/失效锚点交界处形成尖角,被贴图不透明像素蒙到后露出戳出头部的线条。 新增 clamp_hairline_to_silhouette + sample_hairline_clamped,在几何检测后按每列 SegFormer(skin∪hair) 轮廓上沿做安全网钳制;extract_context 固定改用钳制版本。 只在 fallback 越界时生效,正常长发照片结果与旧行为一致,纯 numpy/opencv 与 GPU 无关。 Co-authored-by: Cursor --- hairline/hairline_2d.py | 47 +++++++++++++++++++++++++++++++++++++++++ hairline/service.py | 14 +++++++++--- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/hairline/hairline_2d.py b/hairline/hairline_2d.py index 758e076..e4235c4 100644 --- a/hairline/hairline_2d.py +++ b/hairline/hairline_2d.py @@ -186,6 +186,53 @@ def smooth_hairline_corner_aware( return out +def clamp_hairline_to_silhouette( + hairline_norm: np.ndarray, + parse_map: np.ndarray, + margin_px: float = 2.0, +) -> np.ndarray: + """把发际线点的 y 钳制在 (skin∪hair) silhouette 上沿之下(不含 margin 以上)。 + + 根因(见 issue:男性 ellipse 发际线贴到头部外面):`sample_hairline` 对射线 + 未命中 hair 像素的锚点会 fallback 成「锚点 + 固定 0.18 归一化偏移」,与头部实际 + 大小/位置无关 —— 短发/剃光头场景下这个偏移量常常把点顶到头部轮廓外面的背景里, + 在有效/失效锚点交界处形成尖角,被贴图上的不透明像素蒙到就会露出戳出头部的线条。 + + 本函数在几何检测之后追加一步「安全网」:对每个点按其 x 所在列,取 silhouette + (SegFormer skin∪hair 类,近似头部实际轮廓)上沿 y,若点比这个上沿还高(y 更 + 小),直接钳制到 上沿 + margin_px —— 保证曲线永远不会跑到头部轮廓外面的背景。 + """ + h, w = parse_map.shape + cols_with_head, top_y = _head_top_y_per_column(parse_map, use_full_hair=True) + if cols_with_head.size == 0: + return hairline_norm + out = hairline_norm.copy() + for i in range(out.shape[0]): + x_px = float(out[i, 0]) * w + idx = int(np.searchsorted(cols_with_head, x_px)) + idx = min(max(idx, 0), cols_with_head.size - 1) + sil_y = float(top_y[idx]) + margin_px + y_px = float(out[i, 1]) * h + if y_px < sil_y: + out[i, 1] = sil_y / h + return out + + +def sample_hairline_clamped( + landmarks_norm: np.ndarray, + parse_map: np.ndarray, + fallback_extrapolation: float = 0.18, +) -> tuple[np.ndarray, np.ndarray]: + """策略 A(baseline + 头部轮廓钳制):与默认 `sample_hairline` 完全一致的检测, + 额外用 `clamp_hairline_to_silhouette` 兜底 —— 检测失效 fallback 出的点不再可能 + 跑到头部外面的背景,而是贴着头部实际轮廓顶部。改动小、风险低,只在检测失效/ + fallback 越界时才生效,正常长发照片的结果与 baseline 完全一致。 + """ + hairline, valid = sample_hairline(landmarks_norm, parse_map, fallback_extrapolation) + hairline = clamp_hairline_to_silhouette(hairline, parse_map) + return hairline, valid + + # --------------------------------------------------------------------------- # Alternative hairline sampling strategies. # diff --git a/hairline/service.py b/hairline/service.py index 8271545..44e847f 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -14,7 +14,9 @@ from . import constants as C from . import comfyui from .face_landmarks import FaceLandmarker from .face_parsing import FaceParser -from .hairline_2d import sample_hairline, smooth_hairline +from .hairline_2d import ( + smooth_hairline, sample_hairline_clamped, +) 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 @@ -151,13 +153,19 @@ def extract_502(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`(射线检测 + 头部轮廓钳制): + 短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback + 偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后 + 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(landmarks, parse_map) + hairline_2d, valid = sample_hairline_clamped(landmarks, 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)