feat(接口2/7): 接口2加hair_style参数选单张发型;新增接口7用add_hair2工作流

接口2 变更:
- 新增必填 hair_style(int) 参数,按序号只生成一张(不再全量)
- female:1-5 male:1-4,越界返回1007

接口7 新增:
- POST /api/v1/hair/grow-v2,功能与接口2一致
- 使用 add_hair2.json 工作流(Flux-2 Klein 9b)
- SaveImage输出节点自动检测(75)

comfyui.py 重构:
- run() 支持 workflow_path 参数,多工作流按路径缓存
- SaveImage 输出节点自动检测,不再硬编码
- 输入/种子/提示词节点ID两个工作流相同(26/6/60)

文档:
- 接口文档、实现说明、网关待改动 三份同步更新
- 网关只需加一行路由,base64→URL改写无需改动

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
xsl
2026-06-22 23:22:25 +08:00
co-authored by Claude
parent 28a6062fea
commit cb1989c042
6 changed files with 333 additions and 39 deletions
+131 -5
View File
@@ -446,8 +446,7 @@ async def face_measure(
summary="接口2 C端生发",
tags=["生发"],
description=f"""
输入用户正面照 + **性别**,返回该性别对应的多张「建议发际线预览图」(本期为
**发际线曲线叠加在原照片上的预览图**,非最终文生图生发图)。每个方案包含:
输入用户正面照 + **性别** + **发型序号**,返回指定发际线类型的预览图与生发图。每个方案包含:
- 预览图(worker 返回 `image_base64`,网关落盘后改写为 `image_url`
- 发际线类型 `hairline_type`(英文 key
- 顺序 `order`(本期固定 `1..N`,不排序)
@@ -460,6 +459,8 @@ async def face_measure(
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
非法或缺失返回 `1004`。
- **hair_style**(必填):`int`,发型序号。`female`1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave
`male`1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007`。
- **beauty_enabled**:本期保留但不生效。
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`female),
@@ -477,7 +478,6 @@ async def face_measure(
"data": {
"results": [
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
{"image_base64": "iVBORw0KGgo...", "hairline_type": "flower", "order": 2},
]
},
}
@@ -502,6 +502,7 @@ async def hair_grow(
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
hair_style: Optional[int] = Form(default=None, description="发型序号(必填)。female:1-5 male:1-4"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
@@ -510,7 +511,12 @@ async def hair_grow(
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# 2. 三选一取图
# 2. hair_style 必填校验(越界 → 1007)
max_styles = {"female": 5, "male": 4}[gender]
if hair_style is None or not isinstance(hair_style, int) or hair_style < 1 or hair_style > max_styles:
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数,收到 {hair_style!r}")
# 3. 三选一取图
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
@@ -531,7 +537,7 @@ async def hair_grow(
from hairline.service import generate_grow_results
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt)
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_style)
if items is None:
return err(1001, "无法识别人像")
@@ -550,6 +556,126 @@ async def hair_grow(
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 7C 端生发 v2add_hair2.json 工作流)
# ---------------------------------------------------------------------------
_WORKFLOW2_PATH = os.path.join(os.path.dirname(__file__), "add_hair2.json")
@app.post(
"/api/v1/hair/grow-v2",
summary="接口7 C端生发 v2add_hair2 工作流)",
tags=["生发"],
description=f"""
输入用户正面照 + **性别** + **发型序号**,使用 add_hair2.json 工作流生成指定发际线类型的预览图与生发图。
功能与接口2 完全一致,仅 ComfyUI 工作流不同。
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
---
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 5 张 / male 4 张)。
非法或缺失返回 `1004`。
- **hair_style**(必填):`int`,发型序号。`female`1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave
`male`1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007`。
- **beauty_enabled**:本期保留但不生效。
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave`female),
`ellipse` / `m` / `straight` / `inverse_arc`male)。
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"results": [
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
]
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"examples": {
"图片参数错误": {"value": {"code": 1007, "message": "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个", "request_id": "x", "data": None}},
"非正面照": {"value": {"code": 1003, "message": "角度问题,请上传正面照", "request_id": "x", "data": None}},
}
}
},
},
},
)
async def hair_grow_v2(
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, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
hair_style: Optional[int] = Form(default=None, description="发型序号(必填)。female:1-5 male:1-4"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
):
# 1. gender 必填校验(非法/缺失 → 1004)
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# 2. hair_style 必填校验(越界 → 1007)
max_styles = {"female": 5, "male": 4}[gender]
if hair_style is None or not isinstance(hair_style, int) or hair_style < 1 or hair_style > max_styles:
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数,收到 {hair_style!r}")
# 3. 三选一取图
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 fastapi.concurrency import run_in_threadpool
from hairline.service import generate_grow_results
# 预览 + 生发(ComfyUI) 都是阻塞且较慢,放线程池避免卡住事件循环
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_style, _WORKFLOW2_PATH)
if items is None:
return err(1001, "无法识别人像")
results = []
for p in items:
results.append({
"image_base64": _jpg_b64(p["image_bgr"]), # 预览图 JPG
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
if p["grown_png"] else None),
"hairline_type": p["hairline_type"],
"order": p["order"],
})
return ok({"results": results})
except Exception as ex: # noqa: BLE001
logger.exception("接口7 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 3B 端生发
# ---------------------------------------------------------------------------