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>
This commit is contained in:
xsl
2026-06-14 20:31:41 +08:00
co-authored by Claude Opus 4.8
parent 891bc0da8b
commit 554b64a916
9 changed files with 365 additions and 20 deletions
+64 -15
View File
@@ -19,6 +19,8 @@ from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("hair.worker")
# ---------------------------------------------------------------------------
@@ -68,6 +70,14 @@ async def lifespan(_app: FastAPI):
except Exception as e: # noqa: BLE001
# 方案 B 不可用(如 torch 缺失):降级为方案 A only,不阻塞服务
logger.warning("头发分割不可用,接口1 将走方案A兜底:%s", e)
# 接口2(C端生发)单例预热:FaceLandmarker + SegFormer + mesh/贴图映射
try:
from hairline.service import get_landmarker, get_parser, get_texture_map
from hairline.render import load_ext_mesh
get_landmarker(); get_parser(); load_ext_mesh(); get_texture_map()
logger.info("接口2 发际线管线就绪")
except Exception as e: # noqa: BLE001
logger.warning("接口2 发际线管线初始化失败(该接口将返回错误):%s", e)
_STATE["ready"] = True
yield
@@ -415,10 +425,11 @@ async def face_measure(
summary="接口2 C端生发",
tags=["生发"],
description=f"""
输入用户正面照,返回多个生发方案,每个方案包含:
- 生发后效果图 URL
- 对应的发际线形(如花瓣形、波浪形
- 合适度排序(order=1 最优
输入用户正面照 + **性别**,返回该性别对应的多张「建议发际线预览图」(本期为
**发际线曲线叠加在原照片上的预览图**,非最终文生图生发图)。每个方案包含:
- 预览图(worker 返回 `image_base64`,网关落盘后改写为 `image_url`
- 发际线类型 `hairline_type`(英文 key
- 顺序 `order`(本期固定 `1..N`,不排序)
{_image_fields_desc}
@@ -426,7 +437,12 @@ async def face_measure(
---
**beauty_enabled**:是否对生发后图片开启美颜,默认 `false`。
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
非法或缺失返回 `1004`。
- **beauty_enabled**:本期保留但不生效。
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`female),
`ellipse` / `m` / `straight` / `inverse_arc`male)。
""",
responses={
200: {
@@ -439,8 +455,8 @@ async def face_measure(
"request_id": "mock-request-id",
"data": {
"results": [
{"image_url": SAMPLE_IMAGE_URL, "hairline_type": "花瓣形", "order": 1},
{"image_url": SAMPLE_IMAGE_URL, "hairline_type": "波浪形", "order": 2},
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
{"image_base64": "iVBORw0KGgo...", "hairline_type": "flower", "order": 2},
]
},
}
@@ -464,15 +480,48 @@ async def hair_grow(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜效果,默认 false"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
):
data = {
"results": [
{"image_url": SAMPLE_IMAGE_URL, "hairline_type": "花瓣形", "order": 1},
{"image_url": SAMPLE_IMAGE_URL, "hairline_type": "波浪形", "order": 2},
]
}
return ok(data)
# 1. gender 必填校验(非法/缺失 → 1004)
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# 2. 三选一取图
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
if len(raw) > MAX_FILE_BYTES:
return err(1006, "文件超出 1 MB 限制")
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
h, w = image.shape[:2]
short_side, long_side = min(w, h), max(w, h)
if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE:
return err(1002, "人像分辨率过低")
try:
from hairline.service import generate_previews
previews = generate_previews(image, gender) # 无人脸 → None
if previews is None:
return err(1001, "无法识别人像")
results = []
for p in previews:
ok_enc, png = cv2.imencode(".png", p["image_bgr"])
results.append({
"image_base64": base64.b64encode(png.tobytes()).decode(),
"hairline_type": p["hairline_type"],
"order": p["order"],
})
return ok({"results": results})
except Exception as ex: # noqa: BLE001
logger.exception("接口2 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------