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:
@@ -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)
|
||||
Reference in New Issue
Block a user