Files
xsl db32fa12e5 feat(接口2): 移植head3d发际线管线 + 接口2实现方案文档
- 从head3d复制发际线检测管线到 hairline/ 包:MediaPipe Tasks + SegFormer分割
  + 17锚点射线检测 + 502点mesh(face_ext.obj)+UV
- 复制模型:face_landmarker.task(3.7MB)、SegFormer config/preprocessor
  (model.safetensors 340MB 单独下载中)
- 新增 docs/接口2-C端生发-技术实现方案.md:第一步=发际线曲线叠加预览图,
  新增gender必填参数,按性别贴图数量输出(female5/male4),hairline_type英文key,
  服务端cv2逐三角形warp渲染器(head3d只有浏览器端Three.js渲染)
- 接口文档.md 接口2章节同步:gender参数、输出语义、错误码说明
- hairline_texture/ 9张发际线贴图入库
2026-06-14 16:59:39 +08:00

1030 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""2D hairline detection from a face-parsing label map.
Given the parser output, find the boundary curve between hair-above and
skin-below in the upper face region, then sample one point per anchor
along the face's local "up" direction.
This module also exposes several alternative strategies (see
``sample_hairline_*`` functions) that the Web service runs side-by-side
for visual comparison.
"""
from __future__ import annotations
from typing import Callable
import numpy as np
from . import constants as C
def face_up_vector(landmarks_norm: np.ndarray) -> np.ndarray:
"""Return a unit 2D vector pointing "up" in image space.
landmarks_norm: (468, 3) MediaPipe output (normalized x,y in [0,1]).
Uses chin (152) → forehead-top (10).
"""
chin = landmarks_norm[152, :2]
top = landmarks_norm[10, :2]
v = top - chin # in normalized image space
n = np.linalg.norm(v) + 1e-9
return v / n
def build_hair_mask(parse_map: np.ndarray) -> np.ndarray:
"""Boolean (H, W) mask of pixels strictly classified as hair (or hat)."""
return np.isin(parse_map, [C.PARSE_HAIR, C.PARSE_HAT])
def build_skin_mask(parse_map: np.ndarray) -> np.ndarray:
"""Boolean (H, W) mask of pixels classified as facial skin / ears / brows / nose / lips.
Used to validate that a ray actually travels through the face before
hitting hair — guards against the case where the ray exits laterally
into background (e.g. from a temple anchor next to white background)
and would otherwise stop at the first non-skin pixel.
"""
face_classes = [
C.PARSE_SKIN, C.PARSE_NOSE, C.PARSE_L_BROW, C.PARSE_R_BROW,
C.PARSE_L_EYE, C.PARSE_R_EYE, C.PARSE_EYE_G,
C.PARSE_L_EAR, C.PARSE_R_EAR, C.PARSE_EAR_R,
C.PARSE_MOUTH, C.PARSE_U_LIP, C.PARSE_L_LIP,
]
return np.isin(parse_map, face_classes)
def ray_first_hair_hit(
origin_xy: np.ndarray,
direction_xy: np.ndarray,
hair_mask: np.ndarray,
max_steps: int = 800,
step_px: float = 1.0,
) -> np.ndarray | None:
"""March a ray until it hits a hair pixel. Returns float (x, y) or None.
Unlike a generic boundary tracer, we explicitly target the HAIR class so
a ray that drifts into background while traversing a temple anchor will
continue searching rather than stopping at the face/background edge.
"""
H, W = hair_mask.shape
pos = origin_xy.astype(np.float64).copy()
for _ in range(max_steps):
pos += direction_xy * step_px
ix, iy = int(round(pos[0])), int(round(pos[1]))
if ix < 0 or iy < 0 or ix >= W or iy >= H:
return None
if hair_mask[iy, ix]:
return pos.copy()
return None
def sample_hairline(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
fallback_extrapolation: float = 0.18,
) -> tuple[np.ndarray, np.ndarray]:
"""Find one hairline point per MP_TOP_ANCHORS, in normalized [0,1] image space.
Returns:
hairline_norm: (N_ANCHORS, 2) float — hairline points (x_norm, y_norm)
valid_mask: (N_ANCHORS,) bool — True where a hair-pixel was hit
Algorithm:
For each anchor, march a ray along the face's up-vector and return the
first hair-classified pixel encountered. If the ray exits the image
without ever finding hair (bald, hat, or hairline outside the frame),
fall back to extrapolating along the up-vector by `fallback_extrapolation`.
For lateral anchors (temples, ears) the ray may leave the face into
background skin-less area before encountering hair on top of the
head — that is OK because we only stop on hair, not background.
"""
H, W = parse_map.shape
hair_mask = build_hair_mask(parse_map)
up_norm = face_up_vector(landmarks_norm)
up_px = up_norm * np.array([W, H], dtype=np.float64)
up_px = up_px / (np.linalg.norm(up_px) + 1e-9)
hairline = np.zeros((C.N_ANCHORS, 2), dtype=np.float32)
valid = np.zeros((C.N_ANCHORS,), dtype=bool)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
anchor_norm = landmarks_norm[mp_idx, :2]
anchor_px = anchor_norm * np.array([W, H], dtype=np.float64)
hit_px = ray_first_hair_hit(anchor_px, up_px, hair_mask)
if hit_px is not None:
hairline[i] = (hit_px / np.array([W, H])).astype(np.float32)
valid[i] = True
else:
hairline[i] = (anchor_norm + up_norm * fallback_extrapolation).astype(np.float32)
valid[i] = False
return hairline, valid
# Backwards-compat alias for callers from the previous API.
def build_hairline_mask(parse_map: np.ndarray) -> np.ndarray:
return build_hair_mask(parse_map)
def smooth_hairline(
hairline_norm: np.ndarray,
valid: np.ndarray,
iterations: int = 2,
) -> np.ndarray:
"""Light smoothing along the curve to absorb segmentation jitter.
Uses a 1-2-1 binomial filter over neighbors with the same validity.
Note: this also rounds off real corners. For data with sharp corners
(e.g. M-shaped hairlines), use :func:`smooth_hairline_corner_aware` instead.
"""
out = hairline_norm.copy()
n = len(out)
for _ in range(iterations):
new = out.copy()
for i in range(1, n - 1):
if not valid[i]:
continue
new[i] = 0.25 * out[i - 1] + 0.5 * out[i] + 0.25 * out[i + 1]
out = new
return out
def smooth_hairline_corner_aware(
hairline_norm: np.ndarray,
valid: np.ndarray,
iterations: int = 2,
corner_cos_threshold: float = 0.6,
) -> np.ndarray:
"""1-2-1 smoothing, but skip points that look like corners.
A "corner" is detected by the cosine of the angle between (P[i] - P[i-1])
and (P[i+1] - P[i]): smaller cosine = sharper bend. If
``cos(angle) < corner_cos_threshold`` (i.e. bend > ~53°), point i keeps
its raw position; otherwise the binomial filter is applied.
This preserves the user's expected M-shape / trapezoidal hairline whose
"shoulders" are sharp corners.
"""
out = hairline_norm.copy()
n = len(out)
for _ in range(iterations):
new = out.copy()
for i in range(1, n - 1):
if not valid[i]:
continue
v1 = out[i] - out[i - 1]
v2 = out[i + 1] - out[i]
n1 = float(np.linalg.norm(v1))
n2 = float(np.linalg.norm(v2))
if n1 < 1e-9 or n2 < 1e-9:
new[i] = 0.25 * out[i - 1] + 0.5 * out[i] + 0.25 * out[i + 1]
continue
cos_a = float(np.dot(v1, v2)) / (n1 * n2)
if cos_a >= corner_cos_threshold:
# smooth: not a corner
new[i] = 0.25 * out[i - 1] + 0.5 * out[i] + 0.25 * out[i + 1]
# else: keep raw (preserve corner)
out = new
return out
# ---------------------------------------------------------------------------
# Alternative hairline sampling strategies.
#
# All functions share the signature ``(landmarks_norm, parse_map) -> (hairline,
# valid)`` so the caller can switch strategies uniformly. ``landmarks_norm``
# may be ``None`` for parsing-only strategies (e.g. ``sample_hairline_top_arc``).
# ---------------------------------------------------------------------------
def head_top_target(landmarks_norm: np.ndarray, scale: float = 0.6) -> np.ndarray:
"""Return a 2D point above the forehead used as a ray convergence target.
The point is placed `scale * face_height` above the forehead-top landmark
(10), measured along the chin->forehead axis. ``scale = 0.6`` roughly
matches the human "crown" position relative to the chin->forehead line.
"""
chin = landmarks_norm[152, :2]
top = landmarks_norm[10, :2]
face_height = float(np.linalg.norm(top - chin)) + 1e-9
face_up_unit = (top - chin) / face_height
return top + face_up_unit * face_height * scale
def build_face_adjacent_hair_mask(
parse_map: np.ndarray,
dilate_radius: int | None = None,
) -> np.ndarray:
"""Hair pixels that lie within ``dilate_radius`` px of any face-skin pixel.
Used to filter out background blobs that the parser mis-classified as
hair (chairs, dark walls, etc.) which would otherwise stop a ray prematurely.
"""
import cv2
hair = np.isin(parse_map, [C.PARSE_HAIR, C.PARSE_HAT]).astype(np.uint8)
face = build_skin_mask(parse_map).astype(np.uint8)
if dilate_radius is None:
face_area = int(face.sum())
dilate_radius = max(6, int(np.sqrt(max(face_area, 1)) * 0.05))
k = 2 * dilate_radius + 1
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
face_dil = cv2.dilate(face, kernel)
return (hair > 0) & (face_dil > 0)
def ray_first_mask_hit(
origin_xy: np.ndarray,
direction_xy: np.ndarray,
mask: np.ndarray,
max_steps: int = 1500,
step_px: float = 1.0,
) -> np.ndarray | None:
"""Generic version of :func:`ray_first_hair_hit` that targets any boolean mask."""
H, W = mask.shape
pos = origin_xy.astype(np.float64).copy()
for _ in range(max_steps):
pos += direction_xy * step_px
ix, iy = int(round(pos[0])), int(round(pos[1]))
if ix < 0 or iy < 0 or ix >= W or iy >= H:
return None
if mask[iy, ix]:
return pos.copy()
return None
def ray_first_dense_hit(
origin_xy: np.ndarray,
direction_xy: np.ndarray,
mask: np.ndarray,
run_length: int,
max_steps: int = 2000,
) -> np.ndarray | None:
"""Density-based stopping: stop only after `run_length` consecutive hit pixels.
Returns the position of the FIRST pixel of the qualifying run (i.e., the
edge where the dense region begins). This filters out single-pixel
classification noise and isolated wisps.
The ray is allowed to start outside the image and walk in until it enters
the image bounds (used by the "from-above" strategies). Once inside, if the
ray exits the image again, the search stops.
"""
H, W = mask.shape
pos = np.asarray(origin_xy, dtype=np.float64).copy()
direction = np.asarray(direction_xy, dtype=np.float64)
consecutive = 0
run_start: np.ndarray | None = None
entered_image = False
for _ in range(max_steps):
pos += direction
ix, iy = int(round(pos[0])), int(round(pos[1]))
in_image = (0 <= ix < W) and (0 <= iy < H)
if not in_image:
if entered_image:
return None
continue
entered_image = True
if mask[iy, ix]:
if consecutive == 0:
run_start = pos.copy()
consecutive += 1
if consecutive >= run_length:
return run_start
else:
consecutive = 0
run_start = None
return None
def _face_height_px(landmarks_norm: np.ndarray, W: int, H: int) -> float:
chin = landmarks_norm[152, :2] * np.array([W, H], dtype=np.float64)
top = landmarks_norm[10, :2] * np.array([W, H], dtype=np.float64)
return float(np.linalg.norm(top - chin))
def density_run_length(landmarks_norm: np.ndarray, W: int, H: int, ratio: float = 0.06) -> int:
"""Adaptive density threshold proportional to face size.
Default ratio = 0.06 of the chin-to-forehead distance: for a 200 px face
height that gives a 12-pixel run requirement; for 400 px → 24 pixels.
"""
fh = _face_height_px(landmarks_norm, W, H)
return max(6, int(round(fh * ratio)))
def _ray_sample_with_direction(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
mask: np.ndarray,
direction_factory: Callable[[np.ndarray, np.ndarray, int, int], np.ndarray],
fallback_extrapolation: float = 0.18,
) -> tuple[np.ndarray, np.ndarray]:
"""Common ray-marching loop. ``direction_factory`` returns a unit pixel-space
direction for a given anchor (passed `(landmarks, anchor_px, W, H)`)."""
H, W = parse_map.shape
up_norm_fallback = face_up_vector(landmarks_norm)
hairline = np.zeros((C.N_ANCHORS, 2), dtype=np.float32)
valid = np.zeros((C.N_ANCHORS,), dtype=bool)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
anchor_norm = landmarks_norm[mp_idx, :2]
anchor_px = anchor_norm * np.array([W, H], dtype=np.float64)
direction_px = direction_factory(landmarks_norm, anchor_px, W, H)
hit_px = ray_first_mask_hit(anchor_px, direction_px, mask)
if hit_px is not None:
hairline[i] = (hit_px / np.array([W, H])).astype(np.float32)
valid[i] = True
else:
hairline[i] = (anchor_norm + up_norm_fallback * fallback_extrapolation).astype(np.float32)
valid[i] = False
return hairline, valid
def _direction_face_up(landmarks_norm, anchor_px, W, H) -> np.ndarray:
up_norm = face_up_vector(landmarks_norm)
up_px = up_norm * np.array([W, H], dtype=np.float64)
return up_px / (np.linalg.norm(up_px) + 1e-9)
def _direction_converging(landmarks_norm, anchor_px, W, H, scale: float = 0.6) -> np.ndarray:
target_norm = head_top_target(landmarks_norm, scale=scale)
target_px = target_norm * np.array([W, H], dtype=np.float64)
d = target_px - anchor_px
return d / (np.linalg.norm(d) + 1e-9)
def sample_hairline_baseline(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""策略 1: 当前算法 - 沿 face-up 直上, 命中首个 hair/hat 像素。"""
mask = build_hair_mask(parse_map)
return _ray_sample_with_direction(landmarks_norm, parse_map, mask, _direction_face_up)
def sample_hairline_adjacent(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""策略 2: baseline + hair 必须邻接 face skin, 过滤背景误分。"""
mask = build_face_adjacent_hair_mask(parse_map)
return _ray_sample_with_direction(landmarks_norm, parse_map, mask, _direction_face_up)
def _topmost_hair_y_per_col(mask: np.ndarray) -> np.ndarray:
"""For each column return the smallest y where ``mask[y, x]`` is True, or -1."""
H, W = mask.shape
cols_has = mask.any(axis=0)
out = np.full(W, -1, dtype=np.int32)
cols = np.where(cols_has)[0]
for x in cols:
out[x] = int(np.argmax(mask[:, x]))
return out
def sample_hairline_window(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
window_lateral_ratio: float = 0.05,
window_central_ratio: float = 0.015,
use_adjacent: bool = False,
fallback_extrapolation: float = 0.18,
) -> tuple[np.ndarray, np.ndarray]:
"""For each anchor, search a horizontal window biased OUTWARD and snap to
the topmost hair pixel column inside it.
Why this helps the 6/12-point problem:
The lateral-middle anchors (MP 54/103 on the left, MP 332/284 on the
right) sit on the forehead skin. A pure straight-up ray finds the top
of the hair plateau at the anchor's x — but the real "shoulder corner"
of the hairline is several pixels OUTWARD from the anchor. By widening
the search to a window that extends further toward the image edge,
these anchors can slide LATERALLY onto the actual shoulder.
Per-anchor window:
[anchor_x - lateral_w, anchor_x + central_w] for LEFT anchors
[anchor_x - central_w, anchor_x + lateral_w] for RIGHT anchors
Center anchor (anchor.x ≈ face center) uses symmetric window.
Both ``x`` and ``y`` of each output point are FREELY chosen inside the
window — the function returns the (col, top_y) of the topmost hair pixel.
"""
H, W = parse_map.shape
hair = (
build_face_adjacent_hair_mask(parse_map)
if use_adjacent
else build_hair_mask(parse_map)
)
top_y = _topmost_hair_y_per_col(hair)
up_norm = face_up_vector(landmarks_norm)
face_center_x = float(landmarks_norm[10, 0]) # MP 10 = forehead top
w_lat = max(1, int(round(W * window_lateral_ratio)))
w_cen = max(1, int(round(W * window_central_ratio)))
hairline = np.zeros((C.N_ANCHORS, 2), dtype=np.float32)
valid = np.zeros((C.N_ANCHORS,), dtype=bool)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
anchor_norm = landmarks_norm[mp_idx, :2]
ax = int(round(np.clip(anchor_norm[0] * W, 0, W - 1)))
side = 1 if anchor_norm[0] > face_center_x else (-1 if anchor_norm[0] < face_center_x else 0)
if side > 0:
x_lo = max(0, ax - w_cen)
x_hi = min(W, ax + w_lat + 1)
elif side < 0:
x_lo = max(0, ax - w_lat)
x_hi = min(W, ax + w_cen + 1)
else:
x_lo = max(0, ax - w_cen)
x_hi = min(W, ax + w_cen + 1)
sub = top_y[x_lo:x_hi]
valid_cols = sub >= 0
if not valid_cols.any():
hairline[i] = (anchor_norm + up_norm * fallback_extrapolation).astype(np.float32)
continue
sub_masked = np.where(valid_cols, sub, H + 1)
best_off = int(np.argmin(sub_masked))
best_x = x_lo + best_off
best_y = int(sub[best_off])
hairline[i, 0] = best_x / W
hairline[i, 1] = best_y / H
valid[i] = True
return hairline, valid
def sample_hairline_window_adjacent(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""window 策略的 adjacent 变体: 候选 hair 必须邻接 face skin。"""
return sample_hairline_window(landmarks_norm, parse_map, use_adjacent=True)
def sample_hairline_lateral_extend(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
use_adjacent: bool = True,
outer_anchors_per_side: int = 3,
max_walk_ratio: float = 0.015,
) -> tuple[np.ndarray, np.ndarray]:
"""baseline (+adjacent) 之后, 把最外侧 ``outer_anchors_per_side`` 个锚点
沿 hit 所在的横行向外侧推到真实的 hair 视觉边缘。
动机
----
1, 2, 3 号 / 15, 16, 17 号锚点 (MP 127/234/162 和 389/356/454) 本来就长在
眉外角和耳前的皮肤上, 射线垂直向上停在 parser 给出的 hair 边界 —— 而
parser 对侧鬓的边界比视觉上的 hair 边缘保守几个像素, 所以这些点看起来
比红线略微偏向脸内。
这一步仅修改 X 坐标 (Y 保持 baseline hit 不变): 从 hit 像素出发, 沿同一
行向外 (远离脸部中心) 一步一步走, 只要下一像素还是 hair 就更新 X, 否则
停止。走距上限为 ``max_walk_ratio * W`` (默认 1.5%, ~11 px@734 宽); 太大
容易跑出可见 hair 之外 —— parser 会把侧鬓 / 头侧轮廓后面几像素也判成 hair,
所以 1.5% 是相对保守的值。
只对最外 ``outer_anchors_per_side`` 个锚点应用; 中间锚点保持 baseline。
"""
if use_adjacent:
raw, valid = sample_hairline_adjacent(landmarks_norm, parse_map)
hair = build_face_adjacent_hair_mask(parse_map)
else:
raw, valid = sample_hairline_baseline(landmarks_norm, parse_map)
hair = build_hair_mask(parse_map)
H, W = parse_map.shape
max_walk_px = max(2, int(round(W * max_walk_ratio)))
n = C.N_ANCHORS
targets = (
[(i, -1) for i in range(0, outer_anchors_per_side)]
+ [(i, +1) for i in range(n - outer_anchors_per_side, n)]
)
for i, side in targets:
if not valid[i]:
continue
x_px = int(round(raw[i, 0] * W))
y_px = int(round(raw[i, 1] * H))
if y_px < 0 or y_px >= H:
continue
x_outer = x_px
for step in range(1, max_walk_px + 1):
nx = x_px + step * side
if nx < 0 or nx >= W:
break
if hair[y_px, nx]:
x_outer = nx
else:
break
raw[i, 0] = x_outer / W
return raw, valid
def sample_hairline_lateral_extend_dense(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
intermediates: int = 1,
use_adjacent: bool = True,
max_walk_ratio: float = 0.015,
outer_per_side: int | None = None,
density_run_length: int = 0,
fallback_extrapolation: float = 0.18,
) -> tuple[np.ndarray, np.ndarray]:
"""`lateral_extend` 的加密版本: 每对相邻 MP 锚点之间插入 ``intermediates`` 个
中间采样列, 再各自射线 + 横向延伸。
总点数 = ``N_ANCHORS + (N_ANCHORS - 1) * intermediates``
默认 17 + 16 = **33 点**, 是原始 17 点密度的 ~2 倍。
做法
----
1. 把 17 个 MP_TOP_ANCHORS 的归一化坐标按顺序线性插值, 在每对之间塞入
``intermediates`` 个等距中间种子。种子点 (x, y) 都在原 MP 锚点的连线上。
2. 每个种子点沿 face-up 方向直上, 命中首个 hair 像素 (用 adjacent hair
过滤背景误分)。中间点的 y 来自其相邻两个原锚点的 y 内插, 起射高度
与真实锚点一致, 所以命中位置自然落在 hair 平台上。
3. 对最外侧 ``(intermediates + 1) * 3`` 个点 (覆盖原始 1-3 号 / 15-17 号
周围的加密点), 沿 hit 横行向外侧走最多 1.5% 图宽, 推到可见 hair 边缘。
4. 配合 corner-aware 平滑使用, 转角自动被保留。
加密带来的两个直接收益
----
* **6 号点和 12 号点之间的几个新点** (原始 5-6 / 12-13 之间) 会落在
hair 平台的左上 / 右上转角处, 让发际线在角落处不再被两个稀疏的 MP
点跨过。
* 整条发际线更平滑, 同时 corner-aware 还能定位出新的转角。
"""
if use_adjacent:
hair = build_face_adjacent_hair_mask(parse_map)
else:
hair = build_hair_mask(parse_map)
H, W = parse_map.shape
up_norm = face_up_vector(landmarks_norm)
up_px = up_norm * np.array([W, H], dtype=np.float64)
up_px /= np.linalg.norm(up_px) + 1e-9
seeds: list[np.ndarray] = []
for k in range(C.N_ANCHORS):
p_anchor = landmarks_norm[C.MP_TOP_ANCHORS[k], :2].astype(np.float64).copy()
seeds.append(p_anchor)
if k + 1 < C.N_ANCHORS:
p_next = landmarks_norm[C.MP_TOP_ANCHORS[k + 1], :2].astype(np.float64)
for j in range(1, intermediates + 1):
t = j / (intermediates + 1)
seeds.append((1.0 - t) * p_anchor + t * p_next)
n_dense = len(seeds)
hairline = np.zeros((n_dense, 2), dtype=np.float32)
valid = np.zeros(n_dense, dtype=bool)
for i, sn in enumerate(seeds):
anchor_px = sn * np.array([W, H], dtype=np.float64)
if density_run_length > 0:
hit_px = ray_first_dense_hit(anchor_px, up_px, hair, density_run_length)
else:
hit_px = ray_first_mask_hit(anchor_px, up_px, hair)
if hit_px is not None:
hairline[i] = (hit_px / np.array([W, H])).astype(np.float32)
valid[i] = True
else:
hairline[i] = (sn + up_norm * fallback_extrapolation).astype(np.float32)
if outer_per_side is None:
outer = (intermediates + 1) * 3
else:
outer = max(0, min(outer_per_side, n_dense // 2))
targets = (
[(i, -1) for i in range(0, outer)]
+ [(i, +1) for i in range(n_dense - outer, n_dense)]
)
max_walk_px = max(2, int(round(W * max_walk_ratio))) if max_walk_ratio > 0 else 0
if max_walk_px > 0:
for i, side in targets:
if not valid[i]:
continue
x_px = int(round(hairline[i, 0] * W))
y_px = int(round(hairline[i, 1] * H))
if y_px < 0 or y_px >= H:
continue
x_outer = x_px
for step in range(1, max_walk_px + 1):
nx = x_px + step * side
if nx < 0 or nx >= W:
break
if hair[y_px, nx]:
x_outer = nx
else:
break
hairline[i, 0] = x_outer / W
return hairline, valid
def sample_hairline_converging(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""策略 3: 每个锚点的射线方向都收敛到头顶上方目标点。"""
mask = build_hair_mask(parse_map)
return _ray_sample_with_direction(landmarks_norm, parse_map, mask, _direction_converging)
def sample_hairline_converging_adjacent(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""策略 4: 收敛方向 + 邻接约束。"""
mask = build_face_adjacent_hair_mask(parse_map)
return _ray_sample_with_direction(landmarks_norm, parse_map, mask, _direction_converging)
def sample_hairline_boundary_proj(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""策略 5: 计算 hair-skin 边界曲线, 每个锚点投影到带方向约束的最近边界点。
"方向约束" 指: 候选边界点必须位于锚点的 face-up 方向之上 (cos(angle) >= 0),
并且对横向偏移加额外惩罚, 避免吸到锚点正侧的边界。
"""
import cv2
H, W = parse_map.shape
hair = np.isin(parse_map, [C.PARSE_HAIR, C.PARSE_HAT]).astype(np.uint8)
face = build_skin_mask(parse_map).astype(np.uint8)
face_dil = cv2.dilate(face, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))
boundary = (hair > 0) & (face_dil > 0)
bys, bxs = np.where(boundary)
hairline = np.zeros((C.N_ANCHORS, 2), dtype=np.float32)
valid = np.zeros((C.N_ANCHORS,), dtype=bool)
up_norm = face_up_vector(landmarks_norm)
up_px = up_norm * np.array([W, H], dtype=np.float64)
up_px /= np.linalg.norm(up_px) + 1e-9
perp_px = np.array([-up_px[1], up_px[0]], dtype=np.float64)
if bxs.size == 0:
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
anchor_norm = landmarks_norm[mp_idx, :2]
hairline[i] = (anchor_norm + up_norm * 0.18).astype(np.float32)
return hairline, valid
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
anchor_norm = landmarks_norm[mp_idx, :2]
anchor_px = anchor_norm * np.array([W, H], dtype=np.float64)
dx = bxs - anchor_px[0]
dy = bys - anchor_px[1]
proj_up = dx * up_px[0] + dy * up_px[1]
keep = proj_up > 1.0
if not keep.any():
hairline[i] = (anchor_norm + up_norm * 0.18).astype(np.float32)
continue
d_up = proj_up[keep]
d_perp = np.abs(dx[keep] * perp_px[0] + dy[keep] * perp_px[1])
cost = d_up + 2.5 * d_perp
local_best = int(np.argmin(cost))
chosen = np.where(keep)[0][local_best]
hairline[i, 0] = bxs[chosen] / W
hairline[i, 1] = bys[chosen] / H
valid[i] = True
return hairline, valid
def _ray_dense_sample(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
mask: np.ndarray,
direction_factory: Callable[[np.ndarray, np.ndarray, int, int], np.ndarray],
fallback_extrapolation: float = 0.18,
run_ratio: float = 0.06,
) -> tuple[np.ndarray, np.ndarray]:
"""Density-stopping variant of :func:`_ray_sample_with_direction`."""
H, W = parse_map.shape
run_length = density_run_length(landmarks_norm, W, H, run_ratio)
up_norm_fallback = face_up_vector(landmarks_norm)
hairline = np.zeros((C.N_ANCHORS, 2), dtype=np.float32)
valid = np.zeros((C.N_ANCHORS,), dtype=bool)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
anchor_norm = landmarks_norm[mp_idx, :2]
anchor_px = anchor_norm * np.array([W, H], dtype=np.float64)
direction_px = direction_factory(landmarks_norm, anchor_px, W, H)
hit_px = ray_first_dense_hit(anchor_px, direction_px, mask, run_length)
if hit_px is not None:
hairline[i] = (hit_px / np.array([W, H])).astype(np.float32)
valid[i] = True
else:
hairline[i] = (anchor_norm + up_norm_fallback * fallback_extrapolation).astype(np.float32)
valid[i] = False
return hairline, valid
def sample_hairline_density(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""密度策略 1: 沿 face-up 直上, 但要求连续 `density_run_length` 个 hair 像素才停。"""
mask = build_hair_mask(parse_map)
return _ray_dense_sample(landmarks_norm, parse_map, mask, _direction_face_up)
def sample_hairline_density_adjacent(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""密度策略 2: face-up + density + 邻接约束。"""
mask = build_face_adjacent_hair_mask(parse_map)
return _ray_dense_sample(landmarks_norm, parse_map, mask, _direction_face_up)
def sample_hairline_density_converging(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""密度策略 3: 收敛方向 + density。"""
mask = build_hair_mask(parse_map)
return _ray_dense_sample(landmarks_norm, parse_map, mask, _direction_converging)
def _ray_dense_inverse_sample(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
mask: np.ndarray,
target_scale: float = 1.5,
run_ratio: float = 0.06,
fallback_extrapolation: float = 0.18,
) -> tuple[np.ndarray, np.ndarray]:
"""For each anchor, march FROM a target above the head DOWN toward the anchor.
Density-based stopping returns the first dense hair pixel encountered. Since
the ray comes from outside/above the head, the first dense hair = top edge
of the head silhouette = actual hairline crown. This bypasses the side-hair
interception problem entirely for lateral anchors.
"""
H, W = parse_map.shape
run_length = density_run_length(landmarks_norm, W, H, run_ratio)
up_norm_fallback = face_up_vector(landmarks_norm)
target_norm = head_top_target(landmarks_norm, scale=target_scale)
target_px = target_norm * np.array([W, H], dtype=np.float64)
hairline = np.zeros((C.N_ANCHORS, 2), dtype=np.float32)
valid = np.zeros((C.N_ANCHORS,), dtype=bool)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
anchor_norm = landmarks_norm[mp_idx, :2]
anchor_px = anchor_norm * np.array([W, H], dtype=np.float64)
direction = anchor_px - target_px
norm = float(np.linalg.norm(direction)) + 1e-9
direction_unit = direction / norm
max_steps = int(norm) + 200
hit_px = ray_first_dense_hit(target_px, direction_unit, mask, run_length, max_steps=max_steps)
accepted = False
if hit_px is not None:
t_along = float(np.dot(hit_px - target_px, direction_unit))
if 0.0 < t_along < norm:
hairline[i] = (hit_px / np.array([W, H])).astype(np.float32)
valid[i] = True
accepted = True
if not accepted:
hairline[i] = (anchor_norm + up_norm_fallback * fallback_extrapolation).astype(np.float32)
return hairline, valid
def sample_hairline_density_inverse(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""密度策略 4: 反向射线 - 从头顶上方目标点向锚点扫, density stop。
完全绕开 baseline 的核心痛点: 不再从锚点向上找头发, 而是从头部上方"找下来",
第一段 dense hair 自然就是头部的上边缘 (冠状发际线)。
"""
mask = build_hair_mask(parse_map)
return _ray_dense_inverse_sample(landmarks_norm, parse_map, mask)
def sample_hairline_density_inverse_adj(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""密度策略 5: 反向射线 + adjacent + density。"""
mask = build_face_adjacent_hair_mask(parse_map)
return _ray_dense_inverse_sample(landmarks_norm, parse_map, mask)
def sample_hairline_silhouette_at_anchor_x(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
window_radius_ratio: float = 0.01,
) -> tuple[np.ndarray, np.ndarray]:
"""密度策略 6 (head silhouette 锚点对齐): 对每个 MP 锚点, 取它 x 列附近的头部上沿 y。
与 ``top_arc`` 相比, 17 个点的 x 仍然跟 MediaPipe 锚点的 x 一一对应,
但 y 直接来自 (face adjacent_hair) 在该 x 列的最高点 (最小 y),
所以点会自然贴到头顶轮廓上, 形成冠状弧, 而不是被侧鬓拽下来。
window_radius_ratio: 用 anchor x 周围一小段窗口 (W·ratio) 取最小 y 做平滑。
"""
H, W = parse_map.shape
head = build_skin_mask(parse_map) | build_face_adjacent_hair_mask(parse_map)
up_norm_fallback = face_up_vector(landmarks_norm)
hairline = np.zeros((C.N_ANCHORS, 2), dtype=np.float32)
valid = np.zeros((C.N_ANCHORS,), dtype=bool)
radius = max(1, int(round(W * window_radius_ratio)))
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
anchor_norm = landmarks_norm[mp_idx, :2]
cx = int(round(np.clip(anchor_norm[0] * W, 0, W - 1)))
x0 = max(0, cx - radius)
x1 = min(W, cx + radius + 1)
sub = head[:, x0:x1]
col_has = sub.any(axis=1)
if not col_has.any():
hairline[i] = (anchor_norm + up_norm_fallback * 0.18).astype(np.float32)
continue
top_y = int(np.argmax(col_has))
hairline[i, 0] = anchor_norm[0]
hairline[i, 1] = top_y / H
valid[i] = True
return hairline, valid
def _head_top_y_per_column(
parse_map: np.ndarray,
use_full_hair: bool = True,
) -> tuple[np.ndarray, np.ndarray]:
"""Return (cols_with_head, top_y) where top_y[k] is the smallest y for which
``head_mask[top_y[k], cols_with_head[k]]`` is True.
``use_full_hair=True`` uses the FULL hair mask (proper head silhouette);
pass ``False`` to fall back to the face-adjacent strip only.
"""
if use_full_hair:
head = build_skin_mask(parse_map) | build_hair_mask(parse_map)
else:
head = build_skin_mask(parse_map) | build_face_adjacent_hair_mask(parse_map)
cols_with_head = np.where(head.any(axis=0))[0]
if cols_with_head.size == 0:
return cols_with_head, np.array([], dtype=np.int32)
top_y = np.empty(cols_with_head.size, dtype=np.int32)
for j, x in enumerate(cols_with_head):
top_y[j] = int(np.argmax(head[:, x]))
return cols_with_head, top_y
def sample_hairline_arch(
landmarks_norm: np.ndarray,
parse_map: np.ndarray,
central_drop: int = 3,
require_lift_px_ratio: float = 0.02,
density_ratio: float = 0.03,
use_adjacent_for_central: bool = False,
) -> tuple[np.ndarray, np.ndarray]:
"""冠状弧 (coronal arch) 策略 — 从一只耳朵上方画到另一只耳朵上方。
流程
----
1. 中间 ``N_ANCHORS - 2*central_drop`` 个锚点(默认丢掉颧骨/太阳穴附近的 6 个),
沿 face-up 做密度门限射线,命中 ``run_length`` 个连续 hair 像素的入口作为
真实发际线点。``run_length = face_height * density_ratio``。
2. 拒掉那些 hit 离锚点向上不到 ``face_height * require_lift_px_ratio``
的,它们多半是侧鬓或假阳性。
3. 取整个头部 (face 全部 hair) 的 silhouette:在最左 / 最右极限列上,
记录 silhouette 上沿点。这两个点是耳朵上方的曲线终点。
4. 控制点 = [左极限 silhouette] + [中间锚点真实 hit] + [右极限 silhouette]
按 x 排序,线性插值到 17 个等距输出 x。
5. 钳位:任何输出 y 都不能比该 x 处头部 silhouette 上沿还小,避免曲线
跑到头顶上方的虚空里去。
6. 上层 web_service 会再调用 ``smooth_hairline`` 平滑一遍。
生成的 17 个点 x 均匀覆盖整个头部宽度(从左耳上方到右耳上方),中间 y 贴近
真实发际线、两侧 y 自然抬升到头部 silhouette 上沿,形成冠状弧。它们的 x
不再与对应 MP_TOP_ANCHORS 一一对应,但顺序保持一致。
"""
H, W = parse_map.shape
hair_for_rays = (
build_face_adjacent_hair_mask(parse_map)
if use_adjacent_for_central
else build_hair_mask(parse_map)
)
run_length = max(4, int(round(_face_height_px(landmarks_norm, W, H) * density_ratio)))
up_norm = face_up_vector(landmarks_norm)
up_px = up_norm * np.array([W, H], dtype=np.float64)
up_px /= np.linalg.norm(up_px) + 1e-9
face_h_px = _face_height_px(landmarks_norm, W, H)
min_lift = face_h_px * require_lift_px_ratio
# Step 1: central density-ray hits
central_indices = range(central_drop, C.N_ANCHORS - central_drop)
central_hits: list[tuple[float, float]] = []
for i in central_indices:
mp_idx = C.MP_TOP_ANCHORS[i]
anchor_px = landmarks_norm[mp_idx, :2] * np.array([W, H], dtype=np.float64)
hit = ray_first_dense_hit(anchor_px, up_px, hair_for_rays, run_length)
if hit is None:
continue
# signed distance "up" from anchor to hit (positive when hit is above anchor)
delta_up = float(np.dot(hit - anchor_px, up_px))
if delta_up < min_lift:
continue
central_hits.append((float(hit[0]), float(hit[1])))
# Step 2: head silhouette
cols_with_head, top_y = _head_top_y_per_column(parse_map, use_full_hair=True)
if cols_with_head.size < 2:
return sample_hairline_baseline(landmarks_norm, parse_map)
left_x = float(cols_with_head[0])
right_x = float(cols_with_head[-1])
def silhouette_y_at(x_px: float) -> float:
idx = int(round(np.clip(x_px, left_x, right_x)))
k = int(np.searchsorted(cols_with_head, idx))
k = min(k, cols_with_head.size - 1)
return float(top_y[k])
if not central_hits:
# No reliable central hits — fall back to silhouette top across the head
# (still a coronal arc, just less precise on the forehead)
output_xs = np.linspace(left_x, right_x, C.N_ANCHORS)
output_ys = np.array([silhouette_y_at(float(x)) for x in output_xs], dtype=np.float64)
hairline = np.column_stack([output_xs / W, output_ys / H]).astype(np.float32)
return hairline, np.ones(C.N_ANCHORS, dtype=bool)
# Step 3: control points = [left endpoint] + central hits + [right endpoint]
control_pts: list[tuple[float, float]] = list(central_hits)
control_pts.append((left_x, silhouette_y_at(left_x)))
control_pts.append((right_x, silhouette_y_at(right_x)))
# Step 4: sort + dedupe by x, then linear-interp 17 evenly-spaced output xs
cps = sorted(control_pts, key=lambda p: p[0])
cxs = [cps[0][0]]
cys = [cps[0][1]]
for x, y in cps[1:]:
if x - cxs[-1] < 0.5:
# collision: prefer the LARGER y (the actual hairline, not silhouette)
cys[-1] = max(cys[-1], y)
else:
cxs.append(x)
cys.append(y)
cxs_arr = np.asarray(cxs, dtype=np.float64)
cys_arr = np.asarray(cys, dtype=np.float64)
output_xs = np.linspace(left_x, right_x, C.N_ANCHORS)
output_ys = np.interp(output_xs, cxs_arr, cys_arr)
# Step 5: hairline cannot be above the head silhouette top
for k, x in enumerate(output_xs):
sil_y = silhouette_y_at(float(x))
if output_ys[k] < sil_y:
output_ys[k] = sil_y
hairline = np.column_stack([output_xs / W, output_ys / H]).astype(np.float32)
valid = np.ones(C.N_ANCHORS, dtype=bool)
return hairline, valid
def sample_hairline_arch_strict(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""冠状弧策略 (严格版): 只用中间 5 个最可靠的额头锚点; 同时要求 hit 离锚点更远。
适合中间锚点也容易被刘海/眉毛干扰的场景,曲线更"瘦高"。
"""
return sample_hairline_arch(
landmarks_norm,
parse_map,
central_drop=6, # 只保留中间 5 个锚点
require_lift_px_ratio=0.04,
density_ratio=0.04,
)
def sample_hairline_arch_adj(landmarks_norm: np.ndarray, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""冠状弧策略 (邻接版): 中间锚点的射线只命中"贴脸"的 hair 像素。
更不容易被远离脸部的背景或后脑勺误命中,但要求 face-adjacent hair 必须
形成连续 ``run_length`` 像素 (默认 ratio=0.025) 的密度。
"""
return sample_hairline_arch(
landmarks_norm,
parse_map,
central_drop=3,
require_lift_px_ratio=0.015,
density_ratio=0.025,
use_adjacent_for_central=True,
)
def sample_hairline_top_arc(landmarks_norm, parse_map: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""策略 6: 不用锚点, 取 (face adjacent_hair) 的上沿, 按 x 等距 17 点。
``landmarks_norm`` 仅用于 fallback 并允许传 None。
"""
H, W = parse_map.shape
head = build_skin_mask(parse_map) | build_face_adjacent_hair_mask(parse_map)
hairline = np.zeros((C.N_ANCHORS, 2), dtype=np.float32)
valid = np.zeros((C.N_ANCHORS,), dtype=bool)
cols_with_head = np.where(head.any(axis=0))[0]
if cols_with_head.size < 2:
return hairline, valid
top_y = np.empty(cols_with_head.size, dtype=np.int32)
for j, x in enumerate(cols_with_head):
top_y[j] = int(np.argmax(head[:, x]))
left, right = int(cols_with_head[0]), int(cols_with_head[-1])
xs = np.linspace(left, right, C.N_ANCHORS)
ys = np.interp(xs, cols_with_head.astype(np.float64), top_y.astype(np.float64))
hairline[:, 0] = xs / W
hairline[:, 1] = ys / H
valid[:] = True
return hairline, valid