Compare commits
22
Commits
d242e0fc48
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e842e993e | ||
|
|
49cee683ad | ||
|
|
a63a0565d8 | ||
|
|
6d003dbf80 | ||
|
|
5eca74c9c0 | ||
|
|
c7add84d91 | ||
|
|
51baf96d78 | ||
|
|
dc53a0ecfe | ||
|
|
5f569caa5d | ||
|
|
14ec4964a0 | ||
|
|
544c2a83d6 | ||
|
|
44a6082938 | ||
|
|
3350e0ad77 | ||
|
|
6f46fc0ccd | ||
|
|
12a825315a | ||
|
|
f1b8b7bd82 | ||
|
|
6b24bf0b0d | ||
|
|
88fee6474d | ||
|
|
a0f53ad6ad | ||
|
|
81101715c8 | ||
|
|
b031ce5bbe | ||
|
|
ebcaff1295 |
@@ -29,7 +29,7 @@
|
||||
"60": {
|
||||
"class_type": "JjkText",
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"image": "placeholder.png"}},
|
||||
|
||||
"60": {"class_type": "JjkText", "inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮"}},
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"}},
|
||||
"22": {"class_type": "CLIPTextEncode", "inputs": {
|
||||
"clip": ["61", 0],
|
||||
"text": ["60", 0]}},
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
+2
-2
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
@@ -684,7 +684,7 @@
|
||||
},
|
||||
"87": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发",
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮",
|
||||
"clip": [
|
||||
"78",
|
||||
0
|
||||
|
||||
@@ -722,7 +722,7 @@ async def hair_grow(
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-7 male:1-7"),
|
||||
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
|
||||
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||
if gender not in ("male", "female"):
|
||||
@@ -791,7 +791,7 @@ async def debug_grow_timing(
|
||||
hair_style: str = Form(default="2", description="发型序号(花瓣=2),逗号分隔多选"),
|
||||
webui_steps: Optional[int] = Form(default=None, description="swapHair webui img2img 采样步数,None=服务端默认(15),可填10/15/20/25对比"),
|
||||
redraw_max_side: Optional[int] = Form(default=None, description="ComfyUI重绘分辨率(长边像素)。None=默认896;0=原图不缩;其他如640/768/1024"),
|
||||
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发'"),
|
||||
redraw_prompt: Optional[str] = Form(default=None, description="ComfyUI重绘提示词,None=默认'填充遮罩区域的头发,人物增加一点磨皮'"),
|
||||
):
|
||||
"""单图跑接口2女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
|
||||
|
||||
@@ -978,7 +978,7 @@ async def hair_grow_b(
|
||||
marked_image_url: Optional[str] = Form(default=None, description="划线图片 URL"),
|
||||
marked_image_base64: Optional[str] = Form(default=None, description="划线图片 base64"),
|
||||
use_mask: bool = Form(default=True, description="是否画发际线(测试对比用)。false 时跳过划线检测、直接送划线图"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
):
|
||||
"""接口3 已屏蔽:调用直接返回错误,不再执行生发/ComfyUI 逻辑。
|
||||
保留路由(含原参数签名)避免老客户端裸 404,multipart 入参仍被接受但不处理。"""
|
||||
@@ -1132,7 +1132,7 @@ async def hairline_generate(
|
||||
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
|
||||
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-7"),
|
||||
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的文本"),
|
||||
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
|
||||
):
|
||||
if gender not in ("male", "female"):
|
||||
@@ -1590,7 +1590,7 @@ async def hairline_grow_v2(
|
||||
inpainting_fill: int = Form(default=1, description="change_hair服务端重绘填充:0=保留原图 | 1=噪声 | 2=纯色 | 3=潜变量。默认 1"),
|
||||
mask_blur: int = Form(default=11, description="change_hair服务端遮罩边缘模糊像素,默认 11"),
|
||||
mask_dilate_scale: float = Form(default=1.0, description="change_hair服务端遮罩膨胀缩放,默认 1.0"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发」"),
|
||||
comfyui_prompt: Optional[str] = Form(default=None, description="Flux-2 重绘提示词,None 用默认「填充遮罩区域的头发,人物增加一点磨皮」"),
|
||||
beauty_alpha: float = Form(default=0.6, description="redraw_band 版 band 外的全脸美颜融入强度(0=band外无美颜纯用final,1≈整帧版),默认 0.6"),
|
||||
band_lo_mult: float = Form(default=0.5, description="重绘带外推倍率下限(相对 hairline_push_cm,内轮廓=0×、原外推线=1.0×),默认 0.5"),
|
||||
band_hi_mult: float = Form(default=1.5, description="重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5"),
|
||||
@@ -1756,7 +1756,7 @@ async def hairline_grow_v2_final_v2(
|
||||
async def api_redraw(
|
||||
image_file: UploadFile = File(..., description="人物图片(JPG/PNG)"),
|
||||
mask_file: UploadFile = File(..., description="遮罩图片(PNG,支持红/白/alpha 格式)"),
|
||||
prompt: str = Form(default="填充遮罩区域的头发",
|
||||
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮",
|
||||
description="ComfyUI 提示词"),
|
||||
):
|
||||
image_bytes = await image_file.read()
|
||||
|
||||
@@ -58,7 +58,7 @@ def call_iface2(image_path, hair_style):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
body, boundary = _multipart(
|
||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
|
||||
@@ -56,7 +56,7 @@ def call_iface2_female(image_path, hair_style):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
body, boundary = _multipart(
|
||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
|
||||
@@ -89,7 +89,7 @@ def call_api2(image_path, gender, hair_style="1"):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0",
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
body, boundary = _multipart(fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
@@ -107,7 +107,7 @@ def call_api3(image_path):
|
||||
"""接口3:B端生发(use_mask=False,直接送图)"""
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发"}
|
||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
body, boundary = _multipart(fields, {"marked_image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow-b", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026-09-19~09-20 接口2 空图失败统计
|
||||
|
||||
- **统计窗口**:2026-09-19 00:00:00 ~ 2026-09-20 23:59:59(北京时间)
|
||||
- **生成时间**:2026-09-21 10:45
|
||||
|
||||
## 1. 要抓的失败
|
||||
|
||||
接口2(`POST /api/v1/hair/grow`)在 GPU worker 内部出错时,仍返回 HTTP 200、`code=0`,只把 `grown_image_*` 置空。只看业务码会当成成功。
|
||||
|
||||
| 判定 | 条件 |
|
||||
|------|------|
|
||||
| **真成功(出图)** | `code=0` 且至少一张生发图非空 |
|
||||
| **空图失败** | `code=0` 且有 `results`,但生发图全空 |
|
||||
| **业务码失败** | `code≠0` |
|
||||
|
||||
抓取:jsonl 里看 `results[].grown_image_url|base64`;9/19 18:53 之前 jsonl 已轮转,用 journal 的 `base64→URL: grown_image_base64` 与 `POST /hair/grow` 次数对照(白天每小时 rewrite ≥ 请求数,说明在出图)。
|
||||
|
||||
空图耗时中位约 **2.2~2.5s**;正常出图中位约 **16~23s**。
|
||||
|
||||
## 2. 两天接口2 合计
|
||||
|
||||
| 类别 | 9/19 | 9/20 | 两天合计 |
|
||||
|------|------|------|----------|
|
||||
| 调用量 | 4,958 | 3,352 | **8,310** |
|
||||
| 真成功(有生发图) | 1,968 | 701 | **2,669** |
|
||||
| **空图失败** | **2,988** | **2,649** | **5,637** |
|
||||
| 业务码失败 | 2 | 2 | 4 |
|
||||
| 按业务码成功率 | 99.96% | 99.94% | 99.95% |
|
||||
| **有效出图成功率** | **39.7%** | **20.9%** | **32.1%** |
|
||||
|
||||
9/19 调用量来自 journal(全日 4,958);空图 2,988 全部落在 jsonl 仍在的 18:53 之后,白天 journal 显示仍在出图,故空图数可视为全日完整。9/20 全日 jsonl。
|
||||
|
||||
把空图算失败后:两天有效成功 **2,669**,失败 **5,641**(空图 5,637 + 业务码 4)。
|
||||
|
||||
## 3. 连续故障段
|
||||
|
||||
不是两天各坏一段时间,而是**一段连续故障**:
|
||||
|
||||
| 节点 | 时间 |
|
||||
|------|------|
|
||||
| 9/19 白天~22:20 | 正常出图 |
|
||||
| 9/19 **22:30** | 开始大量空图(该 10 分钟 165 次里 19 张有图、146 张空) |
|
||||
| 9/19 **22:34:49** | 故障前最后一张生发图 |
|
||||
| 9/19 22:40 ~ 9/20 13:36 | **全部空图** |
|
||||
| 9/20 **13:36:54** | 恢复后第一张生发图 |
|
||||
| 9/20 13:40 以后 | 基本正常(偶发单次空图) |
|
||||
|
||||
故障段内空图:
|
||||
|
||||
| 时段 | 空图次数 |
|
||||
|------|----------|
|
||||
| 9/19 22:30–22:39 | 146(另 19 次仍出图,过渡) |
|
||||
| 9/19 22:40–24:00 | 2,835 |
|
||||
| 9/20 00:00–13:36 | 2,625 |
|
||||
| **连续故障空图合计** | **约 5,606** |
|
||||
|
||||
其中 9/20 00:00–00:50 有 2,127 次,来源 IP `47.93.63.201`(客户侧重试/批量)。
|
||||
|
||||
## 4. 按小时(接口2)
|
||||
|
||||
### 9/19
|
||||
|
||||
| 小时 | 调用 | 出图 | 空图 | 状态 |
|
||||
|------|------|------|------|------|
|
||||
| 00–21 | 1,840 | ≈1,832 | 7(零星) | 正常(journal + 18:53 后 jsonl) |
|
||||
| 22 | 849 | 136 | 713 | 22:30 起故障 |
|
||||
| 23 | 2,269 | 0 | 2,269 | 故障 |
|
||||
|
||||
18–21 点 jsonl 可核验:854 次出图、仅 7 次零星空图。
|
||||
|
||||
### 9/20
|
||||
|
||||
| 小时 | 调用 | 出图 | 空图 | 状态 |
|
||||
|------|------|------|------|------|
|
||||
| 00 | 2,127 | 0 | 2,127 | 故障 |
|
||||
| 01–12 | 417 | 0 | 417 | 故障 |
|
||||
| 13 | 125 | 32 | 93 | 13:36 恢复 |
|
||||
| 14–18 | 354 | 354 | 0 | 正常 |
|
||||
| 19–20 | 177 | 165 | 12 | 正常,少量单次空图 |
|
||||
| 21–23 | 152 | 150 | 0 | 正常(另 2 次业务码失败) |
|
||||
|
||||
## 5. 9/20 其它接口(业务码,供对照)
|
||||
|
||||
| 接口 | 调用量 | `code=0` | 业务码失败 |
|
||||
|------|--------|----------|------------|
|
||||
| 接口1 四庭七眼 | 380 | 361 | 19 |
|
||||
| 接口4 特征 | 347 | 345 | 2 |
|
||||
| 接口99 特殊点 | 344 | 344 | 0 |
|
||||
| 接口5 发际线 | 248 | 248 | 0(但 247 次无生发图,全天) |
|
||||
| 接口6 测量 v2 | 105 | 75 | 30 |
|
||||
|
||||
9/20 按业务码成功率 98.89%,同样会漏掉接口2 空图。接口5 当天几乎也无生发图,主结论仍以接口2 为准。
|
||||
|
||||
## 6. 结论
|
||||
|
||||
1. 故障从 **9/19 22:30** 开始,到 **9/20 13:36** 结束,约 **15 小时**。
|
||||
2. 两天空图失败 **5,637** 次(9/19 的 2,988 + 9/20 的 2,649),占两天接口2 的 **67.8%**。
|
||||
3. 有效出图 **2,669** 次,有效成功率 **32.1%**;只看 `code=0` 则是 99.95%。
|
||||
4. 9/19 白天(22:30 前)journal 显示仍在落盘生发图,空图集中在当晚 22:30 之后并跨到次日下午。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 2026-09-20 调用成败统计(含接口2「秒回无图」)
|
||||
|
||||
本文已并入两天统计,请看:
|
||||
|
||||
**[20260919-20_接口2空图失败统计.md](20260919-20_接口2空图失败统计.md)**
|
||||
|
||||
覆盖 9/19~9/20。故障从 **9/19 22:30** 持续到 **9/20 13:36**,两天空图失败合计 **5,637** 次。
|
||||
@@ -7,8 +7,13 @@
|
||||
|------|------|
|
||||
| [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 |
|
||||
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
|
||||
| [客户端网关鉴权](接口42-52-72网关鉴权.md) | 登录、过期、刷新、令牌签名配置与测试页。 |
|
||||
| [接口52网关接入.md](接口52网关接入.md) | 52号请求透传、图片URL与空字段约定、公开测试页及网关部署步骤。 |
|
||||
| [旷视具体需求.md](旷视具体需求.md) | 原始需求。 |
|
||||
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
|
||||
| [最近一周调用统计_20260915-20260921.md](最近一周调用统计_20260915-20260921.md) | 2026-09-15~09-21 网关调用量、接口构成、成功率。 |
|
||||
| [20260919-20_接口2空图失败统计.md](20260919-20_接口2空图失败统计.md) | 9/19~9/20 接口2 `code=0` 但无生发图(GPU 内部错误,跨午夜连续故障)。 |
|
||||
| [监控探活接口.md](监控探活接口.md) | 外部监控用的 `GET /monitor`:有在线 worker 返回 200,全部下线返回 503。 |
|
||||
|
||||
> 原先分散的「各接口技术方案 / 开发任务书 / 系统架构 / 网关任务书」已合并进 `实现说明.md`
|
||||
> (细节可查 git 历史)。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
Handler 签名新增:
|
||||
|
||||
```python
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default=填充遮罩区域的头发,人物增加一点磨皮, description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
```
|
||||
|
||||
透传链路:
|
||||
@@ -31,7 +31,7 @@ handler → generate_grow_results(image, gender, use_mask, prompt)
|
||||
Handler 签名新增:
|
||||
|
||||
```python
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default=填充遮罩区域的头发,人物增加一点磨皮, description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
```
|
||||
|
||||
透传链路:
|
||||
@@ -80,17 +80,17 @@ if prompt is not None:
|
||||
| 2 | `image_base64` | string | — | 同上 |
|
||||
| 2 | `gender` | string | `female` | `male` / `female` |
|
||||
| 2 | `use_mask` | bool | `true` | inpaint 遮罩开关 |
|
||||
| 2 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
|
||||
| 2 | `prompt` | string | 填充遮罩区域的头发,人物增加一点磨皮 | ComfyUI 提示词 |
|
||||
| 3 | `marked_image_file` | file | — | 划线图(三选一) |
|
||||
| 3 | `marked_image_url` | string | — | 同上 |
|
||||
| 3 | `marked_image_base64` | string | — | 同上 |
|
||||
| 3 | `use_mask` | bool | `true` | 画发际线开关 |
|
||||
| 3 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
|
||||
| 3 | `prompt` | string | 填充遮罩区域的头发,人物增加一点磨皮 | ComfyUI 提示词 |
|
||||
|
||||
## 3. 当前工作流提示词节点
|
||||
|
||||
`add_hair.json` 中节点 **60**(`JjkText`)为提示词节点,当前硬编码文本:
|
||||
|
||||
> 补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头
|
||||
> 填充遮罩区域的头发,人物增加一点磨皮
|
||||
|
||||
`comfyui.run()` 收到 `prompt` 参数后将替换此文本。
|
||||
|
||||
+13
-3
@@ -56,7 +56,7 @@
|
||||
## 2. 各接口实现简述
|
||||
|
||||
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、竖线坐标 `vertical_lines`、一张透明底标注 PNG。
|
||||
- **怎么实现**(`face_analysis/`):MediaPipe Face Mesh 468+虹膜点 → solvePnP 姿态校验(非正面 1003) →
|
||||
虹膜直径法定标(px→cm) → **眉心以下实测**;**眉心以上**用 BiSeNet 头发分割取真实发际线/头顶(方案B),
|
||||
失败回退比例推算(方案A,`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`。
|
||||
@@ -65,8 +65,8 @@
|
||||
### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`(worker)—— 接口1 的去顶庭变体
|
||||
- **做什么**:基于接口 1,**去顶庭**:不画头顶横线、不返回顶庭数据(`four_courts` 仅上/中/下庭,`landmarks` 无 `hair_top`,`face_total_height_cm` 为三庭之和)。
|
||||
- **与接口1 的标注差异**(`create_annotated_image(variant="v6")`):①竖线范围改为发际线→下巴尖;②不画人头最左/最右端线(仅七眼 6 点 5 段,接口1 为 8 线 7 段);③左侧只标上/中/下庭。箭头/虚线/字体等与接口1 一致。
|
||||
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。
|
||||
- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。
|
||||
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。`vertical_lines` 与标注图共用 `vertical_line_geometry`:接口 6 的 y 范围是发际线到下巴尖,`lines` 只有 6 条内线。
|
||||
- **网关改动**:路由已有,盲转发。`vertical_lines` 是普通 JSON,不经过 base64→URL 改写。
|
||||
|
||||
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
|
||||
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
|
||||
@@ -98,6 +98,12 @@
|
||||
- **做什么**:入参同接口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。
|
||||
|
||||
### 接口52 发际线新规则 `/api/v1/hairline/generate-52`(网关透传)
|
||||
- **客户端鉴权**:登录后发送 `Authorization: Bearer <access_token>`。校验失败返回 HTTP 401、`code=1009`,不进入队列。详见[客户端鉴权](接口42-52-72网关鉴权.md)。
|
||||
- `main`新增网关路由和`static/test_interface52.html`。GPU worker负责中下庭均值定位、扶正裁切及生发;主备worker均需部署52号实现,网关只透传表单并附加内部Token。
|
||||
- 非空图片使用原有`*_base64`→`*_url`落盘改写;空图片保留`*_base64: null`。metadata和坐标原样返回,middle叠加到`input_image_url`。旧接口、5号队列优先级及网关IP策略保持原样。
|
||||
- 参数、空字段与部署步骤见[接口52网关接入](接口52网关接入.md)。worker分支的IP限制与算法不合入本次网关改动。
|
||||
|
||||
### 接口9 头发遮罩 `/api/v1/head/mask`(worker)
|
||||
- **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。
|
||||
- **怎么实现**(`face_analysis/head_mask.py`):MediaPipe 底部分割线(21/68/104/69/108/151/337/299/333/298/251 连线,21/251 水平延伸)→ 线以上为上半区 → BiSeNet 与 SegFormer 双路头发分割、逐列向下填充到分割线得闭合区域(头发+额头)→ 外缘朝 151 内缩 `erode_cm`(默认1.2cm,虹膜标定) → 最终遮罩。返回 `steps_common` + `bisenet`/`segformer` 两组各四张图。
|
||||
@@ -147,6 +153,10 @@
|
||||
- 很轻:FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
|
||||
- 不装 torch/mediapipe/opencv。配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
|
||||
`ark` 的 api_key/base_url/model、`public_base_url`、超时(**生发接口慢,`request_timeout_seconds` 调大 ≥120s**)。
|
||||
可选 `primary_workers`(`workers` 的子集)启用主备分组:主 worker 全部下线时才派发备用 worker;
|
||||
主组并行度由 `dispatch.max_global_concurrency` 控制(N 台主 worker 就设 N,依赖单进程 uvicorn)。
|
||||
修改配置需重启 `hair-gateway.service` 生效(配置启动时加载一次)。
|
||||
- 外部监控探活是 `GET /monitor`(未演练时有在线 worker 才 200,否则 503)。`POST /monitor/fault` 只改这条探活的返回,用来做报警演练,不摘 worker。`/health` 只表示网关进程存活,不能用来报警。详见 [`监控探活接口.md`](监控探活接口.md)。
|
||||
- 托管 `/static/annotations/`(落盘的图),定期清理。
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 客户端网关鉴权
|
||||
|
||||
| 接口 | 方法与路径 | 执行位置 |
|
||||
|---|---|---|
|
||||
| 11 | `POST /api/v1/face/measure-11` | 网关转发至 worker 的接口 1,沿用四庭七眼测量及标注图 |
|
||||
| 22 | `POST /api/v1/hair/grow-22` | 网关转发至 Worker,发际线预览和生发图转成 URL |
|
||||
| 42 | `POST /api/v1/face/features-42` | 网关按接口 52 的规则扶正裁切,再用接口 4 的分析结果和 Ft/Zy 判定生成特征 |
|
||||
| 52 | `POST /api/v1/hairline/generate-52` | 网关转发至 worker |
|
||||
| 61 | `POST /api/v1/face/measure-61` | 网关转发至 worker 的接口 6,沿用去顶庭的测量及标注图 |
|
||||
| 72 | `POST /api/v1/face/b-end-points` | 网关转发至 worker,底图与标注层转成 URL |
|
||||
|
||||
客户端调用这些接口时,在原请求上添加 `Authorization: Bearer <access_token>`。缺少、过期、签名无效或误用刷新令牌时,返回 HTTP 401 和业务码 `1009`。网关验证通过后,11、22、52、61、72 通过现有 worker 池转发。11 和 61 沿用 1 和 6 的图片参数、响应字段及标注图 URL,Bearer 令牌只在网关校验。
|
||||
|
||||
## 登录
|
||||
|
||||
`POST /api/v1/auth/login`,请求体为 JSON,包含 `username` 和 `password`。当前用户名为 `yonghe`,密码使用约定的服务端固定值。
|
||||
|
||||
成功时 HTTP 200、`code=0`,`data` 包含:
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| `access_token` | 访问令牌 |
|
||||
| `token_type` | `Bearer` |
|
||||
| `expires_in` | 900 秒 |
|
||||
| `refresh_token` | 刷新令牌 |
|
||||
| `refresh_expires_in` | 604800 秒,即 7 天 |
|
||||
|
||||
## 刷新
|
||||
|
||||
`POST /api/v1/auth/refresh`,请求体为 `{"refresh_token":"登录返回的刷新令牌"}`。成功后返回新的 `access_token`、`token_type` 和 `expires_in`。登录时的刷新令牌可以在原有效期内继续使用。到期后重新登录。登录或刷新失败时,响应包含 `code`、`message`、`request_id` 和 `data: null`。
|
||||
|
||||
## 网关配置
|
||||
|
||||
在 `main` 的网关环境中设置 `HAIR_API_TOKEN_SECRET`,或在未跟踪的 `gateway/config.json` 中设置 `api_token_secret`。使用独立且稳定的至少 32 字节随机值。签名密钥缺失时,签发或刷新返回 HTTP 503 和业务码 `1007`。更换签名密钥会使已签发令牌失效。
|
||||
|
||||
网关保留原有 Ark、MediaPipe 与 BiSeNet 配置,42 的裁切还使用 Pillow。部署 72 前,确认所有可接收该请求的 worker 已部署含 72 号的版本(例如 `ubuntu3090_debug` 的 `c9a47a7` 或后续版本)。52、72 沿用现有 worker 池与图片 URL 改写流程。
|
||||
|
||||
部署接口 22 前,确认 Worker 池的主备节点均包含接口 22(例如 `ubuntu3090_debug` 上的 `b84a506` 或后续版本)。Worker 启用 IP 白名单时,配置网关直连地址。
|
||||
|
||||
网关测试页为 `/static/test_interface11.html`、`/static/test_interface22.html`、`/static/test_interface42.html`、`/static/test_interface52.html`、`/static/test_interface61.html` 和 `/static/test_interface72.html`。页面可登录、手动刷新令牌并提交图片,访问令牌到期时自动刷新。「测试鉴权」调用 `GET /api/v1/auth/check`,验证无令牌请求返回 HTTP 401、有效令牌请求返回 HTTP 200。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 接口52:网关接入与部署
|
||||
|
||||
`main`用于网关部署,本次只加入网关调用52号所需的路由、公开测试页及契约。GPU worker继续使用已部署的`ubuntu3090_debug`版本(`1c51f3e`及后续兼容版本),负责算法和本机IP限制;本次不把worker的算法、启动参数或IP中间件合并到网关。
|
||||
|
||||
## 调用流程
|
||||
|
||||
客户端 → `POST /api/v1/hairline/generate-52` → 网关现有队列和worker池 → worker同路径 → 网关落盘非空图片并返回公网URL。
|
||||
|
||||
- 请求体原样转发,网关附加配置中的`X-Internal-Token`;客户端和公开测试页不携带内部密码。
|
||||
- 客户端须登录取得访问令牌,通过 `Authorization: Bearer <access_token>` 发送。网关鉴权后进入队列。
|
||||
- 沿用现有主备派发、隔离期、重试、日志和全局并发限制。原有5号插队优先级保持原样,52号走默认队列。
|
||||
- 5号`/api/v1/hairline/generate`和其他接口保持原样;网关不新增IP限制。
|
||||
- worker的中下庭规则、扶正、3:4裁切和生发结果由同一次worker响应提供,网关不重新计算坐标。
|
||||
|
||||
## 请求
|
||||
|
||||
缺少、无效或过期的访问令牌返回 HTTP 401、`code=1009`。登录、刷新与部署配置见[客户端鉴权](接口42-52-72网关鉴权.md)。
|
||||
|
||||
|
||||
|
||||
使用`multipart/form-data`;图片三选一:`image_file`、`image_url`、`image_base64`。必填`gender=male/female`和`hair_style=1..7`(支持逗号分隔多选)。
|
||||
|
||||
可选参数全部透传:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| normalize_image | false | true先扶正9→152并按当前规则裁切;网关测试页主动开启 |
|
||||
| generate_grow_image | true | false跳过ComfyUI,快速检查曲线和坐标 |
|
||||
| use_mask | true | 是否使用生发遮罩 |
|
||||
| prompt | 填充遮罩区域的头发,人物增加一点磨皮 | 生发提示词 |
|
||||
|
||||
当前标准裁切高约`(3÷0.9)L`、宽`2.5L`,L为9→152像素距离;标准窗口不足时长图尝试保留人脸裁成3:4,其余仅扶正。具体状态以`framing`为准。
|
||||
|
||||
## 响应及空图片
|
||||
|
||||
worker始终返回`*_base64`。网关沿用原有公共改写,不添加52号专用的空字段改名规则:
|
||||
|
||||
| worker字段 | 网关字段 |
|
||||
|---|---|
|
||||
| image_middle_base64(非空PNG) | image_middle_url |
|
||||
| input_image_base64、original_image_base64(非空JPEG) | input_image_url、original_image_url |
|
||||
| grown_image_base64(非空JPEG) | grown_image_url |
|
||||
| image_high_base64、image_low_base64(null) | 原字段保留,值为null |
|
||||
| grown_image_base64(null) | 原字段保留,值为null;没有grown_image_url |
|
||||
|
||||
`height_rule`、每个样式的`height_placement`、`guide_landmarks`、`face_measure`、`framing`、`width/height`和中心点原样返回。开启预处理时`coordinate_space=processed_image`,middle必须叠加到`input_image_url`,原始图`original_image_url`仅供对照。
|
||||
|
||||
公开测试页:`/static/test_interface52.html`,按原图、middle、矩形裁切发际线叠加、生发图排列。矩形左/右边过162/389,底边过9,高为9→152的70%。worker的30案例调试接口和对比报告不在本次网关同步范围。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
1. 网关拉取`main`,保留原有`gateway/config.json`。
|
||||
2. 确保池中所有可能被选中的主/备worker都已提供`/api/v1/hairline/generate-52`;仅更新网关无法让旧worker获得52号功能。网关出口IP须在对应worker白名单内,当前GPU worker已允许`49.232.51.171`。无需在网关配置IP白名单。
|
||||
3. 重启网关服务(本仓库示例名`hair-gateway`)。若线上使用`nginx/hair-gateway.conf`,同步其中的读取超时并检查、重载Nginx。单张高分辨率生发已观察到超过100秒,本次示例从90秒改为660秒,覆盖默认600秒上游单次请求超时;多样式或重试时按总耗时另行调整。
|
||||
4. 先以`generate_grow_image=false`检查请求转发和URL,再在实际需要时验证生发。`/monitor`、worker主备配置和旧接口无需调整。
|
||||
|
||||
```bash
|
||||
curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@/path/to/photo.jpg' \
|
||||
-F 'gender=male' -F 'hair_style=1' \
|
||||
-F 'normalize_image=true' -F 'generate_grow_image=false'
|
||||
```
|
||||
|
||||
成功时`code=0`,检查`input_image_url`和`hairline_images[].image_middle_url`可打开、尺寸一致;high/low仍为`*_base64: null`。访问测试页时无需输入内部Token。
|
||||
|
||||
测试页先登录再提交图片,访问令牌过期时使用当前刷新令牌自动换取新的访问令牌。
|
||||
|
||||
## 验证
|
||||
|
||||
`tests/test_gateway_interface52.py`覆盖5/52路由分离、完整表单透传、由网关注入内部Token、真实公共转发函数的图片落盘与URL可访问、空字段保持原样和队列槽释放。现有pool、serialization、monitor、reqlog测试用于防止回归。
|
||||
|
||||
静态Token测试覆盖默认值、自定义覆盖、缺失/错误/空Token、内部Token不能代替客户端Token、未通过时不派发、无效配置启动失败以及客户端Token不透传至worker。
|
||||
|
||||
加入客户端Token后的验证:57项网关测试通过;临时网关实测缺失/错误Token返回401,测试页携带默认Token成功调用真实worker并显示7型图片,旧5号页面正常。测试关闭生发,未改动正在运行的GPU worker。
|
||||
|
||||
2026-11-04验证结果:上述网关测试共42项通过。另起本机临时网关连接当前真实GPU worker,浏览器验证旧5号仍返回三档,52号女性7型均返回可访问的图片URL,长图输出1062×1416,矩形叠加与空字段正常,页面请求不携带内部Token,无脚本错误。本轮关闭生发以验证网关链路,未重新运行ComfyUI生发;临时网关验证后关闭。
|
||||
+316
-9
@@ -3,7 +3,7 @@
|
||||
> 本文档依据《旷视具体需求.docx》整理,描述各接口的**输入**与**输出**。
|
||||
> 文中标记为 **【待确认】** 的内容为需求文档中尚未明确、需与对方对齐的点。
|
||||
|
||||
> **当前为第一版 Mock 服务**:所有接口已上线,但**不做真实算法计算**,统一返回下文「响应示例」中的默认值;所有图片字段均返回示例图 `https://hair.xiangsilian.com/static/sample.jpg`。真实逻辑后续接入。
|
||||
> 下文示例用于说明响应结构。接口52由网关转发至GPU worker执行真实计算,具体接入与部署见[接口52网关接入](接口52网关接入.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -15,11 +15,19 @@
|
||||
| 接口 | 方法 | 路径 |
|
||||
|------|------|------|
|
||||
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
|
||||
| 11 四庭七眼测量(鉴权) | POST | `/api/v1/face/measure-11` |
|
||||
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
|
||||
| 61 四庭七眼测量 v2(鉴权) | POST | `/api/v1/face/measure-61` |
|
||||
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
|
||||
| 22 生发密度与发际线高度 | POST | `/api/v1/hair/grow-22` |
|
||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||
| 42 用户特征与两项判定 | POST | `/api/v1/face/features-42` |
|
||||
| 72 B 端五个关键点 | POST | `/api/v1/face/b-end-points` |
|
||||
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
|
||||
| 52 发际线新规则 | POST | `/api/v1/hairline/generate-52` |
|
||||
|
||||
接口 11 和 61 分别沿用接口 1 和 6 的图片参数及响应结构,需在请求头添加 `Authorization: Bearer <access_token>`。网关验证令牌后转发给 GPU worker。参见[登录与刷新令牌](接口42-52-72网关鉴权.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +94,7 @@
|
||||
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
||||
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
||||
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
||||
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
|
||||
| 1009 | 未授权 | HTTP 401。网关受保护接口的 Bearer 令牌缺失、无效或过期。旧版 worker 的内部 `X-Internal-Token` 校验失败也使用此错误码。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -109,6 +117,7 @@
|
||||
| four_courts | object | 四庭数据,见下表 |
|
||||
| seven_eyes | object | 七眼数据,见下表 |
|
||||
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
||||
| vertical_lines | object | 标注图竖线坐标,见下表。与标注 PNG 上的竖线同一套几何 |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
@@ -138,7 +147,28 @@
|
||||
| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 |
|
||||
| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null |
|
||||
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。`eye1`/`eye7` 的厘米数由 `vertical_lines` 里对应端点的 x 相减得到。
|
||||
|
||||
`vertical_lines`(竖线,原图像素,原点左上,x 向右,y 向下):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| y_top | int | 竖线共用上端。接口 1 在头顶之上,伸出 `max(6, round(短边 × 0.03))` 像素,可能略小于 0 |
|
||||
| y_bottom | int | 竖线共用下端。接口 1 在下巴尖之下,伸出同样像素,可能略大于图高 |
|
||||
| lines | array | 从左到右实际画出的竖线。每项 `{ "name": string, "x": int }` |
|
||||
|
||||
`lines[].name` 从左到右:
|
||||
|
||||
| name | 竖线 |
|
||||
|------|------|
|
||||
| left_head_edge | 人头最左(耳朵外缘)。该侧耳朵不可见时不出现,此时 `eye1` 为 `null` |
|
||||
| left_cheek | 左脸颊 |
|
||||
| left_eye_outer | 左眼外角 |
|
||||
| left_eye_inner | 左眼内角 |
|
||||
| right_eye_inner | 右眼内角 |
|
||||
| right_eye_outer | 右眼外角 |
|
||||
| right_cheek | 右脸颊 |
|
||||
| right_head_edge | 人头最右(耳朵外缘)。该侧耳朵不可见时不出现,此时 `eye7` 为 `null` |
|
||||
|
||||
### 标注图片(UI)规范
|
||||
|
||||
@@ -182,6 +212,19 @@
|
||||
"brow_center": { "x": 540, "y": 740 },
|
||||
"nose_bottom": { "x": 540, "y": 1050 },
|
||||
"chin_tip": { "x": 540, "y": 1360 }
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 98, "y_bottom": 1410,
|
||||
"lines": [
|
||||
{ "name": "left_head_edge", "x": 120 },
|
||||
{ "name": "left_cheek", "x": 210 },
|
||||
{ "name": "left_eye_outer", "x": 310 },
|
||||
{ "name": "left_eye_inner", "x": 420 },
|
||||
{ "name": "right_eye_inner", "x": 560 },
|
||||
{ "name": "right_eye_outer", "x": 670 },
|
||||
{ "name": "right_cheek", "x": 780 },
|
||||
{ "name": "right_head_edge", "x": 890 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +256,7 @@
|
||||
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
|
||||
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
|
||||
| vertical_lines | object | 标注图竖线坐标。结构同接口 1,但 `y_top`/`y_bottom` 为发际线到下巴尖(不伸出),`lines` 只有中间 6 条,无 `left_head_edge` / `right_head_edge` |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
@@ -242,6 +286,17 @@
|
||||
"brow_center": { "x": 540, "y": 740 },
|
||||
"nose_bottom": { "x": 540, "y": 1050 },
|
||||
"chin_tip": { "x": 540, "y": 1360 }
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 430, "y_bottom": 1360,
|
||||
"lines": [
|
||||
{ "name": "left_cheek", "x": 210 },
|
||||
{ "name": "left_eye_outer", "x": 310 },
|
||||
{ "name": "left_eye_inner", "x": 420 },
|
||||
{ "name": "right_eye_inner", "x": 560 },
|
||||
{ "name": "right_eye_outer", "x": 670 },
|
||||
{ "name": "right_cheek", "x": 780 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,7 +323,7 @@
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。越界/非法返回 `1007` |
|
||||
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
|
||||
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比。仅对原生发(male / female 6/7)管线生效 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发」,会替换工作流节点 60 的文本。仅对原生发管线生效(female 1..5 换发型分支的提示词内部固定) |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本。所有分支生效(male / female 6/7 原生生发,female 1..5 换发型后的发际线带重绘;不进 change_hair)。注:非默认提示词会使 ComfyUI 的 CLIP 条件缓存失效,换词后首次请求明显变慢(实测 45s vs 命中缓存 8s),相同提示词的后续请求恢复正常 |
|
||||
| flux_model | string | 否 | Flux 模型文件名(如 `flux-2-klein-9b-Q5_K_M.gguf`)。缺省用工作流默认(9B-Q4_K_M);`.gguf` 自动切 UnetLoaderGGUF 并同步配套 qwen CLIP |
|
||||
| redraw_max_side | int | 否 | female 1..5 重绘管线送模型前的长边上限像素。缺省=服务端默认(`REDRAW_MAX_SIDE`,当前 1024);`0`=不缩图(原图直送,更慢) |
|
||||
|
||||
@@ -310,6 +365,21 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口 22 生发密度与发际线高度
|
||||
|
||||
`POST /api/v1/hair/grow-22` 需要现有对接账号签发的 `Authorization: Bearer <access_token>`。 [账号登录与刷新](接口42-52-72网关鉴权.md)
|
||||
|
||||
表单图片字段三选一,`gender` 为 `female` 或 `male`,`hair_style` 用逗号分隔发型序号(两种性别均为 `1–7`)。
|
||||
|
||||
| 字段 | 取值 |
|
||||
|---|---|
|
||||
| `hair_density` | 必填,至少一档。稀疏 `sparse`、正常 `normal`、浓密 `dense`。多选时重复提交同名字段。 |
|
||||
| `hairline_height` | 默认 `0`,范围 `[-1, 1]`。负值提高发际线,正值降低发际线。 |
|
||||
|
||||
每个「发型 × 密度」组合返回一项,包含 `order`、`hairline_type`、`hair_density`、发际线透明 PNG `image_url` 和生发图 `grown_image_url`。单项生发失败时保留 `grown_image_base64: null`。
|
||||
|
||||
[网关测试页](/static/test_interface22.html)登录后可直接生图。首次生成使用默认高度 `0`,出图后通过同源接口 52 加载发际线预览,再调整形状、高度和密度。
|
||||
|
||||
## 接口 3:B 端生发接口
|
||||
|
||||
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
|
||||
@@ -365,7 +435,7 @@
|
||||
|
||||
### 输出(data)
|
||||
|
||||
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用),**仅含以下 6 个英文字段**:
|
||||
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用),**仅含以下 7 个英文字段**:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
@@ -375,6 +445,7 @@
|
||||
| dynamic_static_type | 动静类型(静态型/动态型)|
|
||||
| gender | 性别(男/女)|
|
||||
| gene_style | 基因风格(如"自然型")|
|
||||
| contour_type | 轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)|
|
||||
|
||||
> 无人脸返回 `1001`(据 doubao「图片是否有人脸」判定)。⚠️ 本接口是唯一调**外网云模型**的接口,
|
||||
> worker 需可访问 `ark.cn-beijing.volces.com`;API Key 走 worker 配置/环境变量。
|
||||
@@ -387,12 +458,97 @@
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"features": "{\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\"}"
|
||||
"features": "{\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\",\"contour_type\":\"轮廓适中\"}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `features` 为字符串形式的 JSON,固定上述 6 个字段。
|
||||
> `features` 为字符串形式的 JSON,固定上述 7 个字段。
|
||||
|
||||
---
|
||||
|
||||
## 接口 72:B 端五个关键点
|
||||
|
||||
`POST /api/v1/face/b-end-points` 接受三选一图片参数 `image_file`、`image_url`、`image_base64`。复用接口 52 的扶正裁切与虹膜/眼宽厘米估算,返回坐标以处理后底图为准。使用[客户端 Bearer 鉴权](接口42-52-72网关鉴权.md)。
|
||||
|
||||
| 点号 | 位置 | 标注 |
|
||||
|---|---|---|
|
||||
| 1 | MediaPipe 4 | 白点 |
|
||||
| 2 | HL center | 白点 |
|
||||
| 3 | HL center 向画面右侧水平偏移 2 cm | 白点 |
|
||||
| 4 | HL left | 白点 |
|
||||
| 5 | HL left left | 白点 |
|
||||
| 6 | MediaPipe 446 | 白点,兼作射线锚点 |
|
||||
|
||||
六个白点的直径相同。标注层包含三条从起点向目标方向延伸并逐渐变淡的白色虚线(2→1、4→6、5→6),以及 2→3 白色虚线段。2→1 射线比默认可见长度短 15%,5→6 射线的绘制长度比默认长度多 30%,可见范围以图像边界为限。四个距离文字分别放在 2–1 中点左侧、2–3 中点上方、4–6 中点左侧和 5–6 中点右侧,距离保留一位小数,2–3 固定显示 `2.0 CM`。
|
||||
|
||||
| `data` 字段 | 内容 |
|
||||
|---|---|
|
||||
| `input_image_url` | 扶正裁切后的底图 |
|
||||
| `annotated_image_url` | 与底图同尺寸的透明 PNG 标注层 |
|
||||
| `points` | 按字符串点号 `"1"` 到 `"6"` 索引的像素坐标 `{x, y}` |
|
||||
| `measurements_cm` | 四个距离,以 `2-1`、`2-3`、`4-6`、`5-6` 索引 |
|
||||
| `image_size` | 处理后尺寸 `{width, height}` |
|
||||
| `coordinate_space` | `processed_image` |
|
||||
| `px_per_cm` | 估算的每厘米像素数 |
|
||||
|
||||
Worker 直连时图片字段使用 `_base64` 后缀。检测不到人脸返回 `1001`,非正面或标注所需点位无法取得时返回 `1003`,图片无法解码返回 `1008`。
|
||||
|
||||
---
|
||||
|
||||
## 42号接口-加两个特征点
|
||||
|
||||
**请求** `POST /api/v1/face/features-42`,使用 `multipart/form-data`。
|
||||
|
||||
| 请求字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `image_file` | 文件 | 上传 JPG 或 PNG |
|
||||
| `image_url` | 字符串 | 网关可访问的图片 URL |
|
||||
| `image_base64` | 字符串 | 图片 Base64,支持带 `data:image/…;base64,` 前缀 |
|
||||
|
||||
以上三个字段必须且只能传一个。图片按 EXIF 方向解码后,先检查原图的人脸完整性和俯拍角度。俯拍使用头部姿态估算的正向 pitch,超过服务端环境变量 `FRONTAL_PITCH_THR`(默认 30°)时拦截。通过质检后复用接口 52 的扶正裁切规则,再用处理后的图片做面部特征分析。
|
||||
|
||||
客户端通过 Bearer 令牌鉴权,详见[客户端鉴权](接口42-52-72网关鉴权.md)。
|
||||
|
||||
### 质检失败与异常响应
|
||||
|
||||
以下业务响应的 HTTP 状态均为 `200`。
|
||||
|
||||
| `code` | 情况 | `message` |
|
||||
|---|---|---|
|
||||
| `1001` | 原图未检测到人脸 | 无法识别人像,请上传包含完整人脸的照片 |
|
||||
| `1003` | 人脸关键点触及或超出图片边界 | 人脸不完整,请保留完整的脸部轮廓 |
|
||||
| `1003` | 估算的俯拍角度超过阈值 | 俯拍角度过大,请将相机调整至与脸部平视 |
|
||||
| `1003` | 扶正裁切失败,无法保留所需人脸关键点 | 人脸过于贴近边缘,无法扶正裁切 |
|
||||
| `1008` | URL 图片下载失败 | 图片下载失败(URL 不可达) |
|
||||
| `1008` | Base64 解码失败 | 图片格式不支持(base64 解码失败) |
|
||||
| `1008` | 图片无法读取 | 图片格式不支持(仅 JPG / PNG) |
|
||||
| `1007` | 图片字段未按三选一传入 | 图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个 |
|
||||
| `1007` | 图片预处理内部异常 | 图片预处理失败 |
|
||||
| `1001` | 质检通过,但视觉模型未识别到人脸 | 无法识别人像 |
|
||||
| `1007` | 视觉模型调用异常 | 分析服务异常:… |
|
||||
|
||||
响应包含 `code`(整数业务码)、`message`(字符串提示)、`request_id`(字符串请求标识)和 `data`(失败时为 `null`)。三个 `1003` 情况可通过 `message` 区分,响应中没有独立的质检原因或角度字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1003,
|
||||
"message": "俯拍角度过大,请将相机调整至与脸部平视",
|
||||
"request_id": "gw-xxxxxxxx",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
|
||||
`data.features` 仍为 JSON 字符串,保留 main 网关接口 4 的七项特征(含 `contour_type`),在其中新增:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `zygoma_expansion` | boolean 或 null | 颧弓外扩 |
|
||||
| `temple_hollowing` | boolean 或 null | 太阳穴凹陷 |
|
||||
|
||||
Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只有 Ft ÷ Zy 严格小于 0.8 时两项同时为 `true`,否则同时为 `false`。所需点位缺失或无法测量时两项均为 `null`。网关测量复用接口 99 的 MediaPipe 与 BiSeNet 依赖,裁切还需要 Pillow。
|
||||
|
||||
---
|
||||
|
||||
@@ -449,6 +605,7 @@
|
||||
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
|
||||
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
||||
| vertical_lines | object | 竖线坐标,结构同接口1 |
|
||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
|
||||
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
@@ -518,6 +675,156 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口 52:发际线 PNG 生成接口
|
||||
|
||||
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
|
||||
|
||||
**请求**:`POST /api/v1/hairline/generate-52`(网关透传至同路径GPU worker)
|
||||
|
||||
**客户端鉴权**:使用 `Authorization: Bearer <access_token>`。登录、刷新和错误码见[客户端鉴权](接口42-52-72网关鉴权.md)。
|
||||
|
||||
**职责**:GPU worker返回base64并完成算法处理,网关负责透传参数、附加内部Token、保存非空图片及返回公网URL。网关不增加IP限制;网关出口IP须已在worker白名单内。部署与完整字段约定见[接口52网关接入](接口52网关接入.md)。
|
||||
|
||||
### 图片扶正与标准构图
|
||||
|
||||
显式传`normalize_image=true`时,先扶正9→152,再按**高(3÷0.9)×L≈3.3333×L、宽2.5×L(3:4)**裁切,L为9→152的直线像素距离。相对原3L规则,裁切框宽高均扩大约11.11%,同样显示尺寸下人物缩小约10%。高度取最接近的4的倍数,宽度取高度的3/4。眉心定位沿用已扶正参考图:9号点相对位置约(49.0061%,34.4136%)。参考图仍为786×1048,两点X均为385.18784。
|
||||
|
||||
目标窗口完全在原图有效区域内才进行标准裁切。标准窗口不足时,偏长图片(宽/高<3/4)采用原图有效范围内最大的3:4窗口,尽量贴近眉心目标位置并保留完整人脸,`status=aspect_crop`;人脸放不进3:4窗口时回退仅扶正并返回原因。其余图片空间不足时仍仅扶正、去除旋转空角。不放大小图。**所有叠图与坐标使用响应中的处理后底图 `input_image_*`**,调用方应使用该底图叠加;`normalize_image=false` 可关闭此预处理。
|
||||
|
||||
`framing`包含处理前后尺寸、旋转角、是否标准裁切、跳过原因、`source_to_processed`/`processed_to_source`仿射矩阵。`status`区分`reference_crop`(标准窗口)、`aspect_crop`(长图3:4裁切)和`rotation_only`(仅扶正);`standard_crop=false`时仍可能有`aspect_crop=true`。`aspect_crop_skip_reason`说明长图无法保留人脸时的回退原因。`original_image_*`保留EXIF方向修正后的上传图,原图坐标可通过逆矩阵还原。
|
||||
|
||||
### 输入
|
||||
|
||||
图片参数见「通用约定 → 图片传参字段」。专属参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 7)。缺失/非法返回 `1004` |
|
||||
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。缺失/越界/非法返回 `1007` |
|
||||
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
|
||||
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本 |
|
||||
| normalize_image | bool | 否 | 新增可选开关,默认false:保留原图尺寸与坐标。传true开启扶正和裁切;网关测试页显式传true |
|
||||
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回设计叠图、中心点与高度规则数据,可大幅降低耗时 |
|
||||
|
||||
> 生发划线与遮罩直接取新位置的 middle 曲线;旧规则的头发边界与模板用于保留形状和倾斜,不决定新中心高度。计算使用未取整的像素高度,与四庭测量采用同一 Y 方向;测量主流程失败时用发际线管线的同编号关键点计算,`measurement_source` 标明来源。目标越出图片或无法对齐时返回 1007,不静默回退旧规则。
|
||||
|
||||
### 高度规则与对比
|
||||
|
||||
- **参数与字段**:原有8个请求参数、响应外层结构和所有旧返回字段均保留。`use_mask=true`、默认prompt、`generate_grow_image=true`保持原值。新功能只新增可选参数和返回字段;旧请求未传`normalize_image`时保留原图坐标,high/low按已约定保留null,worker始终返回`*_base64`;网关沿用原有规则,有图片时改为`*_url`,空图片保留`*_base64: null`。
|
||||
- `height_rule`:`middle_height_px`、`lower_height_px`、`target_upper_height_px`、`target_upper_height_cm`(可空)、`target_center`、`brow_center`、`nose_bottom`、`chin_tip`、`measurement_source`。
|
||||
- 每项 `height_placement`:`old_center`、`new_center`、`shift_x_px`、`shift_y_px`(正=下移)、`target_error_px`。浮点坐标保留精度,旧中心点字段继续返回取整像素。
|
||||
- `face_measure` 为处理后底图的实际测量值,未被设计目标覆盖。
|
||||
- `guide_landmarks`:9、150、152、162、251、389 号 MediaPipe 点的图片像素坐标,供对比页绘制参考框。参考框左/右边分别经过162/389,底边经过9,高度为9→152距离的70%;两侧均固定平行9→152。
|
||||
- 网关测试页:`/static/test_interface52.html`。worker本机另有旧/新规则对比页,本次不向网关开放调试接口。
|
||||
- 规则页每个发型依次展示未裁切原图、middle、裁切发际线叠加、生发图。“裁切发际线叠加”由页面按`guide_landmarks`生成:在相同处理后底图上叠加矩形内的middle PNG,矩形外保持透明;左/右边过162/389,底边过9,高度0.7×9→152,左右边平行9→152。
|
||||
|
||||
### 网关输出(data)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
|
||||
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,当前底图像素:`{ "x": number, "y": number }` |
|
||||
| high_hairline_center_point | object \| null | 已停用,固定 `null` |
|
||||
| low_hairline_center_point | object \| null | 已停用,固定 `null` |
|
||||
| input_image_url | string | 当前底图JPEG的URL,发际线叠图、参考点和测量值均使用该图的坐标系 |
|
||||
| original_image_url | string | EXIF方向修正后的上传原图JPEG的URL |
|
||||
| width / height | int | 当前底图的实际像素宽 / 高 |
|
||||
| coordinate_space | string | 开启预处理时为 `processed_image`,关闭时为 `original_image` |
|
||||
| framing | object \| null | 裁切状态、旋转角、前后尺寸、轴线误差及双向仿射矩阵;关闭预处理时为 `null` |
|
||||
| height_rule | object | 中庭/下庭、目标上庭高度、目标中心及测量来源,详见“高度规则与对比” |
|
||||
| guide_landmarks | object | 9、150、152、162、251、389 号点的当前底图像素坐标;参考框实际使用9、152、162、389 |
|
||||
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
|
||||
|
||||
`hairline_images` 元素:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
|
||||
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加 `input_image_url` 显示) |
|
||||
| image_high_base64 | null | 已停用,保留字段为 `null` |
|
||||
| image_low_base64 | null | 已停用,保留字段为 `null` |
|
||||
| grown_image_url | string | 生发成功时的完整人像JPEG URL |
|
||||
| grown_image_base64 | null | 生发失败或`generate_grow_image=false`时返回;此时没有`grown_image_url` |
|
||||
| order | int | 发型序号(= 传入的 hair_style 值) |
|
||||
| height_placement | object | 旧/新中心、XY移动距离、目标误差,均为未取整的像素值 |
|
||||
|
||||
> 上表为网关响应字段。worker直连时所有图片字段均为`*_base64`;网关仅对有图片内容的字段生成`*_url`,high/low等空字段保留`*_base64: null`,沿用既有行为。
|
||||
>
|
||||
> middle透明PNG叠加到`input_image_url`,生发图直接显示。网关测试页为`/static/test_interface52.html`,页面不包含内部Token。
|
||||
|
||||
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| face_total_height_cm | number | 全脸总高度(cm)= 四庭之和 |
|
||||
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
|
||||
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
|
||||
| vertical_lines | object | 竖线坐标,结构同接口1 |
|
||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
|
||||
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),当前底图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),当前底图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
|
||||
|
||||
### 网关响应结构示例(normalize_image=false)
|
||||
|
||||
此例未开启裁切,坐标基于原图;`face_measure=null`时仍可按发际线关键点计算目标。有图片内容的字段已落盘为公网URL;空图片字段仍返回null,保留base64字段名。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"hairline_images": [
|
||||
{
|
||||
"hairline_type": "ellipse",
|
||||
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/middle.png",
|
||||
"image_high_base64": null,
|
||||
"image_low_base64": null,
|
||||
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown.jpg",
|
||||
"order": 1,
|
||||
"height_placement": {
|
||||
"old_center": {"x": 540.0, "y": 450.0},
|
||||
"new_center": {"x": 540.0, "y": 430.0},
|
||||
"shift_x_px": 0.0, "shift_y_px": -20.0, "target_error_px": 0.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"best_hairline_center_point": {"x": 540, "y": 430},
|
||||
"high_hairline_center_point": null,
|
||||
"low_hairline_center_point": null,
|
||||
"input_image_url": "https://hair.xiangsilian.com/static/annotations/input.jpg",
|
||||
"original_image_url": "https://hair.xiangsilian.com/static/annotations/original.jpg",
|
||||
"width": 1080, "height": 1440,
|
||||
"coordinate_space": "original_image",
|
||||
"framing": null,
|
||||
"height_rule": {
|
||||
"name": "court_average", "formula": "上庭 = (中庭 + 下庭) / 2",
|
||||
"measurement_source": "hairline_landmarks",
|
||||
"brow_center": {"x": 540.0, "y": 740.0},
|
||||
"nose_bottom": {"x": 540.0, "y": 1050.0},
|
||||
"chin_tip": {"x": 540.0, "y": 1360.0},
|
||||
"middle_height_px": 310.0, "lower_height_px": 310.0,
|
||||
"target_upper_height_px": 310.0, "target_upper_height_cm": null,
|
||||
"target_center": {"x": 540.0, "y": 430.0}
|
||||
},
|
||||
"guide_landmarks": {
|
||||
"9": {"x": 540.0, "y": 740.0}, "150": {"x": 470.0, "y": 1340.0},
|
||||
"152": {"x": 540.0, "y": 1360.0}, "162": {"x": 300.0, "y": 680.0},
|
||||
"251": {"x": 770.0, "y": 630.0}, "389": {"x": 780.0, "y": 680.0}
|
||||
},
|
||||
"face_measure": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
开启`normalize_image=true`后,`coordinate_space=processed_image`,`framing`包含前述裁切状态和坐标变换,所有发际线图片须叠加到`input_image_url`。原始图片仅用于展示和坐标回溯。
|
||||
|
||||
---
|
||||
|
||||
## 接口 9:头发遮罩生成接口
|
||||
|
||||
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
|
||||
@@ -634,7 +941,7 @@
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| comfyui_prompt | string | 否 | Flux-2 重绘提示词,缺省「填充遮罩区域的头发」 |
|
||||
| comfyui_prompt | string | 否 | Flux-2 重绘提示词,缺省「填充遮罩区域的头发,人物增加一点磨皮」 |
|
||||
| beauty_alpha | float | 否 | redraw_band 版 band 外的全脸美颜融入强度(0=band 外纯用 final,1≈整帧版),默认 0.6 |
|
||||
| band_lo_mult | float | 否 | 重绘带外推倍率下限(相对 hairline_push_cm),默认 0.5 |
|
||||
| band_hi_mult | float | 否 | 重绘带外推倍率上限(相对 hairline_push_cm),默认 1.5 |
|
||||
@@ -700,7 +1007,7 @@
|
||||
| 2 C 端生发 | `/api/v1/hair/grow` | 用户照片 + gender + hair_style(多选) | 每发型预览 PNG + 生发图(female 1..5=换发型+重绘,其余=原生生发) |
|
||||
| 7 C 端生发 v2 | `/api/v1/hair/grow-v2` | 同接口2 | 同接口2(add_hair2 工作流,已弃用隐藏) |
|
||||
| 3 B 端生发 | `/api/v1/hair/grow-b` | 划线图片 | 检测到的发际线 + 生发后图片 |
|
||||
| 4 用户特征 | `/api/v1/face/features` | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格),网关本机实现 |
|
||||
| 4 用户特征 | `/api/v1/face/features` | 用户照片 | 7 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格/轮廓类型),网关本机实现 |
|
||||
| 5 发际线 PNG | `/api/v1/hairline/generate` | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
|
||||
| 9 头发遮罩 | `/api/v1/head/mask` | 用户照片 | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
|
||||
| 10 外缘膨胀带 | `/api/v1/head/band` | 用户照片 | 头部外缘带状遮罩 + 分步可视化 |
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# 最近一周接口调用统计
|
||||
|
||||
- **统计窗口**:2026-09-15 00:00:00 ~ 2026-09-21 10:13:00(北京时间,共 7 个自然日;21 日为截至上午的部分数据)
|
||||
- **统计对象**:网关 `hair.xiangsilian.com` 对外业务接口(`POST /api/v1/...`)
|
||||
- **不含**:健康检查、静态页、扫描探测(如 `GET /api/.env`)
|
||||
- **生成时间**:2026-09-21 10:13
|
||||
|
||||
## 1. 口径
|
||||
|
||||
| 指标 | 定义 |
|
||||
|------|------|
|
||||
| 调用量 | 网关访问日志中,下列业务路径的完成次数 |
|
||||
| 业务成功 | HTTP 200 **且** 响应 JSON `code=0`(或无 `code`) |
|
||||
| 业务失败 | 非上述情况(含业务错误码 1001/1003/1008,以及 HTTP 405 等) |
|
||||
| HTTP 完成 | uvicorn 记录的 HTTP 200,**不区分**业务码 |
|
||||
|
||||
`request_log.jsonl` 已轮转,**9/15–9/18 全日、9/19 白天**只剩 systemd journal,这些天的调用量按 journal 统计;业务成功率仅在仍保留 jsonl 的时段可精确计算(见第 4 节)。
|
||||
|
||||
业务接口清单:
|
||||
|
||||
| 接口 | 路径 |
|
||||
|------|------|
|
||||
| 1 四庭七眼测量 | `/api/v1/face/measure` |
|
||||
| 2 C 端生发 | `/api/v1/hair/grow` |
|
||||
| 3 B 端生发 | `/api/v1/hair/grow-b` |
|
||||
| 4 用户特征分析 | `/api/v1/face/features` |
|
||||
| 5 发际线 PNG | `/api/v1/hairline/generate` |
|
||||
| 6 四庭七眼 v2 | `/api/v1/face/measure-v2` |
|
||||
| 7 C 端生发 v2 | `/api/v1/hair/grow-v2`(已弃用,基本无流量) |
|
||||
|
||||
## 2. 总览
|
||||
|
||||
| 项 | 数值 |
|
||||
|----|------|
|
||||
| 7 日合计调用量 | **20,104** |
|
||||
| 完整 6 日(9/15–9/20)合计 | **19,824** |
|
||||
| 完整 6 日日均 | **3,304** |
|
||||
| 峰值日 | **9/19(周六)8,234** |
|
||||
| 最低完整日 | **9/15(周二)1,258** |
|
||||
| 9/21 截至 10:13 | **280** |
|
||||
|
||||
主接口构成(7 日合计):
|
||||
|
||||
| 接口 | 次数 | 占比 |
|
||||
|------|------|------|
|
||||
| 接口2 C 端生发 | 12,470 | 62.0% |
|
||||
| 接口4 特征分析 | 3,025 | 15.0% |
|
||||
| 接口1 四庭七眼 | 2,800 | 13.9% |
|
||||
| 接口5 发际线 | 1,243 | 6.2% |
|
||||
| 接口6 四庭七眼 v2 | 562 | 2.8% |
|
||||
| 接口3 / 接口7 等 | 4 | <0.1% |
|
||||
|
||||
## 3. 按日调用量
|
||||
|
||||
| 日期 | 星期 | 调用量 | 接口2 生发 | 接口4 特征 | 接口1 测量 | 接口5 发际线 | 接口6 测量v2 | 数据来源 |
|
||||
|------|------|--------|------------|------------|------------|--------------|--------------|----------|
|
||||
| 9/15 | 周二 | 1,258 | 643 | 171 | 174 | 170 | 100 | journal |
|
||||
| 9/16 | 周三 | 1,688 | 921 | 236 | 285 | 169 | 75 | journal |
|
||||
| 9/17 | 周四 | 1,789 | 1,069 | 254 | 232 | 183 | 51 | journal |
|
||||
| 9/18 | 周五 | 2,429 | 1,350 | 432 | 381 | 189 | 77 | journal |
|
||||
| 9/19 | 周六 | **8,234** | 4,959 | 1,531 | 1,302 | 287 | 153 | journal |
|
||||
| 9/20 | 周日 | 4,426 | 3,350 | 347 | 380 | 244 | 105 | jsonl / journal 互核 |
|
||||
| 9/21 | 周一 | 280 | 178 | 54 | 46 | 0 | 1 | journal(截至 10:13) |
|
||||
| **合计** | | **20,104** | **12,470** | **3,025** | **2,800** | **1,243** | **562** | |
|
||||
|
||||
9/16 另有接口3 共 2 次;9/19 有接口3、接口7 各 1 次(均为 405,见失败说明)。
|
||||
|
||||
工作日(9/15–9/18)在 1.3k~2.4k / 天,周六冲到 8.2k,周日回落到 4.4k,仍高于工作日。增量主要来自接口2。
|
||||
|
||||
## 4. 成功率
|
||||
|
||||
分两层看:HTTP 几乎全部 200;**业务是否成功要以 `code=0` 为准**。
|
||||
|
||||
### 4.1 HTTP 完成率(全窗口,journal)
|
||||
|
||||
20,104 次中 HTTP 200 为 **20,098**,**99.97%**。非 200 仅 **6 次 HTTP 405**(均为外部对业务路径发 GET,不是客户 POST)。
|
||||
|
||||
| 日期 | 调用量 | HTTP 200 | HTTP 成功率 |
|
||||
|------|--------|----------|-------------|
|
||||
| 9/15 | 1,258 | 1,258 | 100% |
|
||||
| 9/16 | 1,688 | 1,688 | 100% |
|
||||
| 9/17 | 1,789 | 1,789 | 100% |
|
||||
| 9/18 | 2,429 | 2,429 | 100% |
|
||||
| 9/19 | 8,234 | 8,228 | 99.93%(6 次 405) |
|
||||
| 9/20 | 4,426 | 4,426 | 100% |
|
||||
| 9/21 截至 10:13 | 280 | 280 | 100% |
|
||||
|
||||
### 4.2 业务成功率(`code=0`,jsonl 可核验时段)
|
||||
|
||||
jsonl 当前只覆盖 **9/19 18:53 之后、9/20 全日、9/21 截至 10:13**,共 **9,720** 次:
|
||||
|
||||
| 时段 | 调用量 | 业务成功 | 业务失败 | 业务成功率 |
|
||||
|------|--------|----------|----------|------------|
|
||||
| 9/19 18:53–24:00 | 5,011 | 4,991 | 20 | 99.60% |
|
||||
| 9/20 全日 | 4,432 | 4,379 | 53 | 98.80% |
|
||||
| 9/21 00:00–10:13 | 277 | 267 | 10 | 96.39% |
|
||||
| **可核验合计** | **9,720** | **9,637** | **83** | **99.15%** |
|
||||
|
||||
按接口(同一 jsonl 窗口):
|
||||
|
||||
| 接口 | 调用量 | 业务成功 | 失败 | 成功率 |
|
||||
|------|--------|----------|------|--------|
|
||||
| 接口2 C 端生发 | 7,373 | 7,369 | 4 | 99.95% |
|
||||
| 接口5 发际线 | 354 | 353 | 1 | 99.72% |
|
||||
| 接口4 特征分析 | 670 | 661 | 9 | 98.66% |
|
||||
| 接口1 四庭七眼 | 1,157 | 1,120 | 37 | 96.80% |
|
||||
| 接口6 四庭七眼 v2 | 164 | 134 | 30 | 81.71% |
|
||||
|
||||
接口6 成功率偏低,失败几乎全是 `1003 角度问题,请上传正面照`(照片侧脸/角度,不是服务宕机)。接口1 同类原因也较多。
|
||||
|
||||
### 4.3 可核验窗口内的失败原因(83 次)
|
||||
|
||||
| 次数 | HTTP | 业务码 | 含义 |
|
||||
|------|------|--------|------|
|
||||
| 60 | 200 | 1003 | 角度问题,请上传正面照 |
|
||||
| 15 | 200 | 1001 | 无法识别人像 |
|
||||
| 6 | 405 | — | 对业务路径发 GET(扫描,非客户) |
|
||||
| 2 | 200 | 1008 | 图片下载失败或格式不支持 |
|
||||
|
||||
没有出现批量 `1007 后端服务暂不可用` / 排队超时。失败以**入图不合规**为主,不是网关或 GPU worker 整段掉线。
|
||||
|
||||
## 5. 接口2:业务成功 ≠ 一定出了生发图
|
||||
|
||||
接口2 在 ComfyUI / change_hair 失败时仍返回 `code=0`,只是 `grown_image_*` 为 `null`(测试页会显示「ComfyUI 未就绪或生成失败」)。因此第 4 节的成功率**不能**当成「生发图都生成了」。
|
||||
|
||||
jsonl 可核验的接口2 出图情况:
|
||||
|
||||
| 日期 | 接口2 次数 | 业务成功 | 含生发图 | 生发图为空 |
|
||||
|------|------------|----------|----------|------------|
|
||||
| 9/19 18:53 后 | 3,844 | 3,842 | 854 | 2,988 |
|
||||
| 9/20 | 3,352 | 3,350 | 701 | 2,649 |
|
||||
| 9/21 截至 10:13 | 178 | 178 | **178** | **0** |
|
||||
|
||||
9/19–9/20 大量请求只返回了发际线叠图、没有生发效果图(与当时 GPU 上 change_hair「用户图像加载失败」一致)。**9/21 上午接口2 已全部带生发图**。
|
||||
|
||||
## 6. 数据来源与局限
|
||||
|
||||
- 调用量:`journalctl -u hair-gateway` 中 uvicorn 访问日志(`POST /api/v1/...`)。
|
||||
- 业务码 / 失败原因 / 是否含 `grown_image`:`gateway/request_log.jsonl`(及 `.jsonl.1`)。轮转后目前最早一条为 **2026-09-19 18:53**。
|
||||
- 9/15–9/18 没有业务码明细,不能把 HTTP 100% 等同于业务 100%。
|
||||
- 接口4 在网关本机执行(豆包),不占 GPU worker;其余表内接口转发 worker。
|
||||
|
||||
---
|
||||
|
||||
*本文只做观测统计,不包含代码或配置变更。*
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
# 监控探活接口
|
||||
|
||||
外网监控机器用这个接口判断**当前对外服务能不能接业务**。不正常就报警。监控侧每 30 秒调用一次,网关不做主动推送。
|
||||
|
||||
对外地址:
|
||||
|
||||
```text
|
||||
GET https://hair.xiangsilian.com/monitor
|
||||
```
|
||||
|
||||
本机等价地址是 `GET http://127.0.0.1:8080/monitor`。nginx 对 `hair.xiangsilian.com` 的 `location /` 整段反代到网关 8080,所以这条路径不用单独改 nginx。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么不复用原来的 `/health`
|
||||
|
||||
网关上原来就有两个探活,都不能直接拿来报警:
|
||||
|
||||
| 路径 | 谁在用 | HTTP 状态 | 实际含义 |
|
||||
|------|--------|-----------|----------|
|
||||
| `GET /health` | 旧路径,和下面这条相同 | 永远 200 | 网关进程还活着 |
|
||||
| `GET /gateway-health` | 人看池子概况 | 永远 200 | 同上,body 里带 worker 计数 |
|
||||
| worker 的 `GET /health` | 网关自己每 8 秒打各台 GPU 机 | 200 或 503 | 那一台 worker 自己是否就绪 |
|
||||
|
||||
`/health` 和 `/gateway-health` 只要 uvicorn 进程在,就返回 `"status": "ok"`。五台 worker 全部下线时,HTTP 仍然是 200,只是 `workers_healthy` 变成 0。只看状态码的监控不会报警。
|
||||
|
||||
这次加的 `/monitor` 专供外部监控:
|
||||
|
||||
- 至少一台 worker 处于在线(`online`)→ **HTTP 200**
|
||||
- 一台在线的都没有 → **HTTP 503**
|
||||
- 报警演练打开时,即使还有在线 worker,也返回 **HTTP 503**(见第 9 节)
|
||||
|
||||
监控只需要判断状态码。200 正常,非 200(503、连接失败、超时)报警。
|
||||
|
||||
---
|
||||
|
||||
## 2. 调用约定
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| 方法 | `GET` |
|
||||
| 路径 | `/monitor` |
|
||||
| 鉴权 | 不需要。不校验 `X-Internal-Token` |
|
||||
| 请求体 | 无 |
|
||||
| 建议间隔 | 30 秒 |
|
||||
| 超时 | 建议 5 秒。接口本身只读内存,正常几十毫秒内返回 |
|
||||
| 请求日志 | 不记录。网关请求日志只记 `/api/`,这条不会进 `request_log.jsonl`,也不会出现在 `/admin/stats` |
|
||||
|
||||
30 秒一次不会给 worker 增加探测。`/monitor` 不访问 GPU 机,只读网关内存里已经维护好的池状态。真正打 worker `/health` 的是网关后台循环,间隔见 `gateway/config.json` 的 `health_check.interval_seconds`(当前 8 秒)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 怎样算「正常」
|
||||
|
||||
正常的定义是:**网关当前还能把业务请求派到至少一台 worker**。
|
||||
|
||||
池里每台 worker 有一个 `online` 标志。`workers_healthy` 就是 `online == true` 的台数。这个标志由后台健康检查维护,规则和派发用的是同一份状态:
|
||||
|
||||
1. 启动时立刻对配置里的每台 worker 打一次 `GET {worker}/health`(带 `X-Internal-Token`,超时 `health_check.timeout_seconds`,当前 3 秒)。返回 200 且 `healthy_threshold <= 1`(当前就是 1)则标记在线。
|
||||
2. 之后每 8 秒再探一轮。
|
||||
3. 连续失败达到 `unhealthy_threshold`(当前 2 次)才从在线改成下线。单次抖动不会下线。
|
||||
4. 业务转发失败(连不上、超时、HTTP ≥ 500、401)会立刻把那台 worker 标下线,并进入隔离期 `cooldown_seconds`(当前 60 秒)。隔离期内即使 `/health` 恢复 200 也不重新上线。
|
||||
5. 隔离期结束后,再有一次探测 200 才重新上线。
|
||||
|
||||
因此 `/monitor` 看到的「在线」,就是下一笔生发/测量请求实际可以派过去的机器,不是「进程还在」这么宽的概念。
|
||||
|
||||
### 会返回 200 的情况
|
||||
|
||||
- 主 worker 里还有任意一台在线。当前配置有 3 台主 worker。
|
||||
- 主 worker 全挂,但备用 worker 还有在线的。主备分组下,主组全下线之后流量会打到备用,服务仍然能接,所以不报警。body 里 `workers_primary_healthy` 会是 0,需要看主组是否掉光时看这个字段,不要用状态码。
|
||||
- 在线的 worker 全部正在忙。忙只说明队列里有请求,机器没有被摘掉。等某台 worker 空闲最多 `queue_wait_seconds`(当前 30 秒);全局并发槽排满时最多再等 `max_queue_wait_seconds`(当前 590 秒)。这不是宕机。
|
||||
|
||||
### 会返回 503 的情况
|
||||
|
||||
- 配置里的 worker 全部不在线。包括:探测连续失败被摘除、转发失败进入隔离期、启动后第一轮探测就没有一台返回 200。
|
||||
- 池还没初始化好(进程刚起来、`_workers` 仍为空)。这种情况窗口很短,发生在 uvicorn 完成启动之前;启动完成前端口还不会接受连接,监控更多会看到连接失败,而不是这条 503。
|
||||
|
||||
网关进程本身挂了、nginx 挂了、机器网络不通,监控连不上,同样应当按失败报警。那不是这个接口返回的 503,而是连接错误。
|
||||
|
||||
---
|
||||
|
||||
## 4. 响应
|
||||
|
||||
`Content-Type: application/json`。
|
||||
|
||||
### 200 正常
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 3,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 3
|
||||
}
|
||||
```
|
||||
|
||||
### 503 不正常
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 0,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 0,
|
||||
"message": "没有可用的 worker"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| status | string | `ok` 或 `unhealthy`。与 HTTP 状态码一致:`ok` 对应 200,`unhealthy` 对应 503 |
|
||||
| service | string | 固定 `gateway` |
|
||||
| workers_total | int | 配置里的 worker 总数 |
|
||||
| workers_healthy | int | 当前在线台数。未开演练时,大于 0 返回 200,等于 0 返回 503 |
|
||||
| workers_primary_total | int | 主 worker 台数。未配置 `primary_workers` 时,全部 worker 都视为主 worker |
|
||||
| workers_primary_healthy | int | 主 worker 里在线的台数。为 0 且 `workers_healthy > 0` 表示只剩备用在撑 |
|
||||
| message | string | 仅 HTTP 503 时出现。真故障文案是「没有可用的 worker」;演练文案以「模拟故障」开头 |
|
||||
| simulated | bool | 仅演练打开时出现,值为 `true`。用来区分演练 503 和真故障 |
|
||||
|
||||
不返回 worker 地址、密码、队列深度。地址和鉴权信息留在网关日志和 `gateway/config.json` 里。
|
||||
|
||||
---
|
||||
|
||||
## 5. 监控侧建议
|
||||
|
||||
按 **HTTP 状态码** 报警,不要只解析 body。
|
||||
|
||||
| 结果 | 处理 |
|
||||
|------|------|
|
||||
| 200 | 正常 |
|
||||
| 503 | 报警。body 的 `message` 可放进告警内容 |
|
||||
| 连接失败、超时,以及其他 5xx | 报警。网关或 nginx 不可达 |
|
||||
|
||||
建议连续 **2 次**失败再发告警(约 60 秒)。网关重启时端口会有几秒不可用(`hair-gateway.service` 的 `RestartSec=3`,启动时还要等第一轮 worker 探测完成)。只看单次失败会在重启时误报。
|
||||
|
||||
不需要带任何请求头。不要把这条 URL 配成 POST,也不要把它当成业务接口去看 `code` 字段——这里没有业务信封 `{code, message, request_id, data}`。
|
||||
|
||||
报警延迟,从「最后一台可用 worker 真的挂了」到监控看到 503,大致是两段相加:
|
||||
|
||||
- 网关摘除:后台每 8 秒探一次,要连续失败 2 次才下线,大约 8~16 秒。若是转发失败触发的被动摘除,则立刻下线。
|
||||
- 监控拉取:最多再等一个 30 秒周期。
|
||||
|
||||
所以典型告警延迟大约半分钟到一分钟,不是秒级。
|
||||
|
||||
---
|
||||
|
||||
## 6. 和业务接口的关系
|
||||
|
||||
worker 全下线时,走 GPU 的接口(测量、生发、发际线、遮罩等)都会失败,`/monitor` 返回 503。这是要报警的情况。
|
||||
|
||||
有两个接口在网关本机执行,不经过 worker 池:
|
||||
|
||||
- 接口 4 `POST /api/v1/face/features`(豆包)
|
||||
- 接口 99 `POST /api/v1/face/special-points`(本机 CPU)
|
||||
|
||||
这两条在 worker 全挂时仍可能成功。`/monitor` 不管它们。它表示的是「依赖 worker 池的对外服务是否还能接」,不是「网关进程里每一个路由都活着」。
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知边界
|
||||
|
||||
这些情况 `/monitor` **不会**变成 503,和派发逻辑一致,避免把「还能干活」报成故障:
|
||||
|
||||
- 只挂了一部分 worker,还剩至少一台在线。
|
||||
- 主 worker 全挂,备用仍在线。
|
||||
- 在线 worker 都在忙,请求在网关排队。排队超时是业务返回「后端服务繁忙」,不是这个探活的失败。
|
||||
- worker 的 `/health` 仍返回 200,但机器实际上不能生图。当前仓库里 worker 的 `/health` 只看模型是否加载完成(未就绪才 503),不检查磁盘水位,也不检查 ComfyUI、change_hair 是否真的能出图。2026-09-19 那次磁盘写满时,浅探活会一直 200,网关就不会把机器摘掉,`/monitor` 也会一直 200。worker 侧更深的探活不在本接口范围内。
|
||||
|
||||
另外,备用 worker 如果从启动起就探不通,日志里不一定有「下线」警告。下线警告只在「原本在线、随后连续失败」时打印。一直没上线的机器只体现在 `workers_total` 和 `workers_healthy` 的差额里。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实现与生效
|
||||
|
||||
| 项 | 位置 |
|
||||
|----|------|
|
||||
| 路由 | `gateway/app.py` 的 `GET /monitor`、`POST /monitor/fault` |
|
||||
| 判定 | 同文件 `monitor_payload()`:未开演练时 `workers_healthy > 0` 为正常;演练打开则一律 503 |
|
||||
| 池状态 | `gateway/pool.py` 的 `get_pool_status()`,统计 `online` 台数 |
|
||||
| 单测 | `tests/test_gateway_monitor.py` |
|
||||
|
||||
改的是网关代码。worker 不用发版。改完后要重启网关进程才会加载新代码:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart hair-gateway
|
||||
systemctl is-active hair-gateway
|
||||
curl -sS -D - http://127.0.0.1:8080/monitor
|
||||
curl -sS -D - https://hair.xiangsilian.com/monitor
|
||||
```
|
||||
|
||||
配置(worker 列表、探测间隔、下线阈值、隔离期)仍在 `gateway/config.json`,启动时加载一次。只改配置、不改代码,同样要重启 `hair-gateway`。
|
||||
|
||||
`/health` 与 `/gateway-health` 保持原样,继续永远返回 200,给「进程是否存活」这类检查用。外部报警请只挂 `/monitor`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 故障模拟(报警演练)
|
||||
|
||||
`POST /monitor/fault` 用来演练监控会不会报警。打开之后,`GET /monitor` 固定返回 503;worker 池、派发、业务接口都不变。测完必须关掉,否则监控会一直报。
|
||||
|
||||
开关只活在网关进程内存里。重启 `hair-gateway` 后自动回到关闭。
|
||||
|
||||
### 请求
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://hair.xiangsilian.com/monitor/fault \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Internal-Token: <gateway/config.json 里的 shared_password>" \
|
||||
-d '{"enabled": true}'
|
||||
```
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| 方法 | `POST` |
|
||||
| 路径 | `/monitor/fault` |
|
||||
| 鉴权 | 请求头 `X-Internal-Token`,值与 `gateway/config.json` 的 `shared_password` 相同。缺失或错误返回 401 |
|
||||
| Body | JSON 布尔。`{"enabled": true}` 打开,`{"enabled": false}` 关闭。字符串 `"true"`、数字 `1` 都算参数错误,返回 400 |
|
||||
|
||||
打开成功:
|
||||
|
||||
```json
|
||||
{"status": "ok", "simulated": true, "message": "已开启模拟故障,GET /monitor 将返回 503"}
|
||||
```
|
||||
|
||||
关闭成功:
|
||||
|
||||
```json
|
||||
{"status": "ok", "simulated": false, "message": "已关闭模拟故障,GET /monitor 恢复真实状态"}
|
||||
```
|
||||
|
||||
### 演练期间 `/monitor` 的 503
|
||||
|
||||
计数字段仍是真实池状态,多一个 `simulated: true`,用来和「真的没有 worker」区分。若演练期间在线数本身已经是 0,文案会同时写明「当前没有可用的 worker」,避免把真故障误读成纯演练。
|
||||
|
||||
在线数仍大于 0 时:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 3,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 3,
|
||||
"simulated": true,
|
||||
"message": "模拟故障:监控测试中,真实 worker 未摘除"
|
||||
}
|
||||
```
|
||||
|
||||
监控若只看 HTTP 状态码,这次 503 会报警,和真故障同一条链路。告警内容里如果带了 body,可以看到 `simulated` 和「模拟故障」。
|
||||
|
||||
### 建议步骤
|
||||
|
||||
1. 确认当前 `GET /monitor` 是 200。
|
||||
2. `POST /monitor/fault`,`enabled` 设为 `true`。
|
||||
3. 再拉一次 `/monitor`,应为 503,且 `workers_healthy` 没有变成 0。
|
||||
4. 等监控的 30 秒周期(若配了连续 2 次失败才告警,就等两次)。确认告警发出。
|
||||
5. `enabled` 设为 `false`。再拉 `/monitor`,应回到 200。确认告警恢复。
|
||||
|
||||
演练时业务请求仍会打到在线 worker。这个接口不能用来模拟磁盘满、ComfyUI 挂掉或某台机器被摘除。
|
||||
|
||||
---
|
||||
|
||||
## 10. 上线核对(2026-10-15)
|
||||
|
||||
`hair-gateway.service` 已重启并完成启动。启动日志:`Worker 池初始化完成 | 总数=5 | 在线=3 | 主worker=3`。三台主 worker 的 `/health` 均为 200;两台备用在这轮启动探测里没有上线,所以健康数是 3 不是 5。按上面的规则这仍是正常。
|
||||
|
||||
当时公网实查:
|
||||
|
||||
```text
|
||||
HTTP/1.1 200 OK
|
||||
{"status":"ok","service":"gateway","workers_total":5,"workers_healthy":3,"workers_primary_total":3,"workers_primary_healthy":3}
|
||||
```
|
||||
|
||||
旧的 `GET /health` 仍是 200。重启前 8080 上没有已建立连接。
|
||||
|
||||
故障模拟在同一次上线里用本机请求核对过:不带令牌或令牌错误返回 401,且开关保持关闭;`enabled: true` 之后 `GET /monitor` 为 503,`simulated` 为 true,`workers_healthy` 仍是 3;`enabled: false` 之后回到 200。核对结束时开关是关闭的。开关的开、关会在 `journalctl -u hair-gateway` 里各留一条 warning,不进业务请求日志。
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口52(2026-11-04 已接入main)
|
||||
|
||||
42、52、72 统一采用[客户端登录与刷新令牌](接口42-52-72网关鉴权.md)。校验失败返回 HTTP 401、`code=1009`。
|
||||
|
||||
新增`POST /api/v1/hairline/generate-52`,由网关转发到GPU worker同路径。5号保持原样,52号的`normalize_image`等参数原样透传;非空图片沿用公共base64→URL改写,空图片仍保留`*_base64: null`。不在网关增加IP限制,不合入worker算法。
|
||||
|
||||
测试页`/static/test_interface52.html`不包含内部密码。部署前确认主/备worker均支持52号,并检查生发请求的上游超时。详见[接口52网关接入](接口52网关接入.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. ✅【功能必需】base64→URL 落盘扩展名按内容嗅探(支持 JPG)
|
||||
|
||||
**背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。
|
||||
@@ -233,6 +243,17 @@ curl -s -X POST http://127.0.0.1:8080/api/v1/face/special-points \
|
||||
|
||||
---
|
||||
|
||||
## 10. ✅【无需改代码】接口1/6 增加竖线坐标 `vertical_lines`
|
||||
|
||||
worker(`ubuntu3090_debug`,提交 `b64313d`)已在接口 1、接口 6,以及接口 5/52 内嵌的 `face_measure` 中返回 `vertical_lines`。字段说明见 [`接口文档.md`](接口文档.md) 接口 1。
|
||||
|
||||
网关保持现有盲转发即可:
|
||||
|
||||
- `vertical_lines` 是坐标 JSON,不是 `*_base64`,`rewrite_base64_to_url` 不会改写它。
|
||||
- 接口 1 含人头两端 `left_head_edge` / `right_head_edge`(耳朵不可见时该条不出现)。接口 6 只有中间 6 条,`y_top`/`y_bottom` 为发际线到下巴尖。
|
||||
|
||||
---
|
||||
|
||||
## 已经做好、无需再动的
|
||||
|
||||
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`。
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# 网关摘除隔离期实施方案(方案 B)
|
||||
|
||||
> 目标读者:网关机器的部署/维护者。本文档给出基于 **main 分支** `gateway/` 代码的
|
||||
> 完整改动说明,可直接照做。**不需要改 worker 侧任何代码。**
|
||||
|
||||
## 1. 背景与事故复盘
|
||||
|
||||
2026-09-19 晚,GPU worker 机器(ubuntu3090_debug_4090 所在主机)磁盘写满,业务接口
|
||||
全部失败,但网关**持续把请求打到该机器近 16 个小时**(21:30 故障发生 → 次日 13:36
|
||||
人工重启),没有切换到其他 worker。复盘结论是三层防线同时失效:
|
||||
|
||||
| 层 | 问题 | 后果 |
|
||||
|---|---|---|
|
||||
| 1. worker `/health` 太浅 | 只返回内存里的"模型就绪"标志,磁盘满/下游 ComfyUI 挂掉不影响它 | 探测一直 200 |
|
||||
| 2. 业务错误不触发摘除 | 下游出错经包装后以 HTTP 200/400 + 错误码返回,`forward.py` 只对 连接异常/超时/≥500/401 摘除 | 请求失败但 worker 被视为健康 |
|
||||
| 3. 摘除后 8 秒即复活(**本方案要修的**) | `mark_worker_unhealthy()` 下线后,下一轮探测(8s 后)`/health` 返回 200,`healthy_threshold=1` 使其**一次成功立即重新上线** | 即使被摘除也会反复横跳,流量持续打入 |
|
||||
|
||||
worker 侧的第 1 层已经修复(方案 A:`/health` 深检磁盘水位 + 下游依赖 TCP 探测,
|
||||
异常返回 503,代码在分支 `ubuntu3090_debug_4090`,提交 `14562d4`)。本方案修复
|
||||
第 3 层:**被动摘除后进入隔离期,隔离期内即使探测成功也不复活**。两层配合后:
|
||||
worker 磁盘满 → `/health` 503 → 网关 2 次探测失败摘除 → 隔离期内不再接流量。
|
||||
|
||||
> 第 2 层(错误码语义化,涉及调用方兼容)暂不处理,单独立项。
|
||||
|
||||
## 2. 现状代码行为(main 分支 gateway/pool.py)
|
||||
|
||||
- `mark_worker_unhealthy(w)`:被 `forward.py` 在转发失败(连接异常/超时/5xx/401)时
|
||||
调用,置 `w.online = False`。**没有任何冷却机制**。
|
||||
- `_health_check_loop(cfg)`:每 `interval_seconds`(默认 8s)探测所有 worker 的
|
||||
`/health`;返回 200 时 `consecutive_successes += 1`,达到 `healthy_threshold`
|
||||
(默认 **1**)且当前 offline → 立即 `online = True`。
|
||||
- 因此被动摘除最多 8 秒后就被一次探测成功拉回——如果 worker 是"浅健康"或故障
|
||||
间歇性表现,就会陷入 摘除→复活→失败→摘除 的横跳。
|
||||
- 另外 58-96 行有一段**从未被调用的死代码** `_check_worker_health()` /
|
||||
`_mark_failure()`(写了一半的旧实现),本次顺带删除,避免后人改错地方。
|
||||
|
||||
## 3. 代码改动(共 2 个文件 + 1 个配置)
|
||||
|
||||
### 3.1 `gateway/pool.py`
|
||||
|
||||
**(1) `WorkerState` 增加一个字段:**
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class WorkerState:
|
||||
"""单个 worker 的运行时状态。"""
|
||||
url: str
|
||||
online: bool = False # 初始 offline,等健康检查通过后上线
|
||||
busy: bool = False
|
||||
consecutive_failures: int = 0
|
||||
consecutive_successes: int = 0
|
||||
offline_until: float = 0.0 # 被动摘除后的隔离截止时刻(time.monotonic() 基准)
|
||||
```
|
||||
|
||||
**(2) 模块顶部(`logger = ...` 之后)加一个配置读取 helper:**
|
||||
|
||||
```python
|
||||
def _cooldown_seconds() -> float:
|
||||
"""被动摘除后的隔离期时长(秒),配置 health_check.cooldown_seconds,默认 60。"""
|
||||
try:
|
||||
from gateway.config import get_config
|
||||
return float(get_config()["health_check"].get("cooldown_seconds", 60))
|
||||
except Exception: # noqa: BLE001
|
||||
return 60.0
|
||||
```
|
||||
|
||||
(函数内 import 是为了不引入模块级循环依赖;`gateway/config.py` 不依赖 pool,顶层
|
||||
import 也可以。)
|
||||
|
||||
**(3) `mark_worker_unhealthy()` 摘除时同时设置隔离期:**
|
||||
|
||||
```python
|
||||
async def mark_worker_unhealthy(w: WorkerState) -> None:
|
||||
"""被动标记:转发失败时立即将该 worker 下线,并进入隔离期。
|
||||
|
||||
隔离期内即使 /health 探测成功也不复活(由 _health_check_loop 守卫),
|
||||
避免"摘除→8 秒复活→再失败"的横跳。
|
||||
"""
|
||||
async with _pool_condition:
|
||||
if w.online:
|
||||
w.online = False
|
||||
w.consecutive_failures += 1
|
||||
w.offline_until = time.monotonic() + _cooldown_seconds()
|
||||
logger.warning("⚠ Worker 被动下线: %s(转发失败,隔离 %.0fs)",
|
||||
w.url, _cooldown_seconds())
|
||||
```
|
||||
|
||||
**(4) `_health_check_loop()` 的探测成功分支加隔离期守卫。** 找到这段:
|
||||
|
||||
```python
|
||||
if resp.status_code == 200:
|
||||
w.consecutive_failures = 0
|
||||
w.consecutive_successes += 1
|
||||
if w.consecutive_successes >= healthy_threshold and not w.online:
|
||||
w.online = True
|
||||
logger.info("✅ Worker 上线: %s", w.url)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
if resp.status_code == 200:
|
||||
w.consecutive_failures = 0
|
||||
w.consecutive_successes += 1
|
||||
if w.consecutive_successes >= healthy_threshold and not w.online:
|
||||
if time.monotonic() < w.offline_until:
|
||||
logger.debug("Worker %s 探测成功但处于隔离期,暂不复活", w.url)
|
||||
else:
|
||||
w.online = True
|
||||
logger.info("✅ Worker 上线: %s", w.url)
|
||||
```
|
||||
|
||||
注意:`except`(连接异常)分支只累计失败、不涉及复活,无需改动;
|
||||
`init_pool()` 启动时的首轮探测同理无需改动(进程刚启动时 `offline_until` 为 0,
|
||||
不构成隔离)。
|
||||
|
||||
**(5) 删除死代码**:`_check_worker_health()` 与
|
||||
`_mark_failure()` 两个函数整体删除(约 58-96 行,以函数名为准,别删到
|
||||
`_health_check_loop`)。删除前可确认无调用方:
|
||||
`grep -rn "_check_worker_health\|_mark_failure" .` 应只剩定义处。
|
||||
|
||||
### 3.2 `gateway/config.py`
|
||||
|
||||
`DEFAULTS["health_check"]` 增加一个 key:
|
||||
|
||||
```python
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1,
|
||||
"cooldown_seconds": 60, # ← 新增:被动摘除后的隔离期(秒)
|
||||
},
|
||||
```
|
||||
|
||||
`config.example.json` 同步加这个 key。网关机器的实际 `config.json` **不加也不会报错**
|
||||
(代码里有 60 的默认兜底),但建议显式加上,便于运维感知。
|
||||
|
||||
### 3.3 建议单测:`tests/test_gateway_pool.py`(新文件)
|
||||
|
||||
gateway 子包目前没有任何测试。隔离期是纯逻辑,建议至少覆盖以下三个用例
|
||||
(不依赖真实网络):
|
||||
|
||||
```python
|
||||
"""gateway/pool.py 隔离期逻辑单测。"""
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from gateway import pool
|
||||
from gateway.pool import WorkerState
|
||||
|
||||
|
||||
def _drain(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_passive_ejection_sets_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000", online=True)
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
with patch.object(pool, "_cooldown_seconds", return_value=60.0):
|
||||
await pool.mark_worker_unhealthy(w)
|
||||
_drain(run())
|
||||
assert w.online is False
|
||||
assert w.offline_until > time.monotonic()
|
||||
|
||||
|
||||
def test_probe_success_revives_after_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000") # offline
|
||||
w.offline_until = time.monotonic() - 1 # 隔离期已过
|
||||
# 直接验证状态迁移逻辑(若按 3.1(4) 内联实现,可复制该分支逻辑做断言,
|
||||
# 或将判断抽成 _try_revive(w, healthy_threshold) 小函数后单测)
|
||||
...
|
||||
|
||||
|
||||
def test_probe_success_blocked_during_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000")
|
||||
w.offline_until = time.monotonic() + 60
|
||||
... # 断言保持 offline
|
||||
```
|
||||
|
||||
> 提示:如果按 3.1(4) 原样内联修改,后两个用例不好直接驱动,可以把"是否允许复活"
|
||||
> 抽成一个 6 行的纯函数 `_try_revive(w, healthy_threshold) -> bool` 再测,主循环
|
||||
> 调用它即可——实现方式自定,覆盖语义即可。
|
||||
|
||||
## 4. 部署步骤(网关机器)
|
||||
|
||||
1. `git pull` main 分支(含本方案代码改动)。
|
||||
2. (可选但建议)`config.json` 的 `health_check` 段加 `"cooldown_seconds": 60`。
|
||||
3. 重启网关服务(`hair-gateway.service` 或等价的 uvicorn 进程)。重启期间网关
|
||||
本身短暂不可用,选低峰执行。
|
||||
4. 观察启动日志:`健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=N`
|
||||
正常出现。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
**功能验证(低峰演练):**
|
||||
|
||||
1. 人工把主 worker 摘除一次最直接的方法:临时在 worker 机器
|
||||
`systemctl stop hair-worker`(或临时设 `HEALTH_MIN_FREE_GB=999` 重启)。
|
||||
2. 观察网关日志:
|
||||
- 连续 2 次探测失败 → `⚠ Worker 下线`(原有行为);
|
||||
- **转发失败触发的** `⚠ Worker 被动下线: ...(转发失败,隔离 60s)`(新日志);
|
||||
- 恢复 worker 后,若刚被被动摘除过,即使 `/health` 恢复 200,也应看到 60 秒内
|
||||
**不出现** `✅ Worker 上线`,隔离期满后下一次探测成功才上线。
|
||||
3. `GET /gateway-health` 观察 `workers_healthy` 的变化节奏。
|
||||
|
||||
**回归确认:** 正常场景 worker 上下线不受影响(隔离期只对"被动摘除"生效;
|
||||
探测失败导致的下线,恢复后仍按 `healthy_threshold` 立即上线)。
|
||||
|
||||
## 6. 回滚
|
||||
|
||||
- 代码回滚:`git revert` 对应提交即可,无数据/配置迁移。
|
||||
- 紧急关闭隔离期:网关机器 `config.json` 设 `"cooldown_seconds": 0`
|
||||
(等价于旧行为——摘除后下一次探测成功即复活),无需回代码。
|
||||
|
||||
## 7. 附:worker 侧方案 A 的行为(已上线,供联调参考)
|
||||
|
||||
worker(分支 `ubuntu3090_debug_4090`,提交 `14562d4`)的 `/health` 现在会在以下
|
||||
情况返回 **503**(网关 2 次探测失败即摘除):
|
||||
|
||||
- 模型加载中:`503 {"status":"starting"}`(原有行为不变)
|
||||
- 磁盘剩余低于阈值:`503 {"status":"degraded","reason":"disk_free_below_5.0g","disk_free_gb":...}`
|
||||
- 下游依赖(ComfyUI 8188 / change_hair 8801)TCP 不通:
|
||||
`503 {"status":"degraded","reason":"dep_down:comfyui,...","deps":{...}}`
|
||||
|
||||
正常时返回 `200 {"status":"ok","disk_free_gb":24.6,"deps":{"comfyui":true,"change_hair":true}}`。
|
||||
|
||||
worker 侧配置(`worker_config.json` 的 `health` 段,环境变量可覆盖):
|
||||
`min_free_gb`(默认 5,0=关闭)、`check_deps`(默认 true)、`dep_comfyui`(默认
|
||||
127.0.0.1:8188)、`deps_cache_seconds`(默认 5)。**其他 worker 机器如果也想启用
|
||||
深检,cherry-pick 该提交并补一份 worker_config.json 的 health 段即可。**
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Framed Ft and Zy measurements shared by interface 42 and the ratio preview."""
|
||||
|
||||
import math
|
||||
|
||||
FT_POINTS = ("hair_line_right_right", "hair_line_left_left")
|
||||
ZY_POINTS = ("face_edge_234", "face_edge_454")
|
||||
|
||||
|
||||
class PortraitQualityError(ValueError):
|
||||
"""An input that must be rejected before feature analysis."""
|
||||
|
||||
def __init__(self, code, message):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def prepare_feature_portrait(image_bytes):
|
||||
"""Check the source image before applying interface 52 framing."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from face_analysis.pose import PITCH_THRESHOLD, estimate_head_pose
|
||||
from hairline.face_landmarks import get_landmarker, to_landmark_list
|
||||
from hairline.framing import decode_portrait, normalize_portrait
|
||||
|
||||
image = decode_portrait(image_bytes)
|
||||
if image is None:
|
||||
raise PortraitQualityError(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
landmarks = get_landmarker().detect(
|
||||
cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True
|
||||
)
|
||||
if landmarks is None:
|
||||
raise PortraitQualityError(1001, "无法识别人像,请上传包含完整人脸的照片")
|
||||
|
||||
h, w = image.shape[:2]
|
||||
face_xy = landmarks[:468, :2] * [w, h]
|
||||
if not np.isfinite(face_xy).all() or (
|
||||
(face_xy <= 0).any() or (face_xy >= [w - 1, h - 1]).any()
|
||||
):
|
||||
raise PortraitQualityError(1003, "人脸不完整,请保留完整的脸部轮廓")
|
||||
|
||||
pose = estimate_head_pose(to_landmark_list(landmarks), w, h)
|
||||
# In the camera coordinate system, positive pitch points the face down.
|
||||
if pose is not None and pose[1] > PITCH_THRESHOLD:
|
||||
raise PortraitQualityError(1003, "俯拍角度过大,请将相机调整至与脸部平视")
|
||||
|
||||
try:
|
||||
image, landmarks, _ = normalize_portrait(image, landmarks)
|
||||
except ValueError as exc:
|
||||
raise PortraitQualityError(1003, "人脸过于贴近边缘,无法扶正裁切") from exc
|
||||
return image, landmarks
|
||||
|
||||
|
||||
def detect_framed_ratio_points(image):
|
||||
"""Use the interface 52 crop and return the endpoints in its coordinate space."""
|
||||
from hairline.framing import prepare_portrait
|
||||
|
||||
image, landmarks, framing = prepare_portrait(image)
|
||||
data = _detect_ratio_points(image, landmarks)
|
||||
data.update(framing=framing, coordinate_space="processed_image")
|
||||
return image, data
|
||||
|
||||
|
||||
def _detect_ratio_points(image, landmarks):
|
||||
from hairline.face_landmarks import to_landmark_list
|
||||
|
||||
from .calibration import normalized_to_pixel
|
||||
from .special_points import detect_special_points
|
||||
|
||||
override = to_landmark_list(landmarks)
|
||||
data = detect_special_points(image, landmarks_override=override)
|
||||
points = data["points"]
|
||||
height, width = image.shape[:2]
|
||||
x, y = normalized_to_pixel(override.landmark[454], width, height)
|
||||
points["face_edge_454"] = {"x": round(x), "y": round(y)}
|
||||
data["ft_original_points"] = {key: points[key] for key in FT_POINTS}
|
||||
a, b = (points[key] for key in FT_POINTS)
|
||||
if a is not None and b is not None:
|
||||
middle_y = (a["y"] + b["y"]) / 2
|
||||
points[FT_POINTS[0]] = {**a, "y": middle_y}
|
||||
points[FT_POINTS[1]] = {**b, "y": middle_y}
|
||||
|
||||
data["ft_alignment"] = "horizontal_at_mean_y"
|
||||
return data
|
||||
|
||||
|
||||
def analyze_ratio_features(image, landmarks) -> dict[str, bool | None]:
|
||||
"""Return both labels from the same measurement of the processed image."""
|
||||
from .special_points import NoFaceError, NonFrontalError
|
||||
|
||||
matched = None
|
||||
try:
|
||||
data = _detect_ratio_points(image, landmarks)
|
||||
except (NoFaceError, NonFrontalError):
|
||||
pass
|
||||
else:
|
||||
ft_left, ft_right, zy_left, zy_right = (
|
||||
data["points"].get(key) for key in (*FT_POINTS, *ZY_POINTS)
|
||||
)
|
||||
if all(point is not None for point in (ft_left, ft_right, zy_left, zy_right)):
|
||||
ft = abs(ft_left["x"] - ft_right["x"])
|
||||
zy = math.hypot(zy_left["x"] - zy_right["x"], zy_left["y"] - zy_right["y"])
|
||||
if ft > 0 and zy > 0:
|
||||
matched = ft / zy < 0.8
|
||||
return {"zygoma_expansion": matched, "temple_hollowing": matched}
|
||||
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
"hairline_id": hairline_id,
|
||||
"blend_method": blend_method,
|
||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,人物增加一点磨皮",
|
||||
"beauty_alpha": beauty_alpha,
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"mask_pixels": mask_viz["mask_pixels"],
|
||||
|
||||
@@ -87,7 +87,7 @@ def ray_hairline_intersection(p1, p2, hair_mask):
|
||||
return None
|
||||
|
||||
|
||||
def detect_special_points(image_bgr):
|
||||
def detect_special_points(image_bgr, landmarks_override=None):
|
||||
"""主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。
|
||||
|
||||
Returns: {"points": {...}, "image_size": {"width","height"},
|
||||
@@ -99,7 +99,7 @@ def detect_special_points(image_bgr):
|
||||
from face_analysis.pose import check_frontal_face
|
||||
|
||||
h, w = image_bgr.shape[:2]
|
||||
landmarks = detector.detect(image_bgr)
|
||||
landmarks = landmarks_override if landmarks_override is not None else detector.detect(image_bgr)
|
||||
if landmarks is None:
|
||||
raise NoFaceError("未检出人脸")
|
||||
if not check_frontal_face(landmarks, w, h):
|
||||
|
||||
+15
-8
@@ -1,6 +1,6 @@
|
||||
"""接口4:用户面部特征分析。
|
||||
|
||||
- 眉形 / 面部年龄 / 动静类型 / 性别 / 基因风格:火山方舟豆包视觉模型
|
||||
- 眉形 / 面部年龄 / 动静类型 / 性别 / 基因风格 / 轮廓类型:火山方舟豆包视觉模型
|
||||
- face_shape(脸型):本机 MediaPipe 分类(face/face_shape_classifier.py)覆盖,
|
||||
不用豆包结果
|
||||
|
||||
@@ -26,15 +26,20 @@ _KEY_MAP = {
|
||||
"动静类型": "dynamic_static_type",
|
||||
"性别": "gender",
|
||||
"基因风格": "gene_style",
|
||||
"轮廓类型": "contour_type",
|
||||
}
|
||||
|
||||
# 豆包只问 5 项 + 是否有人脸;脸型由本地分类器给出
|
||||
# 豆包只问 6 项 + 是否有人脸;脸型由本地分类器给出。
|
||||
# 「必须包含全部键」:实测豆包会随机漏键(尤其面部年龄,历史 ~26% 缺失),
|
||||
# 显式要求可显著降低漏键率。
|
||||
_PROMPT = (
|
||||
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
|
||||
"必须包含以下全部键且每键都要有值、缺一不可:"
|
||||
"图片是否有人脸(有人/没人) "
|
||||
"眉形 "
|
||||
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
|
||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
|
||||
"面部年龄(给出区间年龄,如18-25岁) 动静类型(静态型/动态型) 性别(男/女) "
|
||||
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型) "
|
||||
"轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)"
|
||||
)
|
||||
|
||||
_client = None # 缓存的 Ark client(api_key 变更时自动重建)
|
||||
@@ -136,11 +141,11 @@ def _local_face_shape(image_bytes: bytes = None, image_url: str = None) -> str:
|
||||
return shape
|
||||
|
||||
|
||||
def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||
"""豆包分析 5 项特征 + 本机 MediaPipe 覆盖 face_shape。
|
||||
def analyze_features(image_bytes: bytes = None, image_url: str = None, *, accept_age_alias=False):
|
||||
"""豆包分析 6 项特征 + 本机 MediaPipe 覆盖 face_shape。
|
||||
|
||||
Returns: dict —— 6 个英文字段(face_shape/eyebrow_shape/facial_age/
|
||||
dynamic_static_type/gender/gene_style);**无人脸返回 None**(调用方据此判 1001)。
|
||||
Returns: dict —— 7 个英文字段(face_shape/eyebrow_shape/facial_age/
|
||||
dynamic_static_type/gender/gene_style/contour_type);**无人脸返回 None**(调用方据此判 1001)。
|
||||
"""
|
||||
url = _image_to_url(image_bytes, image_url)
|
||||
resp = get_client().chat.completions.create(
|
||||
@@ -172,6 +177,8 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None):
|
||||
return None
|
||||
# 豆包 5 项 + 本地脸型覆盖
|
||||
feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
|
||||
if accept_age_alias and "facial_age" not in feats and "面部年龄区间" in raw:
|
||||
feats["facial_age"] = raw["面部年龄区间"]
|
||||
try:
|
||||
feats["face_shape"] = _local_face_shape(image_bytes, image_url)
|
||||
except ValueError as e:
|
||||
|
||||
+228
-6
@@ -12,10 +12,11 @@ from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi import Depends, FastAPI, File, Form, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from gateway.client_auth import client_auth_error, router as client_auth_router
|
||||
from gateway.config import load_config
|
||||
from gateway.logging_middleware import (
|
||||
get_available_dates,
|
||||
@@ -65,10 +66,10 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
app.state._pool_shutdown = None
|
||||
|
||||
# 确保标注图目录存在(永久保留,不做定期清理)
|
||||
# 确保标注图目录存在(超过 30 天的文件由 crontab 每天 03:00 清理)
|
||||
static_dir = Path(cfg["static_dir"])
|
||||
static_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("标注图目录: %s(永久保留,不自动清理)", static_dir)
|
||||
logger.info("标注图目录: %s(>30 天文件由 crontab 每日清理)", static_dir)
|
||||
|
||||
yield
|
||||
|
||||
@@ -90,6 +91,8 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(client_auth_router)
|
||||
|
||||
# 静态文件托管(阶段四完善)
|
||||
static_root = Path(__file__).resolve().parent.parent / "static"
|
||||
static_root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -123,6 +126,8 @@ async def gateway_health():
|
||||
"workers_total": status["total"],
|
||||
"workers_healthy": status["healthy"],
|
||||
"workers_busy": status["busy"],
|
||||
"workers_primary_total": status.get("primary_total", 0),
|
||||
"workers_primary_healthy": status.get("primary_healthy", 0),
|
||||
"global_max": status.get("global_max", 1),
|
||||
"global_busy": status.get("global_busy", 0),
|
||||
"global_waiting": status.get("global_waiting", 0),
|
||||
@@ -135,6 +140,104 @@ async def health():
|
||||
return await gateway_health()
|
||||
|
||||
|
||||
# 监控演练开关。只影响 GET /monitor 的响应,不摘除 worker、不改变派发。
|
||||
# 进程内存态,重启网关后自动关闭。
|
||||
_monitor_drill = {"enabled": False}
|
||||
|
||||
|
||||
def monitor_payload(status: dict, *, simulated: bool = False) -> dict:
|
||||
"""根据池状态生成监控响应。至少一台 worker 在线才算服务正常。
|
||||
|
||||
simulated 为真时强制 unhealthy,用于监控报警演练;计数字段仍是真实池状态。
|
||||
"""
|
||||
healthy = int(status.get("healthy") or 0)
|
||||
ok = healthy > 0 and not simulated
|
||||
body = {
|
||||
"status": "ok" if ok else "unhealthy",
|
||||
"service": "gateway",
|
||||
"workers_total": status.get("total", 0),
|
||||
"workers_healthy": healthy,
|
||||
"workers_primary_total": status.get("primary_total", 0),
|
||||
"workers_primary_healthy": status.get("primary_healthy", 0),
|
||||
}
|
||||
if simulated:
|
||||
body["simulated"] = True
|
||||
if healthy > 0:
|
||||
body["message"] = "模拟故障:监控测试中,真实 worker 未摘除"
|
||||
else:
|
||||
body["message"] = "模拟故障:监控测试中;同时当前没有可用的 worker"
|
||||
elif not ok:
|
||||
body["message"] = "没有可用的 worker"
|
||||
return body
|
||||
|
||||
|
||||
def _monitor_token_error(request: Request):
|
||||
"""校验 X-Internal-Token 与网关 shared_password 一致。通过时返回 None。"""
|
||||
from gateway.config import get_config
|
||||
|
||||
try:
|
||||
expected = get_config().get("shared_password") or ""
|
||||
except Exception: # noqa: BLE001
|
||||
expected = ""
|
||||
token = request.headers.get("X-Internal-Token")
|
||||
if not expected or token != expected:
|
||||
return JSONResponse(status_code=401, content={
|
||||
"status": "unauthorized",
|
||||
"message": "未授权:缺少或错误的 X-Internal-Token",
|
||||
})
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/monitor", include_in_schema=False)
|
||||
async def monitor():
|
||||
"""供外部监控探测当前服务是否可接业务。
|
||||
|
||||
有在线 worker 返回 200,全部下线返回 503(监控侧据此报警)。
|
||||
演练开关打开时也返回 503,但 worker 池保持原样。
|
||||
只读内存中的池状态,后台健康检查已按配置间隔探测过 worker,这里不再额外发包。
|
||||
"""
|
||||
body = monitor_payload(_get_pool_status_safe(), simulated=_monitor_drill["enabled"])
|
||||
if body["status"] != "ok":
|
||||
return JSONResponse(status_code=503, content=body)
|
||||
return body
|
||||
|
||||
|
||||
@app.post("/monitor/fault", include_in_schema=False)
|
||||
async def monitor_fault(request: Request):
|
||||
"""打开或关闭监控故障模拟。
|
||||
|
||||
Body 必须是 ``{"enabled": true}`` 或 ``{"enabled": false}``(JSON 布尔)。
|
||||
打开后 GET /monitor 返回 503,业务派发不变。需要请求头 X-Internal-Token。
|
||||
"""
|
||||
denied = _monitor_token_error(request)
|
||||
if denied is not None:
|
||||
return denied
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
payload = None
|
||||
if not isinstance(payload, dict) or type(payload.get("enabled")) is not bool:
|
||||
return JSONResponse(status_code=400, content={
|
||||
"status": "error",
|
||||
"message": '需要 JSON:{"enabled": true} 或 {"enabled": false}',
|
||||
})
|
||||
enabled = payload["enabled"]
|
||||
_monitor_drill["enabled"] = enabled
|
||||
if enabled:
|
||||
logger.warning("监控故障模拟已开启:GET /monitor 将返回 503,worker 池未改动")
|
||||
else:
|
||||
logger.warning("监控故障模拟已关闭:GET /monitor 恢复真实池状态")
|
||||
return {
|
||||
"status": "ok",
|
||||
"simulated": enabled,
|
||||
"message": (
|
||||
"已开启模拟故障,GET /monitor 将返回 503"
|
||||
if enabled else
|
||||
"已关闭模拟故障,GET /monitor 恢复真实状态"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index():
|
||||
return {
|
||||
@@ -146,11 +249,17 @@ async def index():
|
||||
"integration_guide": "/static/integration.html",
|
||||
"test_pages": {
|
||||
"if1_measure": "/static/test_interface1.html",
|
||||
"if11_measure": "/static/test_interface11.html",
|
||||
"if2_hair_grow": "/static/test_interface2.html",
|
||||
"if22_hair_grow": "/static/test_interface22.html",
|
||||
"if3_hair_grow_b": "/static/test_interface3.html",
|
||||
"if4_features": "/static/test_interface4.html",
|
||||
"if42_features": "/static/test_interface42.html",
|
||||
"if72_b_end_points": "/static/test_interface72.html",
|
||||
"if5_hairline": "/static/test_interface5.html",
|
||||
"if52_hairline": "/static/test_interface52.html",
|
||||
"if6_measure_v2": "/static/test_interface6.html",
|
||||
"if61_measure_v2": "/static/test_interface61.html",
|
||||
"if7_hair_grow_v2": "/static/test_interface7.html",
|
||||
"if9_head_mask": "/static/test_interface9.html",
|
||||
"if10_head_band": "/static/test_interface10.html",
|
||||
@@ -463,18 +572,42 @@ async def face_measure(request: Request):
|
||||
return await _proxy(request, "/api/v1/face/measure")
|
||||
|
||||
|
||||
@app.post("/api/v1/face/measure-11", summary="接口11:四庭七眼测量标注(鉴权)", tags=["人脸分析"])
|
||||
async def face_measure_11(request: Request, auth_error=Depends(client_auth_error)):
|
||||
"""通过 Bearer 鉴权后,使用接口 1 的图片参数和响应格式。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return await face_measure(request)
|
||||
|
||||
|
||||
@app.post("/api/v1/face/measure-v2", tags=["人脸分析"])
|
||||
async def face_measure_v2(request: Request):
|
||||
"""接口6:四庭七眼测量标注 v2(去顶庭 + 去头部端线)"""
|
||||
return await _proxy(request, "/api/v1/face/measure-v2")
|
||||
|
||||
|
||||
@app.post("/api/v1/face/measure-61", summary="接口61:四庭七眼测量标注 v2(鉴权)", tags=["人脸分析"])
|
||||
async def face_measure_61(request: Request, auth_error=Depends(client_auth_error)):
|
||||
"""通过 Bearer 鉴权后,使用接口 6 的图片参数和响应格式。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return await face_measure_v2(request)
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow", tags=["生发"])
|
||||
async def hair_grow(request: Request):
|
||||
"""接口2:C端生发"""
|
||||
return await _proxy(request, "/api/v1/hair/grow")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow-22", summary="接口22:生发密度与发际线高度", tags=["生发"])
|
||||
async def hair_grow_22(request: Request, auth_error=Depends(client_auth_error)):
|
||||
"""鉴权后透传图片、发型、可重复的 hair_density 和 hairline_height 至 GPU Worker。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return await _proxy(request, "/api/v1/hair/grow-22")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow-b", tags=["生发"], deprecated=True)
|
||||
async def hair_grow_b(request: Request):
|
||||
"""接口3:B端生发(已屏蔽)"""
|
||||
@@ -493,7 +626,25 @@ async def face_features(
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
|
||||
):
|
||||
"""接口4:用户特征分析 — 豆包 5 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
|
||||
"""接口4:用户特征分析 — 豆包 6 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
|
||||
return await _face_features_response(request, image_file, image_url, image_base64)
|
||||
|
||||
|
||||
@app.post("/api/v1/face/features-42", summary="42号接口:用户特征与两项判定", tags=["人脸分析"])
|
||||
async def face_features_42(
|
||||
request: Request,
|
||||
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
|
||||
image_url: Optional[str] = Form(default=None, description="图片 URL"),
|
||||
image_base64: Optional[str] = Form(default=None, description="图片 Base64"),
|
||||
auth_error=Depends(client_auth_error),
|
||||
):
|
||||
"""质检、扶正裁切后执行接口4分析,再加入颧弓外扩与太阳穴凹陷。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return await _face_features_response(request, image_file, image_url, image_base64, include_ratios=True)
|
||||
|
||||
|
||||
async def _face_features_response(request, image_file, image_url, image_base64, *, include_ratios=False):
|
||||
import uuid as _uuid
|
||||
|
||||
# 三选一校验
|
||||
@@ -542,8 +693,30 @@ async def face_features(
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from face_features import analyze_features
|
||||
|
||||
ratio_features = {}
|
||||
if include_ratios:
|
||||
from face_analysis.face_ratios import PortraitQualityError
|
||||
|
||||
try:
|
||||
img_bytes, ratio_features = await _prepare_features_42(img_bytes, image_url)
|
||||
except PortraitQualityError as ex:
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": ex.code, "message": str(ex),
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("接口42 图片预处理失败")
|
||||
return JSONResponse(status_code=200, content={
|
||||
"code": 1007, "message": "图片预处理失败",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}", "data": None,
|
||||
})
|
||||
image_url = None
|
||||
|
||||
try:
|
||||
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
|
||||
if include_ratios:
|
||||
feats = await run_in_threadpool(analyze_features, img_bytes, image_url, accept_age_alias=True)
|
||||
else:
|
||||
feats = await run_in_threadpool(analyze_features, img_bytes, image_url)
|
||||
except Exception as ex:
|
||||
logger.exception("接口4 豆包调用失败")
|
||||
return JSONResponse(status_code=200, content={
|
||||
@@ -561,16 +734,65 @@ async def face_features(
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": f"gw-{_uuid.uuid4().hex[:8]}",
|
||||
"data": {"features": json.dumps(feats, ensure_ascii=False)},
|
||||
"data": {"features": json.dumps({**feats, **ratio_features}, ensure_ascii=False)},
|
||||
})
|
||||
|
||||
|
||||
async def _prepare_features_42(image_bytes, image_url):
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
if image_url:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
response = await client.get(image_url)
|
||||
response.raise_for_status()
|
||||
image_bytes = response.content
|
||||
except httpx.HTTPError as exc:
|
||||
from face_analysis.face_ratios import PortraitQualityError
|
||||
|
||||
raise PortraitQualityError(1008, "图片下载失败(URL 不可达)") from exc
|
||||
|
||||
async with _if99_lock:
|
||||
return await run_in_threadpool(_preprocess_and_measure_42, image_bytes)
|
||||
|
||||
|
||||
def _preprocess_and_measure_42(image_bytes):
|
||||
import cv2
|
||||
from face_analysis.face_ratios import analyze_ratio_features, prepare_feature_portrait
|
||||
|
||||
image, landmarks = prepare_feature_portrait(image_bytes)
|
||||
success, encoded = cv2.imencode(".png", image)
|
||||
if not success:
|
||||
raise RuntimeError("处理后图片编码失败")
|
||||
ratios = analyze_ratio_features(image, landmarks)
|
||||
return encoded.tobytes(), ratios
|
||||
|
||||
|
||||
@app.post("/api/v1/hairline/generate", tags=["人脸分析"])
|
||||
async def hairline_generate(request: Request):
|
||||
"""接口5:发际线PNG生成"""
|
||||
return await _proxy(request, "/api/v1/hairline/generate")
|
||||
|
||||
|
||||
@app.post("/api/v1/hairline/generate-52", tags=["人脸分析"],
|
||||
summary="接口52:发际线新规则与人像裁切")
|
||||
async def hairline_generate_52(request: Request, auth_error=Depends(client_auth_error)):
|
||||
"""鉴权后透传图片、gender、hair_style 与 normalize_image 至 GPU worker。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return await _proxy(request, "/api/v1/hairline/generate-52")
|
||||
|
||||
|
||||
@app.post("/api/v1/face/b-end-points", summary="72号接口:B 端五个关键点", tags=["人脸分析"])
|
||||
async def face_b_end_points(request: Request, auth_error=Depends(client_auth_error)):
|
||||
"""鉴权后转发到 worker,图片按网关规则返回 URL。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return await _proxy(request, "/api/v1/face/b-end-points")
|
||||
|
||||
|
||||
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
|
||||
async def hair_grow_v2(request: Request):
|
||||
"""接口7:C端生发 v2(add_hair2 工作流)"""
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Expiring client credentials for the protected gateway interfaces."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, Security
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from gateway.config import get_config
|
||||
|
||||
ACCESS_TTL = 15 * 60
|
||||
REFRESH_TTL = 7 * 24 * 60 * 60
|
||||
_USERNAME = "yonghe"
|
||||
_PASSWORD = "yonghe1999"
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["客户端鉴权"])
|
||||
_bearer = HTTPBearer(auto_error=False, scheme_name="ClientBearer")
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(max_length=128)
|
||||
password: str = Field(max_length=256)
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str = Field(max_length=1024)
|
||||
|
||||
|
||||
def _response(code, message, *, status=200, data=None, challenge=False):
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
if challenge:
|
||||
headers["WWW-Authenticate"] = "Bearer"
|
||||
return JSONResponse(status_code=status, headers=headers, content={
|
||||
"code": code, "message": message, "request_id": f"gw-{uuid4().hex[:8]}", "data": data,
|
||||
})
|
||||
|
||||
|
||||
def _secret():
|
||||
value = os.getenv("HAIR_API_TOKEN_SECRET")
|
||||
if value is None:
|
||||
try:
|
||||
value = get_config().get("api_token_secret")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
|
||||
return None
|
||||
return value.encode("utf-8")
|
||||
|
||||
|
||||
def _encode(raw):
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode(value):
|
||||
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
||||
|
||||
|
||||
def _issue(secret, kind, ttl):
|
||||
payload = _encode(json.dumps({
|
||||
"sub": _USERNAME,
|
||||
"kind": kind,
|
||||
"exp": int(time.time()) + ttl,
|
||||
"nonce": secrets.token_urlsafe(16),
|
||||
}, separators=(",", ":")).encode())
|
||||
signature = hmac.digest(secret, payload.encode("ascii"), hashlib.sha256)
|
||||
return payload + "." + _encode(signature)
|
||||
|
||||
|
||||
def _valid(token, secret, kind):
|
||||
if not token or len(token) > 1024:
|
||||
return False
|
||||
try:
|
||||
payload, signature = token.split(".")
|
||||
expected = _encode(hmac.digest(secret, payload.encode("ascii"), hashlib.sha256))
|
||||
if not hmac.compare_digest(signature.encode("ascii"), expected.encode("ascii")):
|
||||
return False
|
||||
claims = json.loads(_decode(payload))
|
||||
return (
|
||||
isinstance(claims, dict)
|
||||
and claims.get("sub") == _USERNAME
|
||||
and claims.get("kind") == kind
|
||||
and type(claims.get("exp")) is int
|
||||
and time.time() < claims["exp"]
|
||||
)
|
||||
except (ValueError, UnicodeError, binascii.Error):
|
||||
return False
|
||||
|
||||
|
||||
def _unconfigured():
|
||||
return _response(1007, "客户端令牌服务未配置", status=503)
|
||||
|
||||
|
||||
def _unauthorized(message):
|
||||
return _response(1009, message, status=401, challenge=True)
|
||||
|
||||
|
||||
def _access_data(secret):
|
||||
return {
|
||||
"access_token": _issue(secret, "access", ACCESS_TTL),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TTL,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", summary="用户名和密码换取访问及刷新令牌")
|
||||
async def login(body: LoginRequest):
|
||||
username_ok = hmac.compare_digest(body.username.encode("utf-8"), _USERNAME.encode("utf-8"))
|
||||
password_ok = hmac.compare_digest(body.password.encode("utf-8"), _PASSWORD.encode("utf-8"))
|
||||
if not (username_ok and password_ok):
|
||||
return _unauthorized("用户名或密码错误")
|
||||
secret = _secret()
|
||||
if secret is None:
|
||||
return _unconfigured()
|
||||
data = _access_data(secret)
|
||||
data.update(refresh_token=_issue(secret, "refresh", REFRESH_TTL), refresh_expires_in=REFRESH_TTL)
|
||||
return _response(0, "success", data=data)
|
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新访问令牌")
|
||||
async def refresh(body: RefreshRequest):
|
||||
secret = _secret()
|
||||
if secret is None:
|
||||
return _unconfigured()
|
||||
if not _valid(body.refresh_token, secret, "refresh"):
|
||||
return _unauthorized("刷新令牌无效或已过期,请重新登录")
|
||||
return _response(0, "success", data=_access_data(secret))
|
||||
|
||||
|
||||
async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = Security(_bearer)):
|
||||
"""Return an error response for protected handlers before local work or dispatch."""
|
||||
if credentials is None:
|
||||
return _unauthorized("请提供 Authorization: Bearer 访问令牌")
|
||||
secret = _secret()
|
||||
if secret is None:
|
||||
return _unconfigured()
|
||||
if not _valid(credentials.credentials, secret, "access"):
|
||||
return _unauthorized("访问令牌无效或已过期")
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/check", summary="验证当前访问令牌")
|
||||
async def check(auth_error=Depends(client_auth_error)):
|
||||
"""检查受保护业务接口使用的 Bearer 令牌。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return _response(0, "访问令牌有效")
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"api_token_secret": "",
|
||||
"workers": [
|
||||
"http://127.0.0.1:28187",
|
||||
"http://127.0.0.1:28188"
|
||||
],
|
||||
"primary_workers": [],
|
||||
"shared_password": "REPLACE_ME_ROTATE_PERIODICALLY",
|
||||
"public_base_url": "https://hair.xiangsilian.com",
|
||||
"static_dir": "static/annotations",
|
||||
@@ -11,7 +13,8 @@
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1
|
||||
"healthy_threshold": 1,
|
||||
"cooldown_seconds": 60
|
||||
},
|
||||
"ark_api_key": "",
|
||||
"dispatch": {
|
||||
|
||||
@@ -13,14 +13,17 @@ logger = logging.getLogger("gateway.config")
|
||||
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
|
||||
|
||||
DEFAULTS = {
|
||||
"api_token_secret": "",
|
||||
"public_base_url": "https://hair.xiangsilian.com",
|
||||
"static_dir": "static/annotations",
|
||||
"primary_workers": [], # 主 worker 地址列表(须为 workers 的子集);空 = 全部视为主 worker
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1,
|
||||
"cooldown_seconds": 60, # 被动摘除后的隔离期(秒),0=关闭(等价旧行为)
|
||||
},
|
||||
"dispatch": {
|
||||
"per_worker_concurrency": 1,
|
||||
@@ -82,6 +85,15 @@ def load_config() -> dict:
|
||||
if not isinstance(w, str) or not w:
|
||||
raise ValueError(f"配置错误: workers[{i}] 必须是非空字符串")
|
||||
|
||||
primary_workers: List[str] = cfg.get("primary_workers", [])
|
||||
for i, w in enumerate(primary_workers):
|
||||
if not isinstance(w, str) or not w:
|
||||
raise ValueError(f"配置错误: primary_workers[{i}] 必须是非空字符串")
|
||||
for w in primary_workers:
|
||||
if w not in workers:
|
||||
logger.warning("⚠ primary_workers 中的 %s 不在 workers 列表里,将被忽略", w)
|
||||
cfg["primary_workers"] = [w for w in primary_workers if w in workers]
|
||||
|
||||
password: str = cfg.get("shared_password", "")
|
||||
if not password or password == "REPLACE_ME_ROTATE_PERIODICALLY":
|
||||
logger.warning(
|
||||
|
||||
+17
-6
@@ -29,6 +29,15 @@ from gateway.reqlog import extract_form_params
|
||||
|
||||
logger = logging.getLogger("gateway.forward")
|
||||
|
||||
# 插队优先级接口:排队(全局槽 / worker 池)时排到等待队列最前,同优先级 FIFO。
|
||||
PRIORITY_PATHS = frozenset({
|
||||
"/api/v1/hairline/generate", # 接口5:发际线PNG生成
|
||||
})
|
||||
|
||||
|
||||
def _priority_of(path: str) -> int:
|
||||
return 1 if path in PRIORITY_PATHS else 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 全局 httpx client(连接复用)
|
||||
@@ -137,7 +146,8 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
||||
"""代理一次请求到 worker,处理重试与 base64 改写。
|
||||
|
||||
流程:
|
||||
1. acquire worker(排队等待空闲)
|
||||
0. 获取全局串行槽(PRIORITY_PATHS 中的接口插队到最前)
|
||||
1. acquire worker(排队等待空闲,同样支持优先级插队)
|
||||
2. 重构 multipart/form 请求,加 X-Internal-Token
|
||||
3. 转发到 worker
|
||||
4. 成功 → release worker → 改写 base64 → 返回 JSONResponse
|
||||
@@ -161,8 +171,9 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
||||
except Exception as ex: # noqa: BLE001
|
||||
request.state.log_request_params = {"_parse_error": str(ex)}
|
||||
|
||||
# --- 全局串行槽(GPU 请求在此排队;超时拒绝) ---
|
||||
if not await acquire_global_slot(cfg):
|
||||
# --- 全局串行槽(GPU 请求在此排队;超时拒绝;优先级接口插队到最前) ---
|
||||
priority = _priority_of(path)
|
||||
if not await acquire_global_slot(cfg, priority):
|
||||
request.state.worker_url = ""
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
@@ -175,13 +186,13 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
return await _dispatch_with_retries(request, path, cfg, body)
|
||||
return await _dispatch_with_retries(request, path, cfg, body, priority)
|
||||
finally:
|
||||
release_global_slot()
|
||||
|
||||
|
||||
async def _dispatch_with_retries(
|
||||
request: Request, path: str, cfg: dict, body: bytes
|
||||
request: Request, path: str, cfg: dict, body: bytes, priority: int = 0
|
||||
) -> JSONResponse:
|
||||
"""在已持有全局串行槽的前提下:选 worker → 转发 → 重试 → 改写 base64 → 返回。
|
||||
|
||||
@@ -203,7 +214,7 @@ async def _dispatch_with_retries(
|
||||
for attempt in range(attempts):
|
||||
worker = None
|
||||
try:
|
||||
worker = await acquire_worker(cfg)
|
||||
worker = await acquire_worker(cfg, priority)
|
||||
# 记录当前使用的 worker,供日志中间件读取
|
||||
request.state.worker_url = worker.url
|
||||
except NoWorkerAvailable:
|
||||
|
||||
@@ -223,7 +223,7 @@ def is_initialized() -> bool:
|
||||
|
||||
def _should_log(path: str) -> bool:
|
||||
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
|
||||
return path.startswith("/api/")
|
||||
return path.startswith("/api/") and not path.startswith("/api/v1/auth/")
|
||||
|
||||
|
||||
async def request_logging_middleware(request, call_next):
|
||||
@@ -436,6 +436,8 @@ PATH_INTERFACE_MAP: Dict[str, Dict[str, Any]] = {
|
||||
"/api/v1/hair/grow-v2": {"num": 7, "name": "C端生发 v2"},
|
||||
"/api/v1/head/mask": {"num": 9, "name": "头发遮罩生成"},
|
||||
"/api/v1/head/band": {"num": 10, "name": "头部外缘膨胀带遮罩"},
|
||||
"/api/v1/face/measure-11": {"num": 11, "name": "四庭七眼测量标注(鉴权)"},
|
||||
"/api/v1/face/measure-61": {"num": 61, "name": "四庭七眼测量标注 v2(鉴权)"},
|
||||
}
|
||||
|
||||
# 会按「勾选发型数量」串行多次调用 ComfyUI 的接口(耗时 ≈ 固定开销 + 单次换发型耗时 × 发型数)
|
||||
|
||||
+195
-78
@@ -5,6 +5,8 @@
|
||||
- 每个 worker 一个 busy 标志(per_worker_concurrency=1)
|
||||
- acquire_worker() 从在线池挑空闲 worker;全忙排队;池空→NoWorkerAvailable
|
||||
- release_worker() 释放 worker
|
||||
- 两级排队(全局槽 / worker 池)均支持优先级:priority 大者插到队列最前,
|
||||
同优先级 FIFO;已有等待者时新请求不得越过(严格按优先级排序)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -18,6 +20,15 @@ import httpx
|
||||
logger = logging.getLogger("gateway.pool")
|
||||
|
||||
|
||||
def _cooldown_seconds() -> float:
|
||||
"""被动摘除后的隔离期时长(秒),配置 health_check.cooldown_seconds,默认 60。"""
|
||||
try:
|
||||
from gateway.config import get_config
|
||||
return float(get_config()["health_check"].get("cooldown_seconds", 60))
|
||||
except Exception: # noqa: BLE001
|
||||
return 60.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 异常
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -37,8 +48,10 @@ class WorkerState:
|
||||
url: str
|
||||
online: bool = False # 初始 offline,等健康检查通过后上线
|
||||
busy: bool = False
|
||||
primary: bool = True # 主 worker(True 优先派发);全部主 worker 下线才用备组
|
||||
consecutive_failures: int = 0
|
||||
consecutive_successes: int = 0
|
||||
offline_until: float = 0.0 # 被动摘除后的隔离截止时刻(time.monotonic() 基准)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -50,58 +63,90 @@ _pool_condition: Optional[asyncio.Condition] = None
|
||||
_health_task: Optional[asyncio.Task] = None
|
||||
_shutdown_event: Optional[asyncio.Event] = None
|
||||
|
||||
# worker 池等待者:[(-priority, 入队序号, future)],按 (-priority, 序号) 升序排列。
|
||||
# 等待者在持有 _pool_condition 锁时增删;release_worker() 唤醒队首(优先级最高者)。
|
||||
_worker_waiters: List[tuple] = []
|
||||
_waiter_seq: int = 0
|
||||
|
||||
# 全局串行槽:同一时间最多 max_global_concurrency 个请求进入 worker 派发,其余排队。
|
||||
# ⚠️ 依赖单进程 uvicorn 部署(hair-gateway.service 无 --workers)。
|
||||
# 若改多 worker / gunicorn,进程内信号量会失效,需换成跨进程锁(文件锁/Redis)。
|
||||
_global_sem: Optional[asyncio.Semaphore] = None
|
||||
_global_sem: Optional["_PrioritySemaphore"] = None
|
||||
_global_max: int = 1
|
||||
_global_busy: int = 0
|
||||
_global_waiting: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 优先级信号量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _PrioritySemaphore:
|
||||
"""带插队优先级的信号量(替代 asyncio.Semaphore,后者不支持优先级)。
|
||||
|
||||
等待者按 (-priority, 入队序号) 排序:priority 大者先获得槽位(插队到最前),
|
||||
同优先级 FIFO。仅当无等待者时才允许直接获取——防止新请求越过排队者
|
||||
(asyncio.Semaphore 本身允许这种插队,这里收紧以保证优先级语义)。
|
||||
"""
|
||||
|
||||
def __init__(self, value: int) -> None:
|
||||
self._value = value
|
||||
self._waiters: List[tuple] = [] # [(-priority, seq, future)]
|
||||
self._seq = 0
|
||||
|
||||
async def acquire(self, priority: int = 0) -> None:
|
||||
if self._value > 0 and not self._waiters:
|
||||
self._value -= 1
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
fut = loop.create_future()
|
||||
self._seq += 1
|
||||
entry = (-priority, self._seq, fut)
|
||||
idx = 0
|
||||
while idx < len(self._waiters) and self._waiters[idx][:2] <= entry[:2]:
|
||||
idx += 1
|
||||
self._waiters.insert(idx, entry)
|
||||
if priority > 0 and idx < len(self._waiters) - 1:
|
||||
logger.info("优先级请求插队到全局队列第 %d 位(超越 %d 个等待者)",
|
||||
idx + 1, len(self._waiters) - 1 - idx)
|
||||
try:
|
||||
await fut
|
||||
except asyncio.CancelledError:
|
||||
if fut.done() and not fut.cancelled():
|
||||
# 已被 release 授予槽位、但本任务随后被取消 → 归还槽位
|
||||
self.release()
|
||||
else:
|
||||
try:
|
||||
self._waiters.remove(entry)
|
||||
except ValueError:
|
||||
pass
|
||||
raise
|
||||
|
||||
def release(self) -> None:
|
||||
while self._waiters:
|
||||
_, _, fut = self._waiters.pop(0)
|
||||
if not fut.done():
|
||||
fut.set_result(None)
|
||||
return
|
||||
self._value += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康检查后台任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _check_worker_health(
|
||||
client: httpx.AsyncClient,
|
||||
w: WorkerState,
|
||||
cfg: dict,
|
||||
) -> None:
|
||||
"""探测单个 worker 的 /health,更新上下线状态。"""
|
||||
hc_cfg = cfg["health_check"]
|
||||
url = f"{w.url}{hc_cfg['path']}"
|
||||
token = cfg["shared_password"]
|
||||
def _try_revive(w: WorkerState, healthy_threshold: int) -> bool:
|
||||
"""探测成功后是否允许将该 worker 复活上线。
|
||||
|
||||
try:
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={"X-Internal-Token": token},
|
||||
timeout=hc_cfg["timeout_seconds"],
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
w.consecutive_failures = 0
|
||||
w.consecutive_successes += 1
|
||||
if w.consecutive_successes >= hc_cfg["healthy_threshold"]:
|
||||
if not w.online:
|
||||
w.online = True
|
||||
logger.info("Worker 上线: %s(连续成功 %d 次)", w.url, w.consecutive_successes)
|
||||
else:
|
||||
_mark_failure(w, f"HTTP {resp.status_code}")
|
||||
except Exception as exc:
|
||||
_mark_failure(w, str(exc))
|
||||
|
||||
|
||||
def _mark_failure(w: WorkerState, reason: str) -> None:
|
||||
"""记录一次失败,达到阈值后下线。"""
|
||||
w.consecutive_successes = 0
|
||||
w.consecutive_failures += 1
|
||||
threshold = w.consecutive_failures # used in log
|
||||
hc_threshold = 2 # default, will be overridden
|
||||
if w.consecutive_failures >= hc_threshold:
|
||||
# 实际 threshold 从配置读取,这里先做基本判断
|
||||
pass
|
||||
logger.debug("Worker %s 健康检查失败 (%d/%d): %s", w.url, w.consecutive_failures, 99, reason)
|
||||
条件:未在线 + 连续成功达到阈值 + **不在被动摘除隔离期内**
|
||||
(offline_until 之前即使 /health 探测成功也压着不上线,防横跳)。
|
||||
"""
|
||||
if w.online or w.consecutive_successes < healthy_threshold:
|
||||
return False
|
||||
if time.monotonic() < w.offline_until:
|
||||
logger.debug("Worker %s 探测成功但处于隔离期,暂不复活", w.url)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _health_check_loop(cfg: dict) -> None:
|
||||
@@ -113,8 +158,8 @@ async def _health_check_loop(cfg: dict) -> None:
|
||||
token = cfg["shared_password"]
|
||||
|
||||
logger.info(
|
||||
"健康检查循环启动 | 间隔=%ds | 下线阈值=%d | 上线阈值=%d | workers=%d",
|
||||
interval, unhealthy_threshold, healthy_threshold, len(_workers),
|
||||
"健康检查循环启动 | 间隔=%ds | 下线阈值=%d | 上线阈值=%d | 隔离期=%.0fs | workers=%d",
|
||||
interval, unhealthy_threshold, healthy_threshold, _cooldown_seconds(), len(_workers),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
@@ -130,7 +175,7 @@ async def _health_check_loop(cfg: dict) -> None:
|
||||
if resp.status_code == 200:
|
||||
w.consecutive_failures = 0
|
||||
w.consecutive_successes += 1
|
||||
if w.consecutive_successes >= healthy_threshold and not w.online:
|
||||
if _try_revive(w, healthy_threshold):
|
||||
w.online = True
|
||||
logger.info("✅ Worker 上线: %s", w.url)
|
||||
else:
|
||||
@@ -166,7 +211,12 @@ async def init_pool(cfg: dict) -> None:
|
||||
"""初始化 worker 池并启动健康检查后台任务。"""
|
||||
global _workers, _pool_condition, _health_task, _shutdown_event
|
||||
|
||||
_workers = {url: WorkerState(url=url) for url in cfg["workers"]}
|
||||
# 主 worker 分组:配置了 primary_workers 则按其标记,否则全部视为主 worker
|
||||
primary_set = set(cfg.get("primary_workers") or cfg["workers"])
|
||||
|
||||
_workers = {url: WorkerState(url=url, primary=url in primary_set)
|
||||
for url in cfg["workers"]}
|
||||
n_primary = sum(1 for w in _workers.values() if w.primary)
|
||||
_pool_condition = asyncio.Condition()
|
||||
_shutdown_event = asyncio.Event()
|
||||
|
||||
@@ -196,7 +246,8 @@ async def init_pool(cfg: dict) -> None:
|
||||
# 对于 healthy_threshold == 1 的情况,已在上面的检查中上线
|
||||
# 标记初始在线状态
|
||||
online_count = sum(1 for w in _workers.values() if w.online)
|
||||
logger.info("Worker 池初始化完成 | 总数=%d | 在线=%d", len(_workers), online_count)
|
||||
logger.info("Worker 池初始化完成 | 总数=%d | 在线=%d | 主worker=%d",
|
||||
len(_workers), online_count, n_primary)
|
||||
|
||||
# 启动后台健康检查
|
||||
_health_task = asyncio.create_task(_health_check_loop(cfg))
|
||||
@@ -224,23 +275,25 @@ async def shutdown_pool() -> None:
|
||||
def init_global_slot(cfg: dict) -> None:
|
||||
"""初始化全局串行信号量。
|
||||
|
||||
max_global_concurrency=1 → 同一时间只有一个请求进入 worker 派发,其余在信号量上排队。
|
||||
max_global_concurrency=N → 同一时间最多 N 个请求进入 worker 派发,其余排队。
|
||||
priority>0 的请求可插队到等待队列最前(见 _PrioritySemaphore)。
|
||||
依赖单进程 uvicorn 部署;多进程需换跨进程锁。
|
||||
"""
|
||||
global _global_sem, _global_max, _global_busy, _global_waiting
|
||||
n = int(cfg.get("dispatch", {}).get("max_global_concurrency", 1))
|
||||
if n < 1:
|
||||
n = 1
|
||||
_global_sem = asyncio.Semaphore(n)
|
||||
_global_sem = _PrioritySemaphore(n)
|
||||
_global_max = n
|
||||
_global_busy = 0
|
||||
_global_waiting = 0
|
||||
logger.info("全局并发槽初始化: max=%d(单进程生效)", n)
|
||||
|
||||
|
||||
async def acquire_global_slot(cfg: dict) -> bool:
|
||||
async def acquire_global_slot(cfg: dict, priority: int = 0) -> bool:
|
||||
"""获取一个全局处理槽。True=获得;False=排队超时。
|
||||
|
||||
priority>0 的请求插队到等待队列最前,同优先级 FIFO。
|
||||
超时由 dispatch.max_queue_wait_seconds 控制(需 < nginx proxy_read_timeout 600s)。
|
||||
"""
|
||||
global _global_busy, _global_waiting
|
||||
@@ -249,7 +302,7 @@ async def acquire_global_slot(cfg: dict) -> bool:
|
||||
timeout = float(cfg.get("dispatch", {}).get("max_queue_wait_seconds", 590))
|
||||
_global_waiting += 1
|
||||
try:
|
||||
await asyncio.wait_for(_global_sem.acquire(), timeout=timeout)
|
||||
await asyncio.wait_for(_global_sem.acquire(priority), timeout=timeout)
|
||||
_global_busy += 1
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
@@ -272,57 +325,116 @@ def release_global_slot() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def mark_worker_unhealthy(w: WorkerState) -> None:
|
||||
"""被动标记:转发失败时立即将该 worker 下线。"""
|
||||
"""被动标记:转发失败时立即将该 worker 下线,并进入隔离期。
|
||||
|
||||
隔离期内即使 /health 探测成功也不复活(由 _try_revive 守卫),
|
||||
避免"摘除→8 秒复活→再失败"的横跳。
|
||||
"""
|
||||
async with _pool_condition:
|
||||
if w.online:
|
||||
w.online = False
|
||||
w.consecutive_failures += 1
|
||||
logger.warning("⚠ Worker 被动下线: %s(转发失败)", w.url)
|
||||
cooldown = _cooldown_seconds()
|
||||
w.offline_until = time.monotonic() + cooldown
|
||||
logger.warning("⚠ Worker 被动下线: %s(转发失败,隔离 %.0fs)",
|
||||
w.url, cooldown)
|
||||
|
||||
|
||||
async def acquire_worker(cfg: dict) -> WorkerState:
|
||||
def _insert_worker_waiter(key: tuple, fut: asyncio.Future) -> tuple:
|
||||
"""按 (-priority, 序号) 升序插入等待者(须在持有 _pool_condition 锁时调用)。"""
|
||||
entry = (key[0], key[1], fut)
|
||||
idx = 0
|
||||
while idx < len(_worker_waiters) and _worker_waiters[idx][:2] <= entry[:2]:
|
||||
idx += 1
|
||||
_worker_waiters.insert(idx, entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _wake_best_worker_waiter() -> None:
|
||||
"""唤醒优先级最高的等待者(须在持有 _pool_condition 锁时调用)。"""
|
||||
while _worker_waiters:
|
||||
_, _, fut = _worker_waiters.pop(0)
|
||||
if not fut.done():
|
||||
fut.set_result(None)
|
||||
return
|
||||
|
||||
|
||||
async def acquire_worker(cfg: dict, priority: int = 0) -> WorkerState:
|
||||
"""从在线池中获取一个空闲 worker。
|
||||
|
||||
主备分组:只要还有主 worker(primary)在线,就只在主组内挑空闲
|
||||
(全忙则排队等待,不用备组);只有主组全部下线时才用备用 worker。
|
||||
全忙则排队等待,最多 queue_wait_seconds;池空立即抛异常。
|
||||
排队按 (-priority, 入队序号) 排序:priority 大者插队到最前,同优先级 FIFO;
|
||||
已有等待者时新请求不得越过(保证优先级语义,防饿死)。
|
||||
"""
|
||||
queue_wait = cfg["dispatch"]["queue_wait_seconds"]
|
||||
deadline = time.monotonic() + queue_wait
|
||||
my_key: Optional[tuple] = None # (-priority, seq),被唤醒后重排仍用原序号
|
||||
woken = False # 是否刚被 release_worker 唤醒(被唤醒者直接参与挑选)
|
||||
|
||||
async with _pool_condition:
|
||||
while True:
|
||||
# 在线 + 空闲
|
||||
idle = [w for w in _workers.values() if w.online and not w.busy]
|
||||
while True:
|
||||
async with _pool_condition:
|
||||
online = [w for w in _workers.values() if w.online]
|
||||
primary_online = [w for w in online if w.primary]
|
||||
# 主组有在线 → 只在主组内挑;主组全挂 → 才轮到备组
|
||||
candidates = primary_online if primary_online else online
|
||||
idle = [w for w in candidates if not w.busy]
|
||||
|
||||
if not idle:
|
||||
online = [w for w in _workers.values() if w.online]
|
||||
if not online:
|
||||
raise NoWorkerAvailable("后端服务暂不可用,请稍后重试")
|
||||
if idle and (woken or not _worker_waiters):
|
||||
# 取第一个空闲 worker
|
||||
w = idle[0]
|
||||
w.busy = True
|
||||
if not w.primary:
|
||||
logger.info("主 worker 全部下线,使用备用 worker: %s", w.url)
|
||||
logger.debug("Worker 分配: %s", w.url)
|
||||
return w
|
||||
|
||||
# 全忙,等待
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
|
||||
if not online:
|
||||
raise NoWorkerAvailable("后端服务暂不可用,请稍后重试")
|
||||
|
||||
logger.info("所有 worker 全忙(%d 在线),等待 %.1fs...", len(online), remaining)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
|
||||
|
||||
if my_key is None:
|
||||
global _waiter_seq
|
||||
_waiter_seq += 1
|
||||
my_key = (-priority, _waiter_seq)
|
||||
first_enqueue = True
|
||||
else:
|
||||
first_enqueue = False
|
||||
fut = asyncio.get_running_loop().create_future()
|
||||
entry = _insert_worker_waiter(my_key, fut)
|
||||
if first_enqueue:
|
||||
pos = _worker_waiters.index(entry) + 1
|
||||
if not idle:
|
||||
logger.info("所有可用 worker 全忙(%d 在线),等待 %.1fs...",
|
||||
len(candidates), remaining)
|
||||
if priority > 0 and pos < len(_worker_waiters):
|
||||
logger.info("优先级请求插队到 worker 队列第 %d 位(超越 %d 个等待者)",
|
||||
pos, len(_worker_waiters) - pos)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(fut, timeout=remaining)
|
||||
woken = True
|
||||
except asyncio.TimeoutError:
|
||||
async with _pool_condition:
|
||||
try:
|
||||
await asyncio.wait_for(_pool_condition.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
|
||||
continue # 重新检查
|
||||
|
||||
# 取第一个空闲 worker
|
||||
w = idle[0]
|
||||
w.busy = True
|
||||
logger.debug("Worker 分配: %s", w.url)
|
||||
return w
|
||||
_worker_waiters.remove(entry)
|
||||
except ValueError:
|
||||
# 授予与取消竞态:已被弹出授予(worker 空闲),转手唤醒下一位
|
||||
if fut.done() and not fut.cancelled():
|
||||
_wake_best_worker_waiter()
|
||||
raise NoWorkerAvailable("后端服务繁忙,请稍后重试")
|
||||
|
||||
|
||||
async def release_worker(w: WorkerState) -> None:
|
||||
"""释放 worker,标记为空闲并通知等待者。"""
|
||||
"""释放 worker,标记为空闲并唤醒优先级最高的等待者。"""
|
||||
async with _pool_condition:
|
||||
w.busy = False
|
||||
logger.debug("Worker 释放: %s", w.url)
|
||||
_pool_condition.notify(1)
|
||||
_wake_best_worker_waiter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -337,8 +449,13 @@ def get_pool_status() -> dict:
|
||||
"global_waiting": _global_waiting,
|
||||
}
|
||||
if not _workers:
|
||||
return {"total": 0, "healthy": 0, "busy": 0, **global_info}
|
||||
return {"total": 0, "healthy": 0, "busy": 0,
|
||||
"primary_total": 0, "primary_healthy": 0, **global_info}
|
||||
total = len(_workers)
|
||||
healthy = sum(1 for w in _workers.values() if w.online)
|
||||
busy = sum(1 for w in _workers.values() if w.busy)
|
||||
return {"total": total, "healthy": healthy, "busy": busy, **global_info}
|
||||
primary_total = sum(1 for w in _workers.values() if w.primary)
|
||||
primary_healthy = sum(1 for w in _workers.values() if w.primary and w.online)
|
||||
return {"total": total, "healthy": healthy, "busy": busy,
|
||||
"primary_total": primary_total, "primary_healthy": primary_healthy,
|
||||
**global_info}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
设计原则(与用户约定):
|
||||
- 入参/出参日志里**绝不内嵌图片 base64**。图片统一存盘后用 URL 引用,保持日志短小。
|
||||
- 入参图片(image_file / *_base64)补存到 static_dir(in_ 前缀),永久保留;
|
||||
- 入参图片(image_file / *_base64)补存到 static_dir(in_ 前缀);
|
||||
url 输入图本身就在远端,不重新下载,直接记 URL。
|
||||
- 出参响应在 forward.py 已把 base64 改写成 *_url(无大图),记录完整 data,
|
||||
但用递归摘要器截断超长结构(landmarks、长字符串),单条上限 ~8KB。
|
||||
@@ -39,7 +39,7 @@ def save_image_bytes(
|
||||
"""把图片字节存盘并返回公网 URL。失败返回 None(不抛异常)。
|
||||
|
||||
存到 static_dir/{prefix}{uuid}.{ext},URL = {public_base_url}/static/annotations/{file}。
|
||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致;文件永久保留,不自动清理。
|
||||
与 forward.py 的 rewrite_base64_to_url 落盘路径/URL 规则一致;超过 30 天的文件由 crontab 每日清理。
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
+1
-1
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
@@ -44,18 +44,18 @@ class FaceLandmarker:
|
||||
self._detector = vision.FaceLandmarker.create_from_options(options)
|
||||
self._mp = mp
|
||||
|
||||
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
|
||||
"""Returns (468, 3) float32 of normalized x, y and relative z, or None.
|
||||
def detect(self, image_rgb: np.ndarray, include_iris: bool = False) -> np.ndarray | None:
|
||||
"""Returns (468, 3), or (478, 3) with iris, normalized float32 points.
|
||||
|
||||
The task model produces 478 landmarks (468 face + 10 iris); we return
|
||||
only the first 468 to match the canonical FaceMesh topology used by
|
||||
the SDK's OBJ file.
|
||||
The task model produces 478 landmarks (468 face + 10 iris). By default
|
||||
only the first 468 match the SDK's canonical FaceMesh topology;
|
||||
include_iris=True retains all 478 for face-measurement calibration.
|
||||
"""
|
||||
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb)
|
||||
result = self._detector.detect(mp_image)
|
||||
if not result.face_landmarks:
|
||||
return None
|
||||
lm = result.face_landmarks[0][:468]
|
||||
lm = result.face_landmarks[0] if include_iris else result.face_landmarks[0][:468]
|
||||
arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
|
||||
return arr
|
||||
|
||||
@@ -66,6 +66,25 @@ class FaceLandmarker:
|
||||
pass
|
||||
|
||||
|
||||
def to_landmark_list(landmarks):
|
||||
from mediapipe.framework.formats import landmark_pb2
|
||||
|
||||
result = landmark_pb2.NormalizedLandmarkList()
|
||||
for x, y, z in landmarks:
|
||||
result.landmark.add(x=float(x), y=float(y), z=float(z))
|
||||
return result
|
||||
|
||||
|
||||
_landmarker = None
|
||||
|
||||
|
||||
def get_landmarker() -> FaceLandmarker:
|
||||
global _landmarker
|
||||
if _landmarker is None:
|
||||
_landmarker = FaceLandmarker(static_image_mode=True)
|
||||
return _landmarker
|
||||
|
||||
|
||||
class SolutionsFaceLandmarker:
|
||||
"""CPU-oriented fallback using mediapipe.solutions.face_mesh."""
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Interface 52: upright face axis, then native-resolution reference framing."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
PROFILE_PATH = Path(__file__).parent / "reference/framing_profile.json"
|
||||
|
||||
|
||||
def decode_portrait(raw):
|
||||
"""Respect phone EXIF orientation before detecting or transforming points."""
|
||||
try:
|
||||
with Image.open(BytesIO(raw)) as image:
|
||||
return cv2.cvtColor(np.array(ImageOps.exif_transpose(image).convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_profile():
|
||||
return json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def transform_points(points, matrix):
|
||||
return np.asarray(points, np.float64) @ matrix[:, :2].T + matrix[:, 2]
|
||||
|
||||
|
||||
def _rotation(landmarks, width, height):
|
||||
xy = landmarks[:, :2].astype(np.float64) * [width, height]
|
||||
delta = xy[152] - xy[9]
|
||||
length = float(np.linalg.norm(delta))
|
||||
if not np.isfinite(xy).all() or length < 2 or delta[1] <= 0:
|
||||
raise ValueError("9号点与152号点无效,无法扶正人脸")
|
||||
down = delta / length
|
||||
rotation = np.array([[down[1], -down[0]], [down[0], down[1]]], np.float64)
|
||||
return xy, rotation, length
|
||||
|
||||
|
||||
def _matrix(rotation, source_anchor, target_anchor):
|
||||
return np.column_stack((rotation, np.asarray(target_anchor) - rotation @ source_anchor))
|
||||
|
||||
|
||||
def crop_fits(matrix, source_size, output_size):
|
||||
w, h = output_size
|
||||
corners = transform_points([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]], cv2.invertAffineTransform(matrix))
|
||||
sw, sh = source_size
|
||||
return bool((corners[:, 0] >= -1e-7).all() and (corners[:, 0] <= sw-1+1e-7).all()
|
||||
and (corners[:, 1] >= -1e-7).all() and (corners[:, 1] <= sh-1+1e-7).all())
|
||||
|
||||
|
||||
def _inscribed_frame(rotation, source_size, aspect=None):
|
||||
"""Largest centred valid rectangle: no generated background or upscaling."""
|
||||
sw, sh = source_size
|
||||
ratio = sw / sh if aspect is None else aspect
|
||||
source_center = np.array([(sw-1)/2, (sh-1)/2])
|
||||
low, high = 2.0, min(float(sh), sw/ratio)
|
||||
for _ in range(48):
|
||||
h = (low+high)/2
|
||||
w = h*ratio
|
||||
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
|
||||
if crop_fits(matrix, source_size, (w, h)):
|
||||
low = h
|
||||
else:
|
||||
high = h
|
||||
if aspect == .75:
|
||||
h = max(4, int(low+1e-7)//4*4)
|
||||
w = h//4*3
|
||||
else:
|
||||
h, w = max(2, int(low+1e-7)), max(2, int(low*ratio+1e-7))
|
||||
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
|
||||
if not crop_fits(matrix, source_size, (w, h)):
|
||||
raise ValueError("图片边缘空间不足,无法得到有效扶正画面")
|
||||
return matrix, (w, h)
|
||||
|
||||
|
||||
def _tall_portrait_frame(xy, rotation, source_size, profile):
|
||||
"""Largest native 3:4 frame, positioned to retain the entire face."""
|
||||
_, size = _inscribed_frame(rotation, source_size, aspect=.75)
|
||||
half = (np.array(size, np.float64)-1)/2
|
||||
extent = np.abs(rotation.T) @ half
|
||||
low, high = extent, np.array(source_size)-1-extent
|
||||
polygon = [np.array(p) for p in ((low[0], low[1]), (high[0], low[1]),
|
||||
(high[0], high[1]), (low[0], high[1]))]
|
||||
face = xy[:468] @ rotation.T
|
||||
# The crop centre must satisfy both the source boundaries and the face
|
||||
# bounds. Clip the feasible polygon in double precision to avoid padding.
|
||||
face_low, face_high = face.max(axis=0)-half, face.min(axis=0)+half
|
||||
for axis in range(2):
|
||||
for normal, bound in ((rotation[axis], face_high[axis]),
|
||||
(-rotation[axis], -face_low[axis])):
|
||||
clipped = []
|
||||
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
|
||||
a, b = float(normal @ start-bound-1e-8), float(normal @ end-bound-1e-8)
|
||||
if (a <= 0) != (b <= 0):
|
||||
clipped.append(start+(end-start)*(a/(a-b)))
|
||||
if b <= 0:
|
||||
clipped.append(end)
|
||||
polygon = clipped
|
||||
if not polygon:
|
||||
raise ValueError("3:4裁切窗口无法保留完整人脸")
|
||||
target = np.array([profile["brow_x_ratio"]*size[0], profile["brow_y_ratio"]*size[1]])
|
||||
preferred = xy[9]-rotation.T @ (target-half)
|
||||
# Euclidean projection keeps the brow as close as possible to its target.
|
||||
candidates = []
|
||||
if (preferred >= low-1e-8).all() and (preferred <= high+1e-8).all():
|
||||
upright = rotation @ preferred
|
||||
if (upright >= face_low-1e-8).all() and (upright <= face_high+1e-8).all():
|
||||
candidates.append(preferred)
|
||||
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
|
||||
edge = end-start
|
||||
length2 = float(edge @ edge)
|
||||
t = np.clip(float((preferred-start) @ edge)/length2, 0, 1) if length2 > 1e-16 else 0
|
||||
candidates.append(start+t*edge)
|
||||
centre = min(candidates, key=lambda p: float(np.sum((p-preferred)**2)))
|
||||
matrix = _matrix(rotation, centre, half)
|
||||
if not crop_fits(matrix, source_size, size):
|
||||
raise ValueError("3:4裁切窗口超出原图有效范围")
|
||||
return matrix, size
|
||||
|
||||
|
||||
def _apply(photo, landmarks, matrix, size):
|
||||
h, w = photo.shape[:2]
|
||||
ow, oh = size
|
||||
moved = np.asarray(landmarks, np.float64).copy()
|
||||
moved[:, :2] = transform_points(landmarks[:, :2]*[w, h], matrix) / [ow, oh]
|
||||
# MediaPipe Z is measured in normalized image-width units.
|
||||
moved[:, 2] *= w/ow
|
||||
result = cv2.warpAffine(photo, matrix, size, flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))
|
||||
return result, moved
|
||||
|
||||
|
||||
def calibrate_reference(photo, landmarks):
|
||||
"""First straighten the actual reference; derive ratios from its final frame."""
|
||||
h, w = photo.shape[:2]
|
||||
_xy, rotation, length = _rotation(landmarks, w, h)
|
||||
matrix, size = _inscribed_frame(rotation, (w, h), aspect=.75)
|
||||
image, moved = _apply(photo, landmarks, matrix, size)
|
||||
ow, oh = size
|
||||
b, c = moved[[9, 152], :2]*[ow, oh]
|
||||
profile = {
|
||||
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
|
||||
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
|
||||
"reference_image_url": "/static/framing_reference.png",
|
||||
"reference_size": {"width": ow, "height": oh},
|
||||
"source_size": {"width": w, "height": h},
|
||||
"brow_9": {"x": float(b[0]), "y": float(b[1])},
|
||||
"chin_152": {"x": float(c[0]), "y": float(c[1])},
|
||||
"face_axis_length_px": length,
|
||||
"brow_x_ratio": float(b[0]/ow), "brow_y_ratio": float(b[1]/oh),
|
||||
"reference_width_per_face_length": ow/length,
|
||||
"reference_height_per_face_length": oh/length,
|
||||
"width_per_face_length": 2.5, "height_per_face_length": 3.0/.9,
|
||||
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
|
||||
"axis_x_error_px": float(abs(b[0]-c[0])),
|
||||
"source_to_reference": matrix.tolist(),
|
||||
"landmark_source": "source_landmarks_transformed_without_redetection",
|
||||
}
|
||||
return image, moved, profile
|
||||
|
||||
|
||||
def normalize_portrait(photo, landmarks, profile=None):
|
||||
profile = load_profile() if profile is None else profile
|
||||
h, w = photo.shape[:2]
|
||||
xy, rotation, length = _rotation(landmarks, w, h)
|
||||
# Round to an exact 3:4 frame without changing pixel scale.
|
||||
target_h = max(4, round(profile["height_per_face_length"]*length/4)*4)
|
||||
target_w = target_h//4*3
|
||||
matrix = _matrix(rotation, xy[9], [profile["brow_x_ratio"]*target_w,
|
||||
profile["brow_y_ratio"]*target_h])
|
||||
cropped = crop_fits(matrix, (w, h), (target_w, target_h))
|
||||
aspect_cropped = False
|
||||
aspect_skip_reason = None
|
||||
if cropped:
|
||||
size = (target_w, target_h)
|
||||
elif h*3 > w*4:
|
||||
try:
|
||||
matrix, size = _tall_portrait_frame(xy, rotation, (w, h), profile)
|
||||
aspect_cropped = True
|
||||
except ValueError as ex:
|
||||
aspect_skip_reason = str(ex)
|
||||
matrix, size = _inscribed_frame(rotation, (w, h))
|
||||
else:
|
||||
matrix, size = _inscribed_frame(rotation, (w, h))
|
||||
image, moved = _apply(photo, landmarks, matrix, size)
|
||||
ow, oh = size
|
||||
axis = moved[[9, 152], :2]*[ow, oh]
|
||||
if not ((axis[:, 0] >= 0).all() and (axis[:, 0] < ow).all()
|
||||
and (axis[:, 1] >= 0).all() and (axis[:, 1] < oh).all()):
|
||||
raise ValueError("人脸过于贴近边缘,扶正后无法保留9号点与152号点")
|
||||
metadata = {
|
||||
"enabled": True, "standard_crop": cropped, "aspect_crop": aspect_cropped,
|
||||
"aspect_crop_skip_reason": aspect_skip_reason,
|
||||
"status": "reference_crop" if cropped else ("aspect_crop" if aspect_cropped else "rotation_only"),
|
||||
"skip_reason": None if cropped else "insufficient_space",
|
||||
"message": ("已扶正并按参考图裁切" if cropped else
|
||||
"原图偏长,已扶正并裁成3:4,保留完整人脸" if aspect_cropped else
|
||||
"3:4窗口无法完整保留人脸,已仅扶正" if aspect_skip_reason else
|
||||
"空间不足,仅扶正,未按参考比例裁切"),
|
||||
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
|
||||
"source_size": {"width": w, "height": h}, "output_size": {"width": ow, "height": oh},
|
||||
"requested_crop_size": {"width": target_w, "height": target_h},
|
||||
"face_axis_length_px": length,
|
||||
"height_per_face_length": profile["height_per_face_length"],
|
||||
"width_per_face_length": profile["height_per_face_length"]*.75,
|
||||
"source_to_processed": matrix.tolist(),
|
||||
"processed_to_source": cv2.invertAffineTransform(matrix).tolist(),
|
||||
"axis_x_error_px": float(abs(axis[0, 0]-axis[1, 0])),
|
||||
"profile_version": profile["version"],
|
||||
"reference_image_url": profile["reference_image_url"],
|
||||
}
|
||||
return image, moved, metadata
|
||||
|
||||
|
||||
class NoPortraitFaceError(ValueError):
|
||||
"""The portrait landmarker could not find a face."""
|
||||
|
||||
|
||||
def prepare_portrait(photo):
|
||||
"""Shared interface-52 preprocessing, including the same landmark model/profile."""
|
||||
from .face_landmarks import get_landmarker
|
||||
landmarks = get_landmarker().detect(cv2.cvtColor(photo, cv2.COLOR_BGR2RGB), include_iris=True)
|
||||
if landmarks is None:
|
||||
raise NoPortraitFaceError("未检出人脸")
|
||||
return normalize_portrait(photo, landmarks)
|
||||
+2
-2
@@ -16,7 +16,7 @@ from . import comfyui
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发"
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||
|
||||
@@ -62,7 +62,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||
Args:
|
||||
image_bytes: 人物图片字节(JPG/PNG)
|
||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发"
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
timeout: ComfyUI 超时秒数
|
||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
|
||||
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
|
||||
"reference_image_url": "/static/framing_reference.png",
|
||||
"reference_size": {
|
||||
"width": 786,
|
||||
"height": 1048
|
||||
},
|
||||
"source_size": {
|
||||
"width": 792,
|
||||
"height": 1056
|
||||
},
|
||||
"brow_9": {
|
||||
"x": 385.1878369059212,
|
||||
"y": 360.65437126595765
|
||||
},
|
||||
"chin_152": {
|
||||
"x": 385.18783690592113,
|
||||
"y": 725.8251047982238
|
||||
},
|
||||
"face_axis_length_px": 365.1707335322661,
|
||||
"brow_x_ratio": 0.4900608612034621,
|
||||
"brow_y_ratio": 0.3441358504446161,
|
||||
"reference_width_per_face_length": 2.152417835890317,
|
||||
"reference_height_per_face_length": 2.8698904478537566,
|
||||
"width_per_face_length": 2.5,
|
||||
"height_per_face_length": 3.333333333333333,
|
||||
"rotation_degrees": 0.2596248408989324,
|
||||
"axis_x_error_px": 5.684341886080802e-14,
|
||||
"source_to_reference": [
|
||||
[
|
||||
0.9999897336401403,
|
||||
0.004531292786961665,
|
||||
-5.386196599797756
|
||||
],
|
||||
[
|
||||
-0.004531292786961665,
|
||||
0.9999897336401403,
|
||||
-2.202458197930582
|
||||
]
|
||||
],
|
||||
"landmark_source": "source_landmarks_transformed_without_redetection"
|
||||
}
|
||||
+12
-4
@@ -52,15 +52,19 @@ _REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
|
||||
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
|
||||
|
||||
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
|
||||
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
|
||||
prompt=None):
|
||||
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
|
||||
|
||||
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
|
||||
失败抛异常(调用方负责 try/except 跳过)。
|
||||
|
||||
prompt:None 用 run_redraw 内置默认「填充遮罩区域的头发,人物增加一点磨皮」,否则用传入的提示词。
|
||||
"""
|
||||
from .redraw import run_redraw
|
||||
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
|
||||
return run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout, front=True)
|
||||
return run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
|
||||
prompt=prompt, front=True)
|
||||
|
||||
# 发际线贴图档位:middle=默认(hairline_texture/),high/low 各自独立文件夹。
|
||||
_TEXTURE_DIRS = {
|
||||
@@ -259,7 +263,10 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
一致的原生生发(ComfyUI add_hair),由 _grow_native_one 完成(黑模板 + inpaint 遮罩)。
|
||||
|
||||
overlay 仍是发际线曲线透明层(与 generate_grow_results 完全一致)。
|
||||
prompt 仅用于原生生发分支(换发型分支的提示词由 redraw 流程内部固定)。
|
||||
prompt 两条分支都生效:换发型分支传入 _call_local_redraw,原生生发分支传给
|
||||
_grow_native_one。None/默认「填充遮罩区域的头发,人物增加一点磨皮」时走 run_redraw 内置默认,
|
||||
不改变原行为;传不同提示词会使 ComfyUI 的 CLIP 条件缓存失效并重载(实测换词
|
||||
后首次请求重绘 45s vs 缓存命中 8s),后续相同提示词恢复快路径。
|
||||
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
|
||||
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
|
||||
"""
|
||||
@@ -312,7 +319,8 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
||||
final_bytes = base64.b64decode(final_b64)
|
||||
mask_bytes = base64.b64decode(mask_b64)
|
||||
# 后端直接调 ComfyUI 重绘,返回重绘后的 PNG
|
||||
grown_png = _call_local_redraw(final_bytes, mask_bytes)
|
||||
grown_png = _call_local_redraw(final_bytes, mask_bytes,
|
||||
prompt=prompt)
|
||||
if grown_png is None:
|
||||
logger.warning("接口2 换发型:type=%s 重绘结果为空", key)
|
||||
except NoFaceError:
|
||||
|
||||
@@ -48,7 +48,7 @@ cd /home/ubuntu/hair/local_test
|
||||
|--------|------|------|------|
|
||||
| image | File | 是 | 人物图片(支持 jpg, png 等常见格式) |
|
||||
| mask | File | 是 | 遮罩图片(支持 jpg, png,遮罩区域可用红色/白色/alpha 通道标识) |
|
||||
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,皮肤加一点磨皮" |
|
||||
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,人物增加一点磨皮" |
|
||||
|
||||
#### 遮罩图片格式说明
|
||||
|
||||
@@ -70,7 +70,7 @@ cd /home/ubuntu/hair/local_test
|
||||
curl -X POST http://127.0.0.1:8899/api/generate \
|
||||
-F "image=@/path/to/person.jpg" \
|
||||
-F "mask=@/path/to/mask.png" \
|
||||
-F "prompt=填充遮罩区域的头发,皮肤加一点磨皮" \
|
||||
-F "prompt=填充遮罩区域的头发,人物增加一点磨皮" \
|
||||
--output result.png
|
||||
```
|
||||
|
||||
@@ -85,7 +85,7 @@ files = {
|
||||
"mask": open("mask.png", "rb"),
|
||||
}
|
||||
data = {
|
||||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
}
|
||||
|
||||
resp = requests.post(url, files=files, data=data, timeout=600)
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ def generate():
|
||||
return jsonify({"error": msg}), 400
|
||||
image_file = request.files["image"]
|
||||
mask_file = request.files["mask"]
|
||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发")
|
||||
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
log.info(
|
||||
"收到请求: image=%s mask=%s prompt=%r",
|
||||
image_file.filename, mask_file.filename, prompt_text,
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ def prep_and_upload():
|
||||
|
||||
|
||||
def run_once(fname, model, dtype, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
wf["16"]["inputs"]["unet_name"] = model
|
||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
||||
|
||||
|
||||
def run(fname, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
||||
imgs = []
|
||||
labels = []
|
||||
for steps in [2, 3, 4, 6]:
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发", seed=SEED)
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮", seed=SEED)
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
||||
</div>
|
||||
<div class="controls" style="margin-top:16px">
|
||||
<label>提示词:</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮">
|
||||
</div>
|
||||
<div style="text-align:center; margin-top:16px">
|
||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||
"mask": ("mask.png", mask_data, "image/png"),
|
||||
},
|
||||
data={"prompt": "填充遮罩区域的头发"},
|
||||
data={"prompt": "填充遮罩区域的头发,人物增加一点磨皮"},
|
||||
timeout=600,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 网关内部转发可能耗时较长(worker 处理 + 排队)
|
||||
proxy_read_timeout 90s;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 90s;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
(() => {
|
||||
const form = document.getElementById("client-auth");
|
||||
const message = document.getElementById("client-auth-status");
|
||||
const loginButton = form.querySelector('button[type="submit"]');
|
||||
const checkButton = document.getElementById("client-auth-check");
|
||||
const refreshButton = document.getElementById("client-auth-refresh");
|
||||
let accessToken;
|
||||
let refreshToken;
|
||||
let accessExpiry = 0;
|
||||
|
||||
async function requestToken(operation, data) {
|
||||
const response = await fetch("/api/v1/auth/" + operation, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.code !== 0) throw new Error(result.message || "鉴权失败");
|
||||
accessToken = result.data.access_token;
|
||||
accessExpiry = Date.now() + result.data.expires_in * 1000;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function runAction(action) {
|
||||
loginButton.disabled = checkButton.disabled = refreshButton.disabled = true;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
message.textContent = error.message;
|
||||
} finally {
|
||||
loginButton.disabled = checkButton.disabled = false;
|
||||
refreshButton.disabled = !refreshToken;
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(form);
|
||||
form.elements.namedItem("password").value = "";
|
||||
runAction(async () => {
|
||||
const result = await requestToken("login", {
|
||||
username: data.get("username"),
|
||||
password: data.get("password"),
|
||||
});
|
||||
refreshToken = result.refresh_token;
|
||||
message.textContent = "已登录。访问令牌过期时会自动刷新。";
|
||||
});
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
if (!refreshToken) throw new Error("请先登录,再提交图片。");
|
||||
try {
|
||||
await requestToken("refresh", { refresh_token: refreshToken });
|
||||
} catch (error) {
|
||||
accessToken = refreshToken = undefined;
|
||||
refreshButton.disabled = true;
|
||||
message.textContent = "请重新登录。";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
checkButton.addEventListener("click", () => runAction(async () => {
|
||||
const path = "/api/v1/auth/check";
|
||||
const anonymous = await fetch(path);
|
||||
if (anonymous.status !== 401) throw new Error("无令牌检查返回 HTTP " + anonymous.status);
|
||||
if (!accessToken) {
|
||||
message.textContent = "无令牌已拒绝(HTTP 401)。登录后可验证访问令牌。";
|
||||
return;
|
||||
}
|
||||
const response = await fetch(path, { headers: { Authorization: "Bearer " + accessToken } });
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.code !== 0) throw new Error(result.message || "访问令牌检查失败");
|
||||
message.textContent = "鉴权通过:无令牌返回 HTTP 401,有效令牌返回 HTTP 200。";
|
||||
}));
|
||||
|
||||
refreshButton.addEventListener("click", () => runAction(async () => {
|
||||
await refresh();
|
||||
message.textContent = "访问令牌已刷新,有效期 15 分钟。";
|
||||
}));
|
||||
|
||||
window.hairClientFetch = async (path, options) => {
|
||||
if (Date.now() >= accessExpiry || !accessToken) await refresh();
|
||||
const send = () => {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Authorization", "Bearer " + accessToken);
|
||||
return fetch(path, { ...options, headers });
|
||||
};
|
||||
const response = await send();
|
||||
if (response.status !== 401) return response;
|
||||
await refresh();
|
||||
return send();
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 8px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
|
||||
/* 上传区 */
|
||||
.upload-card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 24px; }
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.file-input { flex: 1; min-width: 200px; }
|
||||
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.btn-outline:hover { background: #f9fafb; }
|
||||
.upload-hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
/* 状态提示 */
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
/* 结果双栏 */
|
||||
.results { display: flex; gap: 24px; }
|
||||
.results > div { flex: 1; min-width: 0; background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden; }
|
||||
.results .panel-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
/* JSON 面板 */
|
||||
.json-panel { max-height: 600px; overflow: auto; }
|
||||
.json-content { padding: 16px 18px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
/* 图片面板 — 叠加显示 */
|
||||
.img-panel { display: flex; align-items: center; justify-content: center; min-height: 300px; background: #222; position: relative; }
|
||||
.img-stack:not([hidden]) { max-width: 100%; position: relative; display: inline-block; line-height: 0; }
|
||||
.img-stack img:not([hidden]) { display: block; max-width: min(100%, 520px); max-height: 620px; object-fit: contain; }
|
||||
.img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||
.img-stack .layer-base[hidden] + .layer-anno { position: static; width: auto; height: auto; }
|
||||
.img-panel .placeholder { color: #9ca3af; font-size: 14px; }
|
||||
|
||||
/* 叠加控制 */
|
||||
.anno-controls { display: flex; gap: 12px; align-items: center; font-size: 12px; color: #9ca3af; }
|
||||
.anno-controls label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
||||
.anno-controls input[type=range] { width: 60px; }
|
||||
|
||||
/* 指标卡片 */
|
||||
.metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.metric { background: #f9fafb; border-radius: 8px; padding: 12px 14px; }
|
||||
.metric .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.metric .value { font-size: 18px; font-weight: 700; color: #111827; margin-top: 2px; }
|
||||
.metric .unit { font-size: 12px; color: #6b7280; font-weight: 400; }
|
||||
|
||||
@media (max-width: 768px) { .results { flex-direction: column; } }
|
||||
.hidden { display: none !important; }
|
||||
.auth-fields { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
|
||||
.auth-fields label { display: grid; gap: 4px; font-size: 13px; }
|
||||
.auth-fields input { border: 1px solid #d1d5db; border-radius: 8px; padding: 8px 10px; }
|
||||
.auth-fields button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.diff-list { color: #6b7280; font-size: 13px; line-height: 1.7; margin: -14px 0 24px 18px; }
|
||||
#statusBar { margin: 12px 0 0; }
|
||||
@@ -0,0 +1,156 @@
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const form = $("measure-test");
|
||||
const { endpoint, totalHeightLabel } = form.dataset;
|
||||
const fileInput = $("imageFile");
|
||||
const submitButton = $("submitBtn");
|
||||
const clearButton = $("clearBtn");
|
||||
const copyButton = $("copyBtn");
|
||||
const metrics = $("metricsBar");
|
||||
const results = $("resultsArea");
|
||||
const jsonContent = $("jsonContent");
|
||||
const placeholder = $("imagePlaceholder");
|
||||
const imageStack = $("imageStack");
|
||||
const baseImage = $("baseImage");
|
||||
const annotationImage = $("annotationImage");
|
||||
let originalImageUrl;
|
||||
|
||||
function setStatus(text, type = "") {
|
||||
const status = $("statusBar");
|
||||
status.textContent = text;
|
||||
status.className = type ? "status " + type : "status";
|
||||
}
|
||||
|
||||
function resetResults() {
|
||||
results.classList.add("hidden");
|
||||
metrics.classList.add("hidden");
|
||||
jsonContent.textContent = "";
|
||||
imageStack.hidden = true;
|
||||
placeholder.hidden = false;
|
||||
placeholder.textContent = "提交后显示叠加效果";
|
||||
baseImage.removeAttribute("src");
|
||||
annotationImage.removeAttribute("src");
|
||||
if (originalImageUrl) {
|
||||
URL.revokeObjectURL(originalImageUrl);
|
||||
originalImageUrl = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function updateOverlay() {
|
||||
baseImage.hidden = !$("showBase").checked;
|
||||
annotationImage.hidden = !$("showAnno").checked;
|
||||
annotationImage.style.opacity = $("annoOpacity").value / 100;
|
||||
}
|
||||
|
||||
function renderMetrics(data) {
|
||||
const courts = data.four_courts || {};
|
||||
const eyes = data.seven_eyes || {};
|
||||
const items = [[totalHeightLabel, data.face_total_height_cm]];
|
||||
if (form.hasAttribute("data-include-top-court")) items.push(["顶庭", courts.top_court_cm]);
|
||||
items.push(
|
||||
["上庭", courts.upper_court_cm],
|
||||
["中庭", courts.middle_court_cm],
|
||||
["下庭", courts.lower_court_cm],
|
||||
["单眼宽度", eyes.eye_width_cm],
|
||||
["脸宽", eyes.face_width_cm],
|
||||
["两眼间距", eyes.inter_eye_distance_cm],
|
||||
);
|
||||
|
||||
metrics.replaceChildren();
|
||||
for (const [label, value] of items) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "metric";
|
||||
card.innerHTML = '<div class="label"></div><div class="value"><span></span> <span class="unit">cm</span></div>';
|
||||
card.querySelector(".label").textContent = label;
|
||||
card.querySelector(".value span").textContent = value ?? "—";
|
||||
metrics.append(card);
|
||||
}
|
||||
metrics.classList.remove("hidden");
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const selected = fileInput.files[0];
|
||||
if (!selected) {
|
||||
setStatus("请先选择一张图片", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
fileInput.disabled = submitButton.disabled = clearButton.disabled = true;
|
||||
submitButton.textContent = "⏳ 请求中...";
|
||||
resetResults();
|
||||
setStatus("正在请求 " + endpoint, "info");
|
||||
|
||||
try {
|
||||
const file = await window.downscaleImageFile(selected);
|
||||
const body = new FormData();
|
||||
body.append("image_file", file);
|
||||
const start = performance.now();
|
||||
const response = await window.hairClientFetch(endpoint, { method: "POST", body });
|
||||
if (!response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
throw new Error("服务返回 HTTP " + response.status + ",响应格式异常");
|
||||
}
|
||||
const result = await response.json();
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
jsonContent.textContent = JSON.stringify(result, null, 2);
|
||||
results.classList.remove("hidden");
|
||||
|
||||
if (!response.ok || result.code !== 0) {
|
||||
setStatus(`请求失败 (${elapsed}s) · HTTP ${response.status} · code ${result.code} · ${result.message}`, "error");
|
||||
placeholder.textContent = "请求失败,无标注图";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const annotationSrc = window.resolveImgSrc(data.annotated_image_url, data.annotated_image_base64, "image/png");
|
||||
if (annotationSrc) {
|
||||
originalImageUrl = URL.createObjectURL(file);
|
||||
baseImage.src = originalImageUrl;
|
||||
annotationImage.src = annotationSrc;
|
||||
updateOverlay();
|
||||
imageStack.hidden = false;
|
||||
placeholder.hidden = true;
|
||||
} else {
|
||||
placeholder.textContent = "服务未返回标注图";
|
||||
}
|
||||
renderMetrics(data);
|
||||
setStatus(`请求成功 (${elapsed}s) · request_id ${result.request_id}`, "success");
|
||||
} catch (error) {
|
||||
setStatus(error.message, "error");
|
||||
} finally {
|
||||
fileInput.disabled = submitButton.disabled = clearButton.disabled = false;
|
||||
submitButton.textContent = "🚀 提交测试";
|
||||
}
|
||||
});
|
||||
|
||||
$("showBase").addEventListener("change", updateOverlay);
|
||||
$("showAnno").addEventListener("change", updateOverlay);
|
||||
$("annoOpacity").addEventListener("input", updateOverlay);
|
||||
clearButton.addEventListener("click", () => {
|
||||
resetResults();
|
||||
fileInput.value = "";
|
||||
setStatus("");
|
||||
});
|
||||
|
||||
copyButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonContent.textContent);
|
||||
copyButton.textContent = "✅ 已复制";
|
||||
setTimeout(() => { copyButton.textContent = "📋 复制"; }, 1500);
|
||||
} catch {
|
||||
setStatus("复制失败,请手动复制 JSON", "error");
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files.length) setStatus("已选择 " + fileInput.files[0].name, "info");
|
||||
});
|
||||
fileInput.addEventListener("dragover", (event) => event.preventDefault());
|
||||
fileInput.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files.length) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
fileInput.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
// Reference geometry is in input-image pixels; no screen-vertical assumptions.
|
||||
window.hairlineReferenceBox = function(points) {
|
||||
const p = id => {
|
||||
const value = points[String(id)];
|
||||
if (!value || !Number.isFinite(value.x) || !Number.isFinite(value.y)) throw new Error(`缺少 ${id} 号关键点`);
|
||||
return [value.x, value.y];
|
||||
};
|
||||
const add = (a,b) => [a[0]+b[0],a[1]+b[1]];
|
||||
const sub = (a,b) => [a[0]-b[0],a[1]-b[1]];
|
||||
const mul = (a,s) => [a[0]*s,a[1]*s];
|
||||
const dot = (a,b) => a[0]*b[0]+a[1]*b[1];
|
||||
const brow=p(9),chin=p(152),left=p(162),right=p(389);
|
||||
const delta=sub(chin,brow),length=Math.hypot(...delta);
|
||||
if(length<1)throw new Error("9→152 方向无法确定");
|
||||
const up=mul(delta,-1/length),horizontal=[-up[1],up[0]],height=.7*length;
|
||||
const sidePoint=(anchor,direction,level)=>{
|
||||
const denominator=dot(direction,up);
|
||||
if(Math.abs(denominator)<1e-6)throw new Error("参考框侧边与底边平行");
|
||||
return add(anchor,mul(direction,(level-dot(sub(anchor,brow),up))/denominator));
|
||||
};
|
||||
const corners=[sidePoint(left,up,0),sidePoint(right,up,0),
|
||||
sidePoint(right,up,height),sidePoint(left,up,height)];
|
||||
if(dot(sub(corners[1],corners[0]),horizontal)<=0)throw new Error("参考框左右位置异常");
|
||||
return {corners,height,faceLength:length,
|
||||
landmarks:{9:brow,152:chin,162:left,389:right}};
|
||||
};
|
||||
|
||||
window.clipHairlineToReferenceBox = function(image, points) {
|
||||
const box=window.hairlineReferenceBox(points);
|
||||
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
|
||||
if(!width||!height)throw new Error("发际线图片尚未加载");
|
||||
const canvas=document.createElement("canvas");canvas.width=width;canvas.height=height;
|
||||
const ctx=canvas.getContext("2d");
|
||||
ctx.beginPath();box.corners.forEach((p,i)=>i?ctx.lineTo(...p):ctx.moveTo(...p));ctx.closePath();
|
||||
ctx.clip();ctx.drawImage(image,0,0,width,height);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
window.drawHairlineReferenceBox = function(ctx, points, crop, width, height) {
|
||||
const box=window.hairlineReferenceBox(points);
|
||||
const [x0,y0,x1,y1]=crop;
|
||||
const screen=p=>[(p[0]-x0)/(x1-x0)*width,(p[1]-y0)/(y1-y0)*height];
|
||||
const color="#e33399";
|
||||
ctx.save();ctx.strokeStyle=color;ctx.lineWidth=Math.max(2.5,width/280);ctx.lineJoin="round";
|
||||
ctx.beginPath();box.corners.forEach((p,i)=>{const q=screen(p);i?ctx.lineTo(...q):ctx.moveTo(...q);});ctx.closePath();ctx.stroke();
|
||||
ctx.lineWidth=1.4;ctx.setLineDash([7,6]);ctx.beginPath();ctx.moveTo(...screen(box.landmarks[9]));ctx.lineTo(...screen(box.landmarks[152]));ctx.stroke();ctx.setLineDash([]);
|
||||
const fontSize=Math.max(13,width/43);ctx.font=`600 ${fontSize}px sans-serif`;
|
||||
for(const id of [9,152,162,389]){
|
||||
const [x,y]=screen(box.landmarks[id]);
|
||||
if(x<0||x>width||y<0||y>height)continue;
|
||||
ctx.fillStyle=color;ctx.beginPath();ctx.arc(x,y,Math.max(3,width/160),0,Math.PI*2);ctx.fill();
|
||||
const label=String(id),tw=ctx.measureText(label).width;
|
||||
const tx=Math.max(3,Math.min(width-tw-8,x+7)),ty=Math.max(fontSize+4,Math.min(height-5,y-7));
|
||||
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(label,tx,ty);
|
||||
}
|
||||
const top=screen([(box.corners[2][0]+box.corners[3][0])/2,(box.corners[2][1]+box.corners[3][1])/2]);
|
||||
const text="70% · 9→152",tw=ctx.measureText(text).width;
|
||||
const tx=Math.max(4,Math.min(width-tw-6,top[0]-tw/2)),ty=Math.max(fontSize+4,top[1]-8);
|
||||
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(text,tx,ty);
|
||||
ctx.restore();
|
||||
};
|
||||
@@ -52,4 +52,34 @@
|
||||
}
|
||||
|
||||
window.downscaleImageFile = downscaleImageFile;
|
||||
/**
|
||||
* 将接口返回的图片字段规范成 <img src> 可用值。
|
||||
* 同时兼容:
|
||||
* - 网关改写后的 *_url(http/https/相对路径)
|
||||
* - worker 直连的 *_base64(原始 base64,或已带 data:image/...;base64, 前缀)
|
||||
* @param {string|null|undefined} url
|
||||
* @param {string|null|undefined} b64
|
||||
* @param {string} [mimeHint='image/jpeg'] 原始 base64 时的 MIME 提示(会按魔数再嗅探)
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveImgSrc(url, b64, mimeHint) {
|
||||
var v = url || b64 || '';
|
||||
if (!v || typeof v !== 'string') return '';
|
||||
v = v.trim();
|
||||
if (!v) return '';
|
||||
// 已是可用 URL / data URI / blob / 协议相对 URL
|
||||
if (/^(https?:|blob:|data:|\/\/)/i.test(v)) return v;
|
||||
// 同站相对路径(仅匹配明确的 /static/...;勿把 JPEG base64 的 /9j/ 前缀当路径)
|
||||
if (/^\/static\//.test(v)) return v;
|
||||
// 原始 base64:去掉可能残留的 data URI 前缀后再拼,避免双重前缀导致无法显示
|
||||
var raw = v.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/i, '');
|
||||
if (!raw) return '';
|
||||
var mime = mimeHint || 'image/jpeg';
|
||||
if (raw.indexOf('iVBOR') === 0) mime = 'image/png';
|
||||
else if (raw.indexOf('/9j/') === 0) mime = 'image/jpeg';
|
||||
else if (raw.indexOf('R0lGOD') === 0) mime = 'image/gif';
|
||||
else if (raw.indexOf('UklGR') === 0) mime = 'image/webp';
|
||||
return 'data:' + mime + ';base64,' + raw;
|
||||
}
|
||||
window.resolveImgSrc = resolveImgSrc;
|
||||
})();
|
||||
|
||||
+225
-9
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>旷视五接口 — 前端接入说明</title>
|
||||
<title>旷视接口 — 客户端接入说明</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f8f9fb; color: #1e293b; line-height: 1.6; }
|
||||
@@ -33,6 +33,10 @@
|
||||
.endpoint .url { font-size: 15px; font-weight: 700; margin-bottom: 6px; }
|
||||
.endpoint .desc { color: #64748b; font-size: 13px; margin-bottom: 10px; }
|
||||
.endpoint .params { font-size: 12px; color: #94a3b8; margin-bottom: 8px; }
|
||||
.guide-step { margin-top: 18px; }
|
||||
.guide-step h3 { font-size: 15px; margin-bottom: 5px; }
|
||||
.guide-step p, .guide-step pre { margin-bottom: 8px; }
|
||||
.guide-list { padding-left: 22px; margin-top: 8px; }
|
||||
|
||||
.link { color: #2563eb; text-decoration: none; font-size: 13px; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
@@ -46,17 +50,24 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>📡 旷视五接口 — 前端接入说明</h1>
|
||||
<h1>📡 旷视接口 — 客户端接入说明</h1>
|
||||
<p class="subtitle">Base URL: <code>https://hair.xiangsilian.com</code> | 在线文档: <a href="/docs" class="link">/docs</a> | 在线测试页见底部</p>
|
||||
|
||||
<div class="toc">
|
||||
<a href="#overview">通用约定</a>
|
||||
<a href="#client-auth">客户端鉴权</a>
|
||||
<a href="#if1">接口1</a>
|
||||
<a href="#if11">接口11</a>
|
||||
<a href="#if2">接口2</a>
|
||||
<a href="#if22">接口22</a>
|
||||
<a href="#if3">接口3</a>
|
||||
<a href="#if4">接口4</a>
|
||||
<a href="#if42">接口42</a>
|
||||
<a href="#if5">接口5</a>
|
||||
<a href="#if52">接口52</a>
|
||||
<a href="#if6">接口6</a>
|
||||
<a href="#if61">接口61</a>
|
||||
<a href="#if72">接口72</a>
|
||||
<a href="#errors">错误码</a>
|
||||
<a href="#test">在线测试</a>
|
||||
</div>
|
||||
@@ -67,11 +78,13 @@
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<tr><td>协议</td><td>HTTPS</td></tr>
|
||||
<tr><td>请求方式</td><td>全部 <span class="badge post">POST</span></td></tr>
|
||||
<tr><td>请求方式</td><td>业务接口使用 POST。验证令牌使用 GET。</td></tr>
|
||||
<tr><td>编码</td><td>UTF-8</td></tr>
|
||||
<tr><td>Content-Type</td><td><code>multipart/form-data</code></td></tr>
|
||||
<tr><td>Content-Type</td><td>传图使用 <code>multipart/form-data</code>,登录和刷新使用 <code>application/json</code></td></tr>
|
||||
<tr><td>客户端鉴权</td><td>11、22、42、52、61、72 使用 <a class="link" href="#client-auth">Bearer 访问令牌</a>。</td></tr>
|
||||
<tr><td>图片参数</td><td>三选一:<code>image_file</code>(文件上传)/ <code>image_url</code>(URL)/ <code>image_base64</code>(base64 + 前缀)</td></tr>
|
||||
<tr><td>图片格式</td><td>JPG / PNG,单人正面照</td></tr>
|
||||
<tr><td>请求体上限</td><td><strong>2MB</strong>(nginx 层限制)。超限直接返回 HTTP <strong>413</strong>,不会进入业务逻辑。建议图片文件 ≤1MB;<code>image_base64</code> 传图会膨胀约 33%,编码前原图建议 ≤1.4MB。手机直拍照片通常 3~8MB,<strong>上传前务必先压缩</strong>(如最长边缩到 1500px、JPEG 质量 80)。<code>image_url</code> 方式图片在远端,不受此限制</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:14px;font-size:13px"><strong>统一响应结构</strong>:</p>
|
||||
@@ -84,6 +97,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="client-auth">
|
||||
<h2>客户端鉴权</h2>
|
||||
<div class="card-body">
|
||||
<p>客户端发送 <code>Authorization: Bearer <access_token></code>。账号为 <code>yonghe</code>,密码由对接人员提供。以下接口共用同一个访问令牌。</p>
|
||||
<p><a href="#if11">接口 11</a>、<a href="#if22">接口 22</a>、<a href="#if42">接口 42</a>、<a href="#if52">接口 52</a>、<a href="#if61">接口 61</a>、<a href="#if72">接口 72</a></p>
|
||||
|
||||
<div class="guide-step">
|
||||
<h3>1. 登录获取令牌</h3>
|
||||
<pre>BASE_URL=https://hair.xiangsilian.com
|
||||
curl -sS -X POST "$BASE_URL/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"yonghe","password":"<对接密码>"}'</pre>
|
||||
<p>成功返回 HTTP 200 且 <code>code=0</code>,<code>data</code> 的结构如下:</p>
|
||||
<pre>{
|
||||
"access_token": "<访问令牌>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"refresh_token": "<刷新令牌>",
|
||||
"refresh_expires_in": 604800
|
||||
}</pre>
|
||||
<p>有效期以秒为单位,访问令牌 15 分钟,刷新令牌 7 天。将响应里的令牌分别赋值给下方示例的 <code>ACCESS_TOKEN</code> 和 <code>REFRESH_TOKEN</code>。</p>
|
||||
</div>
|
||||
|
||||
<div class="guide-step">
|
||||
<h3>2. 验证令牌并发起业务请求</h3>
|
||||
<pre>curl -i "$BASE_URL/api/v1/auth/check" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"</pre>
|
||||
<p>有效时返回 HTTP 200 和 <code>code=0</code>。缺少令牌、令牌无效或已过期时返回 HTTP 401 和 <code>code=1009</code>。业务请求使用同一个请求头,图片字段在 <code>image_file</code>、<code>image_url</code>、<code>image_base64</code> 中三选一。</p>
|
||||
</div>
|
||||
|
||||
<div class="guide-step">
|
||||
<h3>3. 刷新并重试</h3>
|
||||
<pre>curl -sS -X POST "$BASE_URL/api/v1/auth/refresh" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"refresh_token\":\"$REFRESH_TOKEN\"}"</pre>
|
||||
<ol class="guide-list">
|
||||
<li>访问令牌到期前可主动刷新。业务请求返回 HTTP 401 且 <code>code=1009</code> 时,也可刷新一次。</li>
|
||||
<li>刷新成功后,用返回的 <code>data.access_token</code> 和 <code>data.expires_in</code> 更新本地状态,再用新令牌重试原请求一次。</li>
|
||||
<li>刷新响应不含新刷新令牌,继续使用登录时的 <code>refresh_token</code>,直到它的原有效期结束。刷新返回 401 时,清除令牌并重新登录。</li>
|
||||
</ol>
|
||||
<p>登录或刷新返回 HTTP 503 且 <code>code=1007</code> 时,请联系网关维护人员检查令牌服务配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口1 ======== -->
|
||||
<div class="card" id="if1">
|
||||
<h2>1. 四庭七眼测量 <span class="badge post">POST</span> <code>/api/v1/face/measure</code></h2>
|
||||
@@ -106,6 +164,7 @@
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>标注图竖线坐标,与标注 PNG 同一套几何。原图像素:<code>y_top</code> / <code>y_bottom</code> 为竖线共用上下端(头顶之上到下巴之下,两端各伸出约短边的 3%,可能略超出图片);<code>lines</code> 从左到右,每项 <code>{ name, x }</code>。name:<code>left_head_edge</code>(人头最左,耳朵不可见则不出现,此时 eye1 为 null)、<code>left_cheek</code>、<code>left_eye_outer</code>、<code>left_eye_inner</code>、<code>right_eye_inner</code>、<code>right_eye_outer</code>、<code>right_cheek</code>、<code>right_head_edge</code>(人头最右,不可见则不出现,此时 eye7 为 null)</td></tr>
|
||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
|
||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
@@ -126,10 +185,22 @@ const { code, data } = await res.json();
|
||||
// data.annotated_image_url → 标注 PNG
|
||||
// data.face_total_height_cm → 全脸高度
|
||||
// data.four_courts.ratios → { top_court: 0.25, ... }
|
||||
// data.vertical_lines → { y_top, y_bottom, lines: [{ name, x }, ...] }
|
||||
// 前端叠加显示:原图 + annotated_image_url(absolute 叠加)</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if11">
|
||||
<h2>11. 四庭七眼测量(鉴权) <span class="badge post">POST</span> <code>/api/v1/face/measure-11</code></h2>
|
||||
<div class="card-body">
|
||||
<p>请求使用 <a href="#client-auth">Bearer 访问令牌</a>,图片参数和响应字段见<a href="#if1">接口 1</a>。返回四庭七眼测量数据和透明标注图 URL。</p>
|
||||
<pre>curl -sS -X POST "$BASE_URL/api/v1/face/measure-11" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@photo.jpg'</pre>
|
||||
<p><a href="/static/test_interface11.html" class="link">打开接口 11 测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口2 ======== -->
|
||||
<div class="card" id="if2">
|
||||
<h2>2. C端生发 <span class="badge post">POST</span> <code>/api/v1/hair/grow</code></h2>
|
||||
@@ -163,6 +234,61 @@ const { code, data } = await res.json();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if22">
|
||||
<h2>22. 生发密度与发际线高度 <span class="badge post">POST</span> <code>/api/v1/hair/grow-22</code></h2>
|
||||
<div class="card-body">
|
||||
<p>使用现有对接账号的 <a href="#client-auth">Bearer 访问令牌</a>,以 <code>multipart/form-data</code> 提交。</p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>说明</th></tr>
|
||||
<tr><td><code>image_file</code> / <code>image_url</code> / <code>image_base64</code></td><td>图片三选一。</td></tr>
|
||||
<tr><td><code>gender</code></td><td>必填,<code>female</code> 或 <code>male</code>。</td></tr>
|
||||
<tr><td><code>hair_style</code></td><td>必填,序号 1–7,多选用逗号分隔,如 <code>1,3</code>。</td></tr>
|
||||
<tr><td><code>hair_density</code></td><td>至少一档,<code>sparse</code> 稀疏、<code>normal</code> 正常、<code>dense</code> 浓密。多选时重复提交同名字段。</td></tr>
|
||||
<tr><td><code>hairline_height</code></td><td>默认 <code>0</code>,范围 <code>[-1, 1]</code>。API 负值上移,正值下移。端点移动眉心到下巴垂直像素距离的 10%。</td></tr>
|
||||
</table>
|
||||
<p>女性 1–7 依次为椭圆、花瓣、心形、直线、波浪、大花瓣、古典花瓣。男性 1–7 依次为椭圆、倒弧、M 形、直线、心形、柔和花瓣、波浪。</p>
|
||||
|
||||
<h3>前端请求示例</h3>
|
||||
<p>以下示例使用网关测试页的滑块约定,正值向上,所以提交时取反。<code>Content-Type</code> 由浏览器根据 <code>FormData</code> 自动设置。</p>
|
||||
<pre id="if22-js-example">async function grow22({ file, gender, styles, densities, slider = 0, accessToken }) {
|
||||
const body = new FormData();
|
||||
body.append("image_file", file);
|
||||
body.append("gender", gender);
|
||||
body.append("hair_style", styles.join(","));
|
||||
for (const density of densities) body.append("hair_density", density);
|
||||
body.append("hairline_height", String(-slider));
|
||||
|
||||
const response = await fetch("/api/v1/hair/grow-22", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.code !== 0) {
|
||||
throw new Error(result.message || `HTTP ${response.status}`);
|
||||
}
|
||||
return result.data.results;
|
||||
}
|
||||
|
||||
// 已登录后调用。选择 2 个发型和 2 档密度,共返回 4 项。
|
||||
// const results = await grow22({
|
||||
// file: input.files[0], gender: "female", styles: [1, 3],
|
||||
// densities: ["sparse", "dense"], slider: 0.5, accessToken,
|
||||
// });</pre>
|
||||
<p>请求返回 <code>401 / 1009</code> 时,按<a href="#client-auth">鉴权章节</a>刷新令牌后重试一次。多选高度时,按高度分别发起请求。</p>
|
||||
|
||||
<h3>展示结果与发际线预览</h3>
|
||||
<table>
|
||||
<tr><th><code>data.results[]</code> 字段</th><th>展示方式</th></tr>
|
||||
<tr><td><code>order</code>、<code>hairline_type</code>、<code>hair_density</code></td><td>标注发型和密度。多高度结果另外记录每次请求所用的高度。</td></tr>
|
||||
<tr><td><code>image_url</code></td><td>与输入照片同尺寸的透明发际线 PNG,可叠加在原图坐标的底图上。</td></tr>
|
||||
<tr><td><code>grown_image_url</code></td><td>生发图 URL。单项生成失败时返回 <code>grown_image_base64: null</code>,这一项仍保留发型和密度信息。</td></tr>
|
||||
</table>
|
||||
<p>如需复刻测试页,首次以高度 <code>0</code> 生图,出图后再用同一个 Bearer 令牌请求<a href="#if52">接口 52</a>,传 <code>normalize_image=true</code> 和 <code>generate_grow_image=false</code>。将各发型的 <code>image_middle_url</code> 叠在接口 52 的 <code>input_image_url</code> 上;如果底图改用首次生发图,先用 <code>framing.source_to_processed</code> 做同样的扶正裁切,再叠加。预览白线是设计参考线,实际出图边界以生发图为准。</p>
|
||||
<p><a href="/static/test_interface22.html" class="link">打开接口 22 测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口3 ======== -->
|
||||
<div class="card" id="if3">
|
||||
<h2>3. B端生发(医生端) <span class="badge post">POST</span> <code>/api/v1/hair/grow-b</code></h2>
|
||||
@@ -190,22 +316,23 @@ const { code, data } = await res.json();
|
||||
<div class="card" id="if4">
|
||||
<h2>4. 用户特征分析 <span class="badge post">POST</span> <code>/api/v1/face/features</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 6 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格)。</p>
|
||||
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 7 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格/轮廓类型)。</p>
|
||||
|
||||
<p><strong>入参</strong>:image_file / image_url / image_base64 三选一。无其他参数。</p>
|
||||
|
||||
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 6 个英文字段</strong></td></tr>
|
||||
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 7 个英文字段</strong></td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 6 个):</p>
|
||||
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 7 个):</p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
|
||||
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
|
||||
<tr><td>facial_age</td><td>面部年龄区间</td><td>gender</td><td>性别</td></tr>
|
||||
<tr><td>dynamic_static_type</td><td>动静类型</td><td>gene_style</td><td>基因风格</td></tr>
|
||||
<tr><td>contour_type</td><td>轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)</td><td>—</td><td>—</td></tr>
|
||||
</table>
|
||||
|
||||
<pre>const res = await fetch('https://hair.xiangsilian.com/api/v1/face/features', {
|
||||
@@ -219,6 +346,26 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if42">
|
||||
<h2>42. 用户特征与两项判定 <span class="badge post">POST</span> <code>/api/v1/face/features-42</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">接收一张照片。先检查人脸是否完整及俯拍角度,再扶正裁切,返回接口 4 的特征与两项判定。需要 <a href="#client-auth">访问令牌</a>。</p>
|
||||
<pre>curl -sS "$BASE_URL/api/v1/face/features-42" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@portrait.jpg'</pre>
|
||||
<p><code>data.features</code> 是 JSON 字符串。成功后先执行 <code>JSON.parse(response.data.features)</code>,再读取以下字段:</p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>说明</th></tr>
|
||||
<tr><td><code>face_shape</code>、<code>eyebrow_shape</code>、<code>facial_age</code>、<code>dynamic_static_type</code>、<code>gender</code>、<code>gene_style</code>、<code>contour_type</code></td><td>沿用接口 4 的面部特征</td></tr>
|
||||
<tr><td><code>zygoma_expansion</code></td><td>颧弓外扩</td></tr>
|
||||
<tr><td><code>temple_hollowing</code></td><td>太阳穴凹陷</td></tr>
|
||||
</table>
|
||||
<p>新增字段均为 <code>true</code>(命中)、<code>false</code>(未命中)或 <code>null</code>(无法测量)。两项在 <code>Ft ÷ Zy < 0.8</code> 时同时命中。</p>
|
||||
<p>未检测到人脸返回 <code>1001</code>。脸部不完整、俯拍过大或无法扶正裁切均返回 <code>1003</code>,具体重拍提示见 <code>message</code>。质检不通过时 <code>data</code> 为 <code>null</code>。</p>
|
||||
<p><a href="/static/test_interface42.html" class="link">42 号登录与特征测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口5 ======== -->
|
||||
<div class="card" id="if5">
|
||||
<h2>5. 发际线 PNG 生成 <span class="badge post">POST</span> <code>/api/v1/hairline/generate</code></h2>
|
||||
@@ -251,6 +398,7 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标,结构同接口1</td></tr>
|
||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
|
||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
@@ -261,6 +409,34 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if52">
|
||||
<h2>52. 发际线新规则与裁切 <span class="badge post">POST</span> <code>/api/v1/hairline/generate-52</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">按中庭与下庭总高度的 45% 定位 middle,支持扶正裁切与生发。需要 <a href="#client-auth">访问令牌</a>。以下示例开启扶正裁切,只获取预览:</p>
|
||||
<pre>curl -sS "$BASE_URL/api/v1/hairline/generate-52" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@portrait.jpg' \
|
||||
-F 'gender=female' \
|
||||
-F 'hair_style=1,3' \
|
||||
-F 'normalize_image=true' \
|
||||
-F 'generate_grow_image=false'</pre>
|
||||
<table>
|
||||
<tr><th>参数</th><th>说明</th></tr>
|
||||
<tr><td>图片字段</td><td>三选一,见通用约定。</td></tr>
|
||||
<tr><td><code>gender</code>、<code>hair_style</code></td><td>必填。性别为 <code>female</code> 或 <code>male</code>,发型序号为 1–7,支持逗号分隔。</td></tr>
|
||||
<tr><td><code>normalize_image</code></td><td>可选,默认 <code>false</code>。传 <code>true</code> 启用扶正裁切。</td></tr>
|
||||
<tr><td><code>generate_grow_image</code></td><td>可选,默认 <code>true</code>。只看设计曲线时传 <code>false</code>。</td></tr>
|
||||
<tr><td><code>use_mask</code>、<code>prompt</code></td><td>可选,沿用接口 5 的遮罩和提示词参数。</td></tr>
|
||||
</table>
|
||||
<ul class="guide-list">
|
||||
<li>用 <code>data.input_image_url</code> 显示当前底图,将每个发型的 <code>data.hairline_images[].image_middle_url</code> 按同尺寸叠加。中心点和 <code>guide_landmarks</code> 也使用当前底图坐标。</li>
|
||||
<li><code>original_image_url</code> 是处理前图片。需要映射坐标时,使用 <code>framing</code> 中的双向矩阵。</li>
|
||||
<li>生发成功时直接显示 <code>grown_image_url</code>。空图片保留为 <code>*_base64: null</code>,high/low 已停用。</li>
|
||||
</ul>
|
||||
<p style="margin-top:12px"><a href="/static/test_interface52.html" class="link">52 号登录与发际线测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口6 ======== -->
|
||||
<div class="card" id="if6">
|
||||
<h2>6. 四庭七眼测量 v2 <span class="badge post">POST</span> <code>/api/v1/face/measure-v2</code> <span class="badge warn">v2</span></h2>
|
||||
@@ -286,12 +462,42 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标。结构同接口1,但 <code>y_top</code> / <code>y_bottom</code> 为发际线到下巴尖(不伸出),<code>lines</code> 只有中间 6 条,无 <code>left_head_edge</code> / <code>right_head_edge</code></td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if61">
|
||||
<h2>61. 四庭七眼测量 v2(鉴权) <span class="badge post">POST</span> <code>/api/v1/face/measure-61</code></h2>
|
||||
<div class="card-body">
|
||||
<p>请求使用 <a href="#client-auth">Bearer 访问令牌</a>,图片参数和响应字段见<a href="#if6">接口 6</a>。返回去顶庭的测量数据和透明标注图 URL。</p>
|
||||
<pre>curl -sS -X POST "$BASE_URL/api/v1/face/measure-61" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@photo.jpg'</pre>
|
||||
<p><a href="/static/test_interface61.html" class="link">打开接口 61 测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if72">
|
||||
<h2>72. B 端五个关键点 <span class="badge post">POST</span> <code>/api/v1/face/b-end-points</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">接收一张正面照,返回扶正底图和透明标注层。标注包含 6 个白点、4 条虚线和 4 段厘米数。需要 <a href="#client-auth">访问令牌</a>。</p>
|
||||
<pre>curl -sS "$BASE_URL/api/v1/face/b-end-points" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@portrait.jpg'</pre>
|
||||
<table>
|
||||
<tr><th>data 字段</th><th>客户端用法</th></tr>
|
||||
<tr><td><code>input_image_url</code>、<code>annotated_image_url</code></td><td>分别为扶正裁切后的底图和透明 PNG 标注层。两图使用同一个容器、相同缩放和位置进行叠加。</td></tr>
|
||||
<tr><td><code>points</code></td><td>键为 <code>"1"</code> 到 <code>"6"</code>,值为 <code>{x, y}</code>。使用 <code>image_size.width</code> 和 <code>image_size.height</code> 缩放至显示尺寸。</td></tr>
|
||||
<tr><td><code>measurements_cm</code></td><td>键为 <code>2-1</code>、<code>2-3</code>、<code>4-6</code>、<code>5-6</code>。值为厘米数,界面使用一位小数。</td></tr>
|
||||
<tr><td><code>coordinate_space</code>、<code>px_per_cm</code></td><td>坐标系为 <code>processed_image</code>,<code>px_per_cm</code> 为每厘米估算像素数。</td></tr>
|
||||
</table>
|
||||
<p style="margin-top:12px"><a href="/static/test_interface72.html" class="link">72 号登录与标注测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 错误码 ======== -->
|
||||
<div class="card" id="errors">
|
||||
<h2>⚠ 错误码</h2>
|
||||
@@ -306,9 +512,10 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
|
||||
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
|
||||
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
|
||||
<tr><td>1009</td><td>未授权</td><td>缺少或错误的 <code>X-Internal-Token</code>(<code>/api/*</code> 路径鉴权)</td></tr>
|
||||
<tr><td>1009</td><td>未授权</td><td>HTTP 401。登录凭据错误,或所需的访问令牌缺失、无效或过期。按 <a href="#client-auth">鉴权流程</a> 处理。</td></tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:8px">注:1004 仍在使用(接口2/5 的 gender 校验);接口7(grow-v2)已弃用,请改用接口2。</p>
|
||||
<p style="font-size:12px;color:#dc2626;margin-top:8px">先检查 HTTP 状态和 <code>Content-Type</code>,再解析 JSON。HTTP 200 仍需检查 <code>code</code>,只有 <code>0</code> 表示成功。HTTP 401 的 JSON 用于鉴权提示与刷新。上传超限可能返回 HTTP 413,网关异常可能返回 502/504,这些响应可能是 HTML。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,11 +526,20 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<table>
|
||||
<tr><th>接口</th><th>测试页</th><th>功能</th></tr>
|
||||
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
|
||||
<tr><td>11. 四庭七眼(鉴权)</td><td><a href="/static/test_interface11.html" class="link">/static/test_interface11.html</a></td><td>登录、测试鉴权、刷新令牌、四庭七眼测量</td></tr>
|
||||
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
|
||||
<tr><td>22. 生发密度与发际线高度</td><td><a href="/static/test_interface22.html" class="link">/static/test_interface22.html</a></td><td>登录后生成,支持密度多选和发际线高度预览</td></tr>
|
||||
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
|
||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 7项面部特征 + 原始JSON</td></tr>
|
||||
<tr><td>42. 用户特征与两项判定</td><td><a href="/static/test_interface42.html" class="link">/static/test_interface42.html</a></td><td>登录、测试鉴权、刷新令牌、上传照片</td></tr>
|
||||
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
||||
<tr><td>52. 发际线新规则</td><td><a href="/static/test_interface52.html" class="link">/static/test_interface52.html</a></td><td>登录、测试鉴权、刷新令牌、发际线预览与生发</td></tr>
|
||||
<tr><td>72. B 端五个关键点</td><td><a href="/static/test_interface72.html" class="link">/static/test_interface72.html</a></td><td>登录、测试鉴权、刷新令牌、关键点和虚线标注</td></tr>
|
||||
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
||||
<tr><td>61. 四庭七眼 v2(鉴权)</td><td><a href="/static/test_interface61.html" class="link">/static/test_interface61.html</a></td><td>登录、测试鉴权、刷新令牌、三庭测量</td></tr>
|
||||
<tr><td>9. 头发遮罩</td><td><a href="/static/test_interface9.html" class="link">/static/test_interface9.html</a></td><td>上传照片 → 头发遮罩 + 分步可视化</td></tr>
|
||||
<tr><td>10. 外缘膨胀带</td><td><a href="/static/test_interface10.html" class="link">/static/test_interface10.html</a></td><td>上传照片 → 头部外缘带状遮罩 + 分步可视化</td></tr>
|
||||
<tr><td>99. 人脸特殊点</td><td><a href="/static/test_interface99.html" class="link">/static/test_interface99.html</a></td><td>上传照片 → 60 关键点 + 5 发际线点坐标(<strong>测试大图请先压缩到 2MB 内</strong>,见通用约定)</td></tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
|
||||
完整 API 文档:<a href="/docs" class="link">/docs</a>(Swagger UI)
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const TOKEN = 'dev-shared-secret-2026';
|
||||
|
||||
const FEAT_LABELS = {
|
||||
face_height: '脸高 (px)',
|
||||
@@ -182,7 +181,6 @@ async function submitTest() {
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/debug/face-shape', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Internal-Token': TOKEN },
|
||||
body: fd,
|
||||
});
|
||||
const json = await r.json();
|
||||
|
||||
@@ -150,7 +150,7 @@ async function submitTest() {
|
||||
form.append('image_file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ async function submitTest() {
|
||||
form.append('dilate_cm', $('dilateCm').value || '2.0');
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
|
||||
|
||||
+53
-390
@@ -3,413 +3,76 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口11 — 发际线生发 测试页</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 8px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; line-height: 1.6; }
|
||||
h2 { font-size: 16px; margin: 24px 0 12px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; line-height: 1.6; }
|
||||
|
||||
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; margin-top: 16px; }
|
||||
.pf { display: flex; flex-direction: column; gap: 4px; }
|
||||
.pf label { font-size: 13px; font-weight: 600; }
|
||||
.pf .desc { font-size: 11px; color: #9ca3af; font-weight: 400; }
|
||||
.pf select, .pf input[type=number], .pf input[type=text] { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
|
||||
.pf .row { display: flex; gap: 8px; align-items: center; }
|
||||
.pf .row input[type=number] { width: 80px; }
|
||||
.pf .row input[type=range] { flex: 1; }
|
||||
.chk { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
.metrics { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.metric { background: #f9fafb; border: 1px solid #eee; border-radius: 8px; padding: 10px 16px; min-width: 110px; }
|
||||
.metric .label { font-size: 12px; color: #888; }
|
||||
.metric .value { font-size: 17px; font-weight: 700; color: #111; }
|
||||
|
||||
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
|
||||
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
|
||||
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
|
||||
|
||||
.big img { max-height: 520px; object-fit: contain; }
|
||||
|
||||
.json-panel { max-height: 320px; overflow: auto; background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px; font: 12px/1.5 Consolas, Monaco, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||
.hidden { display: none; }
|
||||
|
||||
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||
</style>
|
||||
<script src="/static/img_downscale.js"></script>
|
||||
<title>接口11 — 四庭七眼测量 测试页</title>
|
||||
<link rel="stylesheet" href="/static/face_measure_auth_test.css">
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<script src="/static/img_downscale.js" defer></script>
|
||||
<script src="/static/face_measure_auth_test.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>接口11 — 发际线生发</h1>
|
||||
<div class="subtitle">
|
||||
接口9 头发遮罩 → change_hair 换发型(发际线类型 LoRA)→ 严格按遮罩贴回原图 → 接缝羽化融合。<br>
|
||||
输入:发际线较高 / 头发稀少的正脸图 + 发际线类型 ID。输出:同一个人同一发型、压低发际线后的图。每一步可视化,所有可调参数都在下面。
|
||||
</div>
|
||||
<h1>📐 四庭七眼测量 — 接口测试</h1>
|
||||
<p class="subtitle">POST /api/v1/face/measure-11 | 上传正面照 → 原图+标注叠加显示</p>
|
||||
|
||||
<div class="card">
|
||||
<form id="client-auth" class="upload-card" method="post" action="/api/v1/auth/login" aria-label="账号登录">
|
||||
<div class="auth-fields">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn btn-primary btn-sm" type="submit">登录</button>
|
||||
<button class="btn btn-outline btn-sm" id="client-auth-check" type="button">测试鉴权</button>
|
||||
<button class="btn btn-outline btn-sm" id="client-auth-refresh" type="button" disabled>刷新令牌</button>
|
||||
</div>
|
||||
<p id="client-auth-status" class="upload-hint" role="status">请使用对接账号登录后提交图片。</p>
|
||||
</form>
|
||||
|
||||
<!-- 上传区 -->
|
||||
<form class="upload-card" id="measure-test" data-endpoint="/api/v1/face/measure-11"
|
||||
data-total-height-label="全脸总高" data-include-top-court>
|
||||
<div class="upload-row">
|
||||
<input type="file" id="imageFile" accept="image/*">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
|
||||
<button class="btn btn-outline" onclick="clearResults()">清空</button>
|
||||
<div class="file-input">
|
||||
<input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="submitBtn" type="submit">🚀 提交测试</button>
|
||||
<button class="btn btn-outline btn-sm" id="clearBtn" type="button">清除结果</button>
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px; font-weight:600; white-space:nowrap">X-Internal-Token</label>
|
||||
<input type="text" id="token" placeholder="直连 worker 需填;走网关可留空" value="dev-shared-secret-2026"
|
||||
style="flex:1; min-width:200px; padding:8px; border:1px solid #ddd; border-radius:8px">
|
||||
<div class="upload-hint">
|
||||
支持 JPG / PNG 正面照 | 也可拖拽图片到文件选择框
|
||||
</div>
|
||||
<div id="statusBar" class="status" role="status"></div>
|
||||
</form>
|
||||
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
<label>gen_backend <span class="desc">生成后端</span></label>
|
||||
<select id="genBackend">
|
||||
<option value="swaphair">swaphair(换发型LoRA,应用发际线类型)</option>
|
||||
<option value="hairgrow">hairgrow(区域生发inpaint,压低发际线)</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 指标速览 -->
|
||||
<div id="metricsBar" class="metrics hidden"></div>
|
||||
|
||||
<div class="pf" data-show="gen:swaphair">
|
||||
<label>发际线类型 ID <span class="desc">= change_hair hair_id(仅swaphair)</span></label>
|
||||
<select id="hairlineId">
|
||||
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
||||
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
|
||||
<option value="chang_bolang">chang_bolang(波浪)</option>
|
||||
<option value="chang_xinxing">chang_xinxing(心形)</option>
|
||||
<option value="chang_huaban">chang_huaban(花瓣)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pf" data-show="gen:swaphair">
|
||||
<label>swap_mode <span class="desc">换发型取图模式</span></label>
|
||||
<select id="swapMode">
|
||||
<option value="ext_mask">ext_mask(改造换发型,用接口9遮罩重绘)</option>
|
||||
<option value="as_is">as_is(不改换发型,贴回再裁)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>seg_model <span class="desc">头发分割模型</span></label>
|
||||
<select id="segModel">
|
||||
<option value="segformer">segformer(默认)</option>
|
||||
<option value="bisenet">bisenet</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>erode_cm <span class="desc">baseline参考内缩(cm)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="erodeCm" min="0" max="5" step="0.1" value="0.6">
|
||||
<input type="range" id="erodeRange" min="0" max="3" step="0.1" value="0.6">
|
||||
<!-- 结果双栏 -->
|
||||
<div class="results hidden" id="resultsArea">
|
||||
<!-- 左:图片叠加 -->
|
||||
<div>
|
||||
<div class="panel-header">
|
||||
<span>🖼️ 原图 + 标注叠加</span>
|
||||
<div class="anno-controls">
|
||||
<label><input type="checkbox" id="showBase" checked> 底图</label>
|
||||
<label><input type="checkbox" id="showAnno" checked> 标注层</label>
|
||||
<label>透明度 <input type="range" id="annoOpacity" min="0" max="100" value="100"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>hairline_push_cm <span class="desc">发际线外推距离(cm)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="1.0">
|
||||
<input type="range" id="hairlinePushRange" min="0" max="3" step="0.1" value="1.0">
|
||||
<div class="img-panel" id="imgPanel">
|
||||
<span id="imagePlaceholder" class="placeholder">提交后显示叠加效果</span>
|
||||
<div class="img-stack" id="imageStack" hidden>
|
||||
<img class="layer-base" id="baseImage" alt="原图">
|
||||
<img class="layer-anno" id="annotationImage" alt="标注层">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>hairline_edge <span class="desc">发际线提取方式</span></label>
|
||||
<select id="hairlineEdge">
|
||||
<option value="column">column(逐列最低点,稳定)</option>
|
||||
<option value="contour">contour(形态学轮廓,精确)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="mbLevels" min="2" max="6" step="1" value="5">
|
||||
<input type="range" id="mbLevelsRange" min="2" max="6" step="1" value="5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="edgeErodePx" min="0" max="40" step="1" value="3">
|
||||
<input type="range" id="edgeErodeRange" min="0" max="30" step="1" value="3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf" data-show="gen:swaphair">
|
||||
<label>denoising_strength <span class="desc">换发型重绘强度(仅swaphair)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="denoise" min="0.2" max="1.0" step="0.05" value="0.6">
|
||||
<input type="range" id="denoiseRange" min="0.2" max="1.0" step="0.05" value="0.6">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf" data-show="gen:hairgrow">
|
||||
<label>hairgrow_strength <span class="desc">区域生发强度(仅hairgrow)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="hgStrength" min="0.2" max="1.0" step="0.05" value="0.75">
|
||||
<input type="range" id="hgStrengthRange" min="0.2" max="1.0" step="0.05" value="0.75">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf" data-show="gen:swaphair">
|
||||
<label>is_hr <span class="desc">高清模式</span></label>
|
||||
<div class="chk"><input type="checkbox" id="isHr"><span style="font-size:13px">1152×1536(否则 576×768)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">
|
||||
发际线类型 ID 必须是 change_hair 已训练的 hair_id(下拉为已有的 5 个)。参数改动后点「提交测试」重新计算,设置自动记住。<br>
|
||||
换发型走 GPU + SD img2img,单次约 10~15s;is_hr 更慢。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status hidden" id="statusBar"></div>
|
||||
|
||||
<div id="resultsArea" class="hidden">
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">🎯 最终结果对比</h2>
|
||||
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr))">
|
||||
<div class="step big"><div class="cap">输入原图</div><img id="finalInput"></div>
|
||||
<div class="step big"><div class="cap">最终结果 <small>按遮罩贴回 + 接缝融合</small></div><img id="finalOut"></div>
|
||||
<!-- 右:JSON -->
|
||||
<div>
|
||||
<div class="panel-header">
|
||||
<span>📋 JSON 响应</span>
|
||||
<button class="btn btn-outline btn-sm" id="copyBtn" type="button">📋 复制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">📏 参数与指标</h2>
|
||||
<div class="metrics" id="metricsBar"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">🪜 分步可视化</h2>
|
||||
<div class="steps" id="stepsGrid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
|
||||
<span>原始 JSON</span>
|
||||
<button class="btn btn-outline" style="padding:6px 14px; font-size:13px" onclick="copyJson()">复制</button>
|
||||
</h2>
|
||||
<div class="json-panel" id="jsonContent"></div>
|
||||
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const ENDPOINT = '/api/v1/hairline/grow';
|
||||
|
||||
// 分步展示的图(按算法文档 4 步;key 前缀、标题、副标题)
|
||||
const STEPS = [
|
||||
// 遮罩计算全过程(接口9 子步骤,蓝→红)
|
||||
{ key: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline(151中心点标红、其余标绿)' },
|
||||
{ key: 'upper_overlay', title: '①-b 分割线上半区', sub: '青=baseline 以上区域(裁剪范围)' },
|
||||
{ key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=分割模型原始头发像素(segformer/bisenet)' },
|
||||
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=头发向下填充含额头(延伸到图底,未裁剪)' },
|
||||
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=top_fill ∩ 上半区(头发+额头,底=基线)' },
|
||||
// pushed 模式专有(非 pushed 模式无图)
|
||||
{ key: 'hairline_overlay', title: '①-f 头发内轮廓线', sub: '绿=头发内轮廓(额头弧+两侧到下颌) + 黄=baseline(仅pushed)' },
|
||||
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '青=外推线(进头发push_cm) + 红=外推遮罩(仅pushed)' },
|
||||
// 最终遮罩
|
||||
{ key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=遮罩区(含额头,外缘内缩)' },
|
||||
{ key: 'mask', title: '① 纯遮罩', sub: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' },
|
||||
{ key: 'swap_raw', title: '② 生成全帧', sub: 'change_hair 生成,已对齐原图' },
|
||||
{ key: 'hard_paste', title: '③ 严格按遮罩贴回', sub: '遮罩内=生成,遮罩外=原图,无融合' },
|
||||
{ key: 'alpha', title: '④ 融合权重 alpha', sub: '羽化/渐变,白=用生成图' },
|
||||
{ key: 'final', title: '④ 接缝融合(最终)', sub: '遮罩边缘自然过渡' },
|
||||
];
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||
|
||||
// 网关把 *_base64 改写为 *_url;直连 worker 保留 *_base64。两者都兼容。
|
||||
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
||||
|
||||
function stepCard(title, sub, src) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'step';
|
||||
const img = src ? '<img src="' + src + '" alt="' + title + '" onclick="zoom(this.src)">'
|
||||
: '<div style="padding:30px;text-align:center;color:#bbb;font-size:13px">无图</div>';
|
||||
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub || '') + '</small></div>' + img;
|
||||
return div;
|
||||
}
|
||||
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
||||
|
||||
function renderMetrics(d) {
|
||||
const sz = d.image_size || {}, t = d.timings_ms || {};
|
||||
const items = [
|
||||
{ label: 'gen_backend', value: d.gen_backend },
|
||||
{ label: 'hairline_id', value: d.hairline_id },
|
||||
{ label: 'swap_mode', value: d.swap_mode },
|
||||
{ label: 'seg_model', value: d.seg_model },
|
||||
{ label: 'mask', value: d.mask_type },
|
||||
{ label: 'blend', value: d.blend_method },
|
||||
{ label: 'mb_levels', value: d.mb_levels },
|
||||
{ label: 'hairline_push', value: d.hairline_push_cm + 'cm' },
|
||||
{ label: 'hairline_edge', value: d.hairline_edge },
|
||||
{ label: 'denoise', value: d.denoising_strength },
|
||||
{ label: 'px_per_cm', value: d.px_per_cm },
|
||||
{ label: '内缩', value: d.erode_cm + 'cm/' + d.erode_px + 'px' },
|
||||
{ label: '遮罩像素', value: d.mask_pixels },
|
||||
{ label: '尺寸', value: (sz.width||'?') + '×' + (sz.height||'?') },
|
||||
{ label: '耗时(遮罩/换发/融合)', value: (t.mask||0) + '/' + (t.swap||0) + '/' + (t.blend||0) + 'ms' },
|
||||
];
|
||||
$('metricsBar').innerHTML = items.map(m =>
|
||||
'<div class="metric"><div class="label">' + m.label + '</div><div class="value">' + (m.value ?? '—') + '</div></div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderResult(d) {
|
||||
const s = d.steps || {};
|
||||
$('finalInput').src = pick(s, 'input') || '';
|
||||
$('finalInput').onclick = function(){ zoom(this.src); };
|
||||
$('finalOut').src = pick(s, 'final') || '';
|
||||
$('finalOut').onclick = function(){ zoom(this.src); };
|
||||
renderMetrics(d);
|
||||
const grid = $('stepsGrid'); grid.innerHTML = '';
|
||||
// 固定 pushed:只展示 pushed 相关步骤(不展示 top_fill/closed)
|
||||
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay',
|
||||
'hairline_overlay','pushed_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
|
||||
STEPS.filter(st => showKeys.has(st.key)).forEach(st =>
|
||||
grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key))));
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
let file = $('imageFile').files[0];
|
||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||
file = await window.downscaleImageFile(file);
|
||||
const t0 = performance.now();
|
||||
const btn = $('submitBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
||||
setStatus('正在请求(换发型走 GPU,约 10~15s)...', 'info');
|
||||
$('resultsArea').classList.add('hidden');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('image_file', file);
|
||||
form.append('hairline_id', $('hairlineId').value.trim());
|
||||
form.append('gen_backend', $('genBackend').value);
|
||||
form.append('hairgrow_strength', $('hgStrength').value || '0.75');
|
||||
form.append('is_hr', $('isHr').checked ? 'true' : 'false');
|
||||
form.append('seg_model', $('segModel').value);
|
||||
form.append('erode_cm', $('erodeCm').value || '0.6');
|
||||
setStatus('正在请求(固定 pushed + multiband) hairline_push_cm=' + ($('hairlinePushCm').value||'1.0')
|
||||
+ ' hairline_edge=' + $('hairlineEdge').value
|
||||
+ '(换发型走 GPU,约 10~15s)...', 'info');
|
||||
form.append('swap_mode', $('swapMode').value);
|
||||
form.append('edge_erode_px', $('edgeErodePx').value || '3');
|
||||
form.append('denoising_strength', $('denoise').value || '0.6');
|
||||
form.append('mb_levels', $('mbLevels').value || '5');
|
||||
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
|
||||
form.append('hairline_edge', $('hairlineEdge').value);
|
||||
|
||||
try {
|
||||
const headers = {};
|
||||
const tok = $('token').value.trim();
|
||||
if (tok) headers['X-Internal-Token'] = tok;
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers, body: form });
|
||||
const json = await resp.json();
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, (k, v) =>
|
||||
(typeof v === 'string' && v.length > 120) ? v.slice(0, 60) + '…(' + v.length + ')' : v, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
if (json.code === 0) {
|
||||
setStatus('✅ 成功 (' + dt + 's)', 'success');
|
||||
renderResult(json.data);
|
||||
} else {
|
||||
setStatus('❌ 业务错误 (' + dt + 's) — code ' + json.code + ':' + json.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||
$('jsonContent').textContent = 'Error: ' + err.message;
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '🚀 提交测试';
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
$('resultsArea').classList.add('hidden');
|
||||
$('statusBar').className = 'status hidden';
|
||||
$('imageFile').value = '';
|
||||
$('jsonContent').textContent = '';
|
||||
}
|
||||
function copyJson() {
|
||||
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
|
||||
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 参数持久化 + 联动 ----
|
||||
const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','erodeCm','edgeErodePx','denoise','hgStrength','mbLevels','hairlinePushCm','hairlineEdge'];
|
||||
function saveField(id) { try { localStorage.setItem('if11_' + id, $(id).type === 'checkbox' ? ($(id).checked?'1':'0') : $(id).value); } catch(e){} }
|
||||
function linkNumRange(numId, rngId) {
|
||||
const num = $(numId), rng = $(rngId);
|
||||
function sync(from) {
|
||||
let v = parseFloat(from.value); if (isNaN(v)) v = 0;
|
||||
num.value = v; rng.value = Math.min(parseFloat(rng.max), Math.max(parseFloat(rng.min), v));
|
||||
saveField(numId);
|
||||
}
|
||||
num.addEventListener('input', () => sync(num));
|
||||
rng.addEventListener('input', () => sync(rng));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dz = $('imageFile');
|
||||
dz.addEventListener('change', () => { if (dz.files.length) setStatus('已选择: ' + dz.files[0].name, 'info'); });
|
||||
FIELDS.forEach(id => {
|
||||
try { const s = localStorage.getItem('if11_' + id); if (s !== null) $(id).value = s; } catch(e){}
|
||||
$(id).addEventListener('change', () => saveField(id));
|
||||
});
|
||||
try { $('isHr').checked = localStorage.getItem('if11_isHr') === '1'; } catch(e){}
|
||||
$('isHr').addEventListener('change', () => saveField('isHr'));
|
||||
linkNumRange('erodeCm','erodeRange');
|
||||
linkNumRange('edgeErodePx','edgeErodeRange');
|
||||
linkNumRange('denoise','denoiseRange');
|
||||
linkNumRange('hgStrength','hgStrengthRange');
|
||||
linkNumRange('mbLevels','mbLevelsRange');
|
||||
linkNumRange('hairlinePushCm','hairlinePushRange');
|
||||
// 初始化滑块值
|
||||
$('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||0.6);
|
||||
$('edgeErodeRange').value = Math.min(30, parseFloat($('edgeErodePx').value)||3);
|
||||
$('denoiseRange').value = Math.min(1.0, parseFloat($('denoise').value)||0.6);
|
||||
$('hgStrengthRange').value = Math.min(1.0, parseFloat($('hgStrength').value)||0.75);
|
||||
$('mbLevelsRange').value = Math.min(6, Math.max(2, parseFloat($('mbLevels').value)||5));
|
||||
$('hairlinePushRange').value = Math.min(3, parseFloat($('hairlinePushCm').value)||1.0);
|
||||
|
||||
// 参数联动:按 gen_backend 显隐无用参数(data-show 声明,遮罩/融合已固定无选项)
|
||||
function applyVisibility() {
|
||||
const gen = $('genBackend').value;
|
||||
document.querySelectorAll('.pf[data-show]').forEach(el => {
|
||||
const conds = el.dataset.show.split(',').map(s => s.trim());
|
||||
let vis = true;
|
||||
conds.forEach(c => {
|
||||
const [k, v] = c.split(':');
|
||||
if (k === 'gen' && v !== gen) vis = false;
|
||||
});
|
||||
el.style.display = vis ? '' : 'none';
|
||||
});
|
||||
}
|
||||
$('genBackend').addEventListener('change', applyVisibility);
|
||||
applyVisibility();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0; url=/static/test_interface11.html">
|
||||
<title>接口 11 测试页</title>
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="/static/test_interface11.html">打开接口 11 测试页</a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||
<input type="text" id="token" value="dev-shared-secret-2026" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="token" value="" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
|
||||
@@ -69,11 +69,11 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||
<input type="text" id="token" value="dev-shared-secret-2026" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="token" value="" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,人物增加一点磨皮';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
flog('===== 调后端重绘 =====', 'info');
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
@@ -92,7 +92,6 @@
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const ENDPOINT = '/api/v1/hairline/grow_v2_final';
|
||||
const TOKEN = 'dev-shared-secret-2026';
|
||||
|
||||
// 缓存最近一次后端返回的 final(data URI)和纯红遮罩(data URI),供重绘使用
|
||||
let _finalDataUri = '';
|
||||
@@ -145,7 +144,6 @@ async function submitTest() {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Internal-Token': TOKEN },
|
||||
body: form,
|
||||
});
|
||||
const json = await resp.json();
|
||||
@@ -169,7 +167,7 @@ async function submitTest() {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,人物增加一点磨皮';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const ENDPOINT = '/api/v1/hairline/grow_v2_final_v2';
|
||||
const TOKEN = 'dev-shared-secret-2026';
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||
@@ -118,7 +117,6 @@ async function submitTest() {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Internal-Token': TOKEN },
|
||||
body: form,
|
||||
});
|
||||
const json = await resp.json();
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ async function submitTest() {
|
||||
fd.append('prompt', $('promptInput').value);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', headers:{ 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>接口 22 · 生发测试</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f3f5f8; color: #172334; font: 15px/1.5 system-ui, sans-serif; }
|
||||
main { max-width: 1100px; margin: 32px auto; padding: 0 16px; }
|
||||
h1 { margin: 0 0 4px; font-size: 28px; }
|
||||
h2 { margin: 0 0 12px; font-size: 18px; }
|
||||
.muted, figcaption { color: #526075; }
|
||||
.card { background: white; border: 1px solid #dce3eb; border-radius: 12px; padding: 18px; margin: 16px 0; }
|
||||
#client-auth { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; }
|
||||
#client-auth-status { flex-basis: 100%; margin: 0; }
|
||||
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
|
||||
.field { display: grid; gap: 5px; align-content: start; }
|
||||
input, select, button { font: inherit; }
|
||||
.field input, select { min-width: 0; width: 100%; padding: 6px 8px; }
|
||||
fieldset { margin: 16px 0; border: 1px solid #dce3eb; border-radius: 8px; }
|
||||
#sourceFields { margin-top: 0; padding: 0; border: 0; min-width: 0; }
|
||||
.choices { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.choices label { white-space: nowrap; }
|
||||
button { background: #1757ab; color: white; border: 0; border-radius: 8px; padding: 8px 16px; cursor: pointer; }
|
||||
button:disabled { opacity: .6; cursor: wait; }
|
||||
.status { margin-bottom: 0; }
|
||||
.status[data-error="true"] { color: #a02020; }
|
||||
.images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; }
|
||||
.images figure { max-width: 380px; }
|
||||
.result-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 122px), 1fr)); gap: 10px; }
|
||||
.result-tile { min-width: 0; overflow: hidden; background: white; border: 1px solid #dce3eb; border-radius: 8px; }
|
||||
.result-tile img { display: block; width: 100%; height: auto; }
|
||||
.result-tile figcaption { display: grid; gap: 2px; margin: 0; padding: 6px 8px; font-size: 12px; }
|
||||
.result-tile strong { color: #172334; }
|
||||
.preview-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; margin: 16px 0; }
|
||||
.preview-editor:has(#previews > figure:only-child) { grid-template-columns: minmax(0, 380px) auto; justify-content: start; }
|
||||
.height-controls { display: grid; gap: 6px; padding-top: 27px; }
|
||||
.height-scale { display: flex; gap: 6px; height: clamp(170px, 42vw, 300px); }
|
||||
#height { writing-mode: vertical-lr; direction: rtl; width: 24px; height: 100%; margin: 0; }
|
||||
.height-presets { display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.height-presets label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
|
||||
figure { margin: 0; min-width: 0; }
|
||||
figcaption { margin-bottom: 6px; }
|
||||
.stack { position: relative; overflow: hidden; border-radius: 6px; }
|
||||
.stack img, .stack canvas { width: 100%; height: auto; display: block; border-radius: 6px; }
|
||||
.preview-layer, .preview-clip, .stack .overlay, .crop-border { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.preview-layer { transform: translateY(calc(-1 * var(--height-adjustment) * var(--max-shift))); }
|
||||
.crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
@media (max-width: 560px) { .images { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<script src="/static/img_downscale.js?v=2" defer></script>
|
||||
<script src="/static/hairline_reference_box.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>接口 22 生发测试</h1>
|
||||
<div class="muted"><code>POST /api/v1/hair/grow-22</code></div>
|
||||
<form id="client-auth" class="card" method="post" action="/api/v1/auth/login" aria-label="账号登录">
|
||||
<label class="field">用户名
|
||||
<input name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label class="field">密码
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit">登录</button>
|
||||
<button id="client-auth-check" type="button">测试鉴权</button>
|
||||
<button id="client-auth-refresh" type="button" disabled>刷新令牌</button>
|
||||
<p id="client-auth-status" class="muted" role="status">请使用对接账号登录后生图。</p>
|
||||
</form>
|
||||
<form id="generateRequest" class="card">
|
||||
<fieldset id="sourceFields" aria-label="照片与性别">
|
||||
<div class="fields">
|
||||
<label class="field">照片(JPG / PNG)
|
||||
<input type="file" name="image_file" accept="image/jpeg,image/png,.jpg,.jpeg,.png" required>
|
||||
</label>
|
||||
<label class="field">性别
|
||||
<select name="gender"><option value="female">女性</option><option value="male">男性</option></select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset id="styleFields">
|
||||
<legend>发际线形状(可多选)</legend>
|
||||
<div id="styles" class="choices"></div>
|
||||
<p id="generationPreviewStatus" class="status" role="status"></p>
|
||||
</fieldset>
|
||||
<p id="firstRunHint" class="muted">首次按默认高度 0 生图,出图后可预览和调整发际线高度。</p>
|
||||
<div id="heightEditor" hidden>
|
||||
<p class="muted">勾选右侧档位后按所选高度生图。未勾选时,使用滑块当前高度。</p>
|
||||
<div class="preview-editor">
|
||||
<div id="previews" class="images" aria-label="52 号裁切发际线预览"></div>
|
||||
<div class="height-controls">
|
||||
<label for="height">高度 <output id="heightValue" for="height">0</output></label>
|
||||
<div class="height-scale">
|
||||
<input type="range" id="height" name="hairline_height" min="-1" max="1" step="0.1" value="0">
|
||||
<div class="height-presets" role="group" aria-label="预设高度(可多选)">
|
||||
<label><input type="checkbox" name="height_preset" value="1"> +1</label>
|
||||
<label><input type="checkbox" name="height_preset" value="0.5"> +0.5</label>
|
||||
<label><input type="checkbox" name="height_preset" value="0"> 0</label>
|
||||
<label><input type="checkbox" name="height_preset" value="-0.5"> −0.5</label>
|
||||
<label><input type="checkbox" name="height_preset" value="-1"> −1</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>生发密度(至少选择一档)</legend>
|
||||
<div class="choices">
|
||||
<label><input type="checkbox" name="hair_density" value="sparse"> 稀疏</label>
|
||||
<label><input type="checkbox" name="hair_density" value="normal" checked> 正常</label>
|
||||
<label><input type="checkbox" name="hair_density" value="dense"> 浓密</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<button id="submit" type="submit">生成生发效果</button>
|
||||
<p id="status" class="status" role="status"></p>
|
||||
</form>
|
||||
<section id="results" class="result-grid" aria-label="生发结果"></section>
|
||||
<details class="card" id="response" hidden>
|
||||
<summary>响应 JSON(省略图片 Base64)</summary>
|
||||
<pre id="json"></pre>
|
||||
</details>
|
||||
</main>
|
||||
<script>
|
||||
const generateForm = document.getElementById('generateRequest');
|
||||
const sourceFields = document.getElementById('sourceFields');
|
||||
const firstRunHint = document.getElementById('firstRunHint');
|
||||
const heightEditor = document.getElementById('heightEditor');
|
||||
const previews = document.getElementById('previews');
|
||||
const height = document.getElementById('height');
|
||||
const heightPresets = generateForm.querySelectorAll('[name="height_preset"]');
|
||||
const styles = document.getElementById('styles');
|
||||
const styleFields = document.getElementById('styleFields');
|
||||
const generationPreviewStatus = document.getElementById('generationPreviewStatus');
|
||||
const statusBar = document.getElementById('status');
|
||||
const results = document.getElementById('results');
|
||||
const response = document.getElementById('response');
|
||||
const submit = document.getElementById('submit');
|
||||
const densityLabels = { sparse: '稀疏', normal: '正常', dense: '浓密' };
|
||||
const styleNames = {
|
||||
female: ['椭圆', '花瓣', '心形', '直线', '波浪', '大花瓣', '古典花瓣'],
|
||||
male: ['椭圆', '倒弧', 'M 形', '直线', '心形', '柔和花瓣', '波浪'],
|
||||
};
|
||||
let prepared;
|
||||
let previewRevision = 0;
|
||||
|
||||
function renderStyleChoices(container, gender, selected) {
|
||||
container.replaceChildren(...styleNames[gender].map((name, index) => {
|
||||
const label = document.createElement('label');
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.name = 'hair_style';
|
||||
checkbox.value = index + 1;
|
||||
checkbox.checked = selected.includes(checkbox.value);
|
||||
label.append(checkbox, ' ' + (index + 1) + '. ' + name);
|
||||
return label;
|
||||
}));
|
||||
}
|
||||
|
||||
function imageSource(url, base64, mime) {
|
||||
if (url) return url;
|
||||
if (!base64) return '';
|
||||
return base64.startsWith('data:') ? base64 : 'data:' + mime + ';base64,' + base64;
|
||||
}
|
||||
|
||||
function imagePanel(title, src, preview) {
|
||||
const figure = document.createElement('figure');
|
||||
const caption = document.createElement('figcaption');
|
||||
caption.textContent = title;
|
||||
figure.append(caption);
|
||||
if (!src) {
|
||||
figure.append(preview?.error || '未返回图片');
|
||||
return figure;
|
||||
}
|
||||
const stack = document.createElement('div');
|
||||
stack.className = 'stack';
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
image.alt = title;
|
||||
stack.append(image);
|
||||
if (preview) {
|
||||
const movingLayer = document.createElement('div');
|
||||
movingLayer.className = 'preview-layer';
|
||||
movingLayer.style.setProperty('--max-shift', preview.maxShift + '%');
|
||||
const clip = document.createElement('div');
|
||||
clip.className = 'preview-clip';
|
||||
const polygonPoints = preview.corners.map(([x, y]) =>
|
||||
(100 * x / preview.width) + '% ' + (100 * y / preview.height) + '%');
|
||||
clip.style.clipPath = 'polygon(' + polygonPoints.join(', ') + ')';
|
||||
const layer = new Image();
|
||||
layer.src = preview.overlay;
|
||||
layer.alt = '发际线';
|
||||
layer.className = 'overlay';
|
||||
clip.append(layer);
|
||||
movingLayer.append(clip);
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const border = document.createElementNS(ns, 'svg');
|
||||
border.classList.add('crop-border');
|
||||
border.setAttribute('viewBox', '0 0 ' + preview.width + ' ' + preview.height);
|
||||
border.setAttribute('aria-label', '发际线裁切边界');
|
||||
const polygon = document.createElementNS(ns, 'polygon');
|
||||
polygon.setAttribute('points', preview.corners.map(point => point.join(',')).join(' '));
|
||||
border.append(polygon);
|
||||
movingLayer.append(border);
|
||||
stack.append(movingLayer);
|
||||
}
|
||||
figure.append(stack);
|
||||
return figure;
|
||||
}
|
||||
|
||||
async function loadCroppedPreviews(data, selected) {
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of data) formData.set(key, value);
|
||||
formData.set('hair_style', selected.join(','));
|
||||
formData.set('normalize_image', 'true');
|
||||
formData.set('generate_grow_image', 'false');
|
||||
const reply = await window.hairClientFetch('/api/v1/hairline/generate-52', { method: 'POST', body: formData });
|
||||
const body = await reply.json();
|
||||
if (!reply.ok || body.code !== 0) {
|
||||
throw new Error(body.message || 'HTTP ' + reply.status);
|
||||
}
|
||||
const preview = body.data;
|
||||
const base = imageSource(preview.input_image_url, preview.input_image_base64, 'image/jpeg');
|
||||
const { width, height } = preview;
|
||||
const { corners, landmarks } = window.hairlineReferenceBox(preview.guide_landmarks);
|
||||
// Convert the interface 22 scale (10% of brow-to-chin distance) to a percentage of the image height.
|
||||
const maxShift = 100 * 0.10 * Math.abs(landmarks[152][1] - landmarks[9][1]) / height;
|
||||
const items = preview.hairline_images.map(item => {
|
||||
const overlay = imageSource(item.image_middle_url, item.image_middle_base64, 'image/png');
|
||||
return { order: item.order, ...((base && overlay)
|
||||
? { base, corners, width, height, maxShift, overlay }
|
||||
: { error: '接口 52 未返回完整预览图片' }) };
|
||||
});
|
||||
return { items, framing: preview.framing };
|
||||
}
|
||||
|
||||
function makePreviewPanels(items, gender) {
|
||||
const names = styleNames[gender];
|
||||
return new Map(items.filter(item => item.base).map(item => [
|
||||
String(item.order), imagePanel('#' + item.order + ' · ' + names[item.order - 1], item.base, item),
|
||||
]));
|
||||
}
|
||||
|
||||
async function replacePreviewBases(images, panels, framing, fallback = false) {
|
||||
const byOrder = new Map(images.map(item => [String(item.order), item]));
|
||||
const targets = [...panels].flatMap(([order, panel]) => {
|
||||
const image = byOrder.get(order) || (fallback ? images[0] : undefined);
|
||||
return image ? [[image, panel]] : [];
|
||||
});
|
||||
if (!targets.length) return 0;
|
||||
const { source_to_processed: matrix, source_size: source, output_size: output } = framing || {};
|
||||
if (!matrix || !source || !output) return targets.length;
|
||||
const [[a, c, e], [b, d, f]] = matrix;
|
||||
const updated = await Promise.all(targets.map(async ([item, panel]) => {
|
||||
const base = panel.querySelector('.stack > img');
|
||||
if (!base) return false;
|
||||
try {
|
||||
const generated = new Image();
|
||||
generated.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
|
||||
await generated.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = output.width;
|
||||
canvas.height = output.height;
|
||||
canvas.setAttribute('role', 'img');
|
||||
canvas.setAttribute('aria-label', base.alt + ' · 默认高度生发底图');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
ctx.setTransform(a, b, c, d, e, f);
|
||||
ctx.drawImage(generated, 0, 0, source.width, source.height);
|
||||
base.replaceWith(canvas);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
return updated.filter(ok => !ok).length;
|
||||
}
|
||||
|
||||
function renderResult(item, submittedHeight, gender) {
|
||||
const tile = document.createElement('figure');
|
||||
tile.className = 'result-tile';
|
||||
const image = new Image();
|
||||
image.loading = 'lazy';
|
||||
image.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
|
||||
const name = styleNames[gender][item.order - 1] || item.hairline_type;
|
||||
const style = '#' + item.order + ' ' + name + ' · ' + (gender === 'male' ? '男性' : '女性');
|
||||
const details = (densityLabels[item.hair_density] || item.hair_density) + ' · 高度 ' + submittedHeight;
|
||||
image.alt = style + ',' + details;
|
||||
const caption = document.createElement('figcaption');
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = style;
|
||||
const parameters = document.createElement('span');
|
||||
parameters.textContent = details;
|
||||
caption.append(title, parameters);
|
||||
tile.append(image, caption);
|
||||
return tile;
|
||||
}
|
||||
|
||||
async function requestGrowth(data) {
|
||||
const reply = await window.hairClientFetch('/api/v1/hair/grow-22', { method: 'POST', body: data });
|
||||
const body = await reply.json();
|
||||
if (!reply.ok || body.code !== 0) {
|
||||
throw new Error(body.message || 'HTTP ' + reply.status);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function updatePreviewHeight() {
|
||||
document.getElementById('heightValue').value = height.value;
|
||||
previews.style.setProperty('--height-adjustment', height.value);
|
||||
}
|
||||
|
||||
height.addEventListener('input', updatePreviewHeight);
|
||||
heightPresets.forEach(preset => preset.addEventListener('change', () => {
|
||||
if (preset.checked) {
|
||||
height.value = preset.value;
|
||||
updatePreviewHeight();
|
||||
}
|
||||
}));
|
||||
updatePreviewHeight();
|
||||
function renderSourceStyles() {
|
||||
renderStyleChoices(styles, generateForm.elements.gender.value, ['1']);
|
||||
}
|
||||
|
||||
generateForm.elements.gender.addEventListener('change', renderSourceStyles);
|
||||
renderSourceStyles();
|
||||
|
||||
sourceFields.addEventListener('change', () => {
|
||||
prepared = undefined;
|
||||
heightEditor.hidden = true;
|
||||
firstRunHint.hidden = false;
|
||||
previews.replaceChildren();
|
||||
results.replaceChildren();
|
||||
response.hidden = true;
|
||||
statusBar.textContent = '';
|
||||
generationPreviewStatus.textContent = '';
|
||||
submit.disabled = false;
|
||||
height.value = '0';
|
||||
heightPresets.forEach(preset => { preset.checked = false; });
|
||||
updatePreviewHeight();
|
||||
});
|
||||
|
||||
async function refreshStylePreviews(selected) {
|
||||
const current = prepared;
|
||||
if (!current) return;
|
||||
const revision = ++previewRevision;
|
||||
const showSelected = () => previews.replaceChildren(...selected.flatMap(order =>
|
||||
current.panels.has(order) ? [current.panels.get(order)] : []));
|
||||
showSelected();
|
||||
generationPreviewStatus.dataset.error = 'false';
|
||||
generationPreviewStatus.textContent = selected.length ? '' : '请至少选择一种发际线形状。';
|
||||
const missing = selected.filter(order => !current.panels.has(order));
|
||||
submit.disabled = !selected.length || !!missing.length;
|
||||
if (!missing.length) return;
|
||||
generationPreviewStatus.textContent = '正在更新发际线预览…';
|
||||
try {
|
||||
const { items, framing } = await loadCroppedPreviews(current.data, missing);
|
||||
const panels = makePreviewPanels(items, current.data.get('gender'));
|
||||
const unmatched = await replacePreviewBases(current.defaultImages, panels, framing, true);
|
||||
if (current !== prepared || revision !== previewRevision) return;
|
||||
for (const [order, panel] of panels) current.panels.set(order, panel);
|
||||
showSelected();
|
||||
if (missing.some(order => !panels.has(order))) {
|
||||
throw new Error('接口 52 未返回全部所选形状。');
|
||||
}
|
||||
generationPreviewStatus.textContent = unmatched ? '部分生成图无法对齐,已显示对应的裁切原照。' : '';
|
||||
} catch (error) {
|
||||
if (current !== prepared || revision !== previewRevision) return;
|
||||
generationPreviewStatus.dataset.error = 'true';
|
||||
generationPreviewStatus.textContent = '更新发际线预览失败:' + error.message;
|
||||
} finally {
|
||||
if (current === prepared && revision === previewRevision) submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
styles.addEventListener('change', () => {
|
||||
refreshStylePreviews(new FormData(generateForm).getAll('hair_style'));
|
||||
});
|
||||
|
||||
generateForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled) return;
|
||||
const data = new FormData(generateForm);
|
||||
const selectedStyles = data.getAll('hair_style');
|
||||
if (!selectedStyles.length) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '请至少选择一种发际线形状。';
|
||||
return;
|
||||
}
|
||||
const densities = data.getAll('hair_density');
|
||||
if (!densities.length) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '请至少选择一档生发密度。';
|
||||
return;
|
||||
}
|
||||
const selectedHeights = data.getAll('height_preset');
|
||||
const firstRun = !prepared;
|
||||
const submittedHeights = firstRun ? ['0'] : (selectedHeights.length ? selectedHeights : [data.get('hairline_height')]);
|
||||
data.delete('height_preset');
|
||||
data.set('hair_style', selectedStyles.join(','));
|
||||
sourceFields.disabled = styleFields.disabled = submit.disabled = true;
|
||||
statusBar.dataset.error = 'false';
|
||||
results.replaceChildren();
|
||||
response.hidden = true;
|
||||
const reports = [];
|
||||
const failedHeights = [];
|
||||
const defaultImages = new Map();
|
||||
const planned = submittedHeights.length * densities.length * selectedStyles.length;
|
||||
let generated = 0;
|
||||
const start = performance.now();
|
||||
try {
|
||||
statusBar.textContent = '正在准备照片…';
|
||||
const file = firstRun
|
||||
? await window.downscaleImageFile(data.get('image_file'))
|
||||
: prepared.data.get('image_file');
|
||||
data.set('image_file', file);
|
||||
for (const [index, submittedHeight] of submittedHeights.entries()) {
|
||||
statusBar.textContent = '正在生成 ' + (index + 1) + '/' + submittedHeights.length + ' 档 · 高度 ' + submittedHeight + '…';
|
||||
// The slider is positive up, while the API uses positive image Y (down).
|
||||
data.set('hairline_height', String(-Number(submittedHeight)));
|
||||
try {
|
||||
const body = await requestGrowth(data);
|
||||
const images = body.data.results.filter(item => item.grown_image_url || item.grown_image_base64);
|
||||
results.append(...images.map(item => renderResult(item, submittedHeight, data.get('gender'))));
|
||||
generated += images.length;
|
||||
if (firstRun) {
|
||||
for (const item of images) {
|
||||
if (!defaultImages.has(item.order) || item.hair_density === 'normal') {
|
||||
const { order, grown_image_url, grown_image_base64 } = item;
|
||||
defaultImages.set(order, { order, grown_image_url, grown_image_base64 });
|
||||
}
|
||||
}
|
||||
}
|
||||
reports.push(JSON.stringify({ height: Number(submittedHeight), response: body }, (key, value) =>
|
||||
key.endsWith('_base64') && typeof value === 'string' ? '[图片已省略]' : value, 2));
|
||||
} catch (error) {
|
||||
failedHeights.push(submittedHeight);
|
||||
reports.push(JSON.stringify({ height: Number(submittedHeight), error: error.message }, null, 2));
|
||||
}
|
||||
}
|
||||
document.getElementById('json').textContent = '[\n' + reports.join(',\n') + '\n]';
|
||||
response.hidden = false;
|
||||
const seconds = ((performance.now() - start) / 1000).toFixed(1);
|
||||
statusBar.dataset.error = String(generated !== planned);
|
||||
statusBar.textContent = '生发成功 ' + generated + '/' + planned + ' 张 · ' + seconds + ' 秒'
|
||||
+ (failedHeights.length ? ' · 请求失败的高度 ' + failedHeights.join('、') : '');
|
||||
if (firstRun && generated) {
|
||||
const source = new FormData();
|
||||
source.set('image_file', file);
|
||||
source.set('gender', data.get('gender'));
|
||||
prepared = { data: source, panels: new Map(), defaultImages: [...defaultImages.values()] };
|
||||
heightEditor.hidden = false;
|
||||
firstRunHint.hidden = true;
|
||||
await refreshStylePreviews(selectedStyles);
|
||||
}
|
||||
} catch (error) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '生发失败:' + error.message;
|
||||
} finally {
|
||||
sourceFields.disabled = styleFields.disabled = submit.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<div class="upload-group" style="margin-top:14px">
|
||||
<div class="label">💬 提示词(prompt)</div>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||
@@ -162,7 +162,7 @@ async function submitTest() {
|
||||
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
|
||||
@@ -112,10 +112,10 @@
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style'];
|
||||
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style','contour_type'];
|
||||
const EN_LABELS = {
|
||||
face_shape:'脸型', eyebrow_shape:'眉形', facial_age:'面部年龄',
|
||||
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格'
|
||||
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格', contour_type:'轮廓类型'
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
@@ -133,7 +133,7 @@ async function submitTest() {
|
||||
const fd = new FormData(); fd.append('image_file', f);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口42 — 用户特征分析与两项判定 测试页</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 6px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-body { padding: 18px; }
|
||||
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.file-input { flex: 1; min-width: 200px; }
|
||||
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.btn-outline:hover { background: #f9fafb; }
|
||||
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
.results-layout { display: flex; gap: 24px; }
|
||||
.col-main { flex: 1.5; min-width: 0; }
|
||||
.col-side { flex: 1; min-width: 0; }
|
||||
|
||||
/* 特征表格 */
|
||||
.feat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.feat-table th, .feat-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.feat-table th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
|
||||
.feat-table td:first-child { font-weight: 600; color: #1e293b; width: 200px; }
|
||||
.feat-table tr:hover td { background: #f8fafc; }
|
||||
.feat-table .highlight td { background: #eff6ff; }
|
||||
|
||||
/* 图片预览 */
|
||||
.img-preview { text-align: center; }
|
||||
.img-preview .thumb { background: #222; border-radius: 8px; overflow: hidden; display: inline-block; line-height: 0; }
|
||||
.img-preview .thumb img { max-width: 100%; max-height: 280px; object-fit: contain; }
|
||||
|
||||
.json-panel { max-height: 550px; overflow: auto; }
|
||||
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
.count-badge { background: #dbeafe; color: #1e40af; padding: 2px 10px; border-radius: 10px; font-size: 12px; font-weight: 700; }
|
||||
|
||||
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔬 接口42 — 用户特征分析与两项判定 测试</h1>
|
||||
<p class="subtitle">POST /api/v1/face/features-42 | 质检 → 扶正裁切 → 面部特征与两项判定 | data.features 为 JSON 字符串</p>
|
||||
|
||||
<form id="client-auth" method="post" action="/api/v1/auth/login" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">登录</button>
|
||||
<button id="client-auth-check" type="button">测试鉴权</button>
|
||||
<button id="client-auth-refresh" type="button" disabled>刷新令牌</button>
|
||||
<span id="client-auth-status" role="status">登录后可调用接口。</span>
|
||||
</form>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="upload-row">
|
||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 分析特征</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
||||
</div>
|
||||
<div class="hint">JPG/PNG 正面照 | 检测人脸完整性和俯拍角度,通过后扶正裁切</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-layout hidden" id="resultsArea">
|
||||
<div class="col-main">
|
||||
<!-- 图片预览 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📸 上传的照片</span></div>
|
||||
<div class="card-body img-preview">
|
||||
<div class="thumb" id="imgPreview"><span style="color:#9ca3af;padding:40px">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 英文优先字段 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>⭐ 英文优先字段</span><span id="countBadge" class="count-badge hidden"></span></div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<table class="feat-table" id="enTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 全部字段 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📋 全部特征字段</span></div>
|
||||
<div class="card-body" style="padding:0;max-height:500px;overflow:auto">
|
||||
<table class="feat-table" id="allTable"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-side">
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📄 原始 JSON 响应</span><button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button></div>
|
||||
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style','zygoma_expansion','temple_hollowing'];
|
||||
const EN_LABELS = {
|
||||
face_shape:'脸型', eyebrow_shape:'眉形', facial_age:'面部年龄',
|
||||
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格',
|
||||
zygoma_expansion:'颧弓外扩', temple_hollowing:'太阳穴凹陷'
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
||||
|
||||
async function submitTest() {
|
||||
const f = $('imageFile').files[0];
|
||||
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||
|
||||
$('imgPreview').innerHTML = '<img src="'+URL.createObjectURL(f)+'" alt="preview">';
|
||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ 分析中...';
|
||||
setStatus('正在质检、扶正裁切并分析特征…', 'info'); $('resultsArea').classList.add('hidden');
|
||||
|
||||
$('enTable').textContent = '';
|
||||
$('allTable').textContent = '';
|
||||
$('countBadge').classList.add('hidden');
|
||||
const fd = new FormData(); fd.append('image_file', f);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await window.hairClientFetch(API_BASE + '/api/v1/face/features-42', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
|
||||
if (json.code === 0) {
|
||||
const feats = JSON.parse(json.data.features);
|
||||
setStatus('✅ 分析完成 (' + _elapsed + 's) — ' + Object.keys(feats).length + ' 项特征', 'success');
|
||||
renderFeatures(feats);
|
||||
} else {
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 分析特征'; }
|
||||
}
|
||||
|
||||
function displayValue(value, key) {
|
||||
const label = value === true ? (EN_LABELS[key] ?? String(value)) : value === false ? ''
|
||||
: value === null ? '无法测量' : String(value);
|
||||
const element = document.createElement('span');
|
||||
element.textContent = label;
|
||||
return element.innerHTML;
|
||||
}
|
||||
|
||||
function renderFeatures(feats) {
|
||||
// 英文优先字段
|
||||
let enHtml = '';
|
||||
EN_KEYS.forEach(k => {
|
||||
if (feats[k] !== undefined) {
|
||||
enHtml += '<tr class="highlight"><td>'+displayValue(k)+'</td><td>'+EN_LABELS[k]+'</td><td>'+displayValue(feats[k], k)+'</td></tr>';
|
||||
}
|
||||
});
|
||||
$('enTable').innerHTML = enHtml;
|
||||
|
||||
// 全部字段
|
||||
const allKeys = Object.keys(feats);
|
||||
let allHtml = '';
|
||||
allKeys.forEach(k => {
|
||||
const isEn = EN_KEYS.includes(k);
|
||||
allHtml += '<tr'+(isEn?' class="highlight"':'')+'><td>'+displayValue(k)+'</td><td>'+(isEn?EN_LABELS[k]||'':'')+'</td><td>'+displayValue(feats[k], k)+'</td></tr>';
|
||||
});
|
||||
$('allTable').innerHTML = allHtml;
|
||||
|
||||
$('countBadge').textContent = allKeys.length + ' 项';
|
||||
$('countBadge').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
|
||||
$('imageFile').value=''; $('jsonContent').textContent='';
|
||||
$('imgPreview').innerHTML='<span style="color:#9ca3af;padding:40px">—</span>';
|
||||
$('enTable').innerHTML=''; $('allTable').innerHTML='';
|
||||
$('countBadge').classList.add('hidden');
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
|
||||
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dz = $('imageFile');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
|
||||
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
|
||||
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
|
||||
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -220,7 +220,7 @@ async function submitTest() {
|
||||
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false');
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口52 — 发际线PNG生成 测试页</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 6px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-body { padding: 18px; }
|
||||
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.file-input { flex: 1; min-width: 200px; }
|
||||
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.form-group { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.form-group label { font-size: 13px; font-weight: 600; color: #374151; }
|
||||
.form-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
|
||||
.hair-checkboxes { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; }
|
||||
.hair-checkboxes label { font-size: 13px; font-weight: 500; color: #374151; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
|
||||
.hair-checkboxes label input[type=checkbox] { cursor: pointer; }
|
||||
.checkbox-actions { display: flex; gap: 6px; margin-left: 4px; }
|
||||
.checkbox-actions button { font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid #d1d5db; background: #f9fafb; cursor: pointer; }
|
||||
.checkbox-actions button:hover { background: #e5e7eb; }
|
||||
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.btn-outline:hover { background: #f9fafb; }
|
||||
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
.results-layout { display: flex; gap: 24px; }
|
||||
.col-main { flex: 1.5; min-width: 0; }
|
||||
.col-side { flex: 1; min-width: 0; }
|
||||
|
||||
/* 每个发型区块 */
|
||||
.hair-block { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.hair-block:first-child { padding-top: 4px; }
|
||||
.hair-block:last-child { border-bottom: none; padding-bottom: 4px; }
|
||||
.hair-block-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 10px; }
|
||||
|
||||
/* 未裁切原图 + middle + 矩形裁切发际线叠加 + 生发图 */
|
||||
.level-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; align-items: start; }
|
||||
.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; min-height: 38px; display: flex; align-items: center; }
|
||||
.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; }
|
||||
/* 处理后底图 + 同尺寸透明设计曲线叠加 */
|
||||
.level-cell .img-stack { position: relative; line-height: 0; }
|
||||
.level-cell .img-stack .layer-base { width: 100%; max-height: 380px; object-fit: contain; display: block; }
|
||||
.level-cell .img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||
.hairline-crop-border { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
|
||||
.hairline-crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
|
||||
@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 .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
|
||||
.coord-box .value { font-weight: 700; font-size: 18px; color: #111827; }
|
||||
|
||||
/* 四庭七眼测量(face_measure) */
|
||||
.fm-section { margin-bottom: 18px; }
|
||||
.fm-section:last-child { margin-bottom: 0; }
|
||||
.fm-title { font-size: 13px; font-weight: 700; color: #374151; margin-bottom: 8px; }
|
||||
.fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
|
||||
.fm-cell { background: #f9fafb; border-radius: 6px; padding: 8px 10px; border: 1px solid #f0f0f0; }
|
||||
.fm-cell .k { font-size: 11px; color: #9ca3af; margin-bottom: 2px; }
|
||||
.fm-cell .v { font-size: 15px; font-weight: 700; color: #111827; }
|
||||
.fm-cell .v .sub { font-size: 11px; font-weight: 500; color: #6b7280; margin-left: 4px; }
|
||||
.fm-cell.muted .v { color: #9ca3af; font-weight: 600; }
|
||||
.fm-tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; font-weight: 600; }
|
||||
.fm-tag.ok { background: #d1fae5; color: #065f46; }
|
||||
.fm-tag.warn { background: #fef3c7; color: #92400e; }
|
||||
|
||||
.json-panel { max-height: 550px; overflow: auto; }
|
||||
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
.rule-banner { background:#ecfdf5; border:1px solid #a7f3d0; border-radius:12px; padding:18px; margin-bottom:20px; line-height:1.8; }
|
||||
.rule-banner strong { color:#065f46; font-size:19px; }
|
||||
.rule-banner a { color:#065f46; margin-left:18px; font-weight:600; }
|
||||
.placement { font-size:12px; color:#64748b; font-weight:400; margin:6px 0 10px; }
|
||||
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
|
||||
</style>
|
||||
<script src="/static/img_downscale.js?v=52"></script>
|
||||
<script src="/static/hairline_reference_box.js?v=2"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>💈 接口52 — 发际线 PNG 生成 测试</h1>
|
||||
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p>
|
||||
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)÷ 2</strong><br>上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。</div>
|
||||
|
||||
<form id="client-auth" method="post" action="/api/v1/auth/login" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">登录</button>
|
||||
<button id="client-auth-check" type="button">测试鉴权</button>
|
||||
<button id="client-auth-refresh" type="button" disabled>刷新令牌</button>
|
||||
<span id="client-auth-status" role="status">登录后可调用接口。</span>
|
||||
</form>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="upload-row">
|
||||
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
|
||||
<div class="form-group">
|
||||
<label>性别</label>
|
||||
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(7种)</option></select>
|
||||
</div>
|
||||
<div class="form-group" style="align-items:flex-start">
|
||||
<label style="padding-top:3px">发型 *</label>
|
||||
<div>
|
||||
<div class="hair-checkboxes" id="hairStyleGroup"></div>
|
||||
<div class="checkbox-actions" style="margin-top:4px">
|
||||
<button type="button" onclick="selectAllHair(true)">全选</button>
|
||||
<button type="button" onclick="selectAllHair(false)">全不选</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>生发效果图</label>
|
||||
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
|
||||
<input type="checkbox" id="genGrowImg" checked> 同时生成生发效果(关闭后只查看设计曲线,速度更快)
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
|
||||
</div>
|
||||
<div class="hint">JPG/PNG 正面照 | Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 | Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 | 每个发型返回一张设计曲线,生发遮罩同步到新位置</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-layout hidden" id="resultsArea">
|
||||
<div class="col-main">
|
||||
<!-- 四庭七眼测量(face_measure,复用接口1数值) -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📐 处理后图像的四庭七眼测量</span><span style="font-weight:400;font-size:12px;color:#9ca3af">与当前显示底图使用同一坐标系</span></div>
|
||||
<div class="card-body"><div id="faceMeasureArea"><span style="color:#9ca3af">—</span></div></div>
|
||||
</div>
|
||||
<!-- 所有发型平铺 -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🎯 未裁切原图 + middle + 裁切发际线叠加 + 生发图</span><span style="font-weight:400;font-size:12px;color:#9ca3af" id="resultCount">共 0 个发型</span></div>
|
||||
<div class="card-body">
|
||||
<div class="coord-box" style="margin-top:0;margin-bottom:16px">
|
||||
<div class="label">📍 设计中心点 — 图片像素坐标</div>
|
||||
<div class="value">
|
||||
<span id="centerPoint">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ruleDetails" style="font-size:13px;color:#475569;margin-bottom:16px;line-height:1.8"></div>
|
||||
<div id="resultsGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-side">
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📋 JSON</span><button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button></div>
|
||||
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
let _images = [], _uploadUrl = '', _originalSrc = '', _processedSrc = '', _center = null, _guideLandmarks = null, _renderVersion = 0;
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
|
||||
|
||||
// 发型列表(同接口2)
|
||||
const HAIR_STYLES = {
|
||||
female: [
|
||||
{ value:'1', label:'1. ellipse 椭圆' },
|
||||
{ value:'2', label:'2. flower 花瓣' },
|
||||
{ value:'3', label:'3. heart 心形' },
|
||||
{ value:'4', label:'4. straight 直线' },
|
||||
{ value:'5', label:'5. wave 波浪' },
|
||||
{ value:'6', label:'6. bigflower 大花瓣型' },
|
||||
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
|
||||
],
|
||||
male: [
|
||||
{ value:'1', label:'1. ellipse 椭圆' },
|
||||
{ value:'2', label:'2. inverse_arc 倒弧' },
|
||||
{ value:'3', label:'3. m M形' },
|
||||
{ value:'4', label:'4. straight 直线' },
|
||||
{ value:'5', label:'5. heart 桃心形' },
|
||||
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
|
||||
{ value:'7', label:'7. wave 波浪形' },
|
||||
]
|
||||
};
|
||||
|
||||
function onGenderChange() {
|
||||
const g = $('gender').value;
|
||||
const div = $('hairStyleGroup');
|
||||
div.innerHTML = '';
|
||||
HAIR_STYLES[g].forEach(s => {
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox'; cb.value = s.value; cb.id = 'hs_' + s.value;
|
||||
const lb = document.createElement('label');
|
||||
lb.htmlFor = cb.id; lb.appendChild(cb);
|
||||
lb.appendChild(document.createTextNode(' ' + s.label));
|
||||
div.appendChild(lb);
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllHair(select) {
|
||||
document.querySelectorAll('#hairStyleGroup input[type=checkbox]').forEach(cb => { cb.checked = select; });
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
let f = $('imageFile').files[0];
|
||||
if (!f) { setStatus('请选择图片', 'error'); return; }
|
||||
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
|
||||
if (!checked.length) { setStatus('请至少选择一个发型', 'error'); return; }
|
||||
// Preserve native pixels: the worker frames and straightens the image first.
|
||||
|
||||
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
|
||||
_uploadUrl = URL.createObjectURL(f);
|
||||
_originalSrc = _uploadUrl; _processedSrc = ''; _images = []; _guideLandmarks = null; _renderVersion++;
|
||||
$('resultsGrid').innerHTML = '';
|
||||
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
|
||||
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
|
||||
|
||||
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false'); fd.append('normalize_image', 'true');
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await window.hairClientFetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
if (json.code === 0) {
|
||||
_images = json.data.hairline_images || [];
|
||||
_originalSrc = resolveImgSrc(json.data.original_image_url, json.data.original_image_base64, 'image/jpeg') || _uploadUrl;
|
||||
_processedSrc = resolveImgSrc(json.data.input_image_url, json.data.input_image_base64, 'image/jpeg');
|
||||
_guideLandmarks = json.data.guide_landmarks;
|
||||
if (_originalSrc !== _uploadUrl) { URL.revokeObjectURL(_uploadUrl); _uploadUrl = ''; }
|
||||
_center = json.data.best_hairline_center_point;
|
||||
const _fmtPt = p => p ? '(' + p.x + ', ' + p.y + ')' : '—';
|
||||
$('centerPoint').textContent = _fmtPt(json.data.best_hairline_center_point);
|
||||
const rule = json.data.height_rule;
|
||||
$('ruleDetails').textContent = rule
|
||||
? '中庭 ' + rule.middle_height_px.toFixed(2) + ' px + 下庭 ' + rule.lower_height_px.toFixed(2) + ' px → 目标上庭 ' + rule.target_upper_height_px.toFixed(2) + ' px' + (rule.target_upper_height_cm == null ? '' : '(约 ' + rule.target_upper_height_cm.toFixed(2) + ' cm)') + ';目标 y = ' + rule.target_center.y.toFixed(2) + '。测量来源:' + (rule.measurement_source === 'face_measure' ? '当前底图测量' : '发际线关键点')
|
||||
: '—';
|
||||
if (json.data.framing) { const f = json.data.framing; $('ruleDetails').textContent += ';' + f.message + ',旋转 ' + f.rotation_degrees.toFixed(3) + '°,底图 ' + f.output_size.width + '×' + f.output_size.height + '。'; }
|
||||
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
|
||||
setStatus('✅ ' + _images.length + ' 个发型已按新规则定位 (' + _elapsed + 's)', 'success');
|
||||
await renderGrid();
|
||||
renderFaceMeasure(json.data.face_measure);
|
||||
} else {
|
||||
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
|
||||
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
|
||||
}
|
||||
|
||||
async function buildClippedOverlay(src, points) {
|
||||
const image = await new Promise((resolve, reject) => {
|
||||
const img = new Image(); img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img); img.onerror = () => reject(new Error('无法读取发际线图片'));
|
||||
img.src = src;
|
||||
});
|
||||
const canvas = window.clipHairlineToReferenceBox(image, points);
|
||||
const corners = window.hairlineReferenceBox(points).corners.map(p => p.join(',')).join(' ');
|
||||
const result = {
|
||||
src: canvas.toDataURL('image/png'),
|
||||
border: '<svg class="hairline-crop-border" viewBox="0 0 '+canvas.width+' '+canvas.height+'" role="img" aria-label="粉色虚线:发际线裁切边界"><polygon points="'+corners+'"></polygon></svg>'
|
||||
};
|
||||
canvas.width = 0; canvas.height = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function renderGrid() {
|
||||
const version = ++_renderVersion;
|
||||
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
|
||||
const images = _images, originalSrc = _originalSrc, processedSrc = _processedSrc, guideLandmarks = _guideLandmarks;
|
||||
const escapeText = text => String(text).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
// The overlay uses the server's processed image, with matching coordinates.
|
||||
const overlayCell = (label, src, className, emptyText = '暂无处理后图片', border = '') => (src && processedSrc)
|
||||
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="img-stack">'+
|
||||
'<img class="layer-base" src="'+processedSrc+'" alt="处理后底图">'+
|
||||
'<img class="layer-anno" src="'+src+'" alt="'+label+'">'+border+'</div></div>'
|
||||
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">'+escapeText(emptyText)+'</div></div>';
|
||||
const photoCell = (label, src, className) => src
|
||||
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><img src="'+src+'" alt="'+label+'"></div>'
|
||||
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
|
||||
const blocks = await Promise.all(images.map(async function(it) {
|
||||
// 兼容网关 *_url 与 worker *_base64
|
||||
const mid = resolveImgSrc(it.image_middle_url, it.image_middle_base64, 'image/png');
|
||||
const grown = resolveImgSrc(it.grown_image_url, it.grown_image_base64, 'image/jpeg');
|
||||
let clipped = '', clipBorder = '', clipError = '缺少发际线图片或参考点';
|
||||
if (mid && processedSrc && guideLandmarks) {
|
||||
try { const preview = await buildClippedOverlay(mid, guideLandmarks); clipped = preview.src; clipBorder = preview.border; }
|
||||
catch (error) { clipError = error.message; }
|
||||
}
|
||||
return '<div class="hair-block">' +
|
||||
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
|
||||
(grown ? ' <span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
|
||||
(it.height_placement ? '<div class="placement">中心 y:' + it.height_placement.new_center.y.toFixed(2) + ' · 相对旧规则 ' + (it.height_placement.shift_y_px >= 0 ? '下移 ' : '上移 ') + Math.abs(it.height_placement.shift_y_px).toFixed(2) + ' px · 目标误差 ' + it.height_placement.target_error_px.toFixed(2) + ' px</div>' : '') +
|
||||
'<div class="level-row">' +
|
||||
photoCell('未裁切原图', originalSrc, 'original-cell') +
|
||||
overlayCell('middle · 发际线叠加', mid, 'middle-cell') +
|
||||
overlayCell('裁切发际线叠加', clipped, 'clipped-cell', clipError, clipBorder) +
|
||||
photoCell('生发图', grown, 'grown-cell') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}));
|
||||
if (version === _renderVersion) $('resultsGrid').innerHTML = blocks.join('');
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
|
||||
_images=[]; _uploadUrl=''; _originalSrc=''; _processedSrc=''; _center=null; _guideLandmarks=null; _renderVersion++;
|
||||
$('ruleDetails').textContent = '';
|
||||
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
|
||||
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
|
||||
$('centerPoint').textContent='—';
|
||||
$('resultCount').textContent = '共 0 个发型';
|
||||
$('faceMeasureArea').innerHTML = '<span style="color:#9ca3af">—</span>';
|
||||
}
|
||||
|
||||
// 四庭七眼测量渲染(face_measure,接口1数值)
|
||||
const _PCT = r => (r == null ? '' : '(' + (r * 100).toFixed(1) + '%)');
|
||||
const _FMT = v => (v == null ? '—' : Number(v).toFixed(2));
|
||||
function renderFaceMeasure(fm) {
|
||||
const box = $('faceMeasureArea');
|
||||
if (!fm) {
|
||||
box.innerHTML = '<div style="padding:24px;text-align:center;color:#9ca3af;font-size:14px">⚠️ face_measure = null(测量失败或未启用)</div>';
|
||||
return;
|
||||
}
|
||||
// 发际线来源 + 头部姿态
|
||||
const src = fm.hairline_source;
|
||||
const srcTag = src === 'segmentation'
|
||||
? '<span class="fm-tag ok">segmentation 真实分割</span>'
|
||||
: (src === 'estimated' ? '<span class="fm-tag warn">estimated 比例估算</span>' : '');
|
||||
let poseHtml = '—';
|
||||
if (fm.head_pose) {
|
||||
const p = fm.head_pose;
|
||||
poseHtml = 'yaw ' + _FMT(p.yaw) + '° pitch ' + _FMT(p.pitch) + '° roll ' + _FMT(p.roll) + '°';
|
||||
}
|
||||
let h = '<div class="coord-box" style="margin-top:0;margin-bottom:16px;display:flex;gap:32px;flex-wrap:wrap;align-items:center">'
|
||||
+ '<div><div class="label">脸总高</div><div class="value">' + _FMT(fm.face_total_height_cm) + ' cm</div></div>'
|
||||
+ '<div><div class="label">发际线来源</div><div class="value" style="font-size:15px">' + (src || '—') + srcTag + '</div></div>'
|
||||
+ '<div><div class="label">头部姿态</div><div class="value" style="font-size:14px">' + poseHtml + '</div></div>'
|
||||
+ '</div>';
|
||||
|
||||
// 四庭
|
||||
const fc = fm.four_courts || {};
|
||||
const fr = (fc.ratios || {});
|
||||
const courts = [
|
||||
['顶庭', fc.top_court_cm, fr.top_court],
|
||||
['上庭', fc.upper_court_cm, fr.upper_court],
|
||||
['中庭', fc.middle_court_cm, fr.middle_court],
|
||||
['下庭', fc.lower_court_cm, fr.lower_court],
|
||||
];
|
||||
h += '<div class="fm-section"><div class="fm-title">📏 四庭(纵向,自上而下)</div><div class="fm-grid">';
|
||||
courts.forEach(c => {
|
||||
h += '<div class="fm-cell"><div class="k">' + c[0] + '</div><div class="v">' + _FMT(c[1]) + ' cm<span class="sub">' + _PCT(c[2]).replace(/[()]/g, '') + '</span></div></div>';
|
||||
});
|
||||
h += '</div></div>';
|
||||
|
||||
// 七眼
|
||||
const se = fm.seven_eyes || {};
|
||||
const ser = (se.ratios || {});
|
||||
h += '<div class="fm-section"><div class="fm-title">👁 七眼(横向汇总)</div><div class="fm-grid">';
|
||||
[['单眼宽', se.eye_width_cm, ser.eye_width], ['脸宽', se.face_width_cm, null], ['两眼间距', se.inter_eye_distance_cm, ser.inter_eye_distance]].forEach(e => {
|
||||
h += '<div class="fm-cell"><div class="k">' + e[0] + '</div><div class="v">' + _FMT(e[1]) + ' cm' + (e[2] != null ? '<span class="sub">' + (e[2] * 100).toFixed(1) + '%</span>' : '') + '</div></div>';
|
||||
});
|
||||
h += '</div></div>';
|
||||
|
||||
// 七眼 7 段(从左到右)
|
||||
const segLabels = ['eye1 左耳外段', 'eye2 左脸颊段', 'eye3 左眼', 'eye4 两眼间距', 'eye5 右眼', 'eye6 右脸颊段', 'eye7 右耳外段'];
|
||||
h += '<div class="fm-section"><div class="fm-title">↔️ 七眼 7 段宽度(从左到右,cm)</div><div class="fm-grid" style="grid-template-columns:repeat(7,1fr)">';
|
||||
segLabels.forEach((lab, i) => {
|
||||
const v = se['eye' + (i + 1)];
|
||||
const muted = v == null ? ' muted' : '';
|
||||
h += '<div class="fm-cell' + muted + '"><div class="k">' + lab + '</div><div class="v">' + (v == null ? '—' : _FMT(v)) + '</div></div>';
|
||||
});
|
||||
h += '</div></div>';
|
||||
|
||||
// 关键点坐标
|
||||
const lm = fm.landmarks || {};
|
||||
const lmLabels = { hair_top: '头顶', hairline: '发际线', brow_center: '眉心', nose_bottom: '鼻翼下缘', chin_tip: '下巴尖' };
|
||||
h += '<div class="fm-section"><div class="fm-title">📍 关键分界点(当前底图像素坐标)</div><div class="fm-grid">';
|
||||
Object.keys(lmLabels).forEach(k => {
|
||||
const p = lm[k];
|
||||
h += '<div class="fm-cell"><div class="k">' + lmLabels[k] + '</div><div class="v" style="font-size:14px">' + (p ? '(' + p.x + ', ' + p.y + ')' : '—') + '</div></div>';
|
||||
});
|
||||
h += '</div></div>';
|
||||
|
||||
box.innerHTML = h;
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
|
||||
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
onGenderChange(); // 初始填充发型列表
|
||||
const dz = $('imageFile');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
|
||||
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
|
||||
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
|
||||
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -152,7 +152,7 @@ async function submitTest() {
|
||||
form.append('image_file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||
const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口 61 — 四庭七眼测量 v2 鉴权测试</title>
|
||||
<link rel="stylesheet" href="/static/face_measure_auth_test.css">
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<script src="/static/img_downscale.js" defer></script>
|
||||
<script src="/static/face_measure_auth_test.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>接口 61 — 四庭七眼测量 v2 鉴权测试</h1>
|
||||
<p class="subtitle">POST /api/v1/face/measure-61 | 去顶庭 · 竖线发际线→下巴 · 无头部端线</p>
|
||||
<ul class="diff-list">
|
||||
<li><strong>去顶庭</strong>:不画头顶横线,不返回顶庭数据;<code>face_total_height_cm</code> = 上+中+下庭</li>
|
||||
<li><strong>竖线范围</strong>:发际线→下巴尖(接口1 为头顶→下巴尖)</li>
|
||||
<li><strong>不画头部端线</strong>:仅七眼 6 点 5 段标尺(接口1 为 8 点 7 段)</li>
|
||||
</ul>
|
||||
|
||||
<form id="client-auth" class="upload-card" method="post" action="/api/v1/auth/login" aria-label="账号登录">
|
||||
<div class="auth-fields">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn btn-primary btn-sm" type="submit">登录</button>
|
||||
<button class="btn btn-outline btn-sm" id="client-auth-check" type="button">测试鉴权</button>
|
||||
<button class="btn btn-outline btn-sm" id="client-auth-refresh" type="button" disabled>刷新令牌</button>
|
||||
</div>
|
||||
<p id="client-auth-status" class="upload-hint" role="status">请使用对接账号登录后提交图片。</p>
|
||||
</form>
|
||||
|
||||
<!-- 上传区 -->
|
||||
<form class="upload-card" id="measure-test" data-endpoint="/api/v1/face/measure-61"
|
||||
data-total-height-label="面部总高(三庭)">
|
||||
<div class="upload-row">
|
||||
<div class="file-input">
|
||||
<input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="submitBtn" type="submit">🚀 提交测试</button>
|
||||
<button class="btn btn-outline btn-sm" id="clearBtn" type="button">清除结果</button>
|
||||
</div>
|
||||
<div class="upload-hint">
|
||||
支持 JPG / PNG 正面照 | 也可拖拽图片到文件选择框
|
||||
</div>
|
||||
<div id="statusBar" class="status" role="status"></div>
|
||||
</form>
|
||||
|
||||
<!-- 指标速览 -->
|
||||
<div id="metricsBar" class="metrics hidden"></div>
|
||||
|
||||
<!-- 结果双栏 -->
|
||||
<div class="results hidden" id="resultsArea">
|
||||
<!-- 左:图片叠加 -->
|
||||
<div>
|
||||
<div class="panel-header">
|
||||
<span>🖼️ 原图 + 标注叠加</span>
|
||||
<div class="anno-controls">
|
||||
<label><input type="checkbox" id="showBase" checked> 底图</label>
|
||||
<label><input type="checkbox" id="showAnno" checked> 标注层</label>
|
||||
<label>透明度 <input type="range" id="annoOpacity" min="0" max="100" value="100"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="img-panel" id="imgPanel">
|
||||
<span id="imagePlaceholder" class="placeholder">提交后显示叠加效果</span>
|
||||
<div class="img-stack" id="imageStack" hidden>
|
||||
<img class="layer-base" id="baseImage" alt="原图">
|
||||
<img class="layer-anno" id="annotationImage" alt="标注层">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右:JSON -->
|
||||
<div>
|
||||
<div class="panel-header">
|
||||
<span>📋 JSON 响应</span>
|
||||
<button class="btn btn-outline btn-sm" id="copyBtn" type="button">📋 复制</button>
|
||||
</div>
|
||||
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -104,7 +104,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待 | 工作流: add_hair2.json</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
@@ -154,7 +154,7 @@ async function submitTest() {
|
||||
fd.append('prompt', $('promptInput').value);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-v2', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-v2', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>72 号接口 · B 端五个关键点</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #eef2f5; color: #192d3d; font: 16px/1.55 system-ui, sans-serif; }
|
||||
main { width: min(1040px, 100%); margin: auto; padding: 26px 18px; }
|
||||
h1 { margin: 0; font-size: clamp(24px, 4vw, 35px); }
|
||||
h2 { margin: 0 0 10px; font-size: 19px; }
|
||||
p { margin: 8px 0; }
|
||||
.note { color: #4f6577; }
|
||||
.controls, .panel { background: white; border-radius: 12px; padding: 18px; margin-top: 16px; }
|
||||
form { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
button { background: #156a72; color: white; border: 0; border-radius: 7px; padding: 9px 15px; font: inherit; cursor: pointer; }
|
||||
button:disabled { opacity: .55; cursor: wait; }
|
||||
input { max-width: 100%; }
|
||||
#status[data-error="true"] { color: #a22629; }
|
||||
.results { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 16px; }
|
||||
[hidden] { display: none; }
|
||||
.photo { display: grid; border-radius: 8px; overflow: hidden; background: #1b2c37; }
|
||||
.photo img { grid-area: 1 / 1; width: 100%; display: block; }
|
||||
.panel a { color: #156a72; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 8px 0 20px; font-variant-numeric: tabular-nums; }
|
||||
th, td { text-align: left; border-bottom: 1px solid #e6edf2; padding: 5px 7px; }
|
||||
@media(max-width: 680px) { .results { grid-template-columns: 1fr; gap: 0; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>72 号接口 · B 端五个关键点</h1>
|
||||
<p class="note">上传正面照,查看扶正裁切后的六个白点、四条虚线和厘米标注。</p>
|
||||
<form id="client-auth" method="post" action="/api/v1/auth/login" style="display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 0">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">登录</button>
|
||||
<button id="client-auth-check" type="button">测试鉴权</button>
|
||||
<button id="client-auth-refresh" type="button" disabled>刷新令牌</button>
|
||||
<span id="client-auth-status" role="status">登录后可调用接口。</span>
|
||||
</form>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<section class="controls">
|
||||
<form id="form">
|
||||
<label for="file">人脸照片</label>
|
||||
<input id="file" type="file" accept="image/jpeg,image/png" required>
|
||||
<button id="submit" type="submit">生成标注</button>
|
||||
<button id="sample" type="button">测试样例</button>
|
||||
</form>
|
||||
<p id="status" role="status">请选择 JPG 或 PNG 图片。</p>
|
||||
</section>
|
||||
<div id="results" class="results" hidden>
|
||||
<section class="panel">
|
||||
<h2>标注预览</h2>
|
||||
<div class="photo">
|
||||
<img id="base" alt="扶正裁切后的底图">
|
||||
<img id="overlay" alt="白点、虚线及距离标注">
|
||||
</div>
|
||||
<p><a id="download" download="interface72-overlay.png">下载透明标注层</a></p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>测量结果</h2>
|
||||
<table><thead><tr><th>点位</th><th>距离</th></tr></thead><tbody id="distances"></tbody></table>
|
||||
<h2>坐标(像素)</h2>
|
||||
<table><thead><tr><th>点号</th><th>X</th><th>Y</th></tr></thead><tbody id="points"></tbody></table>
|
||||
<p class="note">点 6(MediaPipe 446)是射线锚点。六个白点的直径相同。</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const byId = (id) => document.getElementById(id);
|
||||
const status = byId("status");
|
||||
const results = byId("results");
|
||||
const submit = byId("submit");
|
||||
const sample = byId("sample");
|
||||
|
||||
function setRows(id, rows) {
|
||||
const body = byId(id);
|
||||
body.replaceChildren();
|
||||
for (const values of rows) {
|
||||
const row = body.insertRow();
|
||||
for (const value of values) row.insertCell().textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function imageSource(data, field, mime) {
|
||||
return data[field + "_url"] || ("data:" + mime + ";base64," + data[field + "_base64"]);
|
||||
}
|
||||
|
||||
async function runAnnotation(loadFile) {
|
||||
submit.disabled = sample.disabled = true;
|
||||
results.hidden = true;
|
||||
status.dataset.error = "false";
|
||||
status.textContent = "正在扶正裁切并生成标注…";
|
||||
try {
|
||||
const file = await loadFile();
|
||||
const form = new FormData();
|
||||
form.append("image_file", file, file.name || "sample.jpg");
|
||||
const response = await window.hairClientFetch("/api/v1/face/b-end-points", { method: "POST", body: form });
|
||||
const { code, message, data } = await response.json();
|
||||
if (!response.ok || code !== 0) throw new Error(message || "请求失败");
|
||||
|
||||
byId("base").src = imageSource(data, "input_image", "image/jpeg");
|
||||
const overlay = imageSource(data, "annotated_image", "image/png");
|
||||
byId("overlay").src = overlay;
|
||||
byId("download").href = overlay;
|
||||
setRows("distances", Object.entries(data.measurements_cm).map(([key, value]) => [
|
||||
key, value.toFixed(1) + " cm"
|
||||
]));
|
||||
setRows("points", Object.entries(data.points).map(([key, point]) => [
|
||||
key, point.x.toFixed(1), point.y.toFixed(1)
|
||||
]));
|
||||
results.hidden = false;
|
||||
status.textContent = "标注完成。";
|
||||
} catch (error) {
|
||||
status.dataset.error = "true";
|
||||
status.textContent = error.message;
|
||||
} finally {
|
||||
submit.disabled = sample.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
byId("form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const file = byId("file").files[0];
|
||||
if (file) runAnnotation(() => file);
|
||||
});
|
||||
|
||||
sample.addEventListener("click", () => runAnnotation(async () => {
|
||||
const response = await fetch("/static/sample.jpg");
|
||||
if (!response.ok) throw new Error("无法读取样例图片");
|
||||
return response.blob();
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -210,7 +210,7 @@ async function submitTest() {
|
||||
form.append('erode_cm', $('erodeCm').value || '1.2');
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ async function submitTest() {
|
||||
form.append('image_file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
lastJson = json;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import importlib
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from gateway import client_auth
|
||||
from gateway.logging_middleware import _should_log
|
||||
|
||||
gateway = importlib.import_module("gateway.app")
|
||||
LOGIN = "/api/v1/auth/login"
|
||||
REFRESH = "/api/v1/auth/refresh"
|
||||
FEATURES_42 = "/api/v1/face/features-42"
|
||||
CHECK = "/api/v1/auth/check"
|
||||
MEASURE_ROUTES = {
|
||||
"/api/v1/face/measure-11": "/api/v1/face/measure",
|
||||
"/api/v1/face/measure-61": "/api/v1/face/measure-v2",
|
||||
}
|
||||
PROTECTED = [*MEASURE_ROUTES, FEATURES_42, "/api/v1/hairline/generate-52", "/api/v1/face/b-end-points", "/api/v1/hair/grow-22"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32")
|
||||
session = TestClient(gateway.app)
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post(LOGIN, json={"username": "yonghe", "password": "yonghe1999"})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
return response.json()["data"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PROTECTED)
|
||||
def test_protected_routes_reject_missing_static_and_refresh_tokens(client, monkeypatch, path):
|
||||
proxy = AsyncMock()
|
||||
features = AsyncMock()
|
||||
monkeypatch.setattr(gateway, "_proxy", proxy)
|
||||
monkeypatch.setattr(gateway, "_face_features_response", features)
|
||||
refresh_token = login(client)["refresh_token"]
|
||||
for headers in ({}, {"X-API-Token": "yonghe1999"}, {"Authorization": "Bearer invalid"},
|
||||
{"Authorization": "Bearer " + refresh_token}):
|
||||
result = client.post(path, headers=headers)
|
||||
assert result.status_code == 401
|
||||
assert result.json()["code"] == 1009
|
||||
proxy.assert_not_awaited()
|
||||
features.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,legacy_path", MEASURE_ROUTES.items())
|
||||
def test_measure_alias_uses_legacy_path_and_preserves_legacy_access(client, monkeypatch, path, legacy_path):
|
||||
proxy = AsyncMock(return_value={"code": 0, "data": {"annotated_image_url": "/static/annotations/face.png"}})
|
||||
monkeypatch.setattr(gateway, "_proxy", proxy)
|
||||
headers = {"Authorization": "Bearer " + login(client)["access_token"]}
|
||||
photo = {"image_file": ("face.png", b"raw", "image/png")}
|
||||
|
||||
authenticated = client.post(path, headers=headers, files=photo)
|
||||
legacy = client.post(legacy_path, files=photo)
|
||||
assert authenticated.status_code == legacy.status_code == 200
|
||||
assert authenticated.json() == legacy.json() == proxy.return_value
|
||||
assert [call.args[1] for call in proxy.await_args_list] == [legacy_path, legacy_path]
|
||||
|
||||
|
||||
def test_login_expiry_tampering_and_refresh(client, monkeypatch):
|
||||
clock = SimpleNamespace(now=1_000_000)
|
||||
monkeypatch.setattr(client_auth, "time", SimpleNamespace(time=lambda: clock.now))
|
||||
proxy = AsyncMock(return_value={"code": 0})
|
||||
monkeypatch.setattr(gateway, "_proxy", proxy)
|
||||
path = PROTECTED[-1]
|
||||
assert client.post(LOGIN, json={"username": "yonghe", "password": "wrong"}).status_code == 401
|
||||
data = login(client)
|
||||
assert data["expires_in"] == 900 and data["refresh_expires_in"] == 604800
|
||||
access, refresh = data["access_token"], data["refresh_token"]
|
||||
headers = {"Authorization": f"Bearer {access}"}
|
||||
assert client.post(path, headers=headers).status_code == 200
|
||||
assert client.get(CHECK).status_code == 401
|
||||
assert client.get(CHECK, headers=headers).json()["code"] == 0
|
||||
assert client.post(path, headers={"Authorization": f"Bearer {access}x"}).status_code == 401
|
||||
assert client.post(REFRESH, json={"refresh_token": access}).status_code == 401
|
||||
clock.now += data["expires_in"]
|
||||
assert client.post(path, headers=headers).status_code == 401
|
||||
assert client.get(CHECK, headers=headers).status_code == 401
|
||||
response = client.post(REFRESH, json={"refresh_token": refresh})
|
||||
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
|
||||
refreshed = response.json()["data"]
|
||||
assert "refresh_token" not in refreshed and refreshed["access_token"] != access
|
||||
assert client.post(path, headers={"Authorization": "Bearer " + refreshed["access_token"]}).status_code == 200
|
||||
clock.now += data["refresh_expires_in"] - data["expires_in"]
|
||||
assert client.post(REFRESH, json={"refresh_token": refresh}).status_code == 401
|
||||
assert proxy.await_count == 2
|
||||
|
||||
|
||||
def test_unconfigured_secret_fails_closed_and_token_responses_are_not_logged(client, monkeypatch):
|
||||
monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "")
|
||||
result = client.post(LOGIN, json={"username": "yonghe", "password": "yonghe1999"})
|
||||
assert result.status_code == 503 and result.json()["code"] == 1007
|
||||
assert not _should_log(LOGIN) and not _should_log(REFRESH)
|
||||
|
||||
|
||||
def test_features42_combines_processed_photo_with_main_features(client, monkeypatch):
|
||||
analyzer = Mock(return_value={"face_shape": "鹅蛋脸", "contour_type": "轮廓适中"})
|
||||
monkeypatch.setattr("face_features.analyze_features", analyzer)
|
||||
prepared = AsyncMock(return_value=(b"processed", {"zygoma_expansion": True, "temple_hollowing": True}))
|
||||
monkeypatch.setattr(gateway, "_prepare_features_42", prepared)
|
||||
headers = {"Authorization": "Bearer " + login(client)["access_token"]}
|
||||
response = client.post(FEATURES_42, headers=headers, files={"image_file": ("face.jpg", b"raw")})
|
||||
assert response.status_code == 200 and response.json()["code"] == 0
|
||||
assert json.loads(response.json()["data"]["features"]) == {
|
||||
"face_shape": "鹅蛋脸", "contour_type": "轮廓适中",
|
||||
"zygoma_expansion": True, "temple_hollowing": True,
|
||||
}
|
||||
prepared.assert_awaited_once_with(b"raw", None)
|
||||
analyzer.assert_called_once_with(b"processed", None, accept_age_alias=True)
|
||||
|
||||
|
||||
def test_bearer_requirement_is_scoped_to_protected_business_routes_and_check(client):
|
||||
schema = client.get("/openapi.json").json()
|
||||
protected = {
|
||||
path
|
||||
for path, methods in schema["paths"].items()
|
||||
for method in methods.values()
|
||||
if isinstance(method, dict) and {"ClientBearer": []} in method.get("security", [])
|
||||
}
|
||||
assert protected == {*PROTECTED, CHECK}
|
||||
|
||||
|
||||
def test_original_features_needs_no_bearer_and_preserves_legacy_analysis(client, monkeypatch):
|
||||
analyzer = Mock(return_value={"face_shape": "鹅蛋脸", "contour_type": "轮廓适中"})
|
||||
monkeypatch.setattr("face_features.analyze_features", analyzer)
|
||||
prepared = AsyncMock()
|
||||
monkeypatch.setattr(gateway, "_prepare_features_42", prepared)
|
||||
response = client.post("/api/v1/face/features", files={"image_file": ("face.jpg", b"raw")})
|
||||
assert response.status_code == 200 and response.json()["code"] == 0
|
||||
assert json.loads(response.json()["data"]["features"]) == analyzer.return_value
|
||||
analyzer.assert_called_once_with(b"raw", None)
|
||||
prepared.assert_not_awaited()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Gateway integration for interfaces 52 and 22: raw forwarding, token and image URLs."""
|
||||
import asyncio
|
||||
import base64
|
||||
import importlib
|
||||
import io
|
||||
from contextlib import closing
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from gateway import config, forward
|
||||
|
||||
gateway = importlib.import_module("gateway.app")
|
||||
PATH = "/api/v1/hairline/generate-52"
|
||||
GROW22 = "/api/v1/hair/grow-22"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_headers(monkeypatch):
|
||||
monkeypatch.setenv("HAIR_API_TOKEN_SECRET", "gateway-test-signing-secret-at-least-32")
|
||||
client = TestClient(gateway.app)
|
||||
try:
|
||||
response = client.post("/api/v1/auth/login", json={"username": "yonghe", "password": "yonghe1999"})
|
||||
finally:
|
||||
client.close()
|
||||
return {"Authorization": "Bearer " + response.json()["data"]["access_token"]}
|
||||
|
||||
|
||||
def image_bytes(fmt):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (6, 8), (127, 127, 127)).save(buf, format=fmt)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def proxy_setup(tmp_path, monkeypatch):
|
||||
output = tmp_path / "annotations"
|
||||
output.mkdir()
|
||||
cfg = {"shared_password": "private-gateway-token", "public_base_url": "http://testserver",
|
||||
"static_dir": str(output), "dispatch": {"request_timeout_seconds": 600, "max_retries": 0}}
|
||||
monkeypatch.setattr(config, "get_config", lambda: cfg)
|
||||
acquire = AsyncMock(return_value=True)
|
||||
release = Mock()
|
||||
monkeypatch.setattr(forward, "acquire_global_slot", acquire)
|
||||
monkeypatch.setattr(forward, "release_global_slot", release)
|
||||
monkeypatch.setattr(forward, "acquire_worker", AsyncMock(return_value=SimpleNamespace(url="http://worker")))
|
||||
release_worker = AsyncMock()
|
||||
monkeypatch.setattr(forward, "release_worker", release_worker)
|
||||
unhealthy = AsyncMock()
|
||||
monkeypatch.setattr(forward, "mark_worker_unhealthy", unhealthy)
|
||||
static = next(route.app for route in gateway.app.routes if getattr(route, "name", None) == "static")
|
||||
monkeypatch.setattr(static, "all_directories", [str(tmp_path), *static.all_directories])
|
||||
return cfg, acquire, release, release_worker, unhealthy
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/v1/hairline/generate", PATH, "/api/v1/face/b-end-points"])
|
||||
def test_routes_forward_to_their_own_worker_path(monkeypatch, path, client_headers):
|
||||
monkeypatch.setattr(config, "get_config", lambda: {})
|
||||
proxy = AsyncMock(return_value={"code": 0, "data": None})
|
||||
monkeypatch.setattr(gateway, "_proxy", proxy)
|
||||
client = TestClient(gateway.app, client=("203.0.113.9", 50000))
|
||||
try:
|
||||
assert client.post(path, headers=client_headers if path != "/api/v1/hairline/generate" else {}).json() == {"code": 0, "data": None}
|
||||
finally:
|
||||
client.close()
|
||||
proxy.assert_awaited_once()
|
||||
assert proxy.await_args.args[1] == path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("generate_grow_image", [False, True])
|
||||
def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypatch, generate_grow_image, client_headers):
|
||||
cfg, acquire, release, release_worker, unhealthy = proxy_setup
|
||||
png, jpg = image_bytes("PNG"), image_bytes("JPEG")
|
||||
metadata = {"coordinate_space": "processed_image", "width": 6, "height": 8,
|
||||
"framing": {"status": "reference_crop", "axis_x_error_px": 0},
|
||||
"height_rule": {"target_center": {"x": 3.0, "y": 2.0}},
|
||||
"guide_landmarks": {"9": {"x": 3.0, "y": 3.0}},
|
||||
"best_hairline_center_point": {"x": 3, "y": 2},
|
||||
"high_hairline_center_point": None, "low_hairline_center_point": None,
|
||||
"face_measure": None}
|
||||
worker_json = {"code": 0, "data": {**metadata,
|
||||
"input_image_base64": base64.b64encode(jpg).decode(),
|
||||
"original_image_base64": base64.b64encode(jpg).decode(),
|
||||
"hairline_images": [{"hairline_type": "ellipse", "order": 1,
|
||||
"height_placement": {"target_error_px": 0.01},
|
||||
"image_middle_base64": base64.b64encode(png).decode(),
|
||||
"image_high_base64": None, "image_low_base64": None,
|
||||
"grown_image_base64": base64.b64encode(jpg).decode() if generate_grow_image else None}]}}
|
||||
calls = []
|
||||
|
||||
def worker(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(200, json=worker_json)
|
||||
|
||||
upstream = httpx.AsyncClient(transport=httpx.MockTransport(worker))
|
||||
monkeypatch.setattr(forward, "get_client", lambda: upstream)
|
||||
fields = {"gender": "male", "hair_style": "1,3", "normalize_image": "true",
|
||||
"generate_grow_image": str(generate_grow_image).lower(), "use_mask": "false",
|
||||
"prompt": "test prompt"}
|
||||
source = httpx.Request("POST", "http://testserver" + PATH,
|
||||
files={"image_file": ("face.png", png, "image/png")}, data=fields)
|
||||
raw = source.read()
|
||||
client = TestClient(gateway.app, client=("203.0.113.9", 50000))
|
||||
try:
|
||||
result = client.post(PATH, content=raw, headers={
|
||||
**client_headers,
|
||||
"Content-Type": source.headers["content-type"], "X-Internal-Token": "untrusted-client-token"})
|
||||
assert result.status_code == 200
|
||||
data = result.json()["data"]
|
||||
assert {key: data[key] for key in metadata} == metadata
|
||||
item = data["hairline_images"][0]
|
||||
assert item["image_high_base64"] is None and item["image_low_base64"] is None
|
||||
assert "image_high_url" not in item and "image_low_url" not in item
|
||||
assert item["height_placement"] == {"target_error_px": 0.01}
|
||||
for key, expected in [("input_image_url", jpg), ("original_image_url", jpg)]:
|
||||
assert client.get(data[key]).content == expected
|
||||
assert data[key].endswith(".jpg")
|
||||
assert client.get(item["image_middle_url"]).content == png
|
||||
assert item["image_middle_url"].endswith(".png")
|
||||
if generate_grow_image:
|
||||
assert client.get(item["grown_image_url"]).content == jpg
|
||||
else:
|
||||
assert item["grown_image_base64"] is None and "grown_image_url" not in item
|
||||
finally:
|
||||
client.close()
|
||||
asyncio.run(upstream.aclose())
|
||||
assert len(calls) == 1
|
||||
assert str(calls[0].url) == "http://worker" + PATH
|
||||
assert calls[0].content == raw
|
||||
assert calls[0].headers["content-type"] == source.headers["content-type"]
|
||||
assert calls[0].headers["x-internal-token"] == cfg["shared_password"]
|
||||
assert calls[0].headers.get("x-forwarded-for") is None
|
||||
assert calls[0].headers.get("authorization") is None
|
||||
acquire.assert_awaited_once_with(cfg, 0)
|
||||
release.assert_called_once()
|
||||
release_worker.assert_awaited_once()
|
||||
unhealthy.assert_not_awaited()
|
||||
|
||||
|
||||
def test_worker_ip_rejection_is_preserved(proxy_setup, monkeypatch, client_headers):
|
||||
_, _, release, release_worker, unhealthy = proxy_setup
|
||||
body = {"code": 1010, "message": "来源IP不在接口白名单", "data": None}
|
||||
upstream = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(403, json=body)))
|
||||
monkeypatch.setattr(forward, "get_client", lambda: upstream)
|
||||
client = TestClient(gateway.app)
|
||||
try:
|
||||
response = client.post(PATH, headers=client_headers, data={"gender": "male", "hair_style": "1"})
|
||||
assert response.status_code == 403 and response.json() == body
|
||||
finally:
|
||||
client.close()
|
||||
asyncio.run(upstream.aclose())
|
||||
release.assert_called_once()
|
||||
release_worker.assert_awaited_once()
|
||||
unhealthy.assert_not_awaited()
|
||||
|
||||
|
||||
def test_public_interface52_page_has_no_internal_password():
|
||||
client = TestClient(gateway.app)
|
||||
try:
|
||||
page = client.get("/static/test_interface52.html")
|
||||
assert page.status_code == 200
|
||||
assert PATH in page.text and "X-Internal-Token" not in page.text
|
||||
assert 'id="client-auth"' in page.text and "X-API-Token" not in page.text
|
||||
assert "yonghe1999" not in page.text
|
||||
assert "localhost:8187" not in page.text
|
||||
assert "test_interface52_compare.html" not in page.text
|
||||
assert client.get("/static/hairline_reference_box.js").status_code == 200
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def test_interface22_forwards_repeated_density_fields_and_serves_image_urls(
|
||||
proxy_setup, monkeypatch, client_headers,
|
||||
):
|
||||
cfg, acquire, release, release_worker, unhealthy = proxy_setup
|
||||
png, jpg = image_bytes("PNG"), image_bytes("JPEG")
|
||||
calls = []
|
||||
|
||||
def worker(request):
|
||||
calls.append(request)
|
||||
return httpx.Response(200, json={"code": 0, "data": {"results": [
|
||||
{"order": 1, "hairline_type": "ellipse", "hair_density": "sparse",
|
||||
"image_base64": base64.b64encode(png).decode(),
|
||||
"grown_image_base64": base64.b64encode(jpg).decode()},
|
||||
{"order": 1, "hairline_type": "ellipse", "hair_density": "dense",
|
||||
"image_base64": base64.b64encode(png).decode(), "grown_image_base64": None},
|
||||
]}})
|
||||
|
||||
upstream = httpx.AsyncClient(transport=httpx.MockTransport(worker))
|
||||
monkeypatch.setattr(forward, "get_client", lambda: upstream)
|
||||
source = httpx.Request(
|
||||
"POST", "http://testserver" + GROW22,
|
||||
files={"image_file": ("face.png", png, "image/png")},
|
||||
data={"gender": "female", "hair_style": "1", "hairline_height": "-0.5",
|
||||
"hair_density": ["sparse", "dense"]},
|
||||
)
|
||||
raw = source.read()
|
||||
assert raw.count(b'name="hair_density"') == 2
|
||||
try:
|
||||
with closing(TestClient(gateway.app)) as client:
|
||||
response = client.post(GROW22, content=raw, headers={
|
||||
**client_headers, "Content-Type": source.headers["content-type"],
|
||||
})
|
||||
assert response.status_code == 200
|
||||
sparse, dense = response.json()["data"]["results"]
|
||||
assert [sparse["hair_density"], dense["hair_density"]] == ["sparse", "dense"]
|
||||
assert client.get(sparse["image_url"]).content == png
|
||||
assert client.get(sparse["grown_image_url"]).content == jpg
|
||||
assert dense["grown_image_base64"] is None
|
||||
finally:
|
||||
asyncio.run(upstream.aclose())
|
||||
assert len(calls) == 1
|
||||
assert str(calls[0].url) == "http://worker" + GROW22
|
||||
assert calls[0].content == raw
|
||||
assert calls[0].headers["x-internal-token"] == cfg["shared_password"]
|
||||
assert "authorization" not in calls[0].headers
|
||||
acquire.assert_awaited_once_with(cfg, 0)
|
||||
release.assert_called_once()
|
||||
release_worker.assert_awaited_once()
|
||||
unhealthy.assert_not_awaited()
|
||||
|
||||
|
||||
def test_interface22_page_uses_gateway_login_and_authenticated_requests():
|
||||
with closing(TestClient(gateway.app)) as client:
|
||||
page = client.get("/static/test_interface22.html")
|
||||
assert page.status_code == 200
|
||||
assert 'id="client-auth"' in page.text
|
||||
for path in (GROW22, PATH):
|
||||
assert f"window.hairClientFetch('{path}'" in page.text
|
||||
assert "X-Internal-Token" not in page.text
|
||||
assert "localhost:8187" not in page.text
|
||||
assert client.get("/static/client_auth.js").status_code == 200
|
||||
@@ -0,0 +1,135 @@
|
||||
"""外部监控接口:有在线 worker 才算服务正常;故障模拟只改探活响应。"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway import app as gateway_app
|
||||
from gateway.app import monitor, monitor_fault, monitor_payload
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body=None, token="secret", raw=None):
|
||||
self._body = body
|
||||
self._raw = raw
|
||||
self.headers = {"X-Internal-Token": token} if token is not None else {}
|
||||
|
||||
async def json(self):
|
||||
if self._raw is not None:
|
||||
raise self._raw
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_monitor_drill():
|
||||
gateway_app._monitor_drill["enabled"] = False
|
||||
yield
|
||||
gateway_app._monitor_drill["enabled"] = False
|
||||
|
||||
|
||||
def test_monitor_ok_when_any_worker_online():
|
||||
body = monitor_payload({
|
||||
"total": 5, "healthy": 1, "primary_total": 3, "primary_healthy": 0,
|
||||
})
|
||||
assert body["status"] == "ok"
|
||||
assert body["workers_healthy"] == 1
|
||||
assert "message" not in body
|
||||
|
||||
|
||||
def test_monitor_unhealthy_when_none_online():
|
||||
body = monitor_payload({
|
||||
"total": 5, "healthy": 0, "primary_total": 3, "primary_healthy": 0,
|
||||
})
|
||||
assert body["status"] == "unhealthy"
|
||||
assert body["message"] == "没有可用的 worker"
|
||||
assert body["workers_total"] == 5
|
||||
|
||||
|
||||
def test_monitor_unhealthy_when_pool_empty():
|
||||
body = monitor_payload({"total": 0, "healthy": 0})
|
||||
assert body["status"] == "unhealthy"
|
||||
|
||||
|
||||
def test_monitor_http_503_when_no_worker(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.app._get_pool_status_safe",
|
||||
lambda: {"total": 2, "healthy": 0, "primary_total": 2, "primary_healthy": 0},
|
||||
)
|
||||
resp = asyncio.run(monitor())
|
||||
assert resp.status_code == 503
|
||||
assert resp.body
|
||||
assert b'"status":"unhealthy"' in resp.body or b'"status": "unhealthy"' in resp.body
|
||||
|
||||
|
||||
def test_monitor_http_200_when_worker_online(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.app._get_pool_status_safe",
|
||||
lambda: {"total": 2, "healthy": 2, "primary_total": 2, "primary_healthy": 1},
|
||||
)
|
||||
body = asyncio.run(monitor())
|
||||
assert body["status"] == "ok"
|
||||
assert body["workers_healthy"] == 2
|
||||
|
||||
|
||||
def test_monitor_payload_simulated_and_really_down():
|
||||
body = monitor_payload(
|
||||
{"total": 5, "healthy": 0, "primary_total": 3, "primary_healthy": 0},
|
||||
simulated=True,
|
||||
)
|
||||
assert body["status"] == "unhealthy"
|
||||
assert body["simulated"] is True
|
||||
assert body["workers_healthy"] == 0
|
||||
assert "没有可用的 worker" in body["message"]
|
||||
|
||||
|
||||
def test_monitor_payload_simulated_keeps_real_counts():
|
||||
body = monitor_payload(
|
||||
{"total": 5, "healthy": 3, "primary_total": 3, "primary_healthy": 3},
|
||||
simulated=True,
|
||||
)
|
||||
assert body["status"] == "unhealthy"
|
||||
assert body["simulated"] is True
|
||||
assert body["workers_healthy"] == 3
|
||||
assert "模拟故障" in body["message"]
|
||||
|
||||
|
||||
def _patch_token(monkeypatch, password="secret"):
|
||||
monkeypatch.setattr(
|
||||
"gateway.config.get_config",
|
||||
lambda: {"shared_password": password},
|
||||
)
|
||||
|
||||
|
||||
def test_fault_requires_token(monkeypatch):
|
||||
_patch_token(monkeypatch)
|
||||
resp = asyncio.run(monitor_fault(_Req(body={"enabled": True}, token="nope")))
|
||||
assert resp.status_code == 401
|
||||
assert gateway_app._monitor_drill["enabled"] is False
|
||||
|
||||
|
||||
def test_fault_rejects_non_bool(monkeypatch):
|
||||
_patch_token(monkeypatch)
|
||||
resp = asyncio.run(monitor_fault(_Req(body={"enabled": "true"})))
|
||||
assert resp.status_code == 400
|
||||
assert gateway_app._monitor_drill["enabled"] is False
|
||||
|
||||
|
||||
def test_fault_on_makes_monitor_503_then_off_restores(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"gateway.app._get_pool_status_safe",
|
||||
lambda: {"total": 5, "healthy": 3, "primary_total": 3, "primary_healthy": 3},
|
||||
)
|
||||
_patch_token(monkeypatch)
|
||||
|
||||
opened = asyncio.run(monitor_fault(_Req(body={"enabled": True})))
|
||||
assert opened["simulated"] is True
|
||||
failed = asyncio.run(monitor())
|
||||
assert failed.status_code == 503
|
||||
assert b'"simulated":true' in failed.body or b'"simulated": true' in failed.body
|
||||
assert b'"workers_healthy":3' in failed.body or b'"workers_healthy": 3' in failed.body
|
||||
|
||||
closed = asyncio.run(monitor_fault(_Req(body={"enabled": False})))
|
||||
assert closed["simulated"] is False
|
||||
ok = asyncio.run(monitor())
|
||||
assert ok["status"] == "ok"
|
||||
assert "simulated" not in ok
|
||||
@@ -0,0 +1,351 @@
|
||||
"""gateway/pool.py 隔离期逻辑单测(不依赖真实网络)。
|
||||
|
||||
覆盖《docs/网关摘除隔离期实施方案.md》§3.3 的三个用例:
|
||||
- 被动摘除设置隔离期(offline_until > now)
|
||||
- 隔离期内探测成功不复活
|
||||
- 隔离期满后探测成功恢复上线
|
||||
外加:_try_revive 的阈值/在线短路、mark_worker_unhealthy 对已下线 worker 的幂等、
|
||||
_health_check_loop 整链路验证、_cooldown_seconds 配置读取。
|
||||
|
||||
与 test_gateway_serialization.py 同款:asyncio.run 套同步测试,不依赖 pytest-asyncio。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from gateway import pool
|
||||
from gateway.pool import WorkerState
|
||||
|
||||
_HC_CFG = {
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 0.05, # 测试用:快速轮询
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1,
|
||||
},
|
||||
"shared_password": "test-token",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 被动摘除 → 隔离期
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_passive_ejection_sets_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000", online=True)
|
||||
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
with patch.object(pool, "_cooldown_seconds", return_value=60.0):
|
||||
await pool.mark_worker_unhealthy(w)
|
||||
|
||||
asyncio.run(run())
|
||||
assert w.online is False
|
||||
assert w.offline_until > time.monotonic(), "被动摘除必须设置未来的隔离截止时刻"
|
||||
assert w.consecutive_failures == 1
|
||||
|
||||
|
||||
def test_passive_ejection_idempotent_when_offline():
|
||||
"""已下线的 worker 再次被标记:不重复计失败、不续期隔离。"""
|
||||
w = WorkerState(url="http://127.0.0.1:9000", online=False)
|
||||
w.offline_until = time.monotonic() + 30
|
||||
before = w.offline_until
|
||||
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
with patch.object(pool, "_cooldown_seconds", return_value=60.0):
|
||||
await pool.mark_worker_unhealthy(w)
|
||||
|
||||
asyncio.run(run())
|
||||
assert w.online is False
|
||||
assert w.consecutive_failures == 0
|
||||
assert w.offline_until == before
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _try_revive:探测成功后是否允许复活
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_probe_success_blocked_during_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000") # offline
|
||||
w.consecutive_successes = 1
|
||||
w.offline_until = time.monotonic() + 60
|
||||
assert pool._try_revive(w, healthy_threshold=1) is False
|
||||
assert w.online is False
|
||||
|
||||
|
||||
def test_probe_success_revives_after_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000")
|
||||
w.consecutive_successes = 1
|
||||
w.offline_until = time.monotonic() - 1 # 隔离期已过
|
||||
assert pool._try_revive(w, healthy_threshold=1) is True
|
||||
|
||||
|
||||
def test_try_revive_requires_success_threshold():
|
||||
w = WorkerState(url="http://127.0.0.1:9000")
|
||||
w.consecutive_successes = 1
|
||||
w.offline_until = 0.0
|
||||
assert pool._try_revive(w, healthy_threshold=2) is False
|
||||
|
||||
|
||||
def test_try_revive_noop_when_already_online():
|
||||
w = WorkerState(url="http://127.0.0.1:9000", online=True)
|
||||
w.consecutive_successes = 1
|
||||
assert pool._try_revive(w, healthy_threshold=1) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _health_check_loop 整链路:隔离期内探测 200 不上线,期满后上线
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_health_check_loop_respects_cooldown(monkeypatch):
|
||||
w = WorkerState(url="http://127.0.0.1:9000", online=False)
|
||||
w.offline_until = time.monotonic() + 60 # 处于隔离期
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def get(self, url, headers=None, timeout=None):
|
||||
return _FakeResp()
|
||||
|
||||
async def run():
|
||||
monkeypatch.setattr(pool, "_workers", {"w1": w})
|
||||
monkeypatch.setattr(pool, "_shutdown_event", asyncio.Event())
|
||||
monkeypatch.setattr(pool.httpx, "AsyncClient", _FakeClient)
|
||||
task = asyncio.create_task(pool._health_check_loop(_HC_CFG))
|
||||
try:
|
||||
await asyncio.sleep(0.2) # 跑若干轮探测
|
||||
assert w.consecutive_successes >= 1, "探测应已成功过"
|
||||
assert w.online is False, "隔离期内即使探测成功也不得复活"
|
||||
|
||||
w.offline_until = time.monotonic() - 1 # 隔离期结束
|
||||
await asyncio.sleep(0.2)
|
||||
assert w.online is True, "隔离期满后下一次探测成功应上线"
|
||||
finally:
|
||||
pool._shutdown_event.set()
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _cooldown_seconds 配置读取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cooldown_seconds_reads_config(monkeypatch):
|
||||
from gateway import config as gw_config
|
||||
monkeypatch.setattr(gw_config, "_config_cache",
|
||||
{"health_check": {"cooldown_seconds": 120}})
|
||||
assert pool._cooldown_seconds() == 120.0
|
||||
|
||||
|
||||
def test_cooldown_seconds_falls_back_to_60(monkeypatch):
|
||||
from gateway import config as gw_config
|
||||
monkeypatch.setattr(gw_config, "_config_cache", None) # get_config 会抛 RuntimeError
|
||||
assert pool._cooldown_seconds() == 60.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主备分组派发(primary_workers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DISPATCH_CFG = {
|
||||
"dispatch": {
|
||||
"queue_wait_seconds": 0.05, # 测试用:快速超时
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _make_pool(specs):
|
||||
"""specs: [(url, online, busy, primary), ...] → 构造 pool._workers。"""
|
||||
return {
|
||||
url: WorkerState(url=url, online=online, busy=busy, primary=primary)
|
||||
for url, online, busy, primary in specs
|
||||
}
|
||||
|
||||
|
||||
def _run_acquire(workers):
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
orig = pool._workers
|
||||
pool._workers = workers
|
||||
try:
|
||||
return await pool.acquire_worker(_DISPATCH_CFG)
|
||||
finally:
|
||||
pool._workers = orig
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_primary_preferred_over_backup():
|
||||
"""主 worker 在线时空闲主优先于备 worker。"""
|
||||
workers = _make_pool([
|
||||
("http://backup:1", True, False, False),
|
||||
("http://primary:1", True, False, True),
|
||||
])
|
||||
w = _run_acquire(workers)
|
||||
assert w.url == "http://primary:1"
|
||||
assert w.busy is True
|
||||
|
||||
|
||||
def test_backup_used_only_when_all_primary_down():
|
||||
"""主 worker 全部下线时才用备 worker。"""
|
||||
workers = _make_pool([
|
||||
("http://backup:1", True, False, False),
|
||||
("http://primary:1", False, False, True),
|
||||
("http://primary:2", False, False, True),
|
||||
])
|
||||
w = _run_acquire(workers)
|
||||
assert w.url == "http://backup:1"
|
||||
|
||||
|
||||
def test_backup_not_used_when_primary_busy():
|
||||
"""主 worker 在线但全忙时不退到备 worker(等待后超时拒绝)。"""
|
||||
workers = _make_pool([
|
||||
("http://backup:1", True, False, False),
|
||||
("http://primary:1", True, True, True),
|
||||
])
|
||||
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
try:
|
||||
await pool.acquire_worker(_DISPATCH_CFG)
|
||||
except pool.NoWorkerAvailable:
|
||||
return True
|
||||
return False
|
||||
|
||||
assert asyncio.run(run()) is True, "主组全忙时不得使用备 worker"
|
||||
assert workers["http://backup:1"].busy is False
|
||||
|
||||
|
||||
def test_no_primary_config_all_are_primary():
|
||||
"""未配置 primary_workers 时全部视为主 worker(兼容旧行为)。"""
|
||||
workers = _make_pool([
|
||||
("http://w1:1", True, False, True),
|
||||
("http://w2:1", True, False, True),
|
||||
])
|
||||
w = _run_acquire(workers)
|
||||
assert w.url == "http://w1:1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 优先级插队(接口5):全局槽 + worker 池
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PRIO_CFG = {
|
||||
"dispatch": {
|
||||
"queue_wait_seconds": 5, # 测试用:等待足够长,避免排队超时
|
||||
"max_global_concurrency": 1,
|
||||
"max_queue_wait_seconds": 5,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_global_slot_priority_jumps_queue():
|
||||
"""普通请求已排队时,优先级请求插队到最前,先获得槽位。"""
|
||||
pool.init_global_slot(_PRIO_CFG)
|
||||
order = []
|
||||
|
||||
async def run():
|
||||
assert await pool.acquire_global_slot(_PRIO_CFG) is True # 占住唯一槽
|
||||
|
||||
async def normal():
|
||||
if await pool.acquire_global_slot(_PRIO_CFG):
|
||||
order.append("normal")
|
||||
pool.release_global_slot()
|
||||
|
||||
async def prio():
|
||||
if await pool.acquire_global_slot(_PRIO_CFG, priority=1):
|
||||
order.append("prio")
|
||||
pool.release_global_slot()
|
||||
|
||||
t1 = asyncio.create_task(normal())
|
||||
await asyncio.sleep(0.05) # 确保 normal 先进入等待队列
|
||||
t2 = asyncio.create_task(prio())
|
||||
await asyncio.sleep(0.05) # prio 此时应已插队到队首
|
||||
pool.release_global_slot() # 释放槽位 → 应唤醒 prio 而非 normal
|
||||
await asyncio.wait_for(asyncio.gather(t1, t2), timeout=3)
|
||||
|
||||
asyncio.run(run())
|
||||
assert order == ["prio", "normal"], f"优先级请求应先获得槽位,实际顺序={order}"
|
||||
assert pool.get_pool_status()["global_busy"] == 0
|
||||
|
||||
|
||||
def test_global_slot_same_priority_fifo():
|
||||
"""同优先级仍按 FIFO,不插队。"""
|
||||
pool.init_global_slot(_PRIO_CFG)
|
||||
order = []
|
||||
|
||||
async def run():
|
||||
assert await pool.acquire_global_slot(_PRIO_CFG) is True
|
||||
|
||||
async def waiter(tag):
|
||||
if await pool.acquire_global_slot(_PRIO_CFG):
|
||||
order.append(tag)
|
||||
pool.release_global_slot()
|
||||
|
||||
tasks = []
|
||||
for tag in ("a", "b", "c"):
|
||||
tasks.append(asyncio.create_task(waiter(tag)))
|
||||
await asyncio.sleep(0.05) # 保证入队顺序
|
||||
pool.release_global_slot()
|
||||
await asyncio.wait_for(asyncio.gather(*tasks), timeout=3)
|
||||
|
||||
asyncio.run(run())
|
||||
assert order == ["a", "b", "c"], f"同优先级应保持 FIFO,实际顺序={order}"
|
||||
|
||||
|
||||
def _run_worker_prio_scenario(acquire_specs):
|
||||
"""构造「唯一 worker 忙 → 多个请求排队 → 释放」场景,返回获得顺序。
|
||||
|
||||
acquire_specs: [(tag, priority), ...],按此顺序发起请求。
|
||||
"""
|
||||
workers = _make_pool([
|
||||
("http://w1:1", True, True, True), # 唯一 worker,初始即忙
|
||||
])
|
||||
order = []
|
||||
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
pool._worker_waiters.clear()
|
||||
orig = pool._workers
|
||||
pool._workers = workers
|
||||
try:
|
||||
async def waiter(tag, prio_):
|
||||
w = await pool.acquire_worker(_PRIO_CFG, priority=prio_)
|
||||
order.append(tag)
|
||||
await pool.release_worker(w)
|
||||
|
||||
tasks = []
|
||||
for tag, prio_ in acquire_specs:
|
||||
tasks.append(asyncio.create_task(waiter(tag, prio_)))
|
||||
await asyncio.sleep(0.05) # 保证入队顺序
|
||||
# 释放 worker → 唤醒优先级最高的等待者
|
||||
await pool.release_worker(workers["http://w1:1"])
|
||||
await asyncio.wait_for(asyncio.gather(*tasks), timeout=3)
|
||||
finally:
|
||||
pool._workers = orig
|
||||
pool._worker_waiters.clear()
|
||||
|
||||
asyncio.run(run())
|
||||
return order
|
||||
|
||||
|
||||
def test_worker_queue_priority_jumps_queue():
|
||||
"""普通请求已排队时,优先级请求插队到最前,先获得 worker。"""
|
||||
order = _run_worker_prio_scenario([("normal", 0), ("prio", 1)])
|
||||
assert order == ["prio", "normal"], f"优先级请求应先获得 worker,实际顺序={order}"
|
||||
|
||||
|
||||
def test_worker_queue_same_priority_fifo():
|
||||
"""同优先级仍按 FIFO,不插队。"""
|
||||
order = _run_worker_prio_scenario([("a", 0), ("b", 0), ("c", 0)])
|
||||
assert order == ["a", "b", "c"], f"同优先级应保持 FIFO,实际顺序={order}"
|
||||
@@ -37,7 +37,7 @@ def test_extract_scalars_and_images(tmp_path):
|
||||
b64 = "data:image/png;base64," + base64.b64encode(_PNG).decode()
|
||||
body = _multipart(
|
||||
fields={"gender": "female", "hair_style": "1,3", "use_mask": "True",
|
||||
"prompt": "填充遮罩区域的头发", "image_base64": b64},
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮", "image_base64": b64},
|
||||
files={"image_file": ("x.png", "image/png", _PNG)},
|
||||
)
|
||||
ct = "multipart/form-data; boundary=----testbnd"
|
||||
|
||||
Reference in New Issue
Block a user