feat(接口2/7): hair_style 支持逗号分隔多选,如 1,2,3

- 参数类型从 int 改为 string(逗号分隔),自动解析去重排序
- 越界/非法值返回 1007
- _parse_hair_styles() 辅助函数:解析 + 去重 + 范围校验
- service 层 hair_style 参数改为 hair_styles: list[int]
- 接口2 和 接口7 同步更新
- 服务已重启,smoke test 通过

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
xsl
2026-06-22 23:36:13 +08:00
co-authored by Claude
parent 1d23414d21
commit 9678b54267
5 changed files with 57 additions and 29 deletions
+43 -12
View File
@@ -247,6 +247,33 @@ def _png_to_jpg_b64(png_bytes) -> str:
return _jpg_b64(img)
def _parse_hair_styles(raw: Optional[str], max_styles: int) -> Optional[list[int]]:
"""解析逗号分隔的发型序号字符串 → 去重排序列表。非法返回 None。
"1,2,3" → [1, 2, 3] "3,1" → [1, 3] "1" → [1]
"""
if raw is None or not isinstance(raw, str) or not raw.strip():
return None
try:
styles = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
v = int(part)
if v < 1 or v > max_styles:
return None
styles.append(v)
if not styles:
return None
# 去重保持顺序
seen = set()
unique = [s for s in styles if not (s in seen or seen.add(s))] # type: ignore[func-returns-value]
return unique
except (ValueError, TypeError):
return None
# ---------------------------------------------------------------------------
# 通用图片请求 BodyJSON 方式,用于 url / base64
# ---------------------------------------------------------------------------
@@ -459,8 +486,9 @@ 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`。
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数量。
`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),
@@ -478,6 +506,7 @@ async def face_measure(
"data": {
"results": [
{"image_base64": "iVBORw0KGgo...", "hairline_type": "ellipse", "order": 1},
{"image_base64": "iVBORw0KGgo...", "hairline_type": "flower", "order": 2},
]
},
}
@@ -502,7 +531,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"),
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。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的文本"),
@@ -511,10 +540,11 @@ async def hair_grow(
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# 2. hair_style 必填校验(越界 → 1007)
# 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}")
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
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)
@@ -537,7 +567,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, hair_style)
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles)
if items is None:
return err(1001, "无法识别人像")
@@ -622,7 +652,7 @@ async def hair_grow_v2(
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"),
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。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的文本"),
@@ -631,10 +661,11 @@ async def hair_grow_v2(
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# 2. hair_style 必填校验(越界 → 1007)
# 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}")
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
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)
@@ -657,7 +688,7 @@ async def hair_grow_v2(
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)
items = await run_in_threadpool(generate_grow_results, image, gender, use_mask, prompt, hair_styles, _WORKFLOW2_PATH)
if items is None:
return err(1001, "无法识别人像")
+3 -3
View File
@@ -56,15 +56,15 @@
- 门槛可配:`MIN_SHORT_SIDE`/`MIN_LONG_SIDE`(默认600/800)、姿态阈值 `FRONTAL_*_THR`(默认30°)。
### 接口2 C端生发 `/api/v1/hair/grow`worker)—— 预览 + 生发图
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,int) → 指定发际线类型 **1**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
- **怎么实现**`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
`face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182**
`add_hair.json`(Flux-2) 出图。**关键坑**obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
单张请求(~5s)。返回 `results[].image_base64` + `grown_image_base64`
返回 `results[].image_base64` + `grown_image_base64`
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wavemale 1=ellipse 2=inverse_arc 3=m 4=straight。
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`worker)—— 接口2同款,add_hair2 工作流
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style`1 组预览+生发图)。
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选N 组预览+生发图)。
- **与接口 2 的唯一区别**:ComfyUI 工作流使用 `add_hair2.json`Flux-2 Klein 9b),输入/遮罩节点同为 26,
SaveImage 输出节点为 75(自动检测)。其他参数、响应结构、错误码**完全相同**。
- **网关改动**:在 `gateway/app.py` 新增路由 `POST /api/v1/hair/grow-v2`,转发到 worker 同路径即可(盲转发,
+6 -6
View File
@@ -178,9 +178,9 @@
## 接口 2C 端生发接口
**说明**:输入用户正面照 + 性别 + 发型序号,按指定发际线类型渲染一张预览图 + 生发图。
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。
> **每个请求返回两张图**`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
> **每个方案返回两张图**`image_url`=「原照片 + 发际线曲线叠加的**预览图**」;`grown_image_url`=
> 经 ComfyUI/Flux 的「植发 3 个月**生发后图片**」。两者均已实现,实现简述见 [`实现说明.md`](实现说明.md)。
**请求**`POST /api/v1/hair/grow`
@@ -192,13 +192,13 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| hair_style | int | **是** | 发型序号,1-indexed。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
### 输出(data
`results`:发际线方案数组,**含 1 个元素**(指定发型)。每个元素:
`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
| 字段 | 类型 | 说明 |
|------|------|------|
@@ -376,13 +376,13 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| hair_style | int | **是** | 发型序号,1-indexed。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`。female1=ellipse, 2=flower, 3=heart, 4=straight, 5=wavemale1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线) |
### 输出(data
与接口 2 完全相同。`results`:发际线方案数组,**含 1 个元素**。每个元素:
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
| 字段 | 类型 | 说明 |
|------|------|------|
+1 -1
View File
@@ -70,7 +70,7 @@ async def hair_grow_v2(request: Request):
|------|------|------|------|
| image_file / image_url / image_base64 | — | **三选一** | 用户正面照 |
| gender | string | **是** | `male` / `female` |
| hair_style | int | **是** | 发型序号。female: 15male: 14 |
| hair_style | string | **是** | 发型序号**逗号分隔多选**(如 `1,2,3`。female: 15male: 14 |
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
| use_mask | bool | 否 | 默认 `true``false` 跳过遮罩 |
| prompt | string | 否 | ComfyUI 提示词 |
+4 -7
View File
@@ -129,11 +129,11 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
prompt: str = None, hair_style: int = None,
prompt: str = None, hair_styles: list[int] | None = None,
workflow_path: str | None = None):
"""指定发际线类型:预览图(白线) + 生发图(ComfyUI)。
hair_style1-indexed):指定生成几张发际线(按贴图排序)。female: 1..5male: 1..4。
hair_styles1-indexed 列表):指定生成几张发际线(按贴图排序)。female: 1..5male: 1..4。
为 None 时生成全部(兼容旧调用)。
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
@@ -150,11 +150,8 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
uv, ext_faces = load_ext_mesh()
textures = get_texture_map()[gender] # [(key, path), ...] 已排序
if hair_style is not None:
if hair_style < 1 or hair_style > len(textures):
raise ValueError(
f"hair_style={hair_style} 超出范围,{gender} 可选 1..{len(textures)}")
items = [(hair_style, textures[hair_style - 1])]
if hair_styles is not None:
items = [(s, textures[s - 1]) for s in hair_styles]
else:
items = list(enumerate(textures, start=1))