接口5:改为多选发型 + 每发型返回 middle/high/low 三档叠图(去掉 hairline_level 入参)

- 去掉 hairline_level 入参;middle/high/low 三档都返回
- 入参改为同接口2:gender + hair_style(逗号分隔多选,必填),缺失/越界返回 1007
- 每个选中发型返回 image_{middle,high,low}_base64 三档叠图 + grown_image_base64 生发图,
  按发型分组;order = 发型序号,含 hairline_type
- 生发黑模板仍固定 middle(hairline_texture_black/),每发型 1 张生发图
- best_hairline_center_point 取首个选中发型的 middle 档
- 同步更新测试页(三档并排展示)、integration.html、stub_worker、接口文档/实现说明、test_api

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
xsl
2026-07-10 00:19:08 +08:00
co-authored by Claude Opus 4.8
parent 864d7f969a
commit f8e30ad32e
8 changed files with 161 additions and 120 deletions
+27 -28
View File
@@ -971,7 +971,8 @@ async def face_features(
summary="接口5 发际线PNG生成", summary="接口5 发际线PNG生成",
tags=["人脸分析"], tags=["人脸分析"],
description=f""" description=f"""
输入用户照片,返回 N 张用户发际线的 PNG 图片,并标注最合适发际线的面部中间点坐标。 输入用户照片 + 性别 + 多选发型,对每个选中发型返回 middle/high/low 三档发际线叠图与生发图,
并标注最合适发际线的面部中间点坐标。
{_image_fields_desc} {_image_fields_desc}
@@ -979,22 +980,21 @@ async def face_features(
--- ---
**入参** **入参**(同接口2:先选性别,再多选发型)
- 必填 `gender``male`/`female`),决定返回的发际线集合(female 5 / male 4)。 - 必填 `gender``male`/`female`),决定发型集合(female 5 / male 4)。
- 可选 `hairline_level``middle`(默认) / `high` / `low`),选用不同档位的发际线贴图 - 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`
- 可选 `hair_style`(发型序号,逗号分隔如 `1,2,3`):对选中的发际线类型额外**生发**(同接口2)。
留空则只返回发际线叠图、不生发。越界/非法返回 `1007`。
`female`1=ellipse,2=flower,3=heart,4=straight,5=wave`male`1=ellipse,2=inverse_arc,3=m,4=straight。 `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 的生发控制参数。 - 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
注:生发黑模板固定取 `hairline_texture_black/`middle),与 `hairline_level` 无关 注:生发黑模板固定取 `hairline_texture_black/`middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle
**返回说明** **返回说明**
- `hairline_images`发际线叠加图列表(发际线曲线叠加在用户照片上,同接口2预览), - `hairline_images`**选中发型**的列表,数量 = 所选发型数,`order` = 发型序号。每项含:
数量 = 该性别的发际线类型数,本期按贴图顺序 `order=1..N`(暂不计算合适度)。 - `image_middle_url` / `image_high_url` / `image_low_url`:该发型 middle/high/low 三档发际线叠图(同接口2预览)。
worker 返回 `image_base64`,网关落盘后改写为 `image_url`。 - `grown_image_url`:该发型的**生发图**(生发失败时为 `null`
每项还含 `grown_image_base64`:当 `order` 在 `hair_style` 中时为该类型的**生发图**,否则为 `null` - `hairline_type`:发际线类型 key
- `best_hairline_center_point`:最佳(`order=1`)发际线曲线的**面部中间点**坐标, worker 返回 `*_base64`,网关落盘后改写为 `*_url`。
- `best_hairline_center_point`**首个选中发型**的 middle 档发际线曲线**面部中间点**坐标,
以**原图像素**为基准(左上角为原点,x 向右,y 向下)。 以**原图像素**为基准(左上角为原点,x 向右,y 向下)。
""", """,
responses={ responses={
@@ -1008,8 +1008,8 @@ async def face_features(
"request_id": "mock-request-id", "request_id": "mock-request-id",
"data": { "data": {
"hairline_images": [ "hairline_images": [
{"image_base64": "iVBORw0KGgo...", "grown_image_base64": "iVBORw0KGgo...", "order": 1}, {"hairline_type": "ellipse", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": "iVBORw0KGgo...", "image_low_base64": "iVBORw0KGgo...", "grown_image_base64": "iVBORw0KGgo...", "order": 1},
{"image_base64": "iVBORw0KGgo...", "grown_image_base64": None, "order": 2}, {"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}, "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_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"), 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,测试对比用)"), use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"), prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
): ):
if gender not in ("male", "female"): if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 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_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
hair_styles = None max_styles = {"female": 5, "male": 4}[gender]
if hair_style and hair_style.strip(): hair_styles = _parse_hair_styles(hair_style, max_styles)
max_styles = {"female": 5, "male": 4}[gender] if hair_styles is None:
hair_styles = _parse_hair_styles(hair_style, max_styles) return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
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) raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None: if e is not None:
@@ -1063,15 +1058,19 @@ async def hairline_generate(
from hairline.service import generate_hairline_pngs from hairline.service import generate_hairline_pngs
res = await run_in_threadpool( 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: if res is None:
return err(1001, "无法识别人像") return err(1001, "无法识别人像")
hairline_images = [] hairline_images = []
for it in res["images"]: for it in res["images"]:
ov = it["overlays"]
hairline_images.append({ hairline_images.append({
"image_base64": _jpg_b64(it["image_bgr"]), # 发际线叠图 JPG "hairline_type": it["hairline_type"],
"grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(未生发为 null) "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), if it.get("grown_png") else None),
"order": it["order"], "order": it["order"],
}) })
+3 -3
View File
@@ -32,7 +32,7 @@
| 6 | `annotated_image_base64` | `annotated_image_url` | | 6 | `annotated_image_base64` | `annotated_image_url` |
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_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` | | 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` | | 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — | | 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
@@ -89,8 +89,8 @@
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`API Key 走网关配置(不入 git)。 无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`API Key 走网关配置(不入 git)。
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`worker ### 接口5 发际线PNG生成 `/api/v1/hairline/generate`worker
- **做什么**照片 + `gender`(必填) → N 张发际线叠加图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点坐标。 - **做什么**入参同接口2`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
- **怎么实现**:复用接口2 的 502 点渲染管线,输出 N 张叠图 + `best_hairline_center_point`(眉心 x × 该处发际线 y)。无生发 - **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y
--- ---
+32 -14
View File
@@ -373,7 +373,7 @@
## 接口 5:发际线 PNG 生成接口 ## 接口 5:发际线 PNG 生成接口
**说明**输入用户照片,返回 N 张用户发际线的 PNG 图片,并返回「最合适发际线」的面部中间点坐标。可选地对指定发际线类型**同步生发**(同接口2) **说明**入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标
**请求**`POST /api/v1/hairline/generate` **请求**`POST /api/v1/hairline/generate`
@@ -383,28 +383,32 @@
| 参数 | 类型 | 必填 | 说明 | | 参数 | 类型 | 必填 | 说明 |
|------|------|------|------| |------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定返回的发际线集合(female 5 / male 4 | | gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 5 / male 4。缺失/非法返回 `1004` |
| hairline_level | string | 否 | 发际线贴图档位:`middle`(默认)/ `high` / `low`,选用不同高度档位的发际线贴图(仅影响发际线叠图)。非法返回 `1004` | | 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` |
| 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` |
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 | | use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true``false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发」,会替换工作流节点 60 的文本 | | 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 ### 输出(data
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| hairline_images | object[] | N 张发际线叠加图(发际线曲线叠在用户照片上,同接口2预览),**数量 = 该性别发际线数**,本期按贴图顺序,元素见下表 | | hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
| best_hairline_center_point | object | 最佳(order=1发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | | best_hairline_center_point | object | **首个选中发型**的 middle 档发际线曲线「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` |
`hairline_images` 元素: `hairline_images` 元素:
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| image_url | string | 发际线叠加图 URLworker 返回 `image_base64`,网关落盘后改写为 url | | hairline_type | string | 发际线类型 key`ellipse`/`flower`/`heart`/`straight`/`wave`female),`ellipse`/`m`/`straight`/`inverse_arc`male |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图)。仅当该项 `order``hair_style` 中时有值,否则为 `null`worker 返回 `grown_image_base64`,网关落盘后改写为 url | | image_middle_url | string | middle 档发际线叠加图 URL |
| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度) | | 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 返回值) ### 响应示例(当前 Mock 返回值)
@@ -415,15 +419,29 @@
"request_id": "mock-request-id", "request_id": "mock-request-id",
"data": { "data": {
"hairline_images": [ "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 } "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 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) | | 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 | | 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) | | 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 工作流 | | 7 C 端生发 v2 | 用户照片 + gender + hair_style | 同接口2,使用 add_hair2.json 工作流 |
--- ---
+12 -3
View File
@@ -223,15 +223,24 @@ def create_app(port: int, password: str) -> FastAPI:
if x_internal_token != password: if x_internal_token != password:
return JSONResponse(status_code=401, content={"detail": "unauthorized"}) return JSONResponse(status_code=401, content={"detail": "unauthorized"})
delay = float(request.query_params.get("delay", "1")) delay = float(request.query_params.get("delay", "1"))
_png = f"data:image/png;base64,{TINY_PNG_BASE64}"
data = { data = {
"hairline_images": [ "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, "order": 1,
}, },
{ {
"image_base64": f"data:image/png;base64,{TINY_PNG_BASE64}", "hairline_type": "heart",
"order": 2, "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}, "best_hairline_center_point": {"x": 540, "y": 430},
+27 -21
View File
@@ -223,20 +223,22 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
return None return None
def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, hairline_level: str = "middle", def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
hair_styles: list[int] | None = None, use_mask: bool = True, hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None): prompt: str | None = None):
"""接口5该性别全部发际线叠图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点, """接口5对选中发型返回 middle/high/low 三档发际线叠图 + 生发图(同接口2)。
并对 hair_styles 选中的类型生发(同接口2)。
hairline_levelmiddle(默认)/high/low,选用不同档位的发际线贴图(仅影响叠图)。 入参同接口2:先选 gender,再多选 hair_styles(必填,1-indexed 按贴图排序)。
hair_styles1-indexed 列表,按贴图排序):指定对哪些类型生发;None/[] 时不生发。 每个选中发型返回三档叠图(middle/high/low)与一张生发图;三档贴图同名,
生发黑模板固定取自 hairline_texture_black/middle),与 hairline_level 无关 生发黑模板固定取自 hairline_texture_black/middle),故生发目标固定 middle 档
use_mask/prompt:同接口2 的生发参数。 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"): if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
if not hair_styles:
raise ValueError("hair_styles 必填且不能为空")
ctx = extract_context(image_bgr) ctx = extract_context(image_bgr)
if ctx is None: if ctx is None:
return None return None
@@ -246,25 +248,29 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, hairline_level: s
# 面部中轴 x = 眉心(9/151 中点) # 面部中轴 x = 眉心(9/151 中点)
face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w) face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w)
textures = get_texture_map(hairline_level)[gender] # 三档贴图表(同性别、同 key 顺序,因三个文件夹同名)
grow_set = set(hair_styles or []) tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用 # use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
shared_grown = None 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) shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
images, best_center = [], None images, best_center = [], None
for order, (key, path) in enumerate(textures, start=1): for s in hair_styles: # s = 1-indexed 发型序号
white = load_texture_rgba(path) key, mid_path = tex_by_level["middle"][s - 1]
preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white) overlays = {}
grown_png = None for lv in _TEXTURE_DIRS:
if order in grow_set: white = load_texture_rgba(tex_by_level[lv][s - 1][1])
grown_png = shared_grown if not use_mask else \ overlays[lv] = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white)
_grow_from_texture(image_bgr, ctx, path, use_mask=True, prompt=prompt) # 生发:固定 middle 黑模板
images.append({"hairline_type": key, "order": order, grown_png = shared_grown if not use_mask else \
"image_bgr": preview, "grown_png": grown_png}) _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
if order == 1: # 最佳发际线曲线的中点(面部中轴处的发际线 y) 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) overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
ys, xs = np.where(overlay[:, :, 3] > 40) ys, xs = np.where(overlay[:, :, 3] > 40)
if xs.size: if xs.size:
+5 -4
View File
@@ -218,20 +218,21 @@ console.log(features['四季色彩季型']); // "冷夏型"(中文字段也保
<div class="card" id="if5"> <div class="card" id="if5">
<h2>5. 发际线 PNG 生成 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hairline/generate</code></h2> <h2>5. 发际线 PNG 生成 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hairline/generate</code></h2>
<div class="card-body"> <div class="card-body">
<p class="desc">上传正面照 + 性别 → N 张发际线叠加图 + 最佳发际线中心点坐标。</p> <p class="desc">上传正面照 + 性别 + 多选发型 → 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最佳发际线中心点坐标。</p>
<p><strong>入参</strong></p> <p><strong>入参</strong>(同接口2:先选性别,再多选发型)</p>
<table> <table>
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr> <tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
<tr><td>image_file / image_url / image_base64</td><td></td><td>三选一</td><td>用户正面照</td></tr> <tr><td>image_file / image_url / image_base64</td><td></td><td>三选一</td><td>用户正面照</td></tr>
<tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr> <tr><td>gender</td><td>string</td><td>✅ 必填</td><td><code>"male"</code> / <code>"female"</code></td></tr>
<tr><td>hair_style</td><td>string</td><td>✅ 必填</td><td>发型序号,逗号分隔多选(如 <code>1,2,3</code>)。缺失/越界返回 1007</td></tr>
</table> </table>
<p style="margin-top:12px"><strong>data 字段</strong></p> <p style="margin-top:12px"><strong>data 字段</strong></p>
<table> <table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr> <tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>hairline_images[]</code></td><td>object[]</td><td>发际线叠加图列表,每项含 image_url + order</td></tr> <tr><td><code>hairline_images[]</code></td><td>object[]</td><td>选中发型列表,每项含 <code>hairline_type</code><code>image_middle_url</code>/<code>image_high_url</code>/<code>image_low_url</code> 三档叠图、<code>grown_image_url</code> 生发图(失败为 null)、<code>order</code></td></tr>
<tr><td><code>best_hairline_center_point</code></td><td>object</td><td>最佳发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr> <tr><td><code>best_hairline_center_point</code></td><td>object</td><td>首个选中发型 middle 档发际线中心点像素坐标 <code>{ x: number, y: number }</code></td></tr>
</table> </table>
</div> </div>
</div> </div>
+35 -42
View File
@@ -58,6 +58,14 @@
.preview-controls { display: flex; gap: 14px; align-items: center; font-size: 12px; color: #9ca3af; } .preview-controls { display: flex; gap: 14px; align-items: center; font-size: 12px; color: #9ca3af; }
.preview-controls label { display: flex; align-items: center; gap: 4px; cursor: pointer; } .preview-controls label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
/* 三档 + 生发图 横向排列 */
.level-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.level-cell { background: #222; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
.level-cell .level-label { font-size: 12px; font-weight: 600; color: #fff; background: rgba(0,0,0,.55); padding: 4px 8px; }
.level-cell img { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .level-none { color: #9ca3af; font-size: 13px; padding: 28px 0; text-align: center; }
@media (max-width: 700px) { .level-row { grid-template-columns: repeat(2, 1fr); } }
/* 坐标高亮 */ /* 坐标高亮 */
.coord-box { background: #f9fafb; border-radius: 8px; padding: 12px 16px; margin-top: 10px; font-size: 14px; } .coord-box { background: #f9fafb; border-radius: 8px; padding: 12px 16px; margin-top: 10px; font-size: 14px; }
.coord-box .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; } .coord-box .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
@@ -73,7 +81,7 @@
<body> <body>
<div class="container"> <div class="container">
<h1>💈 接口5 — 发际线 PNG 生成 测试</h1> <h1>💈 接口5 — 发际线 PNG 生成 测试</h1>
<p class="subtitle">POST /api/v1/hairline/generate &nbsp;|&nbsp; 上传正面照 + 性别(+档位/生发序号)→ N 张发际线叠加图(含生发图+ 最佳中心点坐标</p> <p class="subtitle">POST /api/v1/hairline/generate &nbsp;|&nbsp; 上传正面照 + 性别 + 多选发型 → 每个发型 middle/high/low 三档发际线叠图 + 生发图 + 最佳中心点坐标</p>
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -84,17 +92,13 @@
<select id="gender"><option value="female" selected>👩 Female5种)</option><option value="male">👨 Male4种)</option></select> <select id="gender"><option value="female" selected>👩 Female5种)</option><option value="male">👨 Male4种)</option></select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>发际线档位</label> <label title="必填,逗号分隔多选如 1,2,3">发型序号 *</label>
<select id="hairlineLevel"><option value="middle" selected>middle(默认)</option><option value="high">high</option><option value="low">low</option></select> <input type="text" id="hairStyle" placeholder="必填 如 1,2,3">
</div>
<div class="form-group">
<label title="逗号分隔如 1,2,3;留空则只出发际线不生发">生发序号</label>
<input type="text" id="hairStyle" placeholder="如 1,2 留空不生发">
</div> </div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button> <button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button> <button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div> </div>
<div class="hint">JPG/PNG 正面照 &nbsp;|&nbsp; Female: 椭圆/花瓣/心形/直线/波浪 &nbsp;|&nbsp; Male: 椭圆/M形/直线/倒弧 &nbsp;|&nbsp; 返回中心点坐标(原图像素)</div> <div class="hint">JPG/PNG 正面照 &nbsp;|&nbsp; 发型序号 Female: 1椭圆/2花瓣/3心形/4直线/5波浪 &nbsp;|&nbsp; Male: 1椭圆/2倒弧/3M形/4直线 &nbsp;|&nbsp; 每个发型返回 middle/high/low 三档 + 生发图</div>
<div id="statusBar" class="status hidden"></div> <div id="statusBar" class="status hidden"></div>
</div> </div>
</div> </div>
@@ -103,24 +107,18 @@
<div class="col-main"> <div class="col-main">
<!-- 预览 --> <!-- 预览 -->
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header"><span>📐 三档发际线叠图 + 生发图</span></div>
<span>📐 原图 + 发际线叠加</span>
<div class="preview-controls">
<label><input type="checkbox" id="showBase" checked onchange="syncOverlay()"> 底图</label>
<label><input type="checkbox" id="showOver" checked onchange="syncOverlay()"> 发际线</label>
</div>
</div>
<div class="card-body" style="text-align:center"> <div class="card-body" style="text-align:center">
<div id="previewArea"><span style="color:#9ca3af">← 点击下方方案卡片</span></div> <div id="previewArea"><span style="color:#9ca3af">← 点击下方发型卡片</span></div>
<div class="coord-box"> <div class="coord-box">
<div class="label">📍 最佳发际线中心点(best_hairline_center_point)— 原图像素坐标</div> <div class="label">📍 最佳发际线中心点(best_hairline_center_point,首个选中发型 middle 档)— 原图像素坐标</div>
<div class="value" id="centerPoint"></div> <div class="value" id="centerPoint"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- 方案列表 --> <!-- 发型列表 -->
<div class="card"> <div class="card">
<div class="card-header"><span>🎯 发际线方案</span><span style="font-weight:400;font-size:12px;color:#9ca3af">点击切换预览 &nbsp;|&nbsp; order=1 最佳</span></div> <div class="card-header"><span>🎯 选中发型</span><span style="font-weight:400;font-size:12px;color:#9ca3af">点击切换预览</span></div>
<div class="card-body"><div class="results-grid" id="resultsGrid"></div></div> <div class="card-body"><div class="results-grid" id="resultsGrid"></div></div>
</div> </div>
</div> </div>
@@ -143,14 +141,14 @@ function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.classNa
async function submitTest() { async function submitTest() {
const f = $('imageFile').files[0]; const f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; } if (!f) { setStatus('请选择图片', 'error'); return; }
const _hs = $('hairStyle').value.trim();
if (!_hs) { setStatus('请填写发型序号(必填,如 1,2,3)', 'error'); return; }
_origUrl = URL.createObjectURL(f); _origUrl = URL.createObjectURL(f);
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...'; $('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden'); setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hairline_level', $('hairlineLevel').value); const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', _hs);
const _hs = $('hairStyle').value.trim(); if (_hs) fd.append('hair_style', _hs);
const _reqStart = performance.now(); const _reqStart = performance.now();
try { try {
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', body:fd }); const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', body:fd });
@@ -162,7 +160,7 @@ async function submitTest() {
_images = json.data.hairline_images || []; _images = json.data.hairline_images || [];
_center = json.data.best_hairline_center_point; _center = json.data.best_hairline_center_point;
$('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—'; $('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—';
setStatus('✅ ' + _images.length + ' 个方案 (' + _elapsed + 's)', 'success'); setStatus('✅ ' + _images.length + ' 个发型 × 三档 (' + _elapsed + 's)', 'success');
renderGrid(); renderGrid();
if (_images.length) selectCard(0); if (_images.length) selectCard(0);
} else { } else {
@@ -178,10 +176,9 @@ function renderGrid() {
let h = ''; let h = '';
_images.forEach((img, i) => { _images.forEach((img, i) => {
h += '<div class="result-card'+(i===0?' selected':'')+'" onclick="selectCard('+i+',this)">'+ h += '<div class="result-card'+(i===0?' selected':'')+'" onclick="selectCard('+i+',this)">'+
'<div class="img-wrap"><img src="'+img.image_url+'" alt="#'+img.order+'" loading="lazy"></div>'+ '<div class="img-wrap"><img src="'+img.image_middle_url+'" alt="#'+img.order+'" loading="lazy"></div>'+
'<div class="info"><span class="badge">#'+(img.order||'—')+'</span>'+ '<div class="info"><span class="badge">#'+(img.order||'—')+' '+(img.hairline_type||'')+'</span>'+
(img.grown_image_url?'<span style="font-size:11px;color:#7c3aed">🌱 生发</span>':'')+ (img.grown_image_url?'<span style="font-size:11px;color:#7c3aed">🌱 生发</span>':'')+'</div></div>';
(i===0?'<span style="font-size:11px;color:#059669">⭐ 最佳</span>':'')+'</div></div>';
}); });
$('resultsGrid').innerHTML = h; $('resultsGrid').innerHTML = h;
} }
@@ -189,28 +186,24 @@ function renderGrid() {
function selectCard(idx, el) { function selectCard(idx, el) {
document.querySelectorAll('.result-card').forEach(c => c.classList.remove('selected')); document.querySelectorAll('.result-card').forEach(c => c.classList.remove('selected'));
if (el) el.classList.add('selected'); if (el) el.classList.add('selected');
const g = _images[idx].grown_image_url; const it = _images[idx];
const cell = (label, url) => url
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>'
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
$('previewArea').innerHTML = $('previewArea').innerHTML =
'<div class="preview-stack">'+ '<div class="level-row">'+
'<img class="layer-base5" src="'+_origUrl+'" alt="原图">'+ cell('middle', it.image_middle_url)+
'<img class="layer-over5" src="'+_images[idx].image_url+'" alt="#'+_images[idx].order+'">'+ cell('high', it.image_high_url)+
'</div>'+ cell('low', it.image_low_url)+
(g?'<div style="margin-top:10px"><div style="font-size:12px;color:#7c3aed;margin-bottom:4px">🌱 生发图</div><img src="'+g+'" alt="生发 #'+_images[idx].order+'" style="max-width:100%;border-radius:8px"></div>':''); cell('🌱 生发图', it.grown_image_url)+
syncOverlay(); '</div>';
}
function syncOverlay() {
const base = document.querySelector('.layer-base5');
const over = document.querySelector('.layer-over5');
if (base) base.style.display = $('showBase').checked ? '' : 'none';
if (over) over.style.display = $('showOver').checked ? '' : 'none';
} }
function clearResults() { function clearResults() {
_images=[]; _origUrl=''; _center=null; _images=[]; _origUrl=''; _center=null;
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden'; $('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML=''; $('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
$('previewArea').innerHTML='<span style="color:#9ca3af">← 点击下方方案卡片</span>'; $('previewArea').innerHTML='<span style="color:#9ca3af">← 点击下方发型卡片</span>';
$('centerPoint').textContent='—'; $('centerPoint').textContent='—';
} }
+20 -5
View File
@@ -130,16 +130,31 @@ def test_hairline_gen_missing_gender_1004(client):
assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004 assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004
def test_hairline_gen_female(client): def test_hairline_gen_missing_hairstyle_1007(client):
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post(HLGEN, headers=H, files=files, data={"gender": "female"}).json() assert client.post(HLGEN, headers=H, files=files, data={"gender": "female"}).json()["code"] == 1007
def test_hairline_gen_female(client, monkeypatch):
# mock ComfyUI:不依赖 8182,只验证三档叠图 + 生发字段接线
import hairline.comfyui as comfy
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post(HLGEN, headers=H, files=files, data={"gender": "female", "hair_style": "1,3"}).json()
assert body["code"] == 0, body assert body["code"] == 0, body
d = body["data"] d = body["data"]
assert [x["order"] for x in d["hairline_images"]] == [1, 2, 3, 4, 5] imgs = d["hairline_images"]
assert base64.b64decode(d["hairline_images"][0]["image_base64"])[:3] == b"\xff\xd8\xff" # JPEG # 只返回选中发型,order = 发型序号
assert [x["order"] for x in imgs] == [1, 3]
assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"]
# 三档叠图 + 生发图均为 JPEGworker 返回 base64,非 url
for k in ("image_middle_base64", "image_high_base64", "image_low_base64", "grown_image_base64"):
assert base64.b64decode(imgs[0][k])[:3] == b"\xff\xd8\xff", k
assert "image_middle_url" not in imgs[0]
c = d["best_hairline_center_point"] c = d["best_hairline_center_point"]
assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内 assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内
assert "image_url" not in d["hairline_images"][0]
# 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker; # 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker;