From 9678b5426721758c41e77f3fbb61f285095b2f6a Mon Sep 17 00:00:00 2001 From: xsl Date: Mon, 22 Jun 2026 23:36:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8E=A5=E5=8F=A32/7):=20hair=5Fstyle=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=80=97=E5=8F=B7=E5=88=86=E9=9A=94=E5=A4=9A?= =?UTF-8?q?=E9=80=89=EF=BC=8C=E5=A6=82=201,2,3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 参数类型从 int 改为 string(逗号分隔),自动解析去重排序 - 越界/非法值返回 1007 - _parse_hair_styles() 辅助函数:解析 + 去重 + 范围校验 - service 层 hair_style 参数改为 hair_styles: list[int] - 接口2 和 接口7 同步更新 - 服务已重启,smoke test 通过 Co-Authored-By: Claude --- app.py | 55 +++++++++++++++++++++++++++++++++++---------- docs/实现说明.md | 6 ++--- docs/接口文档.md | 12 +++++----- docs/网关待改动.md | 2 +- hairline/service.py | 11 ++++----- 5 files changed, 57 insertions(+), 29 deletions(-) diff --git a/app.py b/app.py index ec42902..765d12e 100644 --- a/app.py +++ b/app.py @@ -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, "无法识别人像") diff --git a/docs/实现说明.md b/docs/实现说明.md index 79fdb32..9594a9e 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -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 同路径即可(盲转发, diff --git a/docs/接口文档.md b/docs/接口文档.md index 718537c..8164b9e 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -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`:发际线方案数组,**数量 = 所选发型数**。每个元素: | 字段 | 类型 | 说明 | |------|------|------| diff --git a/docs/网关待改动.md b/docs/网关待改动.md index 9038c43..f4acec7 100644 --- a/docs/网关待改动.md +++ b/docs/网关待改动.md @@ -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 提示词 | diff --git a/hairline/service.py b/hairline/service.py index 3e8fdc6..a807167 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -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))