29 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
xsl b031ce5bbe security: 清除全部公开测试页硬编码的 worker 内部密码
- 16 个测试页移除 X-Internal-Token 硬编码(网关不校验该头,删除零影响,已线上验证)
- 11/12 系列保留 token 输入框仅清空默认值,直连 worker 调试改为手输
- 接入文档 integration.html:补请求体 2MB 上限与 413 说明、HTTP 层错误提示、接口4 7 字段、补全 9/10/99 测试页入口
2026-09-07 00:35:29 +08:00
xsl ebcaff1295 feat(接口4): 新增轮廓类型 contour_type + 提示词防漏键加固
- 豆包提示词追加「轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)」,_KEY_MAP 映射为 contour_type
- 提示词显式要求包含全部键:实测豆包随机漏键(面部年龄历史 ~26% 缺失),加固后 9/9 次全字段返回
- 同步接口文档/测试页至 7 字段;测试页顺带移除硬编码内部密码
2026-09-07 00:35:24 +08:00
xsl d242e0fc48 fix(gateway): 补 import asyncio —— rebase 到 origin/main 时丢失(origin/main 头部原本无此导入,接口99 的 _if99_lock 需要) 2026-09-06 23:37:41 +08:00
xsl aadab922a9 docs(网关待改动§9): 补无GUI服务器系统库依赖 + torch CPU源安装说明 + 无GPU实测结论 2026-09-06 23:37:01 +08:00
xsl 8b7bfe2023 feat(接口99): 网关改本机实现(纯CPU+单路排队锁),文档同步
- gateway/app.py: 接口99 从盲转发改为本机实现(同接口4 模式):
  显式 Form 三选一 + 惰性导入(缺依赖仅本接口报1007)+ run_in_threadpool
- 单路排队: _if99_lock 全局互斥——串行推理限制 CPU 峰值 +
  规避 MediaPipe 单例线程安全问题;实测 req2≈2×req1 确认串行
- 网关待改动 §9 重写: 本机实现部署步骤 + 依赖清单(mediapipe/opencv/
  numpy<2,torch CPU 版可选——不装则发际线5点null)+ 权重已入git说明
- 实现说明/接口文档/CLAUDE.md 同步: 接口4/99 网关本机实现不转发
- 实测: BiSeNet 13.3M参数/51MB;CPU单核 0.46-0.65s/张;网关路径
  70/70点;worker 路径保留(测试页直连);pytest 15 通过
2026-09-06 23:37:01 +08:00
xsl 63bd265386 feat(接口99): 人脸特殊点检测 + 网关转发路由;文档同步至当前代码
- worker: POST /api/v1/face/special-points,60 个固定编号关键点(6组)
  + 5 个发际线特殊点(沿指定关键点方向射线步进,与 BiSeNet 头发 mask 首次相交;
  双射线取中点,left_left=mid(333→332, 298→284) 与右侧镜像)
- 网关: 同路径盲转发路由(调用频率低,维持转发不占网关依赖;
  响应无 *_base64 字段,改写逻辑天然跳过)
- 测试页 static/test_interface99.html:分组着色小点 + 点ID标注(可开关)
  + 发际线点大圆标签 + JSON 展示
- 文档: CLAUDE.md 重写至当前架构;实现说明/接口文档补接口 9~12/99 契约;
  网关待改动补 §7(9/10已加) §8(11/12待加) §9(接口99部署步骤)
2026-09-06 23:37:01 +08:00
xsl 989f4d0d18 更新 发际线纹理 2026-08-14 21:11:51 +08:00
xsl 8aeca2eb2b Merge branch 'main' of https://git.xiangsilian.com/xsl/hair 2026-08-08 12:02:14 +08:00
xslandClaude 1dc82149b4 feat: 接口1/6 发际线贴近头顶时改为修正而非弃用置null
顶庭(头顶→发际线)<0.7cm 时不再返回 null:
- 上庭 = 0.95×中庭 反推发际线;若越过头顶则顶庭固定 0.7cm(发际线钉在头顶下方)
- hairline_source 新增 "corrected";to_response 始终输出完整四庭
- 标注图始终正常画发际线横线与庭段
- 接口6 三庭逻辑同步,去 discarded 分支

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:50:18 +08:00
115 changed files with 6313 additions and 804 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
+72 -52
View File
@@ -4,80 +4,100 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
"旷视五接口" — a FastAPI facial analysis API service providing 5 endpoints for face measurement, hair growth simulation (C-end and B-end), user feature extraction, and hairline PNG generation. Currently a **Mock v1**: all endpoints return hardcoded sample data; real ML logic is pending.
旷视接口服务 — FastAPI 人像分析/生发服务,**一个仓库、两种部署角色**:
- **Base URL (prod)**: `https://hair.xiangsilian.com`
- **Swagger docs**: `https://hair.xiangsilian.com/docs`
- **Internal**: uvicorn on `127.0.0.1:8000` behind nginx reverse proxy
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真实算法(本地模型 + ComfyUI + change_hair),监听 `0.0.0.0:8187`。**本机(GPU 机)即 worker 角色**,不跑网关。
- **网关**(`gateway/`):外网机器上的薄反向代理,负责鉴权/健康检查/派发,并把 worker 返回的 `*_base64` 落盘改写为 `*_url`。接口4(豆包)和接口99(人脸特殊点,纯 CPU)在网关本机实现,不转发 worker。
> 历史包袱:`app.py` 的 FastAPI `description` 和 `docs/接口文档.md` 部分示例仍写「Mock 第一版」字样,实际全部接口已是真实算法实现。**架构以 `docs/实现说明.md` 为准,契约以 `docs/接口文档.md` 为准。**
## Commands
```bash
# Development server
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 --reload
# ---- worker(本机)----
./run_worker.sh # 后台启动(0.0.0.0:8187,日志 worker.log)
FOREGROUND=1 ./run_worker.sh # 前台调试
./stop_worker.sh # 停止
sudo systemctl restart hair-worker # systemd 方式(生产)
journalctl -u hair-worker -f
# Or via the start script
./start.sh
# ---- 依赖服务(worker 之前要先起)----
sudo systemctl start comfyui # ComfyUI 127.0.0.1:8188(Flux-2 生图)
cd /home/ubuntu/change_hair && bash start_all.sh start # 换发型三件套 8801/32678/57860
# Production (systemd)
sudo systemctl restart hair
sudo systemctl status hair
journalctl -u hair -f
# Install dependencies (uses Tencent Cloud PyPI mirror)
./venv/bin/pip install -r requirements.txt
# ---- 测试 ----
./venv/bin/pytest tests/ # 单元测试(conftest 会准备 fixtures)
./venv/bin/python -m hairline.comfyui tests/output/comfy_input.png # 手动跑一次生发工作流
```
依赖装在 `./venv`(Python 3.10,torch 2.7.1+cu128,RTX 4090 CUDA 可用)。`requirements.txt` 里的 torch 2.2.2/5090 注释是旧机器的记录,本机 venv 实际是 cu128 版。
## Architecture
**Single-file app** — `app.py` (~555 lines) contains the entire server: FastAPI app creation, Pydantic models, all 5 endpoint handlers, and health check routes. There are no separate routers, services, or middleware modules.
### 接口一览(worker 侧路由,`app.py`)
### Endpoints
| 接口 | 路径 | 实现位置 | 说明 |
|------|------|----------|------|
| 1 | `POST /api/v1/face/measure` | `face_analysis/` | 四庭七眼测量 + 标注 PNG |
| 6 | `POST /api/v1/face/measure-v2` | 同上(`variant="v6"`) | 接口1 去顶庭变体 |
| 2 | `POST /api/v1/hair/grow` | `hairline/service.py` | C端生发。**female 1..5** 走 change_hair swapHair + Flux-2 整帧重绘(接口12 final 管线);**female 6/7 与 male 全部**走 ComfyUI `add_hair.json` inpaint |
| 7 | `POST /api/v1/hair/grow-v2`(hidden/deprecated) | 同上 | 同接口2,用 `add_hair2.json` 工作流 |
| 3 | `POST /api/v1/hair/grow-b` | `hairline/marker_detect.py` | B端马克笔划线生发(黑帽响应 + Dijkstra) |
| 4 | `POST /api/v1/face/features` | worker 有实现;生产走**网关本机**(豆包) | 用户特征 |
| 5 | `POST /api/v1/hairline/generate` | `hairline/service.py` | 发际线 PNG 三档(middle/high/low)+ 生发 |
| 9 | `POST /api/v1/head/mask` | `face_analysis/head_mask.py` | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
| 10 | `POST /api/v1/head/band` | `face_analysis/head_band.py` | 头部外缘膨胀带遮罩 |
| 11 | `POST /api/v1/hairline/grow` | `face_analysis/hairline_grow.py` | 发际线生发(接口9 遮罩 + change_hair + 接缝融合),不含重绘 |
| 12 | `POST /api/v1/hairline/grow_v2` | 同上 | 接口11 final + 发际线重绘带 → Flux-2 保色重绘 |
| 12f | `POST /api/v1/hairline/grow_v2_final` | 同上(`_V2_FINAL_DEFAULTS`) | 精简版:仅图片 + hairline_id,取整帧重绘 |
| 12f2 | `POST /api/v1/hairline/grow_v2_final_v2` | 同上 | 同上,取 B 局部加发+全脸美颜 |
| 99 | `POST /api/v1/face/special-points` | `face_analysis/special_points.py`(网关直接 import 本机实现 + `_if99_lock` 单路排队;worker 保留同路径备份) | 固定编号关键点 + 发际线特殊点(射线求交头发 mask)像素坐标 |
| 代理 | `POST /api/swapHair/v1`、`/hairColor/v2` | httpx 转发 127.0.0.1:8801 | 解决测试页 CORS |
| 工具 | `POST /api/v1/redraw` | `hairline/redraw.py` | 直接 ComfyUI 重绘(图+遮罩+prompt) |
| 调试 | `POST /api/v1/debug/grow-timing`、`GET /api/v1/debug/hairline_log` | — | 免鉴权,分步计时/下载日志 |
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/v1/face/measure` | 四庭七眼 measurement + annotated PNG |
| POST | `/api/v1/hair/grow` | C-end hair growth (user-facing) |
| POST | `/api/v1/hair/grow-b` | B-end hair growth (doctor/operator, with marked image) |
| POST | `/api/v1/face/features` | User facial feature analysis |
| POST | `/api/v1/hairline/generate` | Hairline PNG generation |
| GET | `/health` | Health check (excluded from OpenAPI schema) |
| GET | `/` | Service info (excluded from OpenAPI schema) |
配套测试页在 `static/test_interface{1..12}*.html`,经 `/static` 挂载。
### Image input convention (all endpoints)
### 关键模式
Every endpoint accepts images via **exactly one** of three mutually exclusive fields:
- `image_file` — multipart file upload (≤ 1 MB)
- `image_url` — HTTP/HTTPS URL string
- `image_base64` — base64 string with `data:image/...;base64,` prefix
- **统一信封**:`ok(data)` / `err(code, message)` 构造 `{code, message, request_id, data}`;业务错误 HTTP 一律 200。错误码:1001 无人像 / 1002 分辨率低 / 1003 非正面 / 1004 gender 非法 / 1005 多人 / 1006 >1MB / 1007 参数或处理失败 / 1008 格式不支持。
- **图片三选一**:每个接口 `image_file`(multipart) / `image_url` / `image_base64`(带 `data:` 前缀)严格互斥,由 `resolve_image_bytes()` 统一处理(0 个或多个 → 1007)。接口3 是 `marked_image_*`。
- **阻塞算法进线程池**:所有推理 handler 用 `run_in_threadpool(...)`,避免卡事件循环。
- **内网鉴权**:HTTP 中间件校验 `/api/*` 的 `X-Internal-Token` 头,密码来自 `worker_config.json` 的 `accept_passwords`(或环境变量 `WORKER_ACCEPT_PASSWORDS`)。豁免前缀见 `_AUTH_EXEMPT`(health/docs/static/debug/redraw/swapHair/hairColor)。
- **发型序号映射**:`hairline/service.py` 的 `_HAIRSTYLE_ORDER` 显式定义 1..7 顺序(不靠文件名字母序);female 1..5 → change_hair `chang_*` 映射在 `_FEMALE_KEY_TO_CHANG`。
- **接口12 固化参数**:`_V2_FINAL_DEFAULTS`(app.py)是生产默认值(push=0.8cm / two_stage / color_match=关 等),接口2 female 重绘也复用它。
Endpoint 3 (B-end) has **two** sets of image params: `marked_image_*` (the marked-up image) and `original_image_*` (the original photo). Each set follows the same three-way exclusive rule independently.
### 外部依赖(worker 视角)
### Unified response structure
| 依赖 | 地址 | 用途 | 配置 |
|------|------|------|------|
| ComfyUI | `http://127.0.0.1:8188`(`COMFYUI_URL`) | Flux-2 生图/重绘,走 HTTP API(`hairline/comfyui.py`) | Basic Auth `admin`,密码:`COMFYUI_PASSWORD` → `worker_config.json.comfyui_password` → `password.txt` |
| change_hair | `http://127.0.0.1:8801`(`SWAP_HAIR_URL`/`HAIR_GROW_URL`/`CHANGE_HAIR_BASE`) | 换发型(SD WebUI + LoRA)、hairGrow | 服务目录 `/home/ubuntu/change_hair`,`start_all.sh` 启停 |
```json
{"code": 0, "message": "success", "request_id": "mock-request-id", "data": {...}}
```
ComfyUI 工作流 JSON 在仓库根:`add_hair.json`(接口2/3/5 生发)、`add_hair2.json`(接口7)、`0716add-hair-api.json`(接口12/2女 重绘)、`hair_repaint.json`。节点约定:**26** 输入图 / **6** seed / **60** 提示词 / **16** unet(.gguf 自动切 UnetLoaderGGUF)/ **61** CLIP(4b↔9b 自动配套)。
Error codes: 1001–1008 (see `app.py` docstring or `docs/接口文档.md`).
### 性能/显存相关的坑
### Key code patterns
- 三个生发接口共用**同一提示词字符串**以命中 ComfyUI 的 CLIP 文本条件缓存——prompt 变了会触发 Flux(7.7G)+qwen CLIP(3.9G) 互相挤显存重载(每次 +4s)。
- 接口2 女性重绘管线默认把输入长边压到 `REDRAW_MAX_SIDE`(env,默认 1024)再放大回原尺寸;`redraw_max_side=0` 表示不缩图。
- `comfyui.run(front=True)` 可插队(接口2 对时延敏感);超时 `COMFY_TIMEOUT`(默认 600s)。
- `ok(data)` / `err(code, msg)` — helper functions at module level that build the standard response envelope
- `ImageJsonBody` — Pydantic model for JSON-based image params (URL/base64); form-upload endpoints use FastAPI `Form()` / `File()` parameters directly
- `SAMPLE_IMAGE_URL` — `https://hair.xiangsilian.com/static/sample.jpg`, returned as the image URL in all mock responses
- Static files (`/static/*`) are mounted via `app.mount("/static", StaticFiles(directory="static"), name="static")`
- All 5 POST endpoints use `multipart/form-data` with both `File` and `Form` fields — there are no JSON-body POST endpoints despite `ImageJsonBody` being defined (it exists for the OpenAPI schema but the actual handlers use `Form()` for all params)
## Deployment(本机 = gpu_worker)
### Deployment
systemd 单元(仓库根有副本,实装在 `/etc/systemd/system/`):
- **Process manager**: systemd (`hair.service` → `ExecStart` runs uvicorn directly from venv)
- **Reverse proxy**: nginx (`nginx/hair.conf`) proxies port 80 → `127.0.0.1:8000` for `hair.xiangsilian.com`
- **Python**: venv at `./venv`, dependencies in `requirements.txt`
- **PyPI mirror**: Tencent Cloud (`mirrors.cloud.tencent.com`), configured in `pip.conf`
| 服务 | 端口 | 说明 |
|------|------|------|
| `comfyui.service` | 127.0.0.1:8188 | `ComfyUI/venv` 的 `main.py --cache-classic --fast` |
| `hair-worker.service` | 0.0.0.0:8187 | 本仓库 `app:app`(Wants comfyui + change_hair-hair) |
| `change_hair-{webui,photo,hair}.service` | 57860 / 32678 / 8801 | conda envs `sdwebui`/`py310`/`my_hair`,依赖顺序 webui→photo→hair |
### Documentation
网关机另行部署 `gateway/`(config:`workers` 列表 + `shared_password` + `ark` key),见 `gateway/config.example.json` 与 `docs/网关待改动.md`。
- `docs/接口文档.md` — complete API documentation (inputs, outputs, examples, error codes)
- `docs/旷视具体需求.md` — original requirements spec (converted from `.docx`)
- The FastAPI app's `description` docstring in `app.py` is an additional source of API docs visible in Swagger UI
## Documentation
- `docs/接口文档.md` — 对外 API 契约(唯一权威)
- `docs/实现说明.md` — 架构总览 + 各接口实现简述 + 部署要点
- `docs/发际线增强算法.md`、`docs/发际线生发遮罩算法_pushed模式.md` — 接口11/12 算法推导
- `docs/gpu-worker改动说明.md`、`docs/网关待改动.md` — worker↔网关 协同变更记录
- `OFFLINE_ASSETS.md` — 离线模型权重/字体清单
+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
+85 -36
View File
@@ -362,36 +362,22 @@ def _run_face_measure_data(image, variant="v1"):
logger.warning("头发/耳朵分割失败,回退方案A:%s", seg_e)
result = measure_face(landmarks, hair_mask, w, h, head_pose=head_pose)
discarded = result.hairline_discarded
data = result.to_response()
vd = result.vertical
if variant == "v6":
if discarded:
# 发际线弃用:接口6 的上庭也依赖发际线,一并置 null;只保留中/下庭。
base_px = vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["upper_court_cm"] = None
data["four_courts"]["ratios"] = {
"upper_court": None,
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.middle_cm + result.lower_cm, 2)
data["landmarks"]["hairline"] = None
else:
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court / landmarks.hair_top)
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
# 接口6 是三庭:去掉顶庭相关字段(top_court_cm / ratios.top_court),
# 比例分母按上/中/下三庭。发际线贴近头顶时已在 measure 修正,hairline 正常返回。
base_px = vd["upper_court_px"] + vd["middle_court_px"] + vd["lower_court_px"]
data["four_courts"]["ratios"] = {
"upper_court": round(vd["upper_court_px"] / base_px, 3),
"middle_court": round(vd["middle_court_px"] / base_px, 3),
"lower_court": round(vd["lower_court_px"] / base_px, 3),
}
data["four_courts"].pop("top_court_cm", None)
data["face_total_height_cm"] = round(
result.upper_cm + result.middle_cm + result.lower_cm, 2)
# 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、
# 占比、标注图仍按三庭处理,显示效果不变。
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
@@ -407,10 +393,9 @@ def _run_face_measure_data(image, variant="v1"):
None if (a is None or b is None) else round((b - a) / pc, 2))
if variant != "v6":
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
# 竖向范围:发际线弃用时用眉心做上界(hair_top 不可靠),否则用头顶。
# 竖向范围上界用头顶(hair_top 始终可用)。
from face_analysis.annotation import _ear_edges_from_mask
top_y = (vd["brow_center"][1] if discarded
else vd["hair_top"][1])
top_y = vd["hair_top"][1]
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask,
top_y, vd["chin_tip"][1],
@@ -737,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"):
@@ -806,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女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
@@ -993,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 入参仍被接受但不处理。"""
@@ -1147,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"):
@@ -1385,6 +1370,70 @@ async def head_band(
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 99:人脸特殊点检测(固定关键点 + 发际线特殊点)
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/face/special-points",
summary="接口99 人脸特殊点检测",
tags=["人脸分析"],
description=f"""
输入一张人脸照片,返回 MediaPipe Face Mesh 一组**固定编号特殊点**的像素坐标,
以及 5 个**发际线特殊点**(射线与 BiSeNet 头发分割发际线的交点,见 hairline_grow 同款分割)。
**字段命名**:`<组英文名>_<点ID>`,如 `eyebrow_107`、`mouth_17`;发际线点用给定字段名
(`hair_line_center` 等)。坐标为原图像素坐标(int),发际线点不可得(无头发/分割失败)时为 null。
| 组 | 字段前缀 | 点 ID |
|----|----------|-------|
| 眉毛 | eyebrow | 107 66 105 63 70 336 296 334 293 300 |
| 眼睛 | eye | 189 222 224 113 226 413 442 444 342 446 |
| 嘴 | mouth | 0 37 40 67 91 84 17 405 375 409 269 |
| 中线鼻 | nose_midline | 9 168 197 5 4 194 278 48 437 217 |
| 面颊 | cheek | 101 117 123 187 205 50 330 346 352 280 425 |
| 边线 | face_edge | 162 234 58 136 149 152 378 365 288 401 366 389 251 |
**发际线点算法**:从关键点 a 沿 a→b 方向逐像素步进,首次进入头发 mask 处即交点;
双射线的点取两交点中点(仅一条命中返回该点,均未命中为 null):
- `hair_line_center`:151→10
- `hair_line_right`:mid(108→109, 69→67)
- `hair_line_right_right`:mid(104→103, 68→54)
- `hair_line_left`:mid(337→338, 299→297)
- `hair_line_left_left`:mid(333→332, 298→284)
{_image_fields_desc}
""",
)
async def face_special_points(
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(需带 data:image/...;base64, 前缀)"),
):
"""接口99:固定关键点 + 发际线特殊点,像素坐标 JSON 返回。"""
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
from fastapi.concurrency import run_in_threadpool
from face_analysis.special_points import detect_special_points, NoFaceError, NonFrontalError
try:
data = await run_in_threadpool(detect_special_points, image)
except NoFaceError:
return err(1001, "无法识别人像")
except NonFrontalError:
return err(1003, "角度问题,请上传正面照")
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口99 处理异常")
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 11:发际线生发(接口9 遮罩 + change_hair 换发型 + 按遮罩羽化贴回)
# ---------------------------------------------------------------------------
@@ -1541,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"),
@@ -1707,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` 参数后将替换此文本。
+73 -31
View File
@@ -12,15 +12,18 @@
```
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI/change_hair)
└ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/6/7/9/10/11/12 + 工具路由
```
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**(`./start.sh` 控制开关,`./run_worker.sh` 热重载)。
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机
回环外无外网依赖。对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**(systemd `hair-worker.service`,也可 `./run_worker.sh` 手动起)。
另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、
`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免鉴权调试)。
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。
网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测,
直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 一律 200)。
@@ -33,7 +36,10 @@
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
| 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` |
| 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` |
| 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` |
| 12 | `steps.{input,final,redraw_band_overlay,redraw_full,redraw_band,redraw_c}_base64` | `steps.*_url` |
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
@@ -47,10 +53,10 @@
---
## 2. 五个接口实现简述
## 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`。
@@ -59,16 +65,16 @@
### 接口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个月效果)。
- **怎么实现**(`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
返回 `results[].image_base64` + `grown_image_base64`。
- `hair_style` 映射: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。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。
- `hair_style` 映射: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。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线(`generate_grow_results_swap`,复用接口12 final 的 `_V2_FINAL_DEFAULTS`,长边默认压到 `REDRAW_MAX_SIDE`=1024 再放大回原尺寸);female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。可选参数 `flux_model`(切换 Flux unet,.gguf 自动配 UnetLoaderGGUF + 配套 qwen CLIP)、`redraw_max_side`(0=不缩图)。
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
- **做什么**:与接口 2 完全一致(正面照 + `gender` + `hair_style` 逗号分隔多选 → N 组预览+生发图)。
@@ -85,37 +91,73 @@
### 接口4 用户特征 `/api/v1/face/features`(**网关本机**)
- **做什么**:照片 → 几十项面部特征(脸型/眉形/肤色/三庭五眼/四季色彩季型/量感/基因风格/性别…)。`data.features` 是 JSON 字符串。
- **怎么实现**(`gateway/`,逻辑参考 worker `face_features.py` / `/home/xsl/fuyan`):调**火山方舟 豆包视觉模型**
`doubao-seed-2-0-lite-260428`(OpenAI 兼容,base64 data URI 喂图),解析眉形/年龄/动静/性别/基因风格 5 项。
**`face_shape`(脸型)改为本机 `face/face_shape_classifier.py`(MediaPipe)计算并覆盖豆包结果**。
无人脸→1001。网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
⚠️ 因此**网关机不再是纯轻量代理**,需额外安装 `mediapipe`/`opencv-python`/`numpy<2`(见 `requirements.txt`)。
`doubao-seed-1-6-vision`(OpenAI 兼容,base64 data URI 喂图),解析 JSON + 映射 6 个英文优先字段并保留全部中文。
无人脸→1001。**唯一调外网的接口**:网关需可达 `ark.cn-beijing.volces.com`,API Key 走网关配置(不入 git)。
### 接口5 发际线PNG生成 `/api/v1/hairline/generate`(worker)
- **做什么**:入参同接口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` 两组各四张图。
### 接口10 头部外缘膨胀带 `/api/v1/head/band`(worker)
- **做什么**:在接口9 基准遮罩基础上,沿头部外缘生成一条膨胀带遮罩。
- **怎么实现**(`face_analysis/head_band.py`):取基准遮罩外轮廓、去掉贴底分割线段 → 膨胀成总宽 `dilate_cm`(默认2cm) 的带子 → 裁到分割线以上。返回 `base_mask`/`contour`/`band_overlay`/`mask` 分步图。
### 接口11 发际线生发 `/api/v1/hairline/grow`(worker)—— 不含重绘
- **做什么**:发际线偏高/头发稀少正脸图 + `hairline_id`(= change_hair `hair_id`,如 `chang_tuoyuan`)→ 压低发际线图 + 全步骤可视化。
- **怎么实现**(`face_analysis/hairline_grow.py`,算法见 `发际线增强算法.md`):① 接口9 遮罩(`seg_model` 选 bisenet/segformer)→ ② 调 change_hair 生成该发际线类型图(`swap_mode=ext_mask` 把遮罩传给 webui 精确重绘 / `as_is` 用其内部遮罩;`gen_backend` 可换 hairgrow 区域生发 inpaint)→ ③ 严格按遮罩贴回 → ④ 接缝融合 `blend_method`(multiband 默认 / seamless / two_stage / feather / alpha_gradient,可选 Reinhard `color_match`)。返回 `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}`。
### 接口12 发际线带重绘 `/api/v1/hairline/grow_v2[_final[_v2]]`(worker)
- **做什么**:接口11 的下游重绘阶段——先跑接口11 拿 ④final,再取发际线重绘带(外推 `band_lo_mult×push`~`band_hi_mult×push`,默认 0.5×~1.5×)作遮罩,用 **Flux-2(ComfyUI,`0716add-hair-api.json`,reference-latent 保色)** 重绘,产出两版:`redraw_full`(整帧美颜+整帧重绘)与 `redraw_band`(band 内加发、band 外 final 结构 + `beauty_alpha` 融入全脸美颜)。
- **三个变体**:`grow_v2` 全参数暴露(接口11 参数 + `comfyui_prompt`/`beauty_alpha`/`band_lo_mult`/`band_hi_mult`);`grow_v2_final` / `grow_v2_final_v2` 精简生产版——仅图片 + `hairline_id`,其余固化 `_V2_FINAL_DEFAULTS`(push=0.8cm / two_stage / color_match=关 / beauty_alpha=0.6 等),两者后端计算完全一致(同一次输出含 A/B 两版),差异只是测试页展示哪版。接口2 女性 1..5 的生发图即复用此管线。
- ⚠️ 依赖 ComfyUI(:8188);重绘失败在 `data.redraw.c_error` 报告。
### 接口99 人脸特殊点 `/api/v1/face/special-points`(**网关本机**,worker 保留同路径备份)
- **做什么**:人脸照 → 60 个固定编号 MediaPipe 关键点(6 组:eyebrow/eye/mouth/nose_midline/cheek/face_edge)+ 5 个发际线特殊点,原图像素坐标 JSON(字段 `<组名>_<点ID>`,如 `eyebrow_107`)。
- **怎么实现**(`face_analysis/special_points.py`):MediaPipe 单例取关键点;发际线点=接口1 方案B 的 BiSeNet 头发 mask 上沿指定关键点方向(如 151→10)射线步进取首个头发像素,双射线的(right/right_right/left/left_left)取中点。**纯 CPU 可跑**(BiSeNet 单核 <1s),网关直接 import 本机实现(`_if99_lock` 单路排队);worker 侧保留同路径接口供测试页直连。部署依赖清单见 `网关待改动.md` §9。
- 测试页:`static/test_interface99.html`(canvas 可视化 + JSON,worker 本机直连)。
---
## 3. 部署 / 环境要点
**worker(GPU 机)**
- Python **3.12**(系统 3.13 无 mediapipe/torch wheel);venv 在 `./venv`,依赖 `requirements.txt`。
- `numpy<2`(1.26.4),`scikit-image==0.24.0`(**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
- ⚠️ 本机 **RTX 5090(sm_120)**,pinned `torch 2.2.2(cu121)` 只到 sm_90 → GPU 算子报 "no kernel image",
代码已自动**回退 CPU**(BiSeNet/SegFormer CPU 推理可用)。要用 5090 GPU 需换 torch cu128(≥2.7)。
**worker(GPU 机,本机 = gpu_worker 角色)**
- Python **3.10.12**(本机 venv 在 `./venv`);`numpy<2`(1.26.4),`scikit-image==0.24.0`
(**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
- 本机 **RTX 4090**:venv 实装 `torch 2.7.1+cu128`,CUDA 可用(BiSeNet/SegFormer GPU 推理)。
`requirements.txt` 里 pinned 的 `torch 2.2.2(cu121)` 及 5090 注释是旧机(sm_120 无 cu121 kernel、
自动回退 CPU)的记录,本机以 venv 实际版本为准。
- 模型权重/字体见 [`../OFFLINE_ASSETS.md`](../OFFLINE_ASSETS.md);BiSeNet/SegFormer/face_landmarker.task 本地。
- 生发接口依赖本机 **ComfyUI(8188)**(Flux-2,它自带支持 5090 的 torch);worker 只调其 HTTP API,不跑 Flux。
ComfyUI 开了 **HTTP Basic Auth**(user `admin` + 密码);密码放 `password.txt`(不入 git) /
`worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`,URL 用 `COMFYUI_URL`。
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + 鉴权头 `X-Internal-Token`。
- 生发接口依赖本机 **ComfyUI(8188)**(Flux-2,systemd `comfyui.service`,`--cache-classic --fast`);
worker 只调其 HTTP API,不跑 Flux。ComfyUI 开了 **HTTP Basic Auth**(user `admin` + 密码);
密码放 `password.txt`(不入 git) / `worker_config.json.comfyui_password` / 环境变量 `COMFYUI_PASSWORD`,
URL 用 `COMFYUI_URL`。默认模型 flux-2-klein-9b Q4_K_M GGUF(接口2 `flux_model` 参数可切)。
- 换发型依赖本机 **change_hair**(`/home/ubuntu/change_hair`,三进程 webui:57860(sdwebui) →
photo_service:32678(py310/kohya) → hair_service_sd:8801(my_hair),`start_all.sh start|stop|status`,
也有 systemd `change_hair-{webui,photo,hair}.service`)。不跑时接口2女1..5、接口11/12、
swapHair/hairColor 代理不可用。
- `worker_config.json`(不入 git):`accept_passwords`(鉴权) + `comfyui_password`;鉴权头 `X-Internal-Token`。
- systemd 单元:`hair-worker.service`(8187) / `comfyui.service`(8188),仓库根有副本;手动启停用
`./run_worker.sh` / `./stop_worker.sh`。
**网关机**
- FastAPI/uvicorn/httpx + **接口4 的 `volcengine-python-sdk[ark]`**(或直接 httpx 调,OpenAI 兼容)。
- ⚠️ 接口4 `face_shape` 改本机 MediaPipe 计算后,网关机**也需要装** `mediapipe`/`opencv-python`/`numpy<2`
(不再是"网关不装 torch/mediapipe/opencv",只是仍不需要 torch/transformers/scikit-image 等重依赖)。
- 配置 `gateway/config.json`(不入 git):`workers` 列表、`shared_password`、
- 很轻: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**)。
- 托管 `/static/annotations/`(落盘的图),永久保留,不自动清理。
可选 `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生发;临时网关验证后关闭。
+511 -20
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 }
]
}
}
}
@@ -267,8 +322,10 @@
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| 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` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比。仅对原生发(male / female 6/7)管线生效 |
| 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`=不缩图(原图直送,更慢) |
### 输出(data)
@@ -281,7 +338,8 @@
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8188)生成,**一次请求生成指定发型的 1 张、同步返回**;
> female 1..5 的生发图另依赖 change_hair 换发型服务(:8801)。
> worker 侧返回 `image_base64` / `grown_image_base64`,
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
>
@@ -307,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 端生发接口
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
@@ -352,7 +425,7 @@
## 接口 4:用户特征接口
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-2-0-lite-260428`)分析,输出一大批面部特征。
**说明**:输入用户照片,由**火山方舟 豆包视觉模型**(`doubao-seed-1-6-vision`)分析,输出一大批面部特征。
**请求**:`POST /api/v1/face/features`
@@ -362,7 +435,7 @@
### 输出(data)
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用),**仅含以下 6 个英文字段**:
`data.features` 是一个 **JSON 字符串**(不是对象,客户端 `JSON.parse()` 后用),**仅含以下 7 个英文字段**:
| 字段 | 说明 |
|------|------|
@@ -372,6 +445,7 @@
| dynamic_static_type | 动静类型(静态型/动态型)|
| gender | 性别(男/女)|
| gene_style | 基因风格(如"自然型")|
| contour_type | 轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)|
> 无人脸返回 `1001`(据 doubao「图片是否有人脸」判定)。⚠️ 本接口是唯一调**外网云模型**的接口,
> worker 需可访问 `ark.cn-beijing.volces.com`;API Key 走 worker 配置/环境变量。
@@ -384,12 +458,97 @@
"message": "success",
"request_id": "mock-request-id",
"data": {
"features": "{\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\"}"
"features": "{\"face_shape\":\"鹅蛋脸\",\"eyebrow_shape\":\"平眉\",\"facial_age\":\"18-25岁\",\"dynamic_static_type\":\"静态型\",\"gender\":\"女\",\"gene_style\":\"少年型\",\"contour_type\":\"轮廓适中\"}"
}
}
```
> `features` 为字符串形式的 JSON,固定上述 6 个字段。
> `features` 为字符串形式的 JSON,固定上述 7 个字段。
---
## 接口 72:B 端五个关键点
`POST /api/v1/face/b-end-points` 接受三选一图片参数 `image_file`、`image_url`、`image_base64`。复用接口 52 的扶正裁切与虹膜/眼宽厘米估算,返回坐标以处理后底图为准。使用[客户端 Bearer 鉴权](接口42-52-72网关鉴权.md)。
| 点号 | 位置 | 标注 |
|---|---|---|
| 1 | MediaPipe 4 | 白点 |
| 2 | HL center | 白点 |
| 3 | HL center 向画面右侧水平偏移 2 cm | 白点 |
| 4 | HL left | 白点 |
| 5 | HL left left | 白点 |
| 6 | MediaPipe 446 | 白点,兼作射线锚点 |
六个白点的直径相同。标注层包含三条从起点向目标方向延伸并逐渐变淡的白色虚线(2→1、4→6、5→6),以及 2→3 白色虚线段。2→1 射线比默认可见长度短 15%,5→6 射线的绘制长度比默认长度多 30%,可见范围以图像边界为限。四个距离文字分别放在 2–1 中点左侧、2–3 中点上方、4–6 中点左侧和 5–6 中点右侧,距离保留一位小数,2–3 固定显示 `2.0 CM`。
| `data` 字段 | 内容 |
|---|---|
| `input_image_url` | 扶正裁切后的底图 |
| `annotated_image_url` | 与底图同尺寸的透明 PNG 标注层 |
| `points` | 按字符串点号 `"1"` 到 `"6"` 索引的像素坐标 `{x, y}` |
| `measurements_cm` | 四个距离,以 `2-1`、`2-3`、`4-6`、`5-6` 索引 |
| `image_size` | 处理后尺寸 `{width, height}` |
| `coordinate_space` | `processed_image` |
| `px_per_cm` | 估算的每厘米像素数 |
Worker 直连时图片字段使用 `_base64` 后缀。检测不到人脸返回 `1001`,非正面或标注所需点位无法取得时返回 `1003`,图片无法解码返回 `1008`。
---
## 42号接口-加两个特征点
**请求** `POST /api/v1/face/features-42`,使用 `multipart/form-data`。
| 请求字段 | 类型 | 说明 |
|---|---|---|
| `image_file` | 文件 | 上传 JPG 或 PNG |
| `image_url` | 字符串 | 网关可访问的图片 URL |
| `image_base64` | 字符串 | 图片 Base64,支持带 `data:image/…;base64,` 前缀 |
以上三个字段必须且只能传一个。图片按 EXIF 方向解码后,先检查原图的人脸完整性和俯拍角度。俯拍使用头部姿态估算的正向 pitch,超过服务端环境变量 `FRONTAL_PITCH_THR`(默认 30°)时拦截。通过质检后复用接口 52 的扶正裁切规则,再用处理后的图片做面部特征分析。
客户端通过 Bearer 令牌鉴权,详见[客户端鉴权](接口42-52-72网关鉴权.md)。
### 质检失败与异常响应
以下业务响应的 HTTP 状态均为 `200`。
| `code` | 情况 | `message` |
|---|---|---|
| `1001` | 原图未检测到人脸 | 无法识别人像,请上传包含完整人脸的照片 |
| `1003` | 人脸关键点触及或超出图片边界 | 人脸不完整,请保留完整的脸部轮廓 |
| `1003` | 估算的俯拍角度超过阈值 | 俯拍角度过大,请将相机调整至与脸部平视 |
| `1003` | 扶正裁切失败,无法保留所需人脸关键点 | 人脸过于贴近边缘,无法扶正裁切 |
| `1008` | URL 图片下载失败 | 图片下载失败(URL 不可达) |
| `1008` | Base64 解码失败 | 图片格式不支持(base64 解码失败) |
| `1008` | 图片无法读取 | 图片格式不支持(仅 JPG / PNG) |
| `1007` | 图片字段未按三选一传入 | 图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个 |
| `1007` | 图片预处理内部异常 | 图片预处理失败 |
| `1001` | 质检通过,但视觉模型未识别到人脸 | 无法识别人像 |
| `1007` | 视觉模型调用异常 | 分析服务异常:… |
响应包含 `code`(整数业务码)、`message`(字符串提示)、`request_id`(字符串请求标识)和 `data`(失败时为 `null`)。三个 `1003` 情况可通过 `message` 区分,响应中没有独立的质检原因或角度字段。
```json
{
"code": 1003,
"message": "俯拍角度过大,请将相机调整至与脸部平视",
"request_id": "gw-xxxxxxxx",
"data": null
}
```
### 成功响应
`data.features` 仍为 JSON 字符串,保留 main 网关接口 4 的七项特征(含 `contour_type`),在其中新增:
| 字段 | 类型 | 说明 |
|------|------|------|
| `zygoma_expansion` | boolean 或 null | 颧弓外扩 |
| `temple_hollowing` | boolean 或 null | 太阳穴凹陷 |
Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只有 Ft ÷ Zy 严格小于 0.8 时两项同时为 `true`,否则同时为 `false`。所需点位缺失或无法测量时两项均为 `null`。网关测量复用接口 99 的 MediaPipe 与 BiSeNet 依赖,裁切还需要 Pillow。
---
@@ -446,7 +605,8 @@
| 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 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
| 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 }` |
@@ -515,16 +675,347 @@
---
## 接口 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:头发遮罩生成接口
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
**请求**:`POST /api/v1/head/mask`
### 输入
图片参数见「通用约定 → 图片传参字段」。专属参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| erode_cm | float | 否 | 外缘朝中心(鼻梁 151 点)内缩的距离(厘米),默认 1.2 |
### 处理流程
1. MediaPipe 关键点 → 底部分割线(关键点 21,68,104,69,108,151,337,299,333,298,251 的连线,21 水平延伸到最左、251 延伸到最右)。
2. 分割线以上为「上半区」。
3. 头发分割(**BiSeNet** 与 **SegFormer** 两套);从每列最顶端头发向下填充到分割线,得到**含额头**的闭合区域(头发+额头,不割断)。
4. 外缘朝中心点 151 内缩 `erode_cm`(虹膜标定换算像素)、底线不动 → 最终遮罩。
### 输出(data)
| 字段 | 类型 | 说明 |
|------|------|------|
| px_per_cm | float | 虹膜标定出的像素/厘米 |
| erode_cm / erode_px | float / int | 内缩距离(厘米 / 像素) |
| image_size | object | `{width, height}` 原图尺寸 |
| steps_common | object | `landmarks_baseline_*`(关键点/分割线)、`upper_region_*`(上半区) |
| bisenet / segformer | object | 两套分割模型各自的结果:`hair_mask_*`(原始头发分割)、`closed_region_*`(含额头闭合区域)、`final_overlay_*`(最终遮罩叠加可视化)、`mask_*`(最终遮罩 PNG)及 `hair_pixels`/`closed_pixels`/`mask_pixels` 像素计数 |
> worker 返回 `*_base64`(data URI),经网关改写为 `*_url`。
---
## 接口 10:头部外缘膨胀带遮罩接口
**说明**:先和接口9 一样得到内缩后的基准遮罩,再沿头部外缘生成一条膨胀带遮罩。
**请求**:`POST /api/v1/head/band`
### 输入
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| erode_cm | float | 否 | 基准遮罩外缘朝 151 内缩距离(厘米,同接口9),默认 1.2 |
| dilate_cm | float | 否 | 外轮廓线膨胀后带子总宽(厘米),默认 2.0 |
### 处理流程
1. 取基准遮罩的**外轮廓线**,去掉贴着底部分割线的那一段(只留头发/头部外缘弧线)。
2. 把外轮廓线膨胀成带子(总宽 `dilate_cm`;半径=总宽/2,虹膜标定换算像素)。
3. 裁到分割线以上(不越过底线)。
### 输出(data)
结构同接口9,但每组结果为 `base_mask_*`(基准遮罩)/ `contour_*`(外轮廓线)/ `band_overlay_*`(带子叠加可视化)/ `mask_*`(最终带状遮罩)及 `base_pixels`/`band_pixels` 计数,另有 `dilate_cm`/`dilate_radius_px`。
---
## 接口 11:发际线生发接口(不含重绘)
**说明**:输入一张**发际线较高 / 头发稀少**的正脸图 + 发际线类型 ID,输出同一个人、同一发型、按该类型压低发际线后的图片,并返回**每一步可视化**。重绘版见接口12。
**请求**:`POST /api/v1/hairline/grow`
### 输入
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| hairline_id | string | **是** | 发际线类型 ID(= change_hair `hair_id`):`chang_tuoyuan` / `chang_huaban` / `chang_xinxing` / `chang_zhixian` / `chang_bolang` |
| gen_backend | string | 否 | 生成后端:`swaphair`(换发型 LoRA,默认)|`hairgrow`(区域生发 inpaint) |
| hairgrow_strength | float | 否 | 区域生发强度(仅 hairgrow 后端),默认 0.75 |
| is_hr | bool | 否 | 高清模式(换发型输出 1152×1536,否则 576×768),默认 false |
| seg_model | string | 否 | 头发分割模型:`bisenet` | `segformer`(默认) |
| erode_cm | float | 否 | baseline 参考内缩距离(厘米),默认 0.6 |
| hairline_push_cm | float | 否 | 发际线外推距离(厘米,往头发方向推进),默认 1.0 |
| hairline_edge | string | 否 | 发际线提取方式:`column`(默认)|`contour` |
| swap_mode | string | 否 | 换发型取图模式:`ext_mask`(默认,把接口9 遮罩传给 webui 精确重绘)|`as_is`(用内部遮罩,贴回时再裁) |
| edge_erode_px | int | 否 | 贴图前遮罩内缩像素(防边缘露皮/光晕),默认 3 |
| denoising_strength | float | 否 | 换发型 webui 重绘强度(越大生发越激进),默认 0.6 |
| mb_levels | int | 否 | 多频段金字塔层数(2~6),默认 5 |
| blend_method | string | 否 | 接缝融合:`multiband`(默认)|`seamless`(泊松)|`two_stage`(泊松→多频段)|`feather`(高斯羽化)|`alpha_gradient`(距离渐变) |
| color_match | bool | 否 | 融合前 Reinhard 颜色迁移消除整体色差,默认 true |
| color_match_strength | float | 否 | 颜色迁移强度(0~1),默认 1.0 |
| mb_feather_px | int | 否 | 多频段最细层掩码轻羽化像素,默认 1 |
| transition_band_px | int | 否 | keep-region 过渡带边距(-1=自动按层数 2^n),默认 -1 |
| inpainting_fill | int | 否 | change_hair 服务端重绘填充:0=保留原图 | 1=噪声(默认)| 2=纯色 | 3=潜变量噪声 |
| mask_blur | int | 否 | change_hair 服务端遮罩边缘模糊像素,默认 11 |
| mask_dilate_scale | float | 否 | change_hair 服务端遮罩膨胀缩放(<1 收缩防越界),默认 1.0 |
### 输出(data)
`steps`:`input`(原图)/ `baseline`(底部分割线)/ `hair_mask`(头发分割)/ `mask_overlay`(遮罩叠加)/ `mask`(遮罩)/ `swap_raw`(换发型原始输出)/ `alpha`(融合权重)/ `final`(最终结果)各步图(`*_base64`,经网关改写为 `*_url`),另有 `timings_ms` 分步耗时。
> ⚠️ `swaphair` 后端依赖 change_hair(:8801)在跑;`hairgrow` 后端依赖 ComfyUI(:8188)。
---
## 接口 12:发际线带重绘接口
**说明**:接口11 的**下游重绘阶段**——内部先跑接口11 核心管线拿 ④ 接缝融合最终图(final),再取发际线重绘带(外推 `band_lo_mult×push` ~ `band_hi_mult×push` 之间、经 baseline 截断只留上部的带状区域)作遮罩,用 **Flux-2(ComfyUI,reference-latent 保色重绘)** 重绘。
**同时产出两版结果**:
- `redraw_full`:ComfyUI 整帧输出(全脸美颜 + 全脸重绘)。
- `redraw_band`:加发只在发际线带内、band 外保留 final 结构并按 `beauty_alpha` 融入全脸美颜。
**请求**:`POST /api/v1/hairline/grow_v2`
### 输入
接口11 的全部参数(见上表)再叠加:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 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 |
### 输出(data)
`steps`:`input` / `final`(重绘输入基底)/ `redraw_band_overlay`(重绘带可视化)/ `redraw_full`(A 整帧)/ `redraw_band`(B 局部+美颜)/ `redraw_c`(兼容旧字段,=redraw_full);重绘异常在 `redraw.c_error` 报告。
### 精简变体(生产推荐)
| 路径 | 说明 |
|------|------|
| `POST /api/v1/hairline/grow_v2_final` | 仅需图片 + `hairline_id`,其余参数固化为调优默认值(push=0.8cm / two_stage / color_match=关 等),**最终取整帧重绘 `redraw_full`** |
| `POST /api/v1/hairline/grow_v2_final_v2` | 参数同上,**最终取 B 局部加发+全脸美颜 `redraw_band`** |
> ⚠️ 依赖 ComfyUI(:8188)与 change_hair(:8801)均在跑。接口2 女性 1..5 的生发图即复用 `grow_v2_final` 管线。
---
## 接口 99:人脸特殊点检测接口
**说明**:输入一张人脸照片,返回一组**固定编号 MediaPipe Face Mesh 关键点**的像素坐标,外加 5 个**发际线特殊点**(射线与头发分割发际线的交点)。**生产路径:网关本机实现(纯 CPU,不转发 worker,同接口4 模式)**;worker 侧保留同路径接口供测试页直连。
**请求**:`POST /api/v1/face/special-points`
### 输入
图片参数见「通用约定 → 图片传参字段」,无专属参数。要求单人正面照(非正面返回 `1003`)。
### 输出(data)
`points`:字段名 = `<组英文名>_<点ID>`,值为原图像素坐标 `{x, y}`(int);发际线点不可得(秃顶/分割失败)时为 null。
| 组 | 字段前缀 | 点 ID |
|----|----------|-------|
| 眉毛 | `eyebrow` | 107 66 105 63 70 336 296 334 293 300 |
| 眼睛 | `eye` | 189 222 224 113 226 413 442 444 342 446 |
| 嘴 | `mouth` | 0 37 40 67 91 84 17 405 375 409 269 |
| 中线鼻 | `nose_midline` | 9 168 197 5 4 194 278 48 437 217 |
| 面颊 | `cheek` | 101 117 123 187 205 50 330 346 352 280 425 |
| 边线 | `face_edge` | 162 234 58 136 149 152 378 365 288 401 366 389 251 |
**发际线特殊点**(沿关键点 a→b 方向射线步进,首次进入 BiSeNet 头发 mask 处为交点;双射线取中点,仅一条命中返回该点):
| 字段 | 计算 |
|------|------|
| `hair_line_center` | 151→10 方向交点 |
| `hair_line_right` | mid(108→109 交点A, 69→67 交点B) |
| `hair_line_right_right` | mid(104→103 交点A, 68→54 交点B) |
| `hair_line_left` | mid(337→338 交点A, 299→297 交点B) |
| `hair_line_left_left` | mid(333→332 交点A, 298→284 交点B) |
另有 `image_size`(宽高)与 `hairline_source`(`segmentation`=发际线点已算出 / `unavailable`=分割不可用,发际线点为 null)。
---
## 汇总:输入输出一览
| 接口 | 输入 | 主要输出 |
|------|------|----------|
| 1 四庭七眼测量 | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
| 6 四庭七眼测量 v2 | 用户照片 | 同接口1,复刻实现 |
| 2 C 端生发 | 用户照片 | 生发后图片 + 指定发际线预览(单/多张) |
| 3 B 端生发 | 划线图片 | 最合适发际线图片 + 生发后图片 |
| 4 用户特征 | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格) |
| 5 发际线 PNG | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
| 接口 | 路径 | 输入 | 主要输出 |
|------|------|------|----------|
| 1 四庭七眼测量 | `/api/v1/face/measure` | 用户照片 | 标注 PNG(无人物)+ 四庭/七眼厘米数值与坐标 |
| 6 四庭七眼测量 v2 | `/api/v1/face/measure-v2` | 用户照片 | 同接口1,去顶庭变体 |
| 2 C 端生发 | `/api/v1/hair/grow` | 用户照片 + gender + hair_style(多选) | 每发型预览 PNG + 生发图(female 1..5=换发型+重绘,其余=原生生发) |
| 7 C 端生发 v2 | `/api/v1/hair/grow-v2` | 同接口2 | 同接口2(add_hair2 工作流,已弃用隐藏) |
| 3 B 端生发 | `/api/v1/hair/grow-b` | 划线图片 | 检测到的发际线 + 生发后图片 |
| 4 用户特征 | `/api/v1/face/features` | 用户照片 | 7 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格/轮廓类型),网关本机实现 |
| 5 发际线 PNG | `/api/v1/hairline/generate` | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
| 9 头发遮罩 | `/api/v1/head/mask` | 用户照片 | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
| 10 外缘膨胀带 | `/api/v1/head/band` | 用户照片 | 头部外缘带状遮罩 + 分步可视化 |
| 11 发际线生发 | `/api/v1/hairline/grow` | 用户照片 + hairline_id | 压低发际线图 + 全步骤可视化(不含重绘) |
| 12 发际线带重绘 | `/api/v1/hairline/grow_v2` | 同接口11 + 重绘带参数 | final 基底 + Flux-2 保色重绘(整帧/局部两版);精简版 `grow_v2_final[_v2]` 仅需图片+hairline_id |
| 99 人脸特殊点 | `/api/v1/face/special-points` | 用户照片 | 60 个固定编号关键点 + 5 个发际线特殊点像素坐标 JSON(**网关本机实现**,同接口4) |
> 另有 worker 本机工具路由(不在对外契约内):`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理 change_hair)、`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`。
---
@@ -534,7 +1025,7 @@
- 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。
- 所有坐标基准:**原图像素**。
- 接口 2:提供「美颜」开关 `beauty_enabled`。
- 接口 3:需同时上传原图,需返回发际线类型。
- 接口 3:仅传**划线图**(`marked_image_*` 三选一),需返回发际线类型(`custom`)。
- 接口 4:`data` 返回**一个 JSON 字符串**,字段不固定,可随时调整。
- 接口 5:发际线 PNG 数量 **N 不固定**,需按合适度**排序**。
- 英文字段命名由本文档约定(见各接口表格)。
@@ -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,不进业务请求日志。
+120 -1
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**(含透明)。
@@ -70,7 +80,7 @@ async def hair_grow_v2(request: Request):
|------|------|------|------|
| image_file / image_url / image_base64 | — | **三选一** | 用户正面照 |
| gender | string | **是** | `male` / `female` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female: 1–5,male: 1–4 |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`)。female / male 均为 1–7 |
| beauty_enabled | bool | 否 | 美颜开关(本期不生效) |
| use_mask | bool | 否 | 默认 `true`,`false` 跳过遮罩 |
| prompt | string | 否 | ComfyUI 提示词 |
@@ -135,6 +145,115 @@ async def face_measure_v2(request: Request):
---
## 7. ✅【已完成】接口9/10 头部遮罩(`/api/v1/head/mask`、`/api/v1/head/band`)
`gateway/app.py` 已新增两条盲转发路由(同接口6 的写法):
```python
@app.post("/api/v1/head/mask", tags=["人脸分析"])
@app.post("/api/v1/head/band", tags=["人脸分析"])
```
- 入参:图片三选一 + `erode_cm`(接口10 另有 `dilate_cm`)。
- 出参:`steps_common.*_base64` 与 `bisenet`/`segformer` 组内的 `*_base64` 字段——现有递归
`rewrite_base64_to_url` 已覆盖(嵌套 dict/数组),无需额外改动。
- 两个接口响应较慢(双分割模型跑两遍),注意 `request_timeout_seconds`。
---
## 8. 🔲【新增】接口11/12 发际线生发/带重绘(`/api/v1/hairline/grow*`)
worker 侧已上线 4 条路由,**网关尚无对应转发路由**(当前仅测试页直连 worker)。如需对外提供,参考接口9 加法:
```python
@app.post("/api/v1/hairline/grow", tags=["生发"]) # 接口11
@app.post("/api/v1/hairline/grow_v2", tags=["生发"]) # 接口12 全参数
@app.post("/api/v1/hairline/grow_v2_final", tags=["生发"]) # 接口12 final 精简版
@app.post("/api/v1/hairline/grow_v2_final_v2", tags=["生发"])
```
- 入参:图片三选一 + `hairline_id`(接口12 final 两变体仅需这两个)。接口11/12 全参数版还有
20+ 个调优 Form 参数,网关盲转发即可,无需逐个声明。
- 出参:`data.steps.*_base64`(input/final/redraw_full/redraw_band 等各步图)→ 现有递归改写覆盖。
- ⚠️ 依赖 worker 机的 change_hair(:8801) 与 ComfyUI(:8188) 都在跑;单请求 10s+,超时建议 ≥120s。
- 契约细节见 [`接口文档.md`](接口文档.md) 接口11/12 章节。
---
## 9. ✅【新增】接口99 人脸特殊点(`/api/v1/face/special-points`)— **网关本机实现,不转发 worker**
**背景**:接口99(固定编号 MediaPipe 关键点 6 组 60 点 + 5 个发际线特殊点,返回像素坐标 JSON)
**纯 CPU 可跑**(MediaPipe 本就 CPU;BiSeNet 分割单核实测 <1s)且调用频率低。
按约定:**不用 GPU、低频的接口默认网关本机实现**,不占 worker 队列
(worker 池 `per_worker_concurrency=1`,转发会排在生发任务后面等)。
**网关代码已在本仓库 `gateway/app.py` 改好**——与接口4 同款本机实现(显式 Form 参数 +
惰性导入 + `run_in_threadpool` + `gw-` request_id),直接 import 同仓库的
`face_analysis/special_points.detect_special_points`。**单路排队**:`_if99_lock` 全局互斥,
同一时刻只跑一路推理(限制 CPU 峰值 + 规避 MediaPipe 单例线程安全问题),请求 FIFO 排队。
### 网关机部署前检查
1. **Python 3.10~3.12**(mediapipe 0.10.14 无 3.13 wheel)。
2. **系统库**(无 GUI 服务器跑 opencv/mediapipe 必需,已装可跳过):
```bash
sudo apt install -y libgl1 libglib2.0-0 libegl1
```
3. **安装依赖**(网关 venv,国内镜像):
```bash
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple \
mediapipe==0.10.14 opencv-python==4.10.0.84 numpy==1.26.4
# 可选:BiSeNet 头发分割(发际线 5 点需要)。CPU 版 torch 仅 ~200MB:
pip install torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu
# 不装 torch 也能跑:60 个关键点正常返回,发际线 5 点为 null(hairline_source="unavailable")
# ⚠️ 网关无 GPU——务必用上面的 CPU 专用源装 torch;装默认 PyPI 版会带 2.5G 的 CUDA 包,浪费但也能跑(自动回退 CPU)
```
4. **权重已入 git**,`git clone/pull` 自动到位:`face_analysis/weights/79999_iter.pth`(51MB)。
无需手动下载任何模型文件。
5. **无 GPU 已实测**:`CUDA_VISIBLE_DEVICES=""` 模拟验证通过(70/70 点,与 GPU 结果一致);
CPU 推理 16 核 0.18s/次,网关机核少时约 0.5~2s/次,低频可接受。
### 部署与冒烟
```bash
cd /path/to/hair && git pull
sudo systemctl restart hair-gateway # 或网关实际服务名(uvicorn gateway.app:app)
# 冒烟(multipart 传图;经网关无需 X-Internal-Token)
curl -s -X POST http://127.0.0.1:8080/api/v1/face/special-points \
-F "image_file=@/tmp/face.jpg" | python3 -m json.tool | head -30
# 期望 code=0;data.points 含 eyebrow_107 等 60 个关键点 + hair_line_* 5 点
# 缺依赖时返回 code=1007 且 message 提示"网关依赖未安装"
```
### 注意
- **超时无需调大**:单路 CPU 推理 <1s;即使排队,低频下积压可忽略。排队本身无超时,
调用方按需自带客户端超时。
- **错误码透传契约**:1001(无人脸)/ 1003(非正面)/ 1007(参数/处理失败/缺依赖)/
1008(图片格式/下载失败),HTTP 一律 200。
- **worker 侧同路径接口保留**(`app.py`),供测试页 `static/test_interface99.html`
直连调试(需 token);生产路径是网关本机实现。
- 契约详见 [`接口文档.md`](接口文档.md) 接口99 章节。
---
## 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 段即可。**
+5 -13
View File
@@ -213,11 +213,9 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
buf = np.zeros((h, w, 4), dtype=np.uint8)
# 发际线弃用(hairline_discarded):保留头顶横线,去掉发际线横线,
# 也不标顶/上庭(缺发际线作边界,算不出)。横线 = 头顶/眉心/鼻翼下缘/下巴尖。
if getattr(measure_result, "hairline_discarded", False):
order = ["hair_top", "brow_center", "nose_bottom", "chin_tip"]
elif variant == "v6":
# 横线:v1 = 头顶/发际线/眉心/鼻翼下缘/下巴尖;v6 = 发际线/眉心/鼻翼下缘/下巴尖。
# (发际线贴近头顶时已在 MeasureResult 修正 vertical,此处始终正常画发际线。)
if variant == "v6":
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
else:
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
@@ -282,14 +280,8 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
draw.text((x, max(2, ys[i] - th - name_gap)), text, fill=LINE_COLOR, font=font)
# --- 3b. 左侧四庭:名 + 数值两行(无 cm)+ 竖向虚线双箭头 ---
# court_start:庭段在 order 里的起始索引。发际线弃用时 order 首位是头顶(无下界发际线,
# 顶/上庭不标),中庭从眉心开始 → 跳过 order[0]。
if getattr(measure_result, "hairline_discarded", False):
court_cm = [measure_result.middle_cm, measure_result.lower_cm]
court_name = ["中庭", "下庭"]
n_court = 2
court_start = 1
elif variant == "v6":
# court_start:庭段在 order 里的起始索引。v6 为三庭(无顶庭),其余四庭。
if variant == "v6":
court_cm = [measure_result.upper_cm, measure_result.middle_cm, measure_result.lower_cm]
court_name = ["上庭", "中庭", "下庭"]
n_court = 3
+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"],
+61 -82
View File
@@ -153,10 +153,12 @@ def pt_or_none(vertical, name):
class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
HAIRLINE_DISCARD_TOP_CM = 0.7
# 发际线修正阈值/固定值:发际线离头顶(顶庭)< 此值时判定分割不可靠,需修正发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮)→ 上庭改取
# 0.95×中庭反推发际线;若反推后越过头顶,则顶庭固定为 0.7cm(发际线钉在头顶下方
# 0.7cm),上庭取眉心→发际线剩余距离。修正后始终返回完整四庭,不再置 null。
HAIRLINE_CORRECT_TOP_CM = 0.7
UPPER_TO_MIDDLE_RATIO = 0.95 # 贴近头顶时,上庭 = 中庭 × 此比例
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
landmarks=None, image_width=None, image_height=None):
@@ -170,19 +172,32 @@ class MeasureResult:
self.w = image_width
self.h = image_height
# 各庭厘米
# 发际线修正:顶庭(头顶→发际线)< 阈值视为发际线贴近头顶、不可靠。
# 改写:上庭 = 0.95×中庭 → 由眉心反推发际线 y;若发际线越过头顶
# (hairline_y < hair_top_y),顶庭固定为 0.7cm(发际线钉在头顶下方 0.7cm)。
# 回写 vertical,使 to_response/标注图/v6 复用全部拿到修正后的发际线。
self.detected_top_cm = vertical["top_court_px"] / px_per_cm
self.hairline_corrected = self.detected_top_cm < self.HAIRLINE_CORRECT_TOP_CM
if self.hairline_corrected:
brow_x, brow_y = vertical["brow_center"]
hair_top_y = vertical["hair_top"][1]
new_upper_px = vertical["middle_court_px"] * self.UPPER_TO_MIDDLE_RATIO
hairline_y = brow_y - new_upper_px
if hairline_y < hair_top_y: # 反推发际线越过头顶 → 顶庭固定 0.7cm
hairline_y = hair_top_y + self.HAIRLINE_CORRECT_TOP_CM * px_per_cm
new_upper_px = brow_y - hairline_y
vertical["hairline"] = (brow_x, float(hairline_y))
vertical["upper_court_px"] = new_upper_px
vertical["top_court_px"] = hairline_y - hair_top_y
self.hairline_source = "corrected"
# 各庭厘米(修正后)
self.top_cm = vertical["top_court_px"] / px_per_cm
self.upper_cm = vertical["upper_court_px"] / px_per_cm
self.middle_cm = vertical["middle_court_px"] / px_per_cm
self.lower_cm = vertical["lower_court_px"] / px_per_cm
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
# 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
if self.hairline_discarded:
self.hairline_source = "discarded"
self.face_total_cm = self.middle_cm + self.lower_cm
else:
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
self.face_total_cm = (self.top_cm + self.upper_cm
+ self.middle_cm + self.lower_cm)
# 七眼厘米
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
@@ -190,77 +205,41 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
if self.hairline_discarded:
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": None,
"upper_court_cm": None,
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": None,
"upper_court": None,
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
},
# 四庭完整输出(发际线贴近头顶时已在 __init__ 修正 vertical,无 null 分支)。
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
"landmarks": {
"hair_top": None,
"hairline": None,
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
else:
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": pt_or_none(self.vertical, "hair_top"),
"hairline": pt_or_none(self.vertical, "hairline"),
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
},
"landmarks": {
"hair_top": pt_or_none(self.vertical, "hair_top"),
"hairline": pt_or_none(self.vertical, "hairline"),
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
# left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
if self.landmarks is not None and self.w and self.h:
+166
View File
@@ -0,0 +1,166 @@
"""接口99:人脸特殊点检测。
返回 MediaPipe Face Mesh 关键点中一组固定编号的像素坐标(英文组名_点ID 为字段名),
外加 5 个发际线特殊点:从指定关键点沿指定方向射线步进,与 BiSeNet 头发分割 mask
首次相交处即发际线点(参考接口1 方案B 的发际线定位方式,见 hair_segmenter.py)。
组与编号(需求方给定):
- eyebrow 107 66 105 63 70 336 296 334 293 300
- eye 189 222 224 113 226 413 442 444 342 446
- mouth 0 37 40 67 91 84 17 405 375 409 269
- nose_midline 9 168 197 5 4 194 278 48 437 217
- cheek 101 117 123 187 205 50 330 346 352 280 425
- face_edge 162 234 58 136 149 152 378 365 288 401 366 389 251
发际线点(字段名需求方给定;A/B 两射线时取中点,仅一条命中则返回该点,均未命中为 null):
- hair_line_center 151 → 10 方向与发际线交点
- hair_line_right mid(108→109 交点A, 69→67 交点B)
- hair_line_right_right mid(104→103 交点A, 68→54 交点B)
- hair_line_left mid(337→338 交点A, 299→297 交点B)
- hair_line_left_left mid(333→332 交点A, 298→284 交点B) # 与 right_right 镜像对称
"""
import math
import numpy as np
# 固定关键点组:组英文名 → MediaPipe 点 ID 列表(顺序即需求方给定顺序)
GROUPS = {
"eyebrow": [107, 66, 105, 63, 70, 336, 296, 334, 293, 300],
"eye": [189, 222, 224, 113, 226, 413, 442, 444, 342, 446],
"mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425],
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251],
}
# 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点,
# 双射线取两交点中点。
HAIRLINE_RAYS = {
"hair_line_center": [(151, 10)],
"hair_line_right": [(108, 109), (69, 67)],
"hair_line_right_right": [(104, 103), (68, 54)],
"hair_line_left": [(337, 338), (299, 297)],
"hair_line_left_left": [(333, 332), (298, 284)],
}
class NoFaceError(Exception):
"""未检出人脸。"""
class NonFrontalError(Exception):
"""非正面照(复用接口1 姿态阈值)。"""
def ray_hairline_intersection(p1, p2, hair_mask):
"""从 p1 沿 p1→p2 方向逐像素步进,返回首次进入头发 mask 的整数像素坐标。
p1/p2: (x, y) 像素坐标(float)。起点本身已在头发内时直接返回起点(已到发际线)。
步进直到出图或步数上限(3×图对角线,防极端死循环);未命中返回 None。
"""
mask = np.asarray(hair_mask).astype(bool)
if mask.size == 0:
return None
h, w = mask.shape
x0, y0 = float(p1[0]), float(p1[1])
dx, dy = float(p2[0]) - x0, float(p2[1]) - y0
length = math.hypot(dx, dy)
if length < 1e-6:
return None
ux, uy = dx / length, dy / length
max_steps = int(3 * math.hypot(h, w))
def _hit(x, y):
xi, yi = int(round(x)), int(round(y))
if 0 <= xi < w and 0 <= yi < h:
return mask[yi, xi]
return False
x, y = x0, y0
for _ in range(max_steps):
if _hit(x, y):
return int(round(x)), int(round(y))
x += ux
y += uy
if not (0 <= int(round(x)) < w and 0 <= int(round(y)) < h):
return None # 已步出图像,方向上无头发
return None
def detect_special_points(image_bgr, landmarks_override=None):
"""主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。
Returns: {"points": {...}, "image_size": {"width","height"},
"hairline_source": "segmentation"|"unavailable"}
无人脸抛 NoFaceError;非正面抛 NonFrontalError。
"""
from face_analysis.detector import detector
from face_analysis.calibration import normalized_to_pixel
from face_analysis.pose import check_frontal_face
h, w = image_bgr.shape[:2]
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):
raise NonFrontalError("非正面照")
lm = landmarks.landmark
px = {i: normalized_to_pixel(lm[i], w, h) for i in range(len(lm))}
points = {}
for group, ids in GROUPS.items():
for idx in ids:
x, y = px[idx]
points[f"{group}_{idx}"] = {"x": int(round(x)), "y": int(round(y))}
# 头发分割(与接口1 相同:按人脸框裁剪后再分割,脸外背景不干扰)
hair_mask = None
try:
from face_analysis.hair_segmenter import get_segmenter
face_box = (min(p[0] for p in px.values()), min(p[1] for p in px.values()),
max(p[0] for p in px.values()), max(p[1] for p in px.values()))
hair_mask, _ = get_segmenter().segment_hair_and_ears(
image_bgr, face_box=face_box)
except Exception: # noqa: BLE001 分割不可用 → 发际线点置 null,不阻塞关键点
hair_mask = None
for name, rays in HAIRLINE_RAYS.items():
hits = []
if hair_mask is not None:
for a, b in rays:
hits.append(ray_hairline_intersection(px[a], px[b], hair_mask))
found = [p for p in hits if p is not None]
if not found:
points[name] = None
elif len(found) == 1:
points[name] = {"x": found[0][0], "y": found[0][1]}
else:
points[name] = {
"x": int(round(sum(p[0] for p in found) / len(found))),
"y": int(round(sum(p[1] for p in found) / len(found))),
}
return {
"points": points,
"image_size": {"width": w, "height": h},
"hairline_source": "segmentation" if hair_mask is not None else "unavailable",
}
if __name__ == "__main__":
import sys
import cv2
import json
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
img = cv2.imread(path)
if img is None:
print(f"无法读取图片: {path}")
sys.exit(1)
try:
out = detect_special_points(img)
except (NoFaceError, NonFrontalError) as e:
print(f"检测失败: {e}")
sys.exit(1)
print(json.dumps(out, ensure_ascii=False, indent=2))
+15 -8
View File
@@ -1,6 +1,6 @@
"""接口4:用户面部特征分析。
- 眉形 / 面部年龄 / 动静类型 / 性别 / 基因风格:火山方舟豆包视觉模型
- 眉形 / 面部年龄 / 动静类型 / 性别 / 基因风格 / 轮廓类型:火山方舟豆包视觉模型
- face_shape(脸型):本机 MediaPipe 分类(face/face_shape_classifier.py)覆盖,
不用豆包结果
@@ -26,15 +26,20 @@ _KEY_MAP = {
"动静类型": "dynamic_static_type",
"性别": "gender",
"基因风格": "gene_style",
"轮廓类型": "contour_type",
}
# 豆包只问 5 项 + 是否有人脸;脸型由本地分类器给出
# 豆包只问 6 项 + 是否有人脸;脸型由本地分类器给出。
# 「必须包含全部键」:实测豆包会随机漏键(尤其面部年龄,历史 ~26% 缺失),
# 显式要求可显著降低漏键率。
_PROMPT = (
"分析一下图片告诉我以下特征,只要答案,格式为json字符串,"
"必须包含以下全部键且每键都要有值、缺一不可:"
"图片是否有人脸(有人/没人) "
"眉形 "
"面部年龄(给出区间年龄) 动静类型(静态型/动态型) 性别(男/女) "
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型)"
"面部年龄(给出区间年龄,如18-25岁) 动静类型(静态型/动态型) 性别(男/女) "
"基因风格(戏剧型/睿智型/自然型/古典型/优雅型/浪漫型/前卫型/少女型/少年型) "
"轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)"
)
_client = None # 缓存的 Ark client(api_key 变更时自动重建)
@@ -136,11 +141,11 @@ def _local_face_shape(image_bytes: bytes = None, image_url: str = None) -> str:
return shape
def analyze_features(image_bytes: bytes = None, image_url: str = None):
"""豆包分析 5 项特征 + 本机 MediaPipe 覆盖 face_shape。
def analyze_features(image_bytes: bytes = None, image_url: str = None, *, accept_age_alias=False):
"""豆包分析 6 项特征 + 本机 MediaPipe 覆盖 face_shape。
Returns: dict —— 6 个英文字段(face_shape/eyebrow_shape/facial_age/
dynamic_static_type/gender/gene_style);**无人脸返回 None**(调用方据此判 1001)。
Returns: dict —— 7 个英文字段(face_shape/eyebrow_shape/facial_age/
dynamic_static_type/gender/gene_style/contour_type);**无人脸返回 None**(调用方据此判 1001)。
"""
url = _image_to_url(image_bytes, image_url)
resp = get_client().chat.completions.create(
@@ -172,6 +177,8 @@ def analyze_features(image_bytes: bytes = None, image_url: str = None):
return None
# 豆包 5 项 + 本地脸型覆盖
feats = {en: raw[zh] for zh, en in _KEY_MAP.items() if zh in raw}
if accept_age_alias and "facial_age" not in feats and "面部年龄区间" in raw:
feats["facial_age"] = raw["面部年龄区间"]
try:
feats["face_shape"] = _local_face_shape(image_bytes, image_url)
except ValueError as e:
+305 -6
View File
@@ -4,6 +4,7 @@
不跑任何算法(无 torch/mediapipe/opencv 依赖)。
"""
import asyncio
import base64
import json
import logging
@@ -11,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,
@@ -64,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
@@ -89,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)
@@ -122,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),
@@ -134,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 {
@@ -145,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",
@@ -462,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端生发(已屏蔽)"""
@@ -492,7 +626,25 @@ async def face_features(
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带前缀)"),
):
"""接口4:用户特征分析 — 豆包 5 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
"""接口4:用户特征分析 — 豆包 6 项 + 本机 MediaPipe 脸型(face/),不经过 worker。"""
return await _face_features_response(request, image_file, image_url, image_base64)
@app.post("/api/v1/face/features-42", summary="42号接口:用户特征与两项判定", tags=["人脸分析"])
async def face_features_42(
request: Request,
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 Base64"),
auth_error=Depends(client_auth_error),
):
"""质检、扶正裁切后执行接口4分析,再加入颧弓外扩与太阳穴凹陷。"""
if auth_error is not None:
return auth_error
return await _face_features_response(request, image_file, image_url, image_base64, include_ratios=True)
async def _face_features_response(request, image_file, image_url, image_base64, *, include_ratios=False):
import uuid as _uuid
# 三选一校验
@@ -541,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={
@@ -560,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 工作流)"""
@@ -586,3 +809,79 @@ async def head_mask(request: Request):
async def head_band(request: Request):
"""接口10:头部外缘膨胀带遮罩 + 分步可视化"""
return await _proxy(request, "/api/v1/head/band")
# 接口99 单路排队:网关机推理全局互斥。调用频率低,无需并发——串行既限制 CPU 峰值
# (MediaPipe + BiSeNet 全 CPU 推理),也规避检测器单例的线程安全风险。排队无超时,
# 请求方按需自带客户端超时。
_if99_lock = asyncio.Lock()
@app.post("/api/v1/face/special-points", tags=["人脸分析"])
async def face_special_points(
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(需带前缀)"),
):
"""接口99:人脸特殊点检测 — 网关本机实现(纯 CPU:MediaPipe + BiSeNet CPU),不经过 worker。
与 worker 同仓库,直接 import face_analysis.special_points;权重已入 git,clone 即用。
依赖惰性导入:网关缺依赖时仅本接口报 1007,不影响其他路由。
"""
import uuid as _uuid
rid = f"gw-{_uuid.uuid4().hex[:8]}"
def _err(code, message):
return JSONResponse(status_code=200, content={
"code": code, "message": message, "request_id": rid, "data": None})
# 三选一校验
provided = [x for x in (image_file, image_url, image_base64) if x]
if len(provided) != 1:
return _err(1007, "图片参数错误:必须且只能传 image_file / image_url / image_base64 其中一个")
img_bytes = None
if image_file:
img_bytes = await image_file.read()
elif image_base64:
b64 = image_base64.split(",", 1)[1] if "," in image_base64 else image_base64
try:
img_bytes = base64.b64decode(b64)
except Exception:
return _err(1008, "图片格式不支持(base64 解码失败)")
else:
try:
import httpx
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as cli:
r = await cli.get(image_url)
r.raise_for_status()
img_bytes = r.content
except Exception:
return _err(1008, "图片下载失败(URL 不可达)")
try:
import cv2
import numpy as np # noqa: F401 (detect_special_points 依赖)
from face_analysis.special_points import (
detect_special_points, NoFaceError, NonFrontalError)
except ImportError as ex:
return _err(1007, f"网关依赖未安装(安装清单见 docs/网关待改动.md §9):{ex}")
image = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return _err(1008, "图片格式不支持(仅 JPG / PNG)")
from fastapi.concurrency import run_in_threadpool
try:
async with _if99_lock: # 单路排队:同一时刻只跑一路推理
data = await run_in_threadpool(detect_special_points, image)
except NoFaceError:
return _err(1001, "无法识别人像")
except NonFrontalError:
return _err(1003, "角度问题,请上传正面照")
except Exception as ex: # noqa: BLE001
logger.exception("接口99 处理异常")
return _err(1007, f"处理失败:{ex}")
return JSONResponse(status_code=200, content={
"code": 0, "message": "success", "request_id": rid, "data": data})
+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:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

+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;
})();
+225 -9
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,11 +78,13 @@
<div class="card-body">
<table>
<tr><td>协议</td><td>HTTPS</td></tr>
<tr><td>请求方式</td><td>全部 <span class="badge post">POST</span></td></tr>
<tr><td>请求方式</td><td>业务接口使用 POST。验证令牌使用 GET。</td></tr>
<tr><td>编码</td><td>UTF-8</td></tr>
<tr><td>Content-Type</td><td><code>multipart/form-data</code></td></tr>
<tr><td>Content-Type</td><td>传图使用 <code>multipart/form-data</code>,登录和刷新使用 <code>application/json</code></td></tr>
<tr><td>客户端鉴权</td><td>11、22、42、52、61、72 使用 <a class="link" href="#client-auth">Bearer 访问令牌</a>。</td></tr>
<tr><td>图片参数</td><td>三选一:<code>image_file</code>(文件上传)/ <code>image_url</code>(URL)/ <code>image_base64</code>(base64 + 前缀)</td></tr>
<tr><td>图片格式</td><td>JPG / PNG,单人正面照</td></tr>
<tr><td>请求体上限</td><td><strong>2MB</strong>(nginx 层限制)。超限直接返回 HTTP <strong>413</strong>,不会进入业务逻辑。建议图片文件 ≤1MB;<code>image_base64</code> 传图会膨胀约 33%,编码前原图建议 ≤1.4MB。手机直拍照片通常 3~8MB,<strong>上传前务必先压缩</strong>(如最长边缩到 1500px、JPEG 质量 80)。<code>image_url</code> 方式图片在远端,不受此限制</td></tr>
</table>
<p style="margin-top:14px;font-size:13px"><strong>统一响应结构</strong>:</p>
@@ -84,6 +97,51 @@
</div>
</div>
<div class="card" id="client-auth">
<h2>客户端鉴权</h2>
<div class="card-body">
<p>客户端发送 <code>Authorization: Bearer &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>
@@ -106,6 +164,7 @@
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
<tr><td><code>vertical_lines</code></td><td>object</td><td>标注图竖线坐标,与标注 PNG 同一套几何。原图像素:<code>y_top</code> / <code>y_bottom</code> 为竖线共用上下端(头顶之上到下巴之下,两端各伸出约短边的 3%,可能略超出图片);<code>lines</code> 从左到右,每项 <code>{ name, x }</code>。name:<code>left_head_edge</code>(人头最左,耳朵不可见则不出现,此时 eye1 为 null)、<code>left_cheek</code>、<code>left_eye_outer</code>、<code>left_eye_inner</code>、<code>right_eye_inner</code>、<code>right_eye_outer</code>、<code>right_cheek</code>、<code>right_head_edge</code>(人头最右,不可见则不出现,此时 eye7 为 null)</td></tr>
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
@@ -126,10 +185,22 @@ const { code, data } = await res.json();
// data.annotated_image_url → 标注 PNG
// data.face_total_height_cm → 全脸高度
// data.four_courts.ratios → { top_court: 0.25, ... }
// data.vertical_lines → { y_top, y_bottom, lines: [{ name, x }, ...] }
// 前端叠加显示:原图 + annotated_image_url(absolute 叠加)</pre>
</div>
</div>
<div class="card" id="if11">
<h2>11. 四庭七眼测量(鉴权) &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>
@@ -163,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>
@@ -190,22 +316,23 @@ const { code, data } = await res.json();
<div class="card" id="if4">
<h2>4. 用户特征分析 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/face/features</code></h2>
<div class="card-body">
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 6 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格)。</p>
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 7 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格/轮廓类型)。</p>
<p><strong>入参</strong>:image_file / image_url / image_base64 三选一。无其他参数。</p>
<p style="margin-top:12px"><strong>data 字段</strong></p>
<table>
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 6 个英文字段</strong></td></tr>
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 7 个英文字段</strong></td></tr>
</table>
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 6 个):</p>
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 7 个):</p>
<table>
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
<tr><td>facial_age</td><td>面部年龄区间</td><td>gender</td><td>性别</td></tr>
<tr><td>dynamic_static_type</td><td>动静类型</td><td>gene_style</td><td>基因风格</td></tr>
<tr><td>contour_type</td><td>轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)</td><td>—</td><td>—</td></tr>
</table>
<pre>const res = await fetch('https://hair.xiangsilian.com/api/v1/face/features', {
@@ -219,6 +346,26 @@ console.log(features.gene_style); // "自然型"</pre>
</div>
</div>
<div class="card" id="if42">
<h2>42. 用户特征与两项判定 <span class="badge post">POST</span> <code>/api/v1/face/features-42</code></h2>
<div class="card-body">
<p class="desc">接收一张照片。先检查人脸是否完整及俯拍角度,再扶正裁切,返回接口 4 的特征与两项判定。需要 <a href="#client-auth">访问令牌</a>。</p>
<pre>curl -sS "$BASE_URL/api/v1/face/features-42" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F 'image_file=@portrait.jpg'</pre>
<p><code>data.features</code> 是 JSON 字符串。成功后先执行 <code>JSON.parse(response.data.features)</code>,再读取以下字段:</p>
<table>
<tr><th>字段</th><th>说明</th></tr>
<tr><td><code>face_shape</code>、<code>eyebrow_shape</code>、<code>facial_age</code>、<code>dynamic_static_type</code>、<code>gender</code>、<code>gene_style</code>、<code>contour_type</code></td><td>沿用接口 4 的面部特征</td></tr>
<tr><td><code>zygoma_expansion</code></td><td>颧弓外扩</td></tr>
<tr><td><code>temple_hollowing</code></td><td>太阳穴凹陷</td></tr>
</table>
<p>新增字段均为 <code>true</code>(命中)、<code>false</code>(未命中)或 <code>null</code>(无法测量)。两项在 <code>Ft ÷ Zy &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>
@@ -251,6 +398,7 @@ console.log(features.gene_style); // "自然型"</pre>
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标,结构同接口1</td></tr>
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
@@ -261,6 +409,34 @@ console.log(features.gene_style); // "自然型"</pre>
</div>
</div>
<div class="card" id="if52">
<h2>52. 发际线新规则与裁切 &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>
@@ -286,12 +462,42 @@ console.log(features.gene_style); // "自然型"</pre>
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标。结构同接口1,但 <code>y_top</code> / <code>y_bottom</code> 为发际线到下巴尖(不伸出),<code>lines</code> 只有中间 6 条,无 <code>left_head_edge</code> / <code>right_head_edge</code></td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
</table>
</div>
</div>
<div class="card" id="if61">
<h2>61. 四庭七眼测量 v2(鉴权) &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>
@@ -306,9 +512,10 @@ console.log(features.gene_style); // "自然型"</pre>
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
<tr><td>1009</td><td>未授权</td><td>缺少或错误的 <code>X-Internal-Token</code>(<code>/api/*</code> 路径鉴权)</td></tr>
<tr><td>1009</td><td>未授权</td><td>HTTP 401。登录凭据错误,或所需的访问令牌缺失、无效或过期。按 <a href="#client-auth">鉴权流程</a> 处理。</td></tr>
</table>
<p style="font-size:12px;color:#94a3b8;margin-top:8px">注:1004 仍在使用(接口2/5 的 gender 校验);接口7(grow-v2)已弃用,请改用接口2。</p>
<p style="font-size:12px;color:#dc2626;margin-top:8px">先检查 HTTP 状态和 <code>Content-Type</code>,再解析 JSON。HTTP 200 仍需检查 <code>code</code>,只有 <code>0</code> 表示成功。HTTP 401 的 JSON 用于鉴权提示与刷新。上传超限可能返回 HTTP 413,网关异常可能返回 502/504,这些响应可能是 HTML。</p>
</div>
</div>
@@ -319,11 +526,20 @@ console.log(features.gene_style); // "自然型"</pre>
<table>
<tr><th>接口</th><th>测试页</th><th>功能</th></tr>
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
<tr><td>11. 四庭七眼(鉴权)</td><td><a href="/static/test_interface11.html" class="link">/static/test_interface11.html</a></td><td>登录、测试鉴权、刷新令牌、四庭七眼测量</td></tr>
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
<tr><td>22. 生发密度与发际线高度</td><td><a href="/static/test_interface22.html" class="link">/static/test_interface22.html</a></td><td>登录后生成,支持密度多选和发际线高度预览</td></tr>
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 7项面部特征 + 原始JSON</td></tr>
<tr><td>42. 用户特征与两项判定</td><td><a href="/static/test_interface42.html" class="link">/static/test_interface42.html</a></td><td>登录、测试鉴权、刷新令牌、上传照片</td></tr>
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
<tr><td>52. 发际线新规则</td><td><a href="/static/test_interface52.html" class="link">/static/test_interface52.html</a></td><td>登录、测试鉴权、刷新令牌、发际线预览与生发</td></tr>
<tr><td>72. B 端五个关键点</td><td><a href="/static/test_interface72.html" class="link">/static/test_interface72.html</a></td><td>登录、测试鉴权、刷新令牌、关键点和虚线标注</td></tr>
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
<tr><td>61. 四庭七眼 v2(鉴权)</td><td><a href="/static/test_interface61.html" class="link">/static/test_interface61.html</a></td><td>登录、测试鉴权、刷新令牌、三庭测量</td></tr>
<tr><td>9. 头发遮罩</td><td><a href="/static/test_interface9.html" class="link">/static/test_interface9.html</a></td><td>上传照片 → 头发遮罩 + 分步可视化</td></tr>
<tr><td>10. 外缘膨胀带</td><td><a href="/static/test_interface10.html" class="link">/static/test_interface10.html</a></td><td>上传照片 → 头部外缘带状遮罩 + 分步可视化</td></tr>
<tr><td>99. 人脸特殊点</td><td><a href="/static/test_interface99.html" class="link">/static/test_interface99.html</a></td><td>上传照片 → 60 关键点 + 5 发际线点坐标(<strong>测试大图请先压缩到 2MB 内</strong>,见通用约定)</td></tr>
</table>
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
完整 API 文档:<a href="/docs" class="link">/docs</a>(Swagger UI)
-2
View File
@@ -130,7 +130,6 @@
<script>
const API_BASE = window.location.origin;
const TOKEN = 'dev-shared-secret-2026';
const FEAT_LABELS = {
face_height: '脸高 (px)',
@@ -182,7 +181,6 @@ async function submitTest() {
try {
const r = await fetch(API_BASE + '/api/v1/debug/face-shape', {
method: 'POST',
headers: { 'X-Internal-Token': TOKEN },
body: fd,
});
const json = await r.json();
+1 -1
View File
@@ -150,7 +150,7 @@ async function submitTest() {
form.append('image_file', file);
try {
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form });
const json = await resp.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
+1 -1
View File
@@ -214,7 +214,7 @@ async function submitTest() {
form.append('dilate_cm', $('dilateCm').value || '2.0');
try {
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
+53 -390
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="dev-shared-secret-2026"
style="flex:1; min-width:200px; padding:8px; border:1px solid #ddd; border-radius:8px">
<div class="upload-hint">
支持 JPG / PNG 正面照 &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>
+1 -1
View File
@@ -70,7 +70,7 @@
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
<input type="text" id="token" value="dev-shared-secret-2026" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
<input type="text" id="token" value="" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="params">
<div class="pf">
+3 -3
View File
@@ -69,11 +69,11 @@
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
<input type="text" id="token" value="dev-shared-secret-2026" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
<input type="text" id="token" value="" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="params">
<div class="pf">
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,人物增加一点磨皮';
const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
flog('===== 调后端重绘 =====', 'info');
+2 -4
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">
@@ -92,7 +92,6 @@
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/hairline/grow_v2_final';
const TOKEN = 'dev-shared-secret-2026';
// 缓存最近一次后端返回的 final(data URI)和纯红遮罩(data URI),供重绘使用
let _finalDataUri = '';
@@ -145,7 +144,6 @@ async function submitTest() {
try {
const resp = await fetch(API_BASE + ENDPOINT, {
method: 'POST',
headers: { 'X-Internal-Token': TOKEN },
body: form,
});
const json = await resp.json();
@@ -169,7 +167,7 @@ async function submitTest() {
async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,人物增加一点磨皮';
const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
-2
View File
@@ -85,7 +85,6 @@
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/hairline/grow_v2_final_v2';
const TOKEN = 'dev-shared-secret-2026';
function $(id) { return document.getElementById(id); }
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
@@ -118,7 +117,6 @@ async function submitTest() {
try {
const resp = await fetch(API_BASE + ENDPOINT, {
method: 'POST',
headers: { 'X-Internal-Token': TOKEN },
body: form,
});
const json = await resp.json();
+2 -2
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>
@@ -157,7 +157,7 @@ async function submitTest() {
fd.append('prompt', $('promptInput').value);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', headers:{ 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
+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>
+2 -2
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>
@@ -162,7 +162,7 @@ async function submitTest() {
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
+3 -3
View File
@@ -112,10 +112,10 @@
<script>
const API_BASE = window.location.origin;
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style'];
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style','contour_type'];
const EN_LABELS = {
face_shape:'脸型', eyebrow_shape:'眉形', facial_age:'面部年龄',
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格'
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格', contour_type:'轮廓类型'
};
function $(id) { return document.getElementById(id); }
@@ -133,7 +133,7 @@ async function submitTest() {
const fd = new FormData(); fd.append('image_file', f);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);

Some files were not shown because too many files have changed in this diff Show More