Files
hair/hairline/render.py
T
xslandClaude Opus 4.8 554b64a916 feat(接口2): C端生发发际线预览(真实实现,替换Mock)
第一步:按性别把发际线类型贴图渲染到照片,输出 N 张发际线叠加预览图。

- hairline/render.py: 解析 face_ext.obj(502 UV + 64 ribbon扩展面) + OpenCV 逐三角
  仿射 warp 渲染器;关键修复——face_ext.obj 是 OBJ序,用 INDEX_MAP_468 把 MP序
  502点重排成 OBJ序后再投影,否则 ribbon 会错贴到中脸
- hairline/service.py: FaceLandmarker+SegFormer 单例 + 性别贴图映射(扫描去空格)
  + generate_previews 管线(female5/male4)
- 集成点修复: face_landmarks DEFAULT_MODEL_PATH 改 hairline/models/;
  constants HF_FACE_PARSER_MODEL 改本地路径(离线)
- app.py: /api/v1/hair/grow 接真实实现,gender 必填(非法→1004),返回
  results[].image_base64(不落盘),校验/鉴权同接口1;lifespan 预热接口2单例;
  补 logging.basicConfig
- 依赖: transformers==4.45.2;SegFormer 权重走 hf-mirror 下载(见 OFFLINE_ASSETS)
- 测试: tests/test_hairline.py(mesh/重排/贴图映射) + test_api 接口2用例,31 全绿

注:SegFormer 受 5090/torch 限制走 CPU(~2.5s/张),换 cu128 可 SEG_DEVICE=cuda。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 20:31:41 +08:00

108 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""接口2 渲染器:把发际线类型贴图按 502 点 mesh 贴到照片上(OpenCV 逐三角仿射 warp)。
原理(技术方案 §4):face_ext.obj 的 502 顶点里,[468..485) 中间行 + [485..502) 发际线行
与 17 个 MP 顶部锚点连成 ~64 个 ribbon 三角形,其 UV 落在贴图顶部条带(发际线曲线所在)。
只 warp 这些扩展三角形,即可把贴图里的发际线曲线贴到额头/发际线区域,且天然只画在 ribbon 区。
"""
from __future__ import annotations
import os
import cv2
import numpy as np
from PIL import Image
from .obj_io import read_obj
from ._index_map_data import INDEX_MAP_468
_MESH_PATH = os.path.join(os.path.dirname(__file__), "mesh", "face_ext.obj")
_N_MP = 468
_mesh_cache = None
_INDEX_MAP = np.asarray(INDEX_MAP_468, dtype=np.int64) # OBJ顶点i → MP点索引
def mp_order_to_obj_order(points502_mp: np.ndarray) -> np.ndarray:
"""把 MP 顺序的 502 点重排成 face_ext.obj 的顶点顺序。
extract_hairline 输出为 MP 顺序:[0..468) MP / [468..485) middle / [485..502) hairline。
而 face_ext.obj 的前 468 顶点经 INDEX_MAP_468 重排(obj_i → mp_i);扩展顶点
[468..502) 两侧同序,直接对应。
"""
out = np.empty_like(points502_mp)
out[:_N_MP] = points502_mp[_INDEX_MAP] # obj[0..468) = mp[INDEX_MAP]
out[_N_MP:] = points502_mp[_N_MP:] # 扩展行同序
return out
def load_ext_mesh(obj_path: str = _MESH_PATH):
"""解析 face_ext.obj,返回 (uv502, ext_faces)。结果缓存。
- uv502: (502, 2) float32,每个顶点的 UVV_raw,V=1 对应贴图顶部)。
- ext_faces: list[(i,j,k)],仅保留顶点索引含 ≥468 的扩展三角形(ribbon)。
obj 中 v 与 vt 一一对应(face 用相同索引),故按位置索引取 UV。
"""
global _mesh_cache
if _mesh_cache is not None:
return _mesh_cache
mesh = read_obj(obj_path)
n_v = len(mesh.positions)
uv = np.zeros((n_v, 2), dtype=np.float32)
for face in mesh.faces:
for pi, ti, _ni in face:
if ti >= 0 and pi >= 0:
uv[pi] = mesh.texcoords[ti]
ext_faces = []
for face in mesh.faces:
idx = [pi for (pi, _t, _n) in face]
if max(idx) >= _N_MP: # 含扩展顶点 → ribbon 三角形
ext_faces.append(tuple(idx))
_mesh_cache = (uv, ext_faces)
return _mesh_cache
def load_texture_rgba(path: str) -> np.ndarray:
"""读发际线贴图为 (H, W, 4) uint8 RGBA。"""
return np.array(Image.open(path).convert("RGBA"))
def render_hairline_overlay(photo_bgr: np.ndarray,
points502_norm: np.ndarray,
ext_faces,
uv502: np.ndarray,
texture_rgba: np.ndarray) -> np.ndarray:
"""把 texture_rgba 的发际线曲线渲染到 photo_bgr 上,返回 BGR 预览图。
points502_norm: (502, 3) 归一化坐标(x,y ∈ [0,1]),**MP 顺序**extract_hairline 输出)。
"""
H, W = photo_bgr.shape[:2]
TH, TW = texture_rgba.shape[:2]
pts_obj = mp_order_to_obj_order(points502_norm) # MP序 → OBJ序
img_xy = pts_obj[:, :2] * np.array([W, H], dtype=np.float32) # (502,2)
overlay = np.zeros((H, W, 4), np.float32) # 累积曲线层 RGBA
tex = texture_rgba.astype(np.float32)
for (i, j, k) in ext_faces:
dst = img_xy[[i, j, k]].astype(np.float32)
# UV → 贴图像素;flipY:贴图 y = (1 - v_raw) * TH(与 head3d Three.js flipY=true 一致)
src = np.array([[uv502[v][0] * TW, (1.0 - uv502[v][1]) * TH] for v in (i, j, k)],
dtype=np.float32)
# 退化三角形(投影到一条线)跳过,避免 getAffineTransform 奇异
if cv2.contourArea(dst.astype(np.int32)) < 1.0:
continue
M = cv2.getAffineTransform(src, dst)
warped = cv2.warpAffine(tex, M, (W, H), flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
tri_mask = np.zeros((H, W), np.uint8)
cv2.fillConvexPoly(tri_mask, dst.astype(np.int32), 255)
sel = tri_mask > 0
overlay[sel] = warped[sel]
# alpha 合成(RGBA→BGR:贴图 RGB 顺序需反成 BGR)
a = overlay[:, :, 3:4] / 255.0
rgb = overlay[:, :, :3][..., ::-1] # RGB→BGR
out = photo_bgr.astype(np.float32) * (1.0 - a) + rgb * a
return np.clip(out, 0, 255).astype(np.uint8)