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,115 @@
|
||||
"""Main CLI: image -> JSON of 502 3D points consumable by the SDK.
|
||||
|
||||
Pipeline:
|
||||
1. MediaPipe FaceMesh -> 468 normalized landmarks
|
||||
2. Face parsing (HF SegFormer) -> per-pixel class map
|
||||
3. Hairline curve detection + per-anchor ray casting -> 17 hairline 2D points
|
||||
4. Lift 2D hairline points to 3D using anchor Z + curvature offset
|
||||
5. Interpolate 17 middle-row 3D points
|
||||
6. Concatenate into (502, 3) and emit JSON
|
||||
|
||||
Output JSON schema:
|
||||
{
|
||||
"image": {"width": W, "height": H, "path": "..."},
|
||||
"n_total": 502,
|
||||
"n_mp": 468,
|
||||
"n_extension": 34,
|
||||
"layout": ["mp[0..468)", "middle[468..485)", "hairline[485..502)"],
|
||||
"points": [[x_norm, y_norm, z_relative], ...] # length 502
|
||||
"valid_hairline": [true/false, ...] # length 17
|
||||
}
|
||||
|
||||
Usage:
|
||||
py -3 python/extract_hairline.py path/to/image.jpg
|
||||
py -3 python/extract_hairline.py path/to/image.jpg --out data/out.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
if __package__ is None or __package__ == "":
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from python import constants as C
|
||||
from python.face_landmarks import FaceLandmarker
|
||||
from python.face_parsing import FaceParser
|
||||
from python.hairline_2d import sample_hairline, smooth_hairline
|
||||
from python.lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
||||
else:
|
||||
from . import constants as C
|
||||
from .face_landmarks import FaceLandmarker
|
||||
from .face_parsing import FaceParser
|
||||
from .hairline_2d import sample_hairline, smooth_hairline
|
||||
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
|
||||
|
||||
|
||||
def run(image_path: str, out_path: str | None = None, device: str | None = None) -> dict:
|
||||
import cv2
|
||||
bgr = cv2.imread(image_path)
|
||||
if bgr is None:
|
||||
raise FileNotFoundError(f"could not read image: {image_path}")
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
H, W = rgb.shape[:2]
|
||||
|
||||
t0 = time.perf_counter()
|
||||
lmk = FaceLandmarker(static_image_mode=True)
|
||||
landmarks = lmk.detect(rgb)
|
||||
lmk.close()
|
||||
if landmarks is None:
|
||||
raise RuntimeError("no face detected")
|
||||
t1 = time.perf_counter()
|
||||
print(f" [time] mediapipe: {(t1 - t0)*1000:.0f} ms")
|
||||
|
||||
parser = FaceParser(device=device)
|
||||
parse_map = parser.parse(rgb)
|
||||
t2 = time.perf_counter()
|
||||
print(f" [time] parsing: {(t2 - t1)*1000:.0f} ms")
|
||||
|
||||
hairline_2d, valid = sample_hairline(landmarks, parse_map)
|
||||
hairline_2d = smooth_hairline(hairline_2d, valid)
|
||||
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
||||
middle_3d = build_middle_row(landmarks, hairline_3d)
|
||||
points_full = assemble_full(landmarks, middle_3d, hairline_3d)
|
||||
t3 = time.perf_counter()
|
||||
print(f" [time] hairline: {(t3 - t2)*1000:.0f} ms")
|
||||
|
||||
record = {
|
||||
"image": {"width": int(W), "height": int(H), "path": image_path},
|
||||
"n_total": C.N_TOTAL,
|
||||
"n_mp": C.N_MP,
|
||||
"n_extension": C.N_EXT,
|
||||
"layout": [
|
||||
f"mp[0..{C.N_MP})",
|
||||
f"middle[{C.MIDDLE_START}..{C.HAIRLINE_START})",
|
||||
f"hairline[{C.HAIRLINE_START}..{C.N_TOTAL})",
|
||||
],
|
||||
"points": points_full.tolist(),
|
||||
"valid_hairline": valid.tolist(),
|
||||
}
|
||||
|
||||
if out_path is None:
|
||||
base = os.path.splitext(os.path.basename(image_path))[0]
|
||||
out_path = os.path.join("data", base + ".json")
|
||||
|
||||
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||
print(f" wrote {out_path}")
|
||||
return record
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Extract MediaPipe + hairline points from one image.")
|
||||
ap.add_argument("image", help="path to input image (jpg/png)")
|
||||
ap.add_argument("--out", default=None, help="output JSON path")
|
||||
ap.add_argument("--device", default=None, help="torch device (cpu/cuda); auto if omitted")
|
||||
args = ap.parse_args()
|
||||
run(args.image, args.out, args.device)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user