- 从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张发际线贴图入库
1030 lines
41 KiB
Python
1030 lines
41 KiB
Python
"""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
|