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:
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 通用图片请求 Body(JSON 方式,用于 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
@@ -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=wave;male 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
@@ -178,9 +178,9 @@
|
||||
|
||||
## 接口 2:C 端生发接口
|
||||
|
||||
**说明**:输入用户正面照 + 性别 + 发型序号,按指定发际线类型渲染一张预览图 + 生发图。
|
||||
**说明**:输入用户正面照 + 性别 + 发型序号(可多选),按指定发际线类型渲染预览图 + 生发图。
|
||||
|
||||
> **每个请求返回两张图**:`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。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `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 | 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。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight。越界返回 `1007` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `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 | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线) |
|
||||
|
||||
### 输出(data)
|
||||
|
||||
与接口 2 完全相同。`results`:发际线方案数组,**含 1 个元素**。每个元素:
|
||||
与接口 2 完全相同。`results`:发际线方案数组,**数量 = 所选发型数**。每个元素:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
|
||||
+1
-1
@@ -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: 1–5,male: 1–4 |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female: 1–5,male: 1–4 |
|
||||
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
|
||||
| use_mask | bool | 否 | 默认 `true`,`false` 跳过遮罩 |
|
||||
| prompt | string | 否 | ComfyUI 提示词 |
|
||||
|
||||
+4
-7
@@ -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_style(1-indexed):指定生成第几张发际线(按贴图排序)。female: 1..5,male: 1..4。
|
||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..5,male: 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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user