"""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


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.
#
# 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
