diff --git a/app.py b/app.py index ddca5f9..0462cf5 100644 --- a/app.py +++ b/app.py @@ -971,7 +971,8 @@ async def face_features( summary="接口5 发际线PNG生成", tags=["人脸分析"], description=f""" -输入用户照片,返回 N 张用户发际线的 PNG 图片,并标注最合适发际线的面部中间点坐标。 +输入用户照片 + 性别 + 多选发型,对每个选中发型返回 middle/high/low 三档发际线叠图与生发图, +并标注最合适发际线的面部中间点坐标。 {_image_fields_desc} @@ -979,22 +980,21 @@ async def face_features( --- -**入参**: -- 必填 `gender`(`male`/`female`),决定返回的发际线集合(female 5 / male 4)。 -- 可选 `hairline_level`(`middle`(默认) / `high` / `low`),选用不同档位的发际线贴图。 -- 可选 `hair_style`(发型序号,逗号分隔如 `1,2,3`):对选中的发际线类型额外**生发**(同接口2)。 - 留空则只返回发际线叠图、不生发。越界/非法返回 `1007`。 +**入参**(同接口2:先选性别,再多选发型): +- 必填 `gender`(`male`/`female`),决定发型集合(female 5 / male 4)。 +- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。 `female`:1=ellipse,2=flower,3=heart,4=straight,5=wave;`male`:1=ellipse,2=inverse_arc,3=m,4=straight。 - 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。 - 注:生发的黑模板固定取 `hairline_texture_black/`(middle),与 `hairline_level` 无关。 + 注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。 **返回说明**: -- `hairline_images`:发际线叠加图列表(发际线曲线叠加在用户照片上,同接口2预览), - 数量 = 该性别的发际线类型数,本期按贴图顺序 `order=1..N`(暂不计算合适度)。 - worker 返回 `image_base64`,网关落盘后改写为 `image_url`。 - 每项还含 `grown_image_base64`:当 `order` 在 `hair_style` 中时为该类型的**生发图**,否则为 `null`。 -- `best_hairline_center_point`:最佳(`order=1`)发际线曲线的**面部中间点**坐标, +- `hairline_images`:**选中发型**的列表,数量 = 所选发型数,`order` = 发型序号。每项含: + - `image_middle_url` / `image_high_url` / `image_low_url`:该发型 middle/high/low 三档发际线叠图(同接口2预览)。 + - `grown_image_url`:该发型的**生发图**(生发失败时为 `null`)。 + - `hairline_type`:发际线类型 key。 + worker 返回 `*_base64`,网关落盘后改写为 `*_url`。 +- `best_hairline_center_point`:**首个选中发型**的 middle 档发际线曲线**面部中间点**坐标, 以**原图像素**为基准(左上角为原点,x 向右,y 向下)。 """, responses={ @@ -1008,8 +1008,8 @@ async def face_features( "request_id": "mock-request-id", "data": { "hairline_images": [ - {"image_base64": "iVBORw0KGgo...", "grown_image_base64": "iVBORw0KGgo...", "order": 1}, - {"image_base64": "iVBORw0KGgo...", "grown_image_base64": None, "order": 2}, + {"hairline_type": "ellipse", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": "iVBORw0KGgo...", "image_low_base64": "iVBORw0KGgo...", "grown_image_base64": "iVBORw0KGgo...", "order": 1}, + {"hairline_type": "flower", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": "iVBORw0KGgo...", "image_low_base64": "iVBORw0KGgo...", "grown_image_base64": None, "order": 2}, ], "best_hairline_center_point": {"x": 540, "y": 430}, }, @@ -1032,23 +1032,18 @@ async def hairline_generate( 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(必填)"), - hairline_level: str = Form(default="middle", description="发际线贴图档位 middle(默认)/high/low"), - hair_style: Optional[str] = Form(default=None, description="生发发型序号逗号分隔(可选,如 1,2,3)。留空则只返回发际线叠图不生发。female:1-5 male:1-4"), + hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-5 male:1-4"), use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"), prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"), ): if gender not in ("male", "female"): return err(1004, "gender 必填且只能为 male / female") - if hairline_level not in ("middle", "high", "low"): - return err(1004, "hairline_level 只能为 middle / high / low") - # hair_style 可选:留空 → 不生发;非空但非法 → 1007 - hair_styles = None - if hair_style and hair_style.strip(): - max_styles = {"female": 5, "male": 4}[gender] - 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}") + # hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007 + max_styles = {"female": 5, "male": 4}[gender] + 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}") raw, e = await resolve_image_bytes(image_file, image_url, image_base64) if e is not None: @@ -1063,15 +1058,19 @@ async def hairline_generate( from hairline.service import generate_hairline_pngs res = await run_in_threadpool( - generate_hairline_pngs, image, gender, hairline_level, hair_styles, use_mask, prompt) + generate_hairline_pngs, image, gender, hair_styles, use_mask, prompt) if res is None: return err(1001, "无法识别人像") hairline_images = [] for it in res["images"]: + ov = it["overlays"] hairline_images.append({ - "image_base64": _jpg_b64(it["image_bgr"]), # 发际线叠图 JPG - "grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(未生发为 null) + "hairline_type": it["hairline_type"], + "image_middle_base64": _jpg_b64(ov["middle"]), # 发际线叠图 middle 档 JPG + "image_high_base64": _jpg_b64(ov["high"]), # 发际线叠图 high 档 JPG + "image_low_base64": _jpg_b64(ov["low"]), # 发际线叠图 low 档 JPG + "grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null) if it.get("grown_png") else None), "order": it["order"], }) diff --git a/docs/实现说明.md b/docs/实现说明.md index 6c341f5..9aab141 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -32,7 +32,7 @@ | 6 | `annotated_image_base64` | `annotated_image_url` | | 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` | | 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` | -| 5 | `hairline_images[].image_base64` | `hairline_images[].image_url` | +| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` | | 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` | | 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — | @@ -89,8 +89,8 @@ 无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。 ### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker) -- **做什么**:照片 + `gender`(必填) → N 张发际线叠加图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点坐标。 -- **怎么实现**:复用接口2 的 502 点渲染管线,输出 N 张叠图 + `best_hairline_center_point`(眉心 x × 该处发际线 y)。无生发。 +- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。 +- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。 --- diff --git a/docs/接口文档.md b/docs/接口文档.md index 4d91fd3..fbc81b3 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -373,7 +373,7 @@ ## 接口 5:发际线 PNG 生成接口 -**说明**:输入用户照片,返回 N 张用户发际线的 PNG 图片,并返回「最合适发际线」的面部中间点坐标。可选地对指定发际线类型**同步生发**(同接口2)。 +**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。 **请求**:`POST /api/v1/hairline/generate` @@ -383,28 +383,32 @@ | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| gender | string | **是** | 性别:`male` / `female`。决定返回的发际线集合(female 5 / male 4) | -| hairline_level | string | 否 | 发际线贴图档位:`middle`(默认)/ `high` / `low`,选用不同高度档位的发际线贴图(仅影响发际线叠图)。非法返回 `1004` | -| 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` | +| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4)。缺失/非法返回 `1004` | +| 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` | | use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 | | prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发」,会替换工作流节点 60 的文本 | -> ⚠️ 生发的黑模板固定取自 `hairline_texture_black/`(middle 档),与 `hairline_level` 无关:即 `high`/`low` 档下发际线叠图用对应档位贴图,但生发目标仍压到 middle 档。 +> ⚠️ 三档叠图分别用 `hairline_texture` / `hairline_texture_high` / `hairline_texture_low` 三套同名贴图;**生发黑模板固定取自 `hairline_texture_black/`(middle 档)**,即生发目标固定压到 middle 档,每个发型仅 1 张生发图。 ### 输出(data) | 字段 | 类型 | 说明 | |------|------|------| -| hairline_images | object[] | N 张发际线叠加图(发际线曲线叠在用户照片上,同接口2预览),**数量 = 该性别发际线数**,本期按贴图顺序,元素见下表 | -| best_hairline_center_point | object | 最佳(order=1)发际线曲线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | +| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 | +| best_hairline_center_point | object | **首个选中发型**的 middle 档发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | `hairline_images` 元素: | 字段 | 类型 | 说明 | |------|------|------| -| image_url | string | 发际线叠加图 URL(worker 返回 `image_base64`,网关落盘后改写为 url) | -| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图)。仅当该项 `order` 在 `hair_style` 中时有值,否则为 `null`(worker 返回 `grown_image_base64`,网关落盘后改写为 url) | -| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度) | +| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`(female),`ellipse`/`m`/`straight`/`inverse_arc`(male) | +| image_middle_url | string | middle 档发际线叠加图 URL | +| image_high_url | string | high 档发际线叠加图 URL | +| image_low_url | string | low 档发际线叠加图 URL | +| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,生发失败时为 `null`) | +| order | int | 发型序号(= 传入的 hair_style 值) | + +> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。 ### 响应示例(当前 Mock 返回值) @@ -415,15 +419,29 @@ "request_id": "mock-request-id", "data": { "hairline_images": [ - { "image_url": "https://hair.xiangsilian.com/static/annotations/uuid1.png", "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.png", "order": 1 }, - { "image_url": "https://hair.xiangsilian.com/static/annotations/uuid2.png", "grown_image_base64": null, "order": 2 } + { + "hairline_type": "ellipse", + "image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid1.png", + "image_high_url": "https://hair.xiangsilian.com/static/annotations/high1.png", + "image_low_url": "https://hair.xiangsilian.com/static/annotations/low1.png", + "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.png", + "order": 1 + }, + { + "hairline_type": "heart", + "image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid3.png", + "image_high_url": "https://hair.xiangsilian.com/static/annotations/high3.png", + "image_low_url": "https://hair.xiangsilian.com/static/annotations/low3.png", + "grown_image_base64": null, + "order": 3 + } ], "best_hairline_center_point": { "x": 540, "y": 430 } } } ``` -> 说明:未生发的元素中,网关不改写 `null` 值,故字段名保持为 `grown_image_base64: null`(有值时才改写为 `grown_image_url`),与接口2生发失败项一致。 +> 说明:生发失败的元素中,网关不改写 `null` 值,故字段名保持为 `grown_image_base64: null`(有值时才改写为 `grown_image_url`),与接口2生发失败项一致。 --- @@ -489,7 +507,7 @@ | 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) | | 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 | | 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) | -| 5 发际线 PNG | 用户照片 + gender(+ hairline_level 档位 / hair_style 生发) | N 张发际线 PNG(可含生发图)+ 最合适发际线面部中间点坐标 | +| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 | | 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 | --- diff --git a/gateway/stub_worker.py b/gateway/stub_worker.py index e5be5a6..8745d6c 100644 --- a/gateway/stub_worker.py +++ b/gateway/stub_worker.py @@ -223,15 +223,24 @@ def create_app(port: int, password: str) -> FastAPI: if x_internal_token != password: return JSONResponse(status_code=401, content={"detail": "unauthorized"}) delay = float(request.query_params.get("delay", "1")) + _png = f"data:image/png;base64,{TINY_PNG_BASE64}" data = { "hairline_images": [ { - "image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}", + "hairline_type": "ellipse", + "image_middle_base64": _png, + "image_high_base64": _png, + "image_low_base64": _png, + "grown_image_base64": _png, "order": 1, }, { - "image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}", - "order": 2, + "hairline_type": "heart", + "image_middle_base64": _png, + "image_high_base64": _png, + "image_low_base64": _png, + "grown_image_base64": None, + "order": 3, }, ], "best_hairline_center_point": {"x": 540, "y": 430}, diff --git a/hairline/service.py b/hairline/service.py index af9200b..af9c933 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -223,20 +223,22 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None, return None -def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, hairline_level: str = "middle", - hair_styles: list[int] | None = None, use_mask: bool = True, +def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, + hair_styles: list[int], use_mask: bool = True, prompt: str | None = None): - """接口5:该性别全部发际线叠图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点, - 并对 hair_styles 选中的类型生发(同接口2)。 + """接口5:对选中发型返回 middle/high/low 三档发际线叠图 + 生发图(同接口2)。 - hairline_level:middle(默认)/high/low,选用不同档位的发际线贴图(仅影响叠图)。 - hair_styles(1-indexed 列表,按贴图排序):指定对哪些类型生发;None/[] 时不生发。 - 生发黑模板固定取自 hairline_texture_black/(middle),与 hairline_level 无关。 + 入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。 + 每个选中发型返回三档叠图(middle/high/low)与一张生发图;三档贴图同名, + 生发黑模板固定取自 hairline_texture_black/(middle),故生发目标固定 middle 档。 use_mask/prompt:同接口2 的生发参数。 - Returns: {"images":[{hairline_type,order,image_bgr,grown_png}], "best_center":(x,y)};无人脸 None。 + Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}(BGR),grown_png}], + "best_center":(x,y)};无人脸 None。best_center 取首个选中发型的 middle 档。 """ if gender not in ("male", "female"): raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") + if not hair_styles: + raise ValueError("hair_styles 必填且不能为空") ctx = extract_context(image_bgr) if ctx is None: return None @@ -246,25 +248,29 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, hairline_level: s # 面部中轴 x = 眉心(9/151 中点) face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w) - textures = get_texture_map(hairline_level)[gender] - grow_set = set(hair_styles or []) + # 三档贴图表(同性别、同 key 顺序,因三个文件夹同名) + tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS} # use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用 shared_grown = None - if grow_set and not use_mask: + if not use_mask: shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt) images, best_center = [], None - for order, (key, path) in enumerate(textures, start=1): - white = load_texture_rgba(path) - preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white) - grown_png = None - if order in grow_set: - grown_png = shared_grown if not use_mask else \ - _grow_from_texture(image_bgr, ctx, path, use_mask=True, prompt=prompt) - images.append({"hairline_type": key, "order": order, - "image_bgr": preview, "grown_png": grown_png}) - if order == 1: # 最佳发际线曲线的中点(面部中轴处的发际线 y) + for s in hair_styles: # s = 1-indexed 发型序号 + key, mid_path = tex_by_level["middle"][s - 1] + overlays = {} + for lv in _TEXTURE_DIRS: + white = load_texture_rgba(tex_by_level[lv][s - 1][1]) + overlays[lv] = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white) + # 生发:固定 middle 黑模板 + grown_png = shared_grown if not use_mask else \ + _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt) + images.append({"hairline_type": key, "order": s, + "overlays": overlays, "grown_png": grown_png}) + # best_center:首个选中发型的 middle 档发际线中点(面部中轴处的发际线 y) + if best_center is None: + white = load_texture_rgba(mid_path) overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) ys, xs = np.where(overlay[:, :, 3] > 40) if xs.size: diff --git a/static/integration.html b/static/integration.html index 65f77f6..ec8b77d 100644 --- a/static/integration.html +++ b/static/integration.html @@ -218,20 +218,21 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
/api/v1/hairline/generate上传正面照 + 性别 → N 张发际线叠加图 + 最佳发际线中心点坐标。
+上传正面照 + 性别 + 多选发型 → 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最佳发际线中心点坐标。
-入参
+入参(同接口2:先选性别,再多选发型)
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| image_file / image_url / image_base64 | — | 三选一 | 用户正面照 |
| gender | string | ✅ 必填 | "male" / "female" |
| hair_style | string | ✅ 必填 | 发型序号,逗号分隔多选(如 1,2,3)。缺失/越界返回 1007 |
data 字段
| 字段 | 类型 | 说明 |
|---|---|---|
hairline_images[] | object[] | 发际线叠加图列表,每项含 image_url + order |
best_hairline_center_point | object | 最佳发际线中心点像素坐标 { x: number, y: number } |
hairline_images[] | object[] | 选中发型列表,每项含 hairline_type、image_middle_url/image_high_url/image_low_url 三档叠图、grown_image_url 生发图(失败为 null)、order |
best_hairline_center_point | object | 首个选中发型 middle 档发际线中心点像素坐标 { x: number, y: number } |
POST /api/v1/hairline/generate | 上传正面照 + 性别(+档位/生发序号)→ N 张发际线叠加图(含生发图)+ 最佳中心点坐标
+POST /api/v1/hairline/generate | 上传正面照 + 性别 + 多选发型 → 每个发型 middle/high/low 三档发际线叠图 + 生发图 + 最佳中心点坐标