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)