20 Commits
Author SHA1 Message Date
xsl 9e842e993e fix(gateway): 将接口11鉴权测试页移到标准地址 2026-11-10 22:12:26 +08:00
xsl 49cee683ad feat(gateway): 新增接口11和61的鉴权与测试页 2026-11-10 22:03:41 +08:00
xsl a63a0565d8 docs(gateway): 完善接口22前端接入说明 2026-11-09 21:46:31 +08:00
xsl 6d003dbf80 feat(gateway): 接入接口22与账号鉴权测试页 2026-11-09 21:42:16 +08:00
xsl 5eca74c9c0 docs(gateway): 补充客户端鉴权与42/52/72接入示例 2026-11-07 21:29:55 +08:00
xsl c7add84d91 fix(gateway): 补齐鉴权测试页并保持旧接口兼容 2026-11-07 21:11:17 +08:00
xsl 51baf96d78 feat(gateway): 接入42和72并为42/52/72启用可刷新令牌 2026-11-07 20:50:22 +08:00
xslandCursor dc53a0ecfe docs(gateway): 接入页补充接口1/6 的竖线坐标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:29:59 +08:00
xslandCursor 5f569caa5d docs(gateway): 补充接口1/6 竖线坐标 vertical_lines
网关继续盲转发,该字段是坐标 JSON,不参与 base64 改写。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:25:55 +08:00
xsl 14ec4964a0 feat(gateway): 为52号接口增加客户端静态Token校验
请求头X-API-Token默认yonghe1999,可通过interface52_token覆盖;缺少或不匹配返回401/code1009,不进入派发队列。客户端Token不转发到worker,其他接口保持原样。

同步Token输入框、配置示例与调用文档。57项网关测试及临时网关连接真实worker的浏览器验证通过。
2026-11-04 11:35:02 +08:00
xsl 544c2a83d6 feat(gateway): 接入52号发际线接口与公开测试页
新增52号同路径转发,沿用现有主备派发、鉴权、队列和图片URL改写。空图片保留既有base64字段名,不带入worker算法及IP限制。

同步52号测试页、图片解析和矩形叠加依赖,公开页面不包含内部Token。补全网关契约与部署文档,Nginx示例读取超时延长至660秒。

验证:42项网关测试通过;临时网关连接真实GPU worker,5号三档与52号七型URL、长图裁切和边框均通过浏览器检查。
2026-11-04 11:15:58 +08:00
xsl 44a6082938 添加监控接口 2026-10-15 23:04:07 +08:00
xsl 3350e0ad77 feat(gateway): 接口5排队插队优先级,两级队列均支持
- 全局槽换为 _PrioritySemaphore:priority 大者排到等待队列最前,同级 FIFO,
  无等待者时才允许直接获取(收紧 asyncio.Semaphore 的 barging);
  授予槽位瞬间请求被取消时归还槽位,防泄漏
- worker 池 Condition FIFO 等待改为显式优先级等待队列,release 唤醒队首;
  被唤醒但 worker 被抢时按原序号重排;授予/取消竞态时转手唤醒下一位
- forward.py 新增 PRIORITY_PATHS(现仅接口5 /api/v1/hairline/generate)
- 新增 4 个单测:两级队列的插队 + 同级 FIFO
2026-09-23 22:24:21 +08:00
xsl 6f46fc0ccd docs(gateway): 标注图目录改为 crontab 每日清理旧文案同步
app.py 启动日志与 reqlog 注释原写「永久保留,不自动清理」,实际已配置
crontab 每天 03:00 清理 static/annotations 超过 30 天的文件
2026-09-22 23:51:39 +08:00
xsl 12a825315a docs: 9/15-9/21 网关调用统计 + 接口2空图失败统计两篇 2026-09-22 23:48:24 +08:00
xsl f1b8b7bd82 feat(gateway): worker 池主备分组派发,主全挂才用备机
- pool.py: WorkerState 增加 primary 标记;acquire_worker 分级派发——
  主组有在线只在主组内挑空闲(全忙排队,不退备组),主组全部下线才用备机
- config.py: 新增可选 primary_workers 配置(须为 workers 子集,自动过滤+告警);
  不配置/空列表 = 全部视为主 worker,兼容旧行为
- config.json(不入 git): 3 台 GPU worker 设为主,max_global_concurrency 1→3
- app.py: /health 增加 workers_primary_total / workers_primary_healthy
- tests: 新增 4 条主备派发单测(主优先/主全挂用备/主全忙不走备/无配置兼容)
- docs(实现说明): 补充 primary_workers 配置说明,改配置需重启网关生效
2026-09-22 23:48:21 +08:00
xsl 6b24bf0b0d feat(gateway): 被动摘除后进入 60s 隔离期,防探测复活横跳
落地 docs/网关摘除隔离期实施方案.md(9·19 worker 磁盘满事故第 3 层防线):
- pool.py: WorkerState.offline_until + _cooldown_seconds();mark_worker_unhealthy
  摘除时设隔离截止时刻,隔离期内探测成功也不复活(_try_revive 守卫)
- pool.py: 删除从未被调用的死代码 _check_worker_health/_mark_failure
- config.py + config.example.json: health_check.cooldown_seconds(默认 60,0=关闭)
- tests/test_gateway_pool.py: 9 用例(摘除设隔离/隔离内不复活/期满复活/整链路等)

网关机已重启验证:启动日志含"隔离期=60s",/gateway-health 正常。
2026-09-20 23:02:18 +08:00
xsl 88fee6474d docs: 网关摘除隔离期实施方案(摘除后 60s 隔离期,防探测复活横跳)
复盘 9·19 worker 磁盘满事故网关未切换的三层原因;给出本仓库 gateway/
pool.py 的隔离期改动说明(WorkerState.offline_until + 被动摘除冷却 +
探测复活守卫 + 删死代码)、配置项、单测建议、部署/验证/回滚步骤。
worker 侧深检(/health 磁盘水位+依赖探测)已在 ubuntu3090_debug_4090
分支 14562d4 上线。
2026-09-20 21:49:36 +08:00
xsl a0f53ad6ad feat: ComfyUI 默认提示词统一改为「填充遮罩区域的头发,人物增加一点磨皮」
- app.py 各接口 Form 默认值与说明、hairline/service.py _REDRAW_PROMPT、
  hairline/redraw.py _DEFAULT_PROMPT、face_analysis/hairline_grow.py 兜底值
- add_hair.json / add_hair2.json / 0716add-hair-api.json / 0716add-hair.json /
  hair_repaint.json 工作流节点60内置文本
- static 测试页、local_test、benchmark 脚本、接口文档同步
注意:新提示词首次请求会因 CLIP 缓存失效变慢一次,之后恢复
2026-09-16 00:27:33 +08:00
xsl 81101715c8 fix(接口2): female 1..5 换发型分支重绘透传 prompt(此前固定为默认值)
同 59e01d3(ubuntu3090_debug),按 main 代码形态适配:main 的
_call_local_redraw 尚无 prompt/max_side/unet_name 参数,此处只加 prompt。

- _call_local_redraw 增加 prompt 入参并转发给 run_redraw(None 时行为不变,
  仍用内置默认「填充遮罩区域的头发」)
- generate_grow_results_swap 换发型分支调用处补传 prompt;自定义提示词现可
  替换工作流节点60文本(与 male / female 6/7 行为一致);prompt 不进
  change_hair(swapHair),仅作用于最后一步 ComfyUI 发际线带重绘
- 换词后首次请求因缓存失效重载明显变慢(实测 45s vs 命中 8s),后续恢复
- 文档同步:接口文档.md prompt 参数说明更新
2026-09-14 14:40:20 +08:00
70 changed files with 5193 additions and 553 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
"60": {
"class_type": "JjkText",
"inputs": {
"text": "填充遮罩区域的头发"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
}
},
"22": {
+1 -1
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
},
"class_type": "JjkText",
"_meta": {
+2 -2
View File
@@ -381,7 +381,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
},
"class_type": "JjkText",
"_meta": {
@@ -684,7 +684,7 @@
},
"87": {
"inputs": {
"text": "填充遮罩区域的头发",
"text": "填充遮罩区域的头发,人物增加一点磨皮",
"clip": [
"78",
0
+6 -6
View File
@@ -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()
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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")
+2 -2
View File
@@ -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** 次。
+5
View File
@@ -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 历史)。
+5 -5
View File
@@ -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
View File
@@ -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/`(落盘的图),定期清理。
---
+40
View File
@@ -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。
+77
View File
@@ -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生发;临时网关验证后关闭。
+311 -5
View File
@@ -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 端生发接口
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
@@ -397,6 +467,91 @@
---
## 接口 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。
---
## 接口 5:发际线 PNG 生成接口
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回 `middle` / `high` / `low` **三档**发际线叠图与**生发图**,并返回「最合适发际线」的面部中间点坐标。
@@ -450,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 }` |
@@ -519,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:头发遮罩生成接口
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
@@ -635,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 |
@@ -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
View File
@@ -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,不进业务请求日志。
+21
View File
@@ -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`。
+234
View File
@@ -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 段即可。**
+106
View File
@@ -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}
+1 -1
View File
@@ -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"],
+2 -2
View File
@@ -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):
+3 -1
View File
@@ -141,7 +141,7 @@ 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):
def analyze_features(image_bytes: bytes = None, image_url: str = None, *, accept_age_alias=False):
"""豆包分析 6 项特征 + 本机 MediaPipe 覆盖 face_shape。
Returns: dict —— 7 个英文字段(face_shape/eyebrow_shape/facial_age/
@@ -177,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:
+227 -5
View File
@@ -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端生发(已屏蔽)"""
@@ -494,6 +627,24 @@ async def face_features(
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
):
"""接口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 工作流)"""
+155
View File
@@ -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, "访问令牌有效")
+4 -1
View File
@@ -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": {
+12
View File
@@ -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
View File
@@ -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:
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
},
"class_type": "JjkText",
"_meta": {
+25 -6
View File
@@ -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."""
+231
View File
@@ -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
View File
@@ -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 时延敏感路径用)
+43
View File
@@ -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
View File
@@ -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:
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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,
)
+1 -1
View File
@@ -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;
}
+93
View File
@@ -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();
};
})();
+63
View File
@@ -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; }
+156
View File
@@ -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"));
}
});
})();
+62
View File
@@ -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();
};
+30
View File
@@ -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;
})();
+216 -6
View File
@@ -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> &nbsp;|&nbsp; 在线文档: <a href="/docs" class="link">/docs</a> &nbsp;|&nbsp; 在线测试页见底部</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,9 +78,10 @@
<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>
@@ -85,6 +97,51 @@
</div>
</div>
<div class="card" id="client-auth">
<h2>客户端鉴权</h2>
<div class="card-body">
<p>客户端发送 <code>Authorization: Bearer &lt;access_token&gt;</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":"&lt;对接密码&gt;"}'</pre>
<p>成功返回 HTTP 200 且 <code>code=0</code>,<code>data</code> 的结构如下:</p>
<pre>{
"access_token": "&lt;访问令牌&gt;",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "&lt;刷新令牌&gt;",
"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. 四庭七眼测量 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/face/measure</code></h2>
@@ -107,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>
@@ -127,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. 四庭七眼测量(鉴权) &nbsp;<span class="badge post">POST</span> &nbsp;<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端生发 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hair/grow</code></h2>
@@ -164,6 +234,61 @@ const { code, data } = await res.json();
</div>
</div>
<div class="card" id="if22">
<h2>22. 生发密度与发际线高度 &nbsp;<span class="badge post">POST</span> &nbsp;<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端生发(医生端)&nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hair/grow-b</code></h2>
@@ -221,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 &lt; 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 生成 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hairline/generate</code></h2>
@@ -253,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>
@@ -263,6 +409,34 @@ console.log(features.gene_style); // "自然型"</pre>
</div>
</div>
<div class="card" id="if52">
<h2>52. 发际线新规则与裁切 &nbsp;<span class="badge post">POST</span> &nbsp;<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 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/face/measure-v2</code> &nbsp;<span class="badge warn">v2</span></h2>
@@ -288,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(鉴权) &nbsp;<span class="badge post">POST</span> &nbsp;<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>
@@ -308,10 +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">⚠ <strong>HTTP 层错误不是业务 JSON</strong>:业务错误(code≠0)一律返回 HTTP 200;但请求体超 2MB 时 nginx 直接返回 <strong>413</strong>(HTML 错误页)、网关不可用时返回 502/504。客户端务必先判断 HTTP 状态码,非 200 时不要 <code>res.json()</code>,否则会报 <code>Unexpected token '&lt;'</code>(把 HTML 当 JSON 解析)。</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>
@@ -322,11 +526,17 @@ 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>上传照片 → 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>
+53 -390
View File
@@ -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 &nbsp;|&nbsp; 上传正面照 → 原图+标注叠加显示</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=""
style="flex:1; min-width:200px; padding:8px; border:1px solid #ddd; border-radius:8px">
<div class="upload-hint">
支持 JPG / PNG 正面照 &nbsp;|&nbsp; 也可拖拽图片到文件选择框
</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>
+11
View File
@@ -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>
+2 -2
View File
@@ -69,7 +69,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="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
@@ -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');
+2 -2
View File
@@ -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">
@@ -167,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 = '⏳ 重绘中...';
+1 -1
View File
@@ -107,7 +107,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待</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>
+466
View File
@@ -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>
+1 -1
View File
@@ -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>
+220
View File
@@ -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 &nbsp;|&nbsp; 质检 → 扶正裁切 → 面部特征与两项判定 &nbsp;|&nbsp; 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 正面照 &nbsp;|&nbsp; 检测人脸完整性和俯拍角度,通过后扶正裁切</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>
+427
View File
@@ -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 正面照 &nbsp;|&nbsp; Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 &nbsp;|&nbsp; Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 &nbsp;|&nbsp; 每个发型返回一张设计曲线,生发遮罩同步到新位置</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 ? ' &nbsp;<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) + '° &nbsp; pitch ' + _FMT(p.pitch) + '° &nbsp; 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>
+83
View File
@@ -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 &nbsp;|&nbsp; 去顶庭 · 竖线发际线→下巴 · 无头部端线</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 正面照 &nbsp;|&nbsp; 也可拖拽图片到文件选择框
</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>
+1 -1
View File
@@ -104,7 +104,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待 &nbsp;|&nbsp; 工作流: 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>
+137
View File
@@ -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>
+141
View File
@@ -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()
+236
View File
@@ -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
+135
View File
@@ -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
+351
View File
@@ -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}"
+1 -1
View File
@@ -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"