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