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张发际线贴图入库
This commit is contained in:
xsl
2026-06-14 16:59:39 +08:00
parent 5e513c7006
commit db32fa12e5
19 changed files with 6913 additions and 10 deletions
+133
View File
@@ -0,0 +1,133 @@
"""Lift 2D hairline samples to 3D in MediaPipe's normalized space.
Saggital-arc Z model
--------------------
MediaPipe / face.obj use ``-z = front of face`` (nose tip is the most
negative z), ``+z = back of head``. The frontal head surface curves
backward as you move up from the forehead to the crown, so any vertex
**above** an MP top anchor (smaller y in image coords) must have a
**larger** z than that anchor.
For each new hairline / middle vertex (x_h, y_h) attached to MP anchor a
located at (x_a, y_a, z_a), we model the local sagittal cross-section
of the head as a circular arc of radius ``R = HEAD_ARC_RADIUS_FRAC × face_h``
and derive
z_new = z_a + (y_h - y_a)² / (2 R)
The squared dy term guarantees ``z_new ≥ z_a`` (the surface always
bulges backward as you walk up the head, never forward). x is taken
directly from the 2D hairline detection (we don't project x onto the
arc — the parsing tells us exactly where the hairline sits in x).
"""
from __future__ import annotations
import numpy as np
from . import constants as C
from .hairline_2d import face_up_vector
def _face_height(landmarks_norm: np.ndarray) -> float:
"""Range of MP y for the visible face (used for the arc radius)."""
y = landmarks_norm[:, 1]
return float(y.max() - y.min())
def _arc_dz(dy: float, R: float) -> float:
"""Backward (positive) z offset along a circular arc of radius R for
a vertical displacement dy. Always non-negative."""
return (dy * dy) / (2.0 * max(R, 1e-6))
def lift_hairline_to_3d(
landmarks_norm: np.ndarray,
hairline_norm_xy: np.ndarray,
crown_lift_frac: float | None = None,
) -> np.ndarray:
"""Combine 2D hairline samples with a sagittal-arc Z, after lifting
the (x, y) along the face-up direction by ``crown_lift_frac × face_h``
so the ribbon's top row sits at the crown of the head rather than at
the detected hair-skin boundary.
landmarks_norm: (468, 3) MediaPipe normalized landmarks.
hairline_norm_xy: (N_ANCHORS, 2) 2D hairline samples in [0,1] image space.
crown_lift_frac: how far above the detected hairline (in fractions of
face height) the mesh row should sit. ``None`` uses
``C.HAIRLINE_CROWN_LIFT_FRAC``.
Returns: (N_ANCHORS, 3) — (x_norm_lifted, y_norm_lifted, z).
"""
face_h = _face_height(landmarks_norm)
R = C.HEAD_ARC_RADIUS_FRAC * face_h
if crown_lift_frac is None:
crown_lift_frac = C.HAIRLINE_CROWN_LIFT_FRAC
up = face_up_vector(landmarks_norm)
lift = up * (crown_lift_frac * face_h) # 2D offset, face-up direction
out = np.zeros((C.N_ANCHORS, 3), dtype=np.float32)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
z_a = float(landmarks_norm[mp_idx, 2])
y_a = float(landmarks_norm[mp_idx, 1])
x_h = float(hairline_norm_xy[i, 0]) + float(lift[0])
y_h = float(hairline_norm_xy[i, 1]) + float(lift[1])
dy = y_h - y_a # < 0 (上移更多 → dz 更大)
out[i, 0] = x_h
out[i, 1] = y_h
out[i, 2] = z_a + _arc_dz(dy, R)
return out
def build_middle_row(
landmarks_norm: np.ndarray,
hairline_3d: np.ndarray,
bias: float = 0.5,
) -> np.ndarray:
"""Place the middle row at the arc-length midpoint between each MP
anchor and its hairline point.
The old approach used bias=0.5 linear interpolation in XY and then
independently recomputed Z via the arc formula. Because dz ∝ dy²,
the middle row only received 25 % of the hairline Z offset, creating
a visible dent where the extension met the face mesh.
New approach: for each anchor→hairline pair, parameterise the
sagittal circular arc by the angle θ and place the middle vertex at
θ_mid = bias × θ_hair. This distributes both the Y displacement
**and** the Z displacement smoothly along the arc, so the surface
transitions from the face mesh through middle to hairline without
any concavity.
"""
R = C.HEAD_ARC_RADIUS_FRAC * _face_height(landmarks_norm)
out = np.zeros((C.N_ANCHORS, 3), dtype=np.float32)
for i, mp_idx in enumerate(C.MP_TOP_ANCHORS):
a = landmarks_norm[mp_idx]
h = hairline_3d[i]
# X: simple linear interpolation (no arc model in the coronal plane)
out[i, 0] = (1 - bias) * float(a[0]) + bias * float(h[0])
# Y, Z: interpolate along the circular arc in the sagittal plane.
# The arc angle for the hairline point:
# θ_h = |dy_h| / R (small-angle: arc-length ≈ R θ)
# Middle sits at θ_m = bias × θ_h, giving:
# dy_m = R sin(θ_m) ≈ R θ_m for small θ
# dz_m = R (1 cos(θ_m))
# For the parabolic regime (θ < 0.8 rad ≈ 46°) these simplify to
# the same formula but we use the full trig for correctness.
dy_h = float(h[1]) - float(a[1]) # negative (upward)
sign = -1.0 if dy_h < 0 else 1.0
theta_h = abs(dy_h) / max(R, 1e-9)
theta_m = bias * theta_h
dy_m = sign * R * np.sin(theta_m) # same sign as dy_h
dz_m = R * (1.0 - np.cos(theta_m)) # always ≥ 0
out[i, 1] = float(a[1]) + dy_m
out[i, 2] = float(a[2]) + dz_m
return out
def assemble_full(landmarks_norm: np.ndarray,
middle_3d: np.ndarray,
hairline_3d: np.ndarray) -> np.ndarray:
"""Concatenate to a (N_TOTAL, 3) array in the SDK's expected order:
[0..468) MediaPipe / [468..485) middle / [485..502) hairline."""
assert landmarks_norm.shape == (C.N_MP, 3)
assert middle_3d.shape == (C.N_ANCHORS, 3)
assert hairline_3d.shape == (C.N_ANCHORS, 3)
return np.concatenate([landmarks_norm, middle_3d, hairline_3d], axis=0).astype(np.float32)