Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e842e993e | ||
|
|
49cee683ad | ||
|
|
a63a0565d8 | ||
|
|
6d003dbf80 | ||
|
|
5eca74c9c0 | ||
|
|
c7add84d91 | ||
|
|
51baf96d78 | ||
|
|
dc53a0ecfe | ||
|
|
5f569caa5d | ||
|
|
14ec4964a0 | ||
|
|
544c2a83d6 | ||
|
|
44a6082938 | ||
|
|
3350e0ad77 | ||
|
|
6f46fc0ccd | ||
|
|
12a825315a | ||
|
|
f1b8b7bd82 | ||
|
|
6b24bf0b0d | ||
|
|
88fee6474d | ||
|
|
a0f53ad6ad | ||
|
|
81101715c8 | ||
|
|
b031ce5bbe | ||
|
|
ebcaff1295 | ||
|
|
d242e0fc48 | ||
|
|
aadab922a9 | ||
|
|
8b7bfe2023 | ||
|
|
63bd265386 | ||
|
|
989f4d0d18 | ||
|
|
8aeca2eb2b | ||
|
|
1dc82149b4 |
@@ -29,7 +29,7 @@
|
||||
"60": {
|
||||
"class_type": "JjkText",
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"image": "placeholder.png"}},
|
||||
|
||||
"60": {"class_type": "JjkText", "inputs": {
|
||||
"text": "填充遮罩区域的头发,皮肤加一点磨皮"}},
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"}},
|
||||
"22": {"class_type": "CLIPTextEncode", "inputs": {
|
||||
"clip": ["61", 0],
|
||||
"text": ["60", 0]}},
|
||||
|
||||
@@ -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` — 离线模型权重/字体清单
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
@@ -684,7 +684,7 @@
|
||||
},
|
||||
"87": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发",
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮",
|
||||
"clip": [
|
||||
"78",
|
||||
0
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -58,7 +58,7 @@ def call_iface2(image_path, hair_style):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
body, boundary = _multipart(
|
||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
|
||||
@@ -56,7 +56,7 @@ def call_iface2_female(image_path, hair_style):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
body, boundary = _multipart(
|
||||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
|
||||
@@ -89,7 +89,7 @@ def call_api2(image_path, gender, hair_style="1"):
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"gender": gender, "hair_style": hair_style, "use_mask": "0",
|
||||
"prompt": "填充遮罩区域的头发"}
|
||||
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
body, boundary = _multipart(fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
@@ -107,7 +107,7 @@ def call_api3(image_path):
|
||||
"""接口3:B端生发(use_mask=False,直接送图)"""
|
||||
with open(image_path, "rb") as f:
|
||||
img_data = f.read()
|
||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发"}
|
||||
fields = {"use_mask": "true", "prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
|
||||
body, boundary = _multipart(fields, {"marked_image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow-b", data=body, method="POST")
|
||||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026-09-19~09-20 接口2 空图失败统计
|
||||
|
||||
- **统计窗口**:2026-09-19 00:00:00 ~ 2026-09-20 23:59:59(北京时间)
|
||||
- **生成时间**:2026-09-21 10:45
|
||||
|
||||
## 1. 要抓的失败
|
||||
|
||||
接口2(`POST /api/v1/hair/grow`)在 GPU worker 内部出错时,仍返回 HTTP 200、`code=0`,只把 `grown_image_*` 置空。只看业务码会当成成功。
|
||||
|
||||
| 判定 | 条件 |
|
||||
|------|------|
|
||||
| **真成功(出图)** | `code=0` 且至少一张生发图非空 |
|
||||
| **空图失败** | `code=0` 且有 `results`,但生发图全空 |
|
||||
| **业务码失败** | `code≠0` |
|
||||
|
||||
抓取:jsonl 里看 `results[].grown_image_url|base64`;9/19 18:53 之前 jsonl 已轮转,用 journal 的 `base64→URL: grown_image_base64` 与 `POST /hair/grow` 次数对照(白天每小时 rewrite ≥ 请求数,说明在出图)。
|
||||
|
||||
空图耗时中位约 **2.2~2.5s**;正常出图中位约 **16~23s**。
|
||||
|
||||
## 2. 两天接口2 合计
|
||||
|
||||
| 类别 | 9/19 | 9/20 | 两天合计 |
|
||||
|------|------|------|----------|
|
||||
| 调用量 | 4,958 | 3,352 | **8,310** |
|
||||
| 真成功(有生发图) | 1,968 | 701 | **2,669** |
|
||||
| **空图失败** | **2,988** | **2,649** | **5,637** |
|
||||
| 业务码失败 | 2 | 2 | 4 |
|
||||
| 按业务码成功率 | 99.96% | 99.94% | 99.95% |
|
||||
| **有效出图成功率** | **39.7%** | **20.9%** | **32.1%** |
|
||||
|
||||
9/19 调用量来自 journal(全日 4,958);空图 2,988 全部落在 jsonl 仍在的 18:53 之后,白天 journal 显示仍在出图,故空图数可视为全日完整。9/20 全日 jsonl。
|
||||
|
||||
把空图算失败后:两天有效成功 **2,669**,失败 **5,641**(空图 5,637 + 业务码 4)。
|
||||
|
||||
## 3. 连续故障段
|
||||
|
||||
不是两天各坏一段时间,而是**一段连续故障**:
|
||||
|
||||
| 节点 | 时间 |
|
||||
|------|------|
|
||||
| 9/19 白天~22:20 | 正常出图 |
|
||||
| 9/19 **22:30** | 开始大量空图(该 10 分钟 165 次里 19 张有图、146 张空) |
|
||||
| 9/19 **22:34:49** | 故障前最后一张生发图 |
|
||||
| 9/19 22:40 ~ 9/20 13:36 | **全部空图** |
|
||||
| 9/20 **13:36:54** | 恢复后第一张生发图 |
|
||||
| 9/20 13:40 以后 | 基本正常(偶发单次空图) |
|
||||
|
||||
故障段内空图:
|
||||
|
||||
| 时段 | 空图次数 |
|
||||
|------|----------|
|
||||
| 9/19 22:30–22:39 | 146(另 19 次仍出图,过渡) |
|
||||
| 9/19 22:40–24:00 | 2,835 |
|
||||
| 9/20 00:00–13:36 | 2,625 |
|
||||
| **连续故障空图合计** | **约 5,606** |
|
||||
|
||||
其中 9/20 00:00–00:50 有 2,127 次,来源 IP `47.93.63.201`(客户侧重试/批量)。
|
||||
|
||||
## 4. 按小时(接口2)
|
||||
|
||||
### 9/19
|
||||
|
||||
| 小时 | 调用 | 出图 | 空图 | 状态 |
|
||||
|------|------|------|------|------|
|
||||
| 00–21 | 1,840 | ≈1,832 | 7(零星) | 正常(journal + 18:53 后 jsonl) |
|
||||
| 22 | 849 | 136 | 713 | 22:30 起故障 |
|
||||
| 23 | 2,269 | 0 | 2,269 | 故障 |
|
||||
|
||||
18–21 点 jsonl 可核验:854 次出图、仅 7 次零星空图。
|
||||
|
||||
### 9/20
|
||||
|
||||
| 小时 | 调用 | 出图 | 空图 | 状态 |
|
||||
|------|------|------|------|------|
|
||||
| 00 | 2,127 | 0 | 2,127 | 故障 |
|
||||
| 01–12 | 417 | 0 | 417 | 故障 |
|
||||
| 13 | 125 | 32 | 93 | 13:36 恢复 |
|
||||
| 14–18 | 354 | 354 | 0 | 正常 |
|
||||
| 19–20 | 177 | 165 | 12 | 正常,少量单次空图 |
|
||||
| 21–23 | 152 | 150 | 0 | 正常(另 2 次业务码失败) |
|
||||
|
||||
## 5. 9/20 其它接口(业务码,供对照)
|
||||
|
||||
| 接口 | 调用量 | `code=0` | 业务码失败 |
|
||||
|------|--------|----------|------------|
|
||||
| 接口1 四庭七眼 | 380 | 361 | 19 |
|
||||
| 接口4 特征 | 347 | 345 | 2 |
|
||||
| 接口99 特殊点 | 344 | 344 | 0 |
|
||||
| 接口5 发际线 | 248 | 248 | 0(但 247 次无生发图,全天) |
|
||||
| 接口6 测量 v2 | 105 | 75 | 30 |
|
||||
|
||||
9/20 按业务码成功率 98.89%,同样会漏掉接口2 空图。接口5 当天几乎也无生发图,主结论仍以接口2 为准。
|
||||
|
||||
## 6. 结论
|
||||
|
||||
1. 故障从 **9/19 22:30** 开始,到 **9/20 13:36** 结束,约 **15 小时**。
|
||||
2. 两天空图失败 **5,637** 次(9/19 的 2,988 + 9/20 的 2,649),占两天接口2 的 **67.8%**。
|
||||
3. 有效出图 **2,669** 次,有效成功率 **32.1%**;只看 `code=0` 则是 99.95%。
|
||||
4. 9/19 白天(22:30 前)journal 显示仍在落盘生发图,空图集中在当晚 22:30 之后并跨到次日下午。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 2026-09-20 调用成败统计(含接口2「秒回无图」)
|
||||
|
||||
本文已并入两天统计,请看:
|
||||
|
||||
**[20260919-20_接口2空图失败统计.md](20260919-20_接口2空图失败统计.md)**
|
||||
|
||||
覆盖 9/19~9/20。故障从 **9/19 22:30** 持续到 **9/20 13:36**,两天空图失败合计 **5,637** 次。
|
||||
@@ -7,8 +7,13 @@
|
||||
|------|------|
|
||||
| [实现说明.md](实现说明.md) | **实现总览**:架构、五个接口怎么实现、base64→URL 映射、错误码、部署/环境要点。先读这份。 |
|
||||
| [接口文档.md](接口文档.md) | 对外 API 契约(字段 / 错误码)。字段命名的唯一权威。 |
|
||||
| [客户端网关鉴权](接口42-52-72网关鉴权.md) | 登录、过期、刷新、令牌签名配置与测试页。 |
|
||||
| [接口52网关接入.md](接口52网关接入.md) | 52号请求透传、图片URL与空字段约定、公开测试页及网关部署步骤。 |
|
||||
| [旷视具体需求.md](旷视具体需求.md) | 原始需求。 |
|
||||
| [../OFFLINE_ASSETS.md](../OFFLINE_ASSETS.md) | worker 离线模型权重/字体清单(内网部署)。 |
|
||||
| [最近一周调用统计_20260915-20260921.md](最近一周调用统计_20260915-20260921.md) | 2026-09-15~09-21 网关调用量、接口构成、成功率。 |
|
||||
| [20260919-20_接口2空图失败统计.md](20260919-20_接口2空图失败统计.md) | 9/19~9/20 接口2 `code=0` 但无生发图(GPU 内部错误,跨午夜连续故障)。 |
|
||||
| [监控探活接口.md](监控探活接口.md) | 外部监控用的 `GET /monitor`:有在线 worker 返回 200,全部下线返回 503。 |
|
||||
|
||||
> 原先分散的「各接口技术方案 / 开发任务书 / 系统架构 / 网关任务书」已合并进 `实现说明.md`
|
||||
> (细节可查 git 历史)。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
Handler 签名新增:
|
||||
|
||||
```python
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default=填充遮罩区域的头发,人物增加一点磨皮, description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
```
|
||||
|
||||
透传链路:
|
||||
@@ -31,7 +31,7 @@ handler → generate_grow_results(image, gender, use_mask, prompt)
|
||||
Handler 签名新增:
|
||||
|
||||
```python
|
||||
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
prompt: str = Form(default=填充遮罩区域的头发,人物增加一点磨皮, description="ComfyUI 提示词,会替换工作流节点60的文本"),
|
||||
```
|
||||
|
||||
透传链路:
|
||||
@@ -80,17 +80,17 @@ if prompt is not None:
|
||||
| 2 | `image_base64` | string | — | 同上 |
|
||||
| 2 | `gender` | string | `female` | `male` / `female` |
|
||||
| 2 | `use_mask` | bool | `true` | inpaint 遮罩开关 |
|
||||
| 2 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
|
||||
| 2 | `prompt` | string | 填充遮罩区域的头发,人物增加一点磨皮 | ComfyUI 提示词 |
|
||||
| 3 | `marked_image_file` | file | — | 划线图(三选一) |
|
||||
| 3 | `marked_image_url` | string | — | 同上 |
|
||||
| 3 | `marked_image_base64` | string | — | 同上 |
|
||||
| 3 | `use_mask` | bool | `true` | 画发际线开关 |
|
||||
| 3 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
|
||||
| 3 | `prompt` | string | 填充遮罩区域的头发,人物增加一点磨皮 | ComfyUI 提示词 |
|
||||
|
||||
## 3. 当前工作流提示词节点
|
||||
|
||||
`add_hair.json` 中节点 **60**(`JjkText`)为提示词节点,当前硬编码文本:
|
||||
|
||||
> 补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头
|
||||
> 填充遮罩区域的头发,人物增加一点磨皮
|
||||
|
||||
`comfyui.run()` 收到 `prompt` 参数后将替换此文本。
|
||||
|
||||
@@ -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/`(落盘的图),定期清理。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 客户端网关鉴权
|
||||
|
||||
| 接口 | 方法与路径 | 执行位置 |
|
||||
|---|---|---|
|
||||
| 11 | `POST /api/v1/face/measure-11` | 网关转发至 worker 的接口 1,沿用四庭七眼测量及标注图 |
|
||||
| 22 | `POST /api/v1/hair/grow-22` | 网关转发至 Worker,发际线预览和生发图转成 URL |
|
||||
| 42 | `POST /api/v1/face/features-42` | 网关按接口 52 的规则扶正裁切,再用接口 4 的分析结果和 Ft/Zy 判定生成特征 |
|
||||
| 52 | `POST /api/v1/hairline/generate-52` | 网关转发至 worker |
|
||||
| 61 | `POST /api/v1/face/measure-61` | 网关转发至 worker 的接口 6,沿用去顶庭的测量及标注图 |
|
||||
| 72 | `POST /api/v1/face/b-end-points` | 网关转发至 worker,底图与标注层转成 URL |
|
||||
|
||||
客户端调用这些接口时,在原请求上添加 `Authorization: Bearer <access_token>`。缺少、过期、签名无效或误用刷新令牌时,返回 HTTP 401 和业务码 `1009`。网关验证通过后,11、22、52、61、72 通过现有 worker 池转发。11 和 61 沿用 1 和 6 的图片参数、响应字段及标注图 URL,Bearer 令牌只在网关校验。
|
||||
|
||||
## 登录
|
||||
|
||||
`POST /api/v1/auth/login`,请求体为 JSON,包含 `username` 和 `password`。当前用户名为 `yonghe`,密码使用约定的服务端固定值。
|
||||
|
||||
成功时 HTTP 200、`code=0`,`data` 包含:
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| `access_token` | 访问令牌 |
|
||||
| `token_type` | `Bearer` |
|
||||
| `expires_in` | 900 秒 |
|
||||
| `refresh_token` | 刷新令牌 |
|
||||
| `refresh_expires_in` | 604800 秒,即 7 天 |
|
||||
|
||||
## 刷新
|
||||
|
||||
`POST /api/v1/auth/refresh`,请求体为 `{"refresh_token":"登录返回的刷新令牌"}`。成功后返回新的 `access_token`、`token_type` 和 `expires_in`。登录时的刷新令牌可以在原有效期内继续使用。到期后重新登录。登录或刷新失败时,响应包含 `code`、`message`、`request_id` 和 `data: null`。
|
||||
|
||||
## 网关配置
|
||||
|
||||
在 `main` 的网关环境中设置 `HAIR_API_TOKEN_SECRET`,或在未跟踪的 `gateway/config.json` 中设置 `api_token_secret`。使用独立且稳定的至少 32 字节随机值。签名密钥缺失时,签发或刷新返回 HTTP 503 和业务码 `1007`。更换签名密钥会使已签发令牌失效。
|
||||
|
||||
网关保留原有 Ark、MediaPipe 与 BiSeNet 配置,42 的裁切还使用 Pillow。部署 72 前,确认所有可接收该请求的 worker 已部署含 72 号的版本(例如 `ubuntu3090_debug` 的 `c9a47a7` 或后续版本)。52、72 沿用现有 worker 池与图片 URL 改写流程。
|
||||
|
||||
部署接口 22 前,确认 Worker 池的主备节点均包含接口 22(例如 `ubuntu3090_debug` 上的 `b84a506` 或后续版本)。Worker 启用 IP 白名单时,配置网关直连地址。
|
||||
|
||||
网关测试页为 `/static/test_interface11.html`、`/static/test_interface22.html`、`/static/test_interface42.html`、`/static/test_interface52.html`、`/static/test_interface61.html` 和 `/static/test_interface72.html`。页面可登录、手动刷新令牌并提交图片,访问令牌到期时自动刷新。「测试鉴权」调用 `GET /api/v1/auth/check`,验证无令牌请求返回 HTTP 401、有效令牌请求返回 HTTP 200。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 接口52:网关接入与部署
|
||||
|
||||
`main`用于网关部署,本次只加入网关调用52号所需的路由、公开测试页及契约。GPU worker继续使用已部署的`ubuntu3090_debug`版本(`1c51f3e`及后续兼容版本),负责算法和本机IP限制;本次不把worker的算法、启动参数或IP中间件合并到网关。
|
||||
|
||||
## 调用流程
|
||||
|
||||
客户端 → `POST /api/v1/hairline/generate-52` → 网关现有队列和worker池 → worker同路径 → 网关落盘非空图片并返回公网URL。
|
||||
|
||||
- 请求体原样转发,网关附加配置中的`X-Internal-Token`;客户端和公开测试页不携带内部密码。
|
||||
- 客户端须登录取得访问令牌,通过 `Authorization: Bearer <access_token>` 发送。网关鉴权后进入队列。
|
||||
- 沿用现有主备派发、隔离期、重试、日志和全局并发限制。原有5号插队优先级保持原样,52号走默认队列。
|
||||
- 5号`/api/v1/hairline/generate`和其他接口保持原样;网关不新增IP限制。
|
||||
- worker的中下庭规则、扶正、3:4裁切和生发结果由同一次worker响应提供,网关不重新计算坐标。
|
||||
|
||||
## 请求
|
||||
|
||||
缺少、无效或过期的访问令牌返回 HTTP 401、`code=1009`。登录、刷新与部署配置见[客户端鉴权](接口42-52-72网关鉴权.md)。
|
||||
|
||||
|
||||
|
||||
使用`multipart/form-data`;图片三选一:`image_file`、`image_url`、`image_base64`。必填`gender=male/female`和`hair_style=1..7`(支持逗号分隔多选)。
|
||||
|
||||
可选参数全部透传:
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| normalize_image | false | true先扶正9→152并按当前规则裁切;网关测试页主动开启 |
|
||||
| generate_grow_image | true | false跳过ComfyUI,快速检查曲线和坐标 |
|
||||
| use_mask | true | 是否使用生发遮罩 |
|
||||
| prompt | 填充遮罩区域的头发,人物增加一点磨皮 | 生发提示词 |
|
||||
|
||||
当前标准裁切高约`(3÷0.9)L`、宽`2.5L`,L为9→152像素距离;标准窗口不足时长图尝试保留人脸裁成3:4,其余仅扶正。具体状态以`framing`为准。
|
||||
|
||||
## 响应及空图片
|
||||
|
||||
worker始终返回`*_base64`。网关沿用原有公共改写,不添加52号专用的空字段改名规则:
|
||||
|
||||
| worker字段 | 网关字段 |
|
||||
|---|---|
|
||||
| image_middle_base64(非空PNG) | image_middle_url |
|
||||
| input_image_base64、original_image_base64(非空JPEG) | input_image_url、original_image_url |
|
||||
| grown_image_base64(非空JPEG) | grown_image_url |
|
||||
| image_high_base64、image_low_base64(null) | 原字段保留,值为null |
|
||||
| grown_image_base64(null) | 原字段保留,值为null;没有grown_image_url |
|
||||
|
||||
`height_rule`、每个样式的`height_placement`、`guide_landmarks`、`face_measure`、`framing`、`width/height`和中心点原样返回。开启预处理时`coordinate_space=processed_image`,middle必须叠加到`input_image_url`,原始图`original_image_url`仅供对照。
|
||||
|
||||
公开测试页:`/static/test_interface52.html`,按原图、middle、矩形裁切发际线叠加、生发图排列。矩形左/右边过162/389,底边过9,高为9→152的70%。worker的30案例调试接口和对比报告不在本次网关同步范围。
|
||||
|
||||
## 部署步骤
|
||||
|
||||
1. 网关拉取`main`,保留原有`gateway/config.json`。
|
||||
2. 确保池中所有可能被选中的主/备worker都已提供`/api/v1/hairline/generate-52`;仅更新网关无法让旧worker获得52号功能。网关出口IP须在对应worker白名单内,当前GPU worker已允许`49.232.51.171`。无需在网关配置IP白名单。
|
||||
3. 重启网关服务(本仓库示例名`hair-gateway`)。若线上使用`nginx/hair-gateway.conf`,同步其中的读取超时并检查、重载Nginx。单张高分辨率生发已观察到超过100秒,本次示例从90秒改为660秒,覆盖默认600秒上游单次请求超时;多样式或重试时按总耗时另行调整。
|
||||
4. 先以`generate_grow_image=false`检查请求转发和URL,再在实际需要时验证生发。`/monitor`、worker主备配置和旧接口无需调整。
|
||||
|
||||
```bash
|
||||
curl -X POST https://hair.xiangsilian.com/api/v1/hairline/generate-52 \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@/path/to/photo.jpg' \
|
||||
-F 'gender=male' -F 'hair_style=1' \
|
||||
-F 'normalize_image=true' -F 'generate_grow_image=false'
|
||||
```
|
||||
|
||||
成功时`code=0`,检查`input_image_url`和`hairline_images[].image_middle_url`可打开、尺寸一致;high/low仍为`*_base64: null`。访问测试页时无需输入内部Token。
|
||||
|
||||
测试页先登录再提交图片,访问令牌过期时使用当前刷新令牌自动换取新的访问令牌。
|
||||
|
||||
## 验证
|
||||
|
||||
`tests/test_gateway_interface52.py`覆盖5/52路由分离、完整表单透传、由网关注入内部Token、真实公共转发函数的图片落盘与URL可访问、空字段保持原样和队列槽释放。现有pool、serialization、monitor、reqlog测试用于防止回归。
|
||||
|
||||
静态Token测试覆盖默认值、自定义覆盖、缺失/错误/空Token、内部Token不能代替客户端Token、未通过时不派发、无效配置启动失败以及客户端Token不透传至worker。
|
||||
|
||||
加入客户端Token后的验证:57项网关测试通过;临时网关实测缺失/错误Token返回401,测试页携带默认Token成功调用真实worker并显示7型图片,旧5号页面正常。测试关闭生发,未改动正在运行的GPU worker。
|
||||
|
||||
2026-11-04验证结果:上述网关测试共42项通过。另起本机临时网关连接当前真实GPU worker,浏览器验证旧5号仍返回三档,52号女性7型均返回可访问的图片URL,长图输出1062×1416,矩形叠加与空字段正常,页面请求不携带内部Token,无脚本错误。本轮关闭生发以验证网关链路,未重新运行ComfyUI生发;临时网关验证后关闭。
|
||||
@@ -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。
|
||||
|
||||
---
|
||||
|
||||
*本文只做观测统计,不包含代码或配置变更。*
|
||||
@@ -0,0 +1,281 @@
|
||||
# 监控探活接口
|
||||
|
||||
外网监控机器用这个接口判断**当前对外服务能不能接业务**。不正常就报警。监控侧每 30 秒调用一次,网关不做主动推送。
|
||||
|
||||
对外地址:
|
||||
|
||||
```text
|
||||
GET https://hair.xiangsilian.com/monitor
|
||||
```
|
||||
|
||||
本机等价地址是 `GET http://127.0.0.1:8080/monitor`。nginx 对 `hair.xiangsilian.com` 的 `location /` 整段反代到网关 8080,所以这条路径不用单独改 nginx。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么不复用原来的 `/health`
|
||||
|
||||
网关上原来就有两个探活,都不能直接拿来报警:
|
||||
|
||||
| 路径 | 谁在用 | HTTP 状态 | 实际含义 |
|
||||
|------|--------|-----------|----------|
|
||||
| `GET /health` | 旧路径,和下面这条相同 | 永远 200 | 网关进程还活着 |
|
||||
| `GET /gateway-health` | 人看池子概况 | 永远 200 | 同上,body 里带 worker 计数 |
|
||||
| worker 的 `GET /health` | 网关自己每 8 秒打各台 GPU 机 | 200 或 503 | 那一台 worker 自己是否就绪 |
|
||||
|
||||
`/health` 和 `/gateway-health` 只要 uvicorn 进程在,就返回 `"status": "ok"`。五台 worker 全部下线时,HTTP 仍然是 200,只是 `workers_healthy` 变成 0。只看状态码的监控不会报警。
|
||||
|
||||
这次加的 `/monitor` 专供外部监控:
|
||||
|
||||
- 至少一台 worker 处于在线(`online`)→ **HTTP 200**
|
||||
- 一台在线的都没有 → **HTTP 503**
|
||||
- 报警演练打开时,即使还有在线 worker,也返回 **HTTP 503**(见第 9 节)
|
||||
|
||||
监控只需要判断状态码。200 正常,非 200(503、连接失败、超时)报警。
|
||||
|
||||
---
|
||||
|
||||
## 2. 调用约定
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| 方法 | `GET` |
|
||||
| 路径 | `/monitor` |
|
||||
| 鉴权 | 不需要。不校验 `X-Internal-Token` |
|
||||
| 请求体 | 无 |
|
||||
| 建议间隔 | 30 秒 |
|
||||
| 超时 | 建议 5 秒。接口本身只读内存,正常几十毫秒内返回 |
|
||||
| 请求日志 | 不记录。网关请求日志只记 `/api/`,这条不会进 `request_log.jsonl`,也不会出现在 `/admin/stats` |
|
||||
|
||||
30 秒一次不会给 worker 增加探测。`/monitor` 不访问 GPU 机,只读网关内存里已经维护好的池状态。真正打 worker `/health` 的是网关后台循环,间隔见 `gateway/config.json` 的 `health_check.interval_seconds`(当前 8 秒)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 怎样算「正常」
|
||||
|
||||
正常的定义是:**网关当前还能把业务请求派到至少一台 worker**。
|
||||
|
||||
池里每台 worker 有一个 `online` 标志。`workers_healthy` 就是 `online == true` 的台数。这个标志由后台健康检查维护,规则和派发用的是同一份状态:
|
||||
|
||||
1. 启动时立刻对配置里的每台 worker 打一次 `GET {worker}/health`(带 `X-Internal-Token`,超时 `health_check.timeout_seconds`,当前 3 秒)。返回 200 且 `healthy_threshold <= 1`(当前就是 1)则标记在线。
|
||||
2. 之后每 8 秒再探一轮。
|
||||
3. 连续失败达到 `unhealthy_threshold`(当前 2 次)才从在线改成下线。单次抖动不会下线。
|
||||
4. 业务转发失败(连不上、超时、HTTP ≥ 500、401)会立刻把那台 worker 标下线,并进入隔离期 `cooldown_seconds`(当前 60 秒)。隔离期内即使 `/health` 恢复 200 也不重新上线。
|
||||
5. 隔离期结束后,再有一次探测 200 才重新上线。
|
||||
|
||||
因此 `/monitor` 看到的「在线」,就是下一笔生发/测量请求实际可以派过去的机器,不是「进程还在」这么宽的概念。
|
||||
|
||||
### 会返回 200 的情况
|
||||
|
||||
- 主 worker 里还有任意一台在线。当前配置有 3 台主 worker。
|
||||
- 主 worker 全挂,但备用 worker 还有在线的。主备分组下,主组全下线之后流量会打到备用,服务仍然能接,所以不报警。body 里 `workers_primary_healthy` 会是 0,需要看主组是否掉光时看这个字段,不要用状态码。
|
||||
- 在线的 worker 全部正在忙。忙只说明队列里有请求,机器没有被摘掉。等某台 worker 空闲最多 `queue_wait_seconds`(当前 30 秒);全局并发槽排满时最多再等 `max_queue_wait_seconds`(当前 590 秒)。这不是宕机。
|
||||
|
||||
### 会返回 503 的情况
|
||||
|
||||
- 配置里的 worker 全部不在线。包括:探测连续失败被摘除、转发失败进入隔离期、启动后第一轮探测就没有一台返回 200。
|
||||
- 池还没初始化好(进程刚起来、`_workers` 仍为空)。这种情况窗口很短,发生在 uvicorn 完成启动之前;启动完成前端口还不会接受连接,监控更多会看到连接失败,而不是这条 503。
|
||||
|
||||
网关进程本身挂了、nginx 挂了、机器网络不通,监控连不上,同样应当按失败报警。那不是这个接口返回的 503,而是连接错误。
|
||||
|
||||
---
|
||||
|
||||
## 4. 响应
|
||||
|
||||
`Content-Type: application/json`。
|
||||
|
||||
### 200 正常
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 3,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 3
|
||||
}
|
||||
```
|
||||
|
||||
### 503 不正常
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 0,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 0,
|
||||
"message": "没有可用的 worker"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| status | string | `ok` 或 `unhealthy`。与 HTTP 状态码一致:`ok` 对应 200,`unhealthy` 对应 503 |
|
||||
| service | string | 固定 `gateway` |
|
||||
| workers_total | int | 配置里的 worker 总数 |
|
||||
| workers_healthy | int | 当前在线台数。未开演练时,大于 0 返回 200,等于 0 返回 503 |
|
||||
| workers_primary_total | int | 主 worker 台数。未配置 `primary_workers` 时,全部 worker 都视为主 worker |
|
||||
| workers_primary_healthy | int | 主 worker 里在线的台数。为 0 且 `workers_healthy > 0` 表示只剩备用在撑 |
|
||||
| message | string | 仅 HTTP 503 时出现。真故障文案是「没有可用的 worker」;演练文案以「模拟故障」开头 |
|
||||
| simulated | bool | 仅演练打开时出现,值为 `true`。用来区分演练 503 和真故障 |
|
||||
|
||||
不返回 worker 地址、密码、队列深度。地址和鉴权信息留在网关日志和 `gateway/config.json` 里。
|
||||
|
||||
---
|
||||
|
||||
## 5. 监控侧建议
|
||||
|
||||
按 **HTTP 状态码** 报警,不要只解析 body。
|
||||
|
||||
| 结果 | 处理 |
|
||||
|------|------|
|
||||
| 200 | 正常 |
|
||||
| 503 | 报警。body 的 `message` 可放进告警内容 |
|
||||
| 连接失败、超时,以及其他 5xx | 报警。网关或 nginx 不可达 |
|
||||
|
||||
建议连续 **2 次**失败再发告警(约 60 秒)。网关重启时端口会有几秒不可用(`hair-gateway.service` 的 `RestartSec=3`,启动时还要等第一轮 worker 探测完成)。只看单次失败会在重启时误报。
|
||||
|
||||
不需要带任何请求头。不要把这条 URL 配成 POST,也不要把它当成业务接口去看 `code` 字段——这里没有业务信封 `{code, message, request_id, data}`。
|
||||
|
||||
报警延迟,从「最后一台可用 worker 真的挂了」到监控看到 503,大致是两段相加:
|
||||
|
||||
- 网关摘除:后台每 8 秒探一次,要连续失败 2 次才下线,大约 8~16 秒。若是转发失败触发的被动摘除,则立刻下线。
|
||||
- 监控拉取:最多再等一个 30 秒周期。
|
||||
|
||||
所以典型告警延迟大约半分钟到一分钟,不是秒级。
|
||||
|
||||
---
|
||||
|
||||
## 6. 和业务接口的关系
|
||||
|
||||
worker 全下线时,走 GPU 的接口(测量、生发、发际线、遮罩等)都会失败,`/monitor` 返回 503。这是要报警的情况。
|
||||
|
||||
有两个接口在网关本机执行,不经过 worker 池:
|
||||
|
||||
- 接口 4 `POST /api/v1/face/features`(豆包)
|
||||
- 接口 99 `POST /api/v1/face/special-points`(本机 CPU)
|
||||
|
||||
这两条在 worker 全挂时仍可能成功。`/monitor` 不管它们。它表示的是「依赖 worker 池的对外服务是否还能接」,不是「网关进程里每一个路由都活着」。
|
||||
|
||||
---
|
||||
|
||||
## 7. 已知边界
|
||||
|
||||
这些情况 `/monitor` **不会**变成 503,和派发逻辑一致,避免把「还能干活」报成故障:
|
||||
|
||||
- 只挂了一部分 worker,还剩至少一台在线。
|
||||
- 主 worker 全挂,备用仍在线。
|
||||
- 在线 worker 都在忙,请求在网关排队。排队超时是业务返回「后端服务繁忙」,不是这个探活的失败。
|
||||
- worker 的 `/health` 仍返回 200,但机器实际上不能生图。当前仓库里 worker 的 `/health` 只看模型是否加载完成(未就绪才 503),不检查磁盘水位,也不检查 ComfyUI、change_hair 是否真的能出图。2026-09-19 那次磁盘写满时,浅探活会一直 200,网关就不会把机器摘掉,`/monitor` 也会一直 200。worker 侧更深的探活不在本接口范围内。
|
||||
|
||||
另外,备用 worker 如果从启动起就探不通,日志里不一定有「下线」警告。下线警告只在「原本在线、随后连续失败」时打印。一直没上线的机器只体现在 `workers_total` 和 `workers_healthy` 的差额里。
|
||||
|
||||
---
|
||||
|
||||
## 8. 实现与生效
|
||||
|
||||
| 项 | 位置 |
|
||||
|----|------|
|
||||
| 路由 | `gateway/app.py` 的 `GET /monitor`、`POST /monitor/fault` |
|
||||
| 判定 | 同文件 `monitor_payload()`:未开演练时 `workers_healthy > 0` 为正常;演练打开则一律 503 |
|
||||
| 池状态 | `gateway/pool.py` 的 `get_pool_status()`,统计 `online` 台数 |
|
||||
| 单测 | `tests/test_gateway_monitor.py` |
|
||||
|
||||
改的是网关代码。worker 不用发版。改完后要重启网关进程才会加载新代码:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart hair-gateway
|
||||
systemctl is-active hair-gateway
|
||||
curl -sS -D - http://127.0.0.1:8080/monitor
|
||||
curl -sS -D - https://hair.xiangsilian.com/monitor
|
||||
```
|
||||
|
||||
配置(worker 列表、探测间隔、下线阈值、隔离期)仍在 `gateway/config.json`,启动时加载一次。只改配置、不改代码,同样要重启 `hair-gateway`。
|
||||
|
||||
`/health` 与 `/gateway-health` 保持原样,继续永远返回 200,给「进程是否存活」这类检查用。外部报警请只挂 `/monitor`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 故障模拟(报警演练)
|
||||
|
||||
`POST /monitor/fault` 用来演练监控会不会报警。打开之后,`GET /monitor` 固定返回 503;worker 池、派发、业务接口都不变。测完必须关掉,否则监控会一直报。
|
||||
|
||||
开关只活在网关进程内存里。重启 `hair-gateway` 后自动回到关闭。
|
||||
|
||||
### 请求
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://hair.xiangsilian.com/monitor/fault \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Internal-Token: <gateway/config.json 里的 shared_password>" \
|
||||
-d '{"enabled": true}'
|
||||
```
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| 方法 | `POST` |
|
||||
| 路径 | `/monitor/fault` |
|
||||
| 鉴权 | 请求头 `X-Internal-Token`,值与 `gateway/config.json` 的 `shared_password` 相同。缺失或错误返回 401 |
|
||||
| Body | JSON 布尔。`{"enabled": true}` 打开,`{"enabled": false}` 关闭。字符串 `"true"`、数字 `1` 都算参数错误,返回 400 |
|
||||
|
||||
打开成功:
|
||||
|
||||
```json
|
||||
{"status": "ok", "simulated": true, "message": "已开启模拟故障,GET /monitor 将返回 503"}
|
||||
```
|
||||
|
||||
关闭成功:
|
||||
|
||||
```json
|
||||
{"status": "ok", "simulated": false, "message": "已关闭模拟故障,GET /monitor 恢复真实状态"}
|
||||
```
|
||||
|
||||
### 演练期间 `/monitor` 的 503
|
||||
|
||||
计数字段仍是真实池状态,多一个 `simulated: true`,用来和「真的没有 worker」区分。若演练期间在线数本身已经是 0,文案会同时写明「当前没有可用的 worker」,避免把真故障误读成纯演练。
|
||||
|
||||
在线数仍大于 0 时:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy",
|
||||
"service": "gateway",
|
||||
"workers_total": 5,
|
||||
"workers_healthy": 3,
|
||||
"workers_primary_total": 3,
|
||||
"workers_primary_healthy": 3,
|
||||
"simulated": true,
|
||||
"message": "模拟故障:监控测试中,真实 worker 未摘除"
|
||||
}
|
||||
```
|
||||
|
||||
监控若只看 HTTP 状态码,这次 503 会报警,和真故障同一条链路。告警内容里如果带了 body,可以看到 `simulated` 和「模拟故障」。
|
||||
|
||||
### 建议步骤
|
||||
|
||||
1. 确认当前 `GET /monitor` 是 200。
|
||||
2. `POST /monitor/fault`,`enabled` 设为 `true`。
|
||||
3. 再拉一次 `/monitor`,应为 503,且 `workers_healthy` 没有变成 0。
|
||||
4. 等监控的 30 秒周期(若配了连续 2 次失败才告警,就等两次)。确认告警发出。
|
||||
5. `enabled` 设为 `false`。再拉 `/monitor`,应回到 200。确认告警恢复。
|
||||
|
||||
演练时业务请求仍会打到在线 worker。这个接口不能用来模拟磁盘满、ComfyUI 挂掉或某台机器被摘除。
|
||||
|
||||
---
|
||||
|
||||
## 10. 上线核对(2026-10-15)
|
||||
|
||||
`hair-gateway.service` 已重启并完成启动。启动日志:`Worker 池初始化完成 | 总数=5 | 在线=3 | 主worker=3`。三台主 worker 的 `/health` 均为 200;两台备用在这轮启动探测里没有上线,所以健康数是 3 不是 5。按上面的规则这仍是正常。
|
||||
|
||||
当时公网实查:
|
||||
|
||||
```text
|
||||
HTTP/1.1 200 OK
|
||||
{"status":"ok","service":"gateway","workers_total":5,"workers_healthy":3,"workers_primary_total":3,"workers_primary_healthy":3}
|
||||
```
|
||||
|
||||
旧的 `GET /health` 仍是 200。重启前 8080 上没有已建立连接。
|
||||
|
||||
故障模拟在同一次上线里用本机请求核对过:不带令牌或令牌错误返回 401,且开关保持关闭;`enabled: true` 之后 `GET /monitor` 为 503,`simulated` 为 true,`workers_healthy` 仍是 3;`enabled: false` 之后回到 200。核对结束时开关是关闭的。开关的开、关会在 `journalctl -u hair-gateway` 里各留一条 warning,不进业务请求日志。
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口52(2026-11-04 已接入main)
|
||||
|
||||
42、52、72 统一采用[客户端登录与刷新令牌](接口42-52-72网关鉴权.md)。校验失败返回 HTTP 401、`code=1009`。
|
||||
|
||||
新增`POST /api/v1/hairline/generate-52`,由网关转发到GPU worker同路径。5号保持原样,52号的`normalize_image`等参数原样透传;非空图片沿用公共base64→URL改写,空图片仍保留`*_base64: null`。不在网关增加IP限制,不合入worker算法。
|
||||
|
||||
测试页`/static/test_interface52.html`不包含内部密码。部署前确认主/备worker均支持52号,并检查生发请求的上游超时。详见[接口52网关接入](接口52网关接入.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. ✅【功能必需】base64→URL 落盘扩展名按内容嗅探(支持 JPG)
|
||||
|
||||
**背景**:接口 **2/3/5 的返回图改成了 JPG**(体积约小 9×),**接口 1 标注图仍是 PNG**(含透明)。
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 段即可。**
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Framed Ft and Zy measurements shared by interface 42 and the ratio preview."""
|
||||
|
||||
import math
|
||||
|
||||
FT_POINTS = ("hair_line_right_right", "hair_line_left_left")
|
||||
ZY_POINTS = ("face_edge_234", "face_edge_454")
|
||||
|
||||
|
||||
class PortraitQualityError(ValueError):
|
||||
"""An input that must be rejected before feature analysis."""
|
||||
|
||||
def __init__(self, code, message):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def prepare_feature_portrait(image_bytes):
|
||||
"""Check the source image before applying interface 52 framing."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from face_analysis.pose import PITCH_THRESHOLD, estimate_head_pose
|
||||
from hairline.face_landmarks import get_landmarker, to_landmark_list
|
||||
from hairline.framing import decode_portrait, normalize_portrait
|
||||
|
||||
image = decode_portrait(image_bytes)
|
||||
if image is None:
|
||||
raise PortraitQualityError(1008, "图片格式不支持(仅 JPG / PNG)")
|
||||
|
||||
landmarks = get_landmarker().detect(
|
||||
cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True
|
||||
)
|
||||
if landmarks is None:
|
||||
raise PortraitQualityError(1001, "无法识别人像,请上传包含完整人脸的照片")
|
||||
|
||||
h, w = image.shape[:2]
|
||||
face_xy = landmarks[:468, :2] * [w, h]
|
||||
if not np.isfinite(face_xy).all() or (
|
||||
(face_xy <= 0).any() or (face_xy >= [w - 1, h - 1]).any()
|
||||
):
|
||||
raise PortraitQualityError(1003, "人脸不完整,请保留完整的脸部轮廓")
|
||||
|
||||
pose = estimate_head_pose(to_landmark_list(landmarks), w, h)
|
||||
# In the camera coordinate system, positive pitch points the face down.
|
||||
if pose is not None and pose[1] > PITCH_THRESHOLD:
|
||||
raise PortraitQualityError(1003, "俯拍角度过大,请将相机调整至与脸部平视")
|
||||
|
||||
try:
|
||||
image, landmarks, _ = normalize_portrait(image, landmarks)
|
||||
except ValueError as exc:
|
||||
raise PortraitQualityError(1003, "人脸过于贴近边缘,无法扶正裁切") from exc
|
||||
return image, landmarks
|
||||
|
||||
|
||||
def detect_framed_ratio_points(image):
|
||||
"""Use the interface 52 crop and return the endpoints in its coordinate space."""
|
||||
from hairline.framing import prepare_portrait
|
||||
|
||||
image, landmarks, framing = prepare_portrait(image)
|
||||
data = _detect_ratio_points(image, landmarks)
|
||||
data.update(framing=framing, coordinate_space="processed_image")
|
||||
return image, data
|
||||
|
||||
|
||||
def _detect_ratio_points(image, landmarks):
|
||||
from hairline.face_landmarks import to_landmark_list
|
||||
|
||||
from .calibration import normalized_to_pixel
|
||||
from .special_points import detect_special_points
|
||||
|
||||
override = to_landmark_list(landmarks)
|
||||
data = detect_special_points(image, landmarks_override=override)
|
||||
points = data["points"]
|
||||
height, width = image.shape[:2]
|
||||
x, y = normalized_to_pixel(override.landmark[454], width, height)
|
||||
points["face_edge_454"] = {"x": round(x), "y": round(y)}
|
||||
data["ft_original_points"] = {key: points[key] for key in FT_POINTS}
|
||||
a, b = (points[key] for key in FT_POINTS)
|
||||
if a is not None and b is not None:
|
||||
middle_y = (a["y"] + b["y"]) / 2
|
||||
points[FT_POINTS[0]] = {**a, "y": middle_y}
|
||||
points[FT_POINTS[1]] = {**b, "y": middle_y}
|
||||
|
||||
data["ft_alignment"] = "horizontal_at_mean_y"
|
||||
return data
|
||||
|
||||
|
||||
def analyze_ratio_features(image, landmarks) -> dict[str, bool | None]:
|
||||
"""Return both labels from the same measurement of the processed image."""
|
||||
from .special_points import NoFaceError, NonFrontalError
|
||||
|
||||
matched = None
|
||||
try:
|
||||
data = _detect_ratio_points(image, landmarks)
|
||||
except (NoFaceError, NonFrontalError):
|
||||
pass
|
||||
else:
|
||||
ft_left, ft_right, zy_left, zy_right = (
|
||||
data["points"].get(key) for key in (*FT_POINTS, *ZY_POINTS)
|
||||
)
|
||||
if all(point is not None for point in (ft_left, ft_right, zy_left, zy_right)):
|
||||
ft = abs(ft_left["x"] - ft_right["x"])
|
||||
zy = math.hypot(zy_left["x"] - zy_right["x"], zy_left["y"] - zy_right["y"])
|
||||
if ft > 0 and zy > 0:
|
||||
matched = ft / zy < 0.8
|
||||
return {"zygoma_expansion": matched, "temple_hollowing": matched}
|
||||
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
|
||||
"hairline_id": hairline_id,
|
||||
"blend_method": blend_method,
|
||||
"hairline_push_cm": round(float(hairline_push_cm), 2),
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
|
||||
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,人物增加一点磨皮",
|
||||
"beauty_alpha": beauty_alpha,
|
||||
"px_per_cm": round(float(px_per_cm), 4),
|
||||
"mask_pixels": mask_viz["mask_pixels"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Expiring client credentials for the protected gateway interfaces."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, Security
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from gateway.config import get_config
|
||||
|
||||
ACCESS_TTL = 15 * 60
|
||||
REFRESH_TTL = 7 * 24 * 60 * 60
|
||||
_USERNAME = "yonghe"
|
||||
_PASSWORD = "yonghe1999"
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["客户端鉴权"])
|
||||
_bearer = HTTPBearer(auto_error=False, scheme_name="ClientBearer")
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(max_length=128)
|
||||
password: str = Field(max_length=256)
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str = Field(max_length=1024)
|
||||
|
||||
|
||||
def _response(code, message, *, status=200, data=None, challenge=False):
|
||||
headers = {"Cache-Control": "no-store"}
|
||||
if challenge:
|
||||
headers["WWW-Authenticate"] = "Bearer"
|
||||
return JSONResponse(status_code=status, headers=headers, content={
|
||||
"code": code, "message": message, "request_id": f"gw-{uuid4().hex[:8]}", "data": data,
|
||||
})
|
||||
|
||||
|
||||
def _secret():
|
||||
value = os.getenv("HAIR_API_TOKEN_SECRET")
|
||||
if value is None:
|
||||
try:
|
||||
value = get_config().get("api_token_secret")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
|
||||
return None
|
||||
return value.encode("utf-8")
|
||||
|
||||
|
||||
def _encode(raw):
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode(value):
|
||||
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
||||
|
||||
|
||||
def _issue(secret, kind, ttl):
|
||||
payload = _encode(json.dumps({
|
||||
"sub": _USERNAME,
|
||||
"kind": kind,
|
||||
"exp": int(time.time()) + ttl,
|
||||
"nonce": secrets.token_urlsafe(16),
|
||||
}, separators=(",", ":")).encode())
|
||||
signature = hmac.digest(secret, payload.encode("ascii"), hashlib.sha256)
|
||||
return payload + "." + _encode(signature)
|
||||
|
||||
|
||||
def _valid(token, secret, kind):
|
||||
if not token or len(token) > 1024:
|
||||
return False
|
||||
try:
|
||||
payload, signature = token.split(".")
|
||||
expected = _encode(hmac.digest(secret, payload.encode("ascii"), hashlib.sha256))
|
||||
if not hmac.compare_digest(signature.encode("ascii"), expected.encode("ascii")):
|
||||
return False
|
||||
claims = json.loads(_decode(payload))
|
||||
return (
|
||||
isinstance(claims, dict)
|
||||
and claims.get("sub") == _USERNAME
|
||||
and claims.get("kind") == kind
|
||||
and type(claims.get("exp")) is int
|
||||
and time.time() < claims["exp"]
|
||||
)
|
||||
except (ValueError, UnicodeError, binascii.Error):
|
||||
return False
|
||||
|
||||
|
||||
def _unconfigured():
|
||||
return _response(1007, "客户端令牌服务未配置", status=503)
|
||||
|
||||
|
||||
def _unauthorized(message):
|
||||
return _response(1009, message, status=401, challenge=True)
|
||||
|
||||
|
||||
def _access_data(secret):
|
||||
return {
|
||||
"access_token": _issue(secret, "access", ACCESS_TTL),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": ACCESS_TTL,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", summary="用户名和密码换取访问及刷新令牌")
|
||||
async def login(body: LoginRequest):
|
||||
username_ok = hmac.compare_digest(body.username.encode("utf-8"), _USERNAME.encode("utf-8"))
|
||||
password_ok = hmac.compare_digest(body.password.encode("utf-8"), _PASSWORD.encode("utf-8"))
|
||||
if not (username_ok and password_ok):
|
||||
return _unauthorized("用户名或密码错误")
|
||||
secret = _secret()
|
||||
if secret is None:
|
||||
return _unconfigured()
|
||||
data = _access_data(secret)
|
||||
data.update(refresh_token=_issue(secret, "refresh", REFRESH_TTL), refresh_expires_in=REFRESH_TTL)
|
||||
return _response(0, "success", data=data)
|
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新访问令牌")
|
||||
async def refresh(body: RefreshRequest):
|
||||
secret = _secret()
|
||||
if secret is None:
|
||||
return _unconfigured()
|
||||
if not _valid(body.refresh_token, secret, "refresh"):
|
||||
return _unauthorized("刷新令牌无效或已过期,请重新登录")
|
||||
return _response(0, "success", data=_access_data(secret))
|
||||
|
||||
|
||||
async def client_auth_error(credentials: HTTPAuthorizationCredentials | None = Security(_bearer)):
|
||||
"""Return an error response for protected handlers before local work or dispatch."""
|
||||
if credentials is None:
|
||||
return _unauthorized("请提供 Authorization: Bearer 访问令牌")
|
||||
secret = _secret()
|
||||
if secret is None:
|
||||
return _unconfigured()
|
||||
if not _valid(credentials.credentials, secret, "access"):
|
||||
return _unauthorized("访问令牌无效或已过期")
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/check", summary="验证当前访问令牌")
|
||||
async def check(auth_error=Depends(client_auth_error)):
|
||||
"""检查受保护业务接口使用的 Bearer 令牌。"""
|
||||
if auth_error is not None:
|
||||
return auth_error
|
||||
return _response(0, "访问令牌有效")
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"api_token_secret": "",
|
||||
"workers": [
|
||||
"http://127.0.0.1:28187",
|
||||
"http://127.0.0.1:28188"
|
||||
],
|
||||
"primary_workers": [],
|
||||
"shared_password": "REPLACE_ME_ROTATE_PERIODICALLY",
|
||||
"public_base_url": "https://hair.xiangsilian.com",
|
||||
"static_dir": "static/annotations",
|
||||
@@ -11,7 +13,8 @@
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1
|
||||
"healthy_threshold": 1,
|
||||
"cooldown_seconds": 60
|
||||
},
|
||||
"ark_api_key": "",
|
||||
"dispatch": {
|
||||
|
||||
@@ -13,14 +13,17 @@ logger = logging.getLogger("gateway.config")
|
||||
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
|
||||
|
||||
DEFAULTS = {
|
||||
"api_token_secret": "",
|
||||
"public_base_url": "https://hair.xiangsilian.com",
|
||||
"static_dir": "static/annotations",
|
||||
"primary_workers": [], # 主 worker 地址列表(须为 workers 的子集);空 = 全部视为主 worker
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1,
|
||||
"cooldown_seconds": 60, # 被动摘除后的隔离期(秒),0=关闭(等价旧行为)
|
||||
},
|
||||
"dispatch": {
|
||||
"per_worker_concurrency": 1,
|
||||
@@ -82,6 +85,15 @@ def load_config() -> dict:
|
||||
if not isinstance(w, str) or not w:
|
||||
raise ValueError(f"配置错误: workers[{i}] 必须是非空字符串")
|
||||
|
||||
primary_workers: List[str] = cfg.get("primary_workers", [])
|
||||
for i, w in enumerate(primary_workers):
|
||||
if not isinstance(w, str) or not w:
|
||||
raise ValueError(f"配置错误: primary_workers[{i}] 必须是非空字符串")
|
||||
for w in primary_workers:
|
||||
if w not in workers:
|
||||
logger.warning("⚠ primary_workers 中的 %s 不在 workers 列表里,将被忽略", w)
|
||||
cfg["primary_workers"] = [w for w in primary_workers if w in workers]
|
||||
|
||||
password: str = cfg.get("shared_password", "")
|
||||
if not password or password == "REPLACE_ME_ROTATE_PERIODICALLY":
|
||||
logger.warning(
|
||||
|
||||
@@ -29,6 +29,15 @@ from gateway.reqlog import extract_form_params
|
||||
|
||||
logger = logging.getLogger("gateway.forward")
|
||||
|
||||
# 插队优先级接口:排队(全局槽 / worker 池)时排到等待队列最前,同优先级 FIFO。
|
||||
PRIORITY_PATHS = frozenset({
|
||||
"/api/v1/hairline/generate", # 接口5:发际线PNG生成
|
||||
})
|
||||
|
||||
|
||||
def _priority_of(path: str) -> int:
|
||||
return 1 if path in PRIORITY_PATHS else 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 全局 httpx client(连接复用)
|
||||
@@ -137,7 +146,8 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
||||
"""代理一次请求到 worker,处理重试与 base64 改写。
|
||||
|
||||
流程:
|
||||
1. acquire worker(排队等待空闲)
|
||||
0. 获取全局串行槽(PRIORITY_PATHS 中的接口插队到最前)
|
||||
1. acquire worker(排队等待空闲,同样支持优先级插队)
|
||||
2. 重构 multipart/form 请求,加 X-Internal-Token
|
||||
3. 转发到 worker
|
||||
4. 成功 → release worker → 改写 base64 → 返回 JSONResponse
|
||||
@@ -161,8 +171,9 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
||||
except Exception as ex: # noqa: BLE001
|
||||
request.state.log_request_params = {"_parse_error": str(ex)}
|
||||
|
||||
# --- 全局串行槽(GPU 请求在此排队;超时拒绝) ---
|
||||
if not await acquire_global_slot(cfg):
|
||||
# --- 全局串行槽(GPU 请求在此排队;超时拒绝;优先级接口插队到最前) ---
|
||||
priority = _priority_of(path)
|
||||
if not await acquire_global_slot(cfg, priority):
|
||||
request.state.worker_url = ""
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
@@ -175,13 +186,13 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
return await _dispatch_with_retries(request, path, cfg, body)
|
||||
return await _dispatch_with_retries(request, path, cfg, body, priority)
|
||||
finally:
|
||||
release_global_slot()
|
||||
|
||||
|
||||
async def _dispatch_with_retries(
|
||||
request: Request, path: str, cfg: dict, body: bytes
|
||||
request: Request, path: str, cfg: dict, body: bytes, priority: int = 0
|
||||
) -> JSONResponse:
|
||||
"""在已持有全局串行槽的前提下:选 worker → 转发 → 重试 → 改写 base64 → 返回。
|
||||
|
||||
@@ -203,7 +214,7 @@ async def _dispatch_with_retries(
|
||||
for attempt in range(attempts):
|
||||
worker = None
|
||||
try:
|
||||
worker = await acquire_worker(cfg)
|
||||
worker = await acquire_worker(cfg, priority)
|
||||
# 记录当前使用的 worker,供日志中间件读取
|
||||
request.state.worker_url = worker.url
|
||||
except NoWorkerAvailable:
|
||||
|
||||
@@ -223,7 +223,7 @@ def is_initialized() -> bool:
|
||||
|
||||
def _should_log(path: str) -> bool:
|
||||
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
|
||||
return path.startswith("/api/")
|
||||
return path.startswith("/api/") and not path.startswith("/api/v1/auth/")
|
||||
|
||||
|
||||
async def request_logging_middleware(request, call_next):
|
||||
@@ -436,6 +436,8 @@ PATH_INTERFACE_MAP: Dict[str, Dict[str, Any]] = {
|
||||
"/api/v1/hair/grow-v2": {"num": 7, "name": "C端生发 v2"},
|
||||
"/api/v1/head/mask": {"num": 9, "name": "头发遮罩生成"},
|
||||
"/api/v1/head/band": {"num": 10, "name": "头部外缘膨胀带遮罩"},
|
||||
"/api/v1/face/measure-11": {"num": 11, "name": "四庭七眼测量标注(鉴权)"},
|
||||
"/api/v1/face/measure-61": {"num": 61, "name": "四庭七眼测量标注 v2(鉴权)"},
|
||||
}
|
||||
|
||||
# 会按「勾选发型数量」串行多次调用 ComfyUI 的接口(耗时 ≈ 固定开销 + 单次换发型耗时 × 发型数)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"60": {
|
||||
"inputs": {
|
||||
"text": "填充遮罩区域的头发"
|
||||
"text": "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
},
|
||||
"class_type": "JjkText",
|
||||
"_meta": {
|
||||
|
||||
@@ -44,18 +44,18 @@ class FaceLandmarker:
|
||||
self._detector = vision.FaceLandmarker.create_from_options(options)
|
||||
self._mp = mp
|
||||
|
||||
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
|
||||
"""Returns (468, 3) float32 of normalized x, y and relative z, or None.
|
||||
def detect(self, image_rgb: np.ndarray, include_iris: bool = False) -> np.ndarray | None:
|
||||
"""Returns (468, 3), or (478, 3) with iris, normalized float32 points.
|
||||
|
||||
The task model produces 478 landmarks (468 face + 10 iris); we return
|
||||
only the first 468 to match the canonical FaceMesh topology used by
|
||||
the SDK's OBJ file.
|
||||
The task model produces 478 landmarks (468 face + 10 iris). By default
|
||||
only the first 468 match the SDK's canonical FaceMesh topology;
|
||||
include_iris=True retains all 478 for face-measurement calibration.
|
||||
"""
|
||||
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb)
|
||||
result = self._detector.detect(mp_image)
|
||||
if not result.face_landmarks:
|
||||
return None
|
||||
lm = result.face_landmarks[0][:468]
|
||||
lm = result.face_landmarks[0] if include_iris else result.face_landmarks[0][:468]
|
||||
arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
|
||||
return arr
|
||||
|
||||
@@ -66,6 +66,25 @@ class FaceLandmarker:
|
||||
pass
|
||||
|
||||
|
||||
def to_landmark_list(landmarks):
|
||||
from mediapipe.framework.formats import landmark_pb2
|
||||
|
||||
result = landmark_pb2.NormalizedLandmarkList()
|
||||
for x, y, z in landmarks:
|
||||
result.landmark.add(x=float(x), y=float(y), z=float(z))
|
||||
return result
|
||||
|
||||
|
||||
_landmarker = None
|
||||
|
||||
|
||||
def get_landmarker() -> FaceLandmarker:
|
||||
global _landmarker
|
||||
if _landmarker is None:
|
||||
_landmarker = FaceLandmarker(static_image_mode=True)
|
||||
return _landmarker
|
||||
|
||||
|
||||
class SolutionsFaceLandmarker:
|
||||
"""CPU-oriented fallback using mediapipe.solutions.face_mesh."""
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Interface 52: upright face axis, then native-resolution reference framing."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
PROFILE_PATH = Path(__file__).parent / "reference/framing_profile.json"
|
||||
|
||||
|
||||
def decode_portrait(raw):
|
||||
"""Respect phone EXIF orientation before detecting or transforming points."""
|
||||
try:
|
||||
with Image.open(BytesIO(raw)) as image:
|
||||
return cv2.cvtColor(np.array(ImageOps.exif_transpose(image).convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_profile():
|
||||
return json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def transform_points(points, matrix):
|
||||
return np.asarray(points, np.float64) @ matrix[:, :2].T + matrix[:, 2]
|
||||
|
||||
|
||||
def _rotation(landmarks, width, height):
|
||||
xy = landmarks[:, :2].astype(np.float64) * [width, height]
|
||||
delta = xy[152] - xy[9]
|
||||
length = float(np.linalg.norm(delta))
|
||||
if not np.isfinite(xy).all() or length < 2 or delta[1] <= 0:
|
||||
raise ValueError("9号点与152号点无效,无法扶正人脸")
|
||||
down = delta / length
|
||||
rotation = np.array([[down[1], -down[0]], [down[0], down[1]]], np.float64)
|
||||
return xy, rotation, length
|
||||
|
||||
|
||||
def _matrix(rotation, source_anchor, target_anchor):
|
||||
return np.column_stack((rotation, np.asarray(target_anchor) - rotation @ source_anchor))
|
||||
|
||||
|
||||
def crop_fits(matrix, source_size, output_size):
|
||||
w, h = output_size
|
||||
corners = transform_points([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]], cv2.invertAffineTransform(matrix))
|
||||
sw, sh = source_size
|
||||
return bool((corners[:, 0] >= -1e-7).all() and (corners[:, 0] <= sw-1+1e-7).all()
|
||||
and (corners[:, 1] >= -1e-7).all() and (corners[:, 1] <= sh-1+1e-7).all())
|
||||
|
||||
|
||||
def _inscribed_frame(rotation, source_size, aspect=None):
|
||||
"""Largest centred valid rectangle: no generated background or upscaling."""
|
||||
sw, sh = source_size
|
||||
ratio = sw / sh if aspect is None else aspect
|
||||
source_center = np.array([(sw-1)/2, (sh-1)/2])
|
||||
low, high = 2.0, min(float(sh), sw/ratio)
|
||||
for _ in range(48):
|
||||
h = (low+high)/2
|
||||
w = h*ratio
|
||||
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
|
||||
if crop_fits(matrix, source_size, (w, h)):
|
||||
low = h
|
||||
else:
|
||||
high = h
|
||||
if aspect == .75:
|
||||
h = max(4, int(low+1e-7)//4*4)
|
||||
w = h//4*3
|
||||
else:
|
||||
h, w = max(2, int(low+1e-7)), max(2, int(low*ratio+1e-7))
|
||||
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
|
||||
if not crop_fits(matrix, source_size, (w, h)):
|
||||
raise ValueError("图片边缘空间不足,无法得到有效扶正画面")
|
||||
return matrix, (w, h)
|
||||
|
||||
|
||||
def _tall_portrait_frame(xy, rotation, source_size, profile):
|
||||
"""Largest native 3:4 frame, positioned to retain the entire face."""
|
||||
_, size = _inscribed_frame(rotation, source_size, aspect=.75)
|
||||
half = (np.array(size, np.float64)-1)/2
|
||||
extent = np.abs(rotation.T) @ half
|
||||
low, high = extent, np.array(source_size)-1-extent
|
||||
polygon = [np.array(p) for p in ((low[0], low[1]), (high[0], low[1]),
|
||||
(high[0], high[1]), (low[0], high[1]))]
|
||||
face = xy[:468] @ rotation.T
|
||||
# The crop centre must satisfy both the source boundaries and the face
|
||||
# bounds. Clip the feasible polygon in double precision to avoid padding.
|
||||
face_low, face_high = face.max(axis=0)-half, face.min(axis=0)+half
|
||||
for axis in range(2):
|
||||
for normal, bound in ((rotation[axis], face_high[axis]),
|
||||
(-rotation[axis], -face_low[axis])):
|
||||
clipped = []
|
||||
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
|
||||
a, b = float(normal @ start-bound-1e-8), float(normal @ end-bound-1e-8)
|
||||
if (a <= 0) != (b <= 0):
|
||||
clipped.append(start+(end-start)*(a/(a-b)))
|
||||
if b <= 0:
|
||||
clipped.append(end)
|
||||
polygon = clipped
|
||||
if not polygon:
|
||||
raise ValueError("3:4裁切窗口无法保留完整人脸")
|
||||
target = np.array([profile["brow_x_ratio"]*size[0], profile["brow_y_ratio"]*size[1]])
|
||||
preferred = xy[9]-rotation.T @ (target-half)
|
||||
# Euclidean projection keeps the brow as close as possible to its target.
|
||||
candidates = []
|
||||
if (preferred >= low-1e-8).all() and (preferred <= high+1e-8).all():
|
||||
upright = rotation @ preferred
|
||||
if (upright >= face_low-1e-8).all() and (upright <= face_high+1e-8).all():
|
||||
candidates.append(preferred)
|
||||
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
|
||||
edge = end-start
|
||||
length2 = float(edge @ edge)
|
||||
t = np.clip(float((preferred-start) @ edge)/length2, 0, 1) if length2 > 1e-16 else 0
|
||||
candidates.append(start+t*edge)
|
||||
centre = min(candidates, key=lambda p: float(np.sum((p-preferred)**2)))
|
||||
matrix = _matrix(rotation, centre, half)
|
||||
if not crop_fits(matrix, source_size, size):
|
||||
raise ValueError("3:4裁切窗口超出原图有效范围")
|
||||
return matrix, size
|
||||
|
||||
|
||||
def _apply(photo, landmarks, matrix, size):
|
||||
h, w = photo.shape[:2]
|
||||
ow, oh = size
|
||||
moved = np.asarray(landmarks, np.float64).copy()
|
||||
moved[:, :2] = transform_points(landmarks[:, :2]*[w, h], matrix) / [ow, oh]
|
||||
# MediaPipe Z is measured in normalized image-width units.
|
||||
moved[:, 2] *= w/ow
|
||||
result = cv2.warpAffine(photo, matrix, size, flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))
|
||||
return result, moved
|
||||
|
||||
|
||||
def calibrate_reference(photo, landmarks):
|
||||
"""First straighten the actual reference; derive ratios from its final frame."""
|
||||
h, w = photo.shape[:2]
|
||||
_xy, rotation, length = _rotation(landmarks, w, h)
|
||||
matrix, size = _inscribed_frame(rotation, (w, h), aspect=.75)
|
||||
image, moved = _apply(photo, landmarks, matrix, size)
|
||||
ow, oh = size
|
||||
b, c = moved[[9, 152], :2]*[ow, oh]
|
||||
profile = {
|
||||
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
|
||||
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
|
||||
"reference_image_url": "/static/framing_reference.png",
|
||||
"reference_size": {"width": ow, "height": oh},
|
||||
"source_size": {"width": w, "height": h},
|
||||
"brow_9": {"x": float(b[0]), "y": float(b[1])},
|
||||
"chin_152": {"x": float(c[0]), "y": float(c[1])},
|
||||
"face_axis_length_px": length,
|
||||
"brow_x_ratio": float(b[0]/ow), "brow_y_ratio": float(b[1]/oh),
|
||||
"reference_width_per_face_length": ow/length,
|
||||
"reference_height_per_face_length": oh/length,
|
||||
"width_per_face_length": 2.5, "height_per_face_length": 3.0/.9,
|
||||
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
|
||||
"axis_x_error_px": float(abs(b[0]-c[0])),
|
||||
"source_to_reference": matrix.tolist(),
|
||||
"landmark_source": "source_landmarks_transformed_without_redetection",
|
||||
}
|
||||
return image, moved, profile
|
||||
|
||||
|
||||
def normalize_portrait(photo, landmarks, profile=None):
|
||||
profile = load_profile() if profile is None else profile
|
||||
h, w = photo.shape[:2]
|
||||
xy, rotation, length = _rotation(landmarks, w, h)
|
||||
# Round to an exact 3:4 frame without changing pixel scale.
|
||||
target_h = max(4, round(profile["height_per_face_length"]*length/4)*4)
|
||||
target_w = target_h//4*3
|
||||
matrix = _matrix(rotation, xy[9], [profile["brow_x_ratio"]*target_w,
|
||||
profile["brow_y_ratio"]*target_h])
|
||||
cropped = crop_fits(matrix, (w, h), (target_w, target_h))
|
||||
aspect_cropped = False
|
||||
aspect_skip_reason = None
|
||||
if cropped:
|
||||
size = (target_w, target_h)
|
||||
elif h*3 > w*4:
|
||||
try:
|
||||
matrix, size = _tall_portrait_frame(xy, rotation, (w, h), profile)
|
||||
aspect_cropped = True
|
||||
except ValueError as ex:
|
||||
aspect_skip_reason = str(ex)
|
||||
matrix, size = _inscribed_frame(rotation, (w, h))
|
||||
else:
|
||||
matrix, size = _inscribed_frame(rotation, (w, h))
|
||||
image, moved = _apply(photo, landmarks, matrix, size)
|
||||
ow, oh = size
|
||||
axis = moved[[9, 152], :2]*[ow, oh]
|
||||
if not ((axis[:, 0] >= 0).all() and (axis[:, 0] < ow).all()
|
||||
and (axis[:, 1] >= 0).all() and (axis[:, 1] < oh).all()):
|
||||
raise ValueError("人脸过于贴近边缘,扶正后无法保留9号点与152号点")
|
||||
metadata = {
|
||||
"enabled": True, "standard_crop": cropped, "aspect_crop": aspect_cropped,
|
||||
"aspect_crop_skip_reason": aspect_skip_reason,
|
||||
"status": "reference_crop" if cropped else ("aspect_crop" if aspect_cropped else "rotation_only"),
|
||||
"skip_reason": None if cropped else "insufficient_space",
|
||||
"message": ("已扶正并按参考图裁切" if cropped else
|
||||
"原图偏长,已扶正并裁成3:4,保留完整人脸" if aspect_cropped else
|
||||
"3:4窗口无法完整保留人脸,已仅扶正" if aspect_skip_reason else
|
||||
"空间不足,仅扶正,未按参考比例裁切"),
|
||||
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
|
||||
"source_size": {"width": w, "height": h}, "output_size": {"width": ow, "height": oh},
|
||||
"requested_crop_size": {"width": target_w, "height": target_h},
|
||||
"face_axis_length_px": length,
|
||||
"height_per_face_length": profile["height_per_face_length"],
|
||||
"width_per_face_length": profile["height_per_face_length"]*.75,
|
||||
"source_to_processed": matrix.tolist(),
|
||||
"processed_to_source": cv2.invertAffineTransform(matrix).tolist(),
|
||||
"axis_x_error_px": float(abs(axis[0, 0]-axis[1, 0])),
|
||||
"profile_version": profile["version"],
|
||||
"reference_image_url": profile["reference_image_url"],
|
||||
}
|
||||
return image, moved, metadata
|
||||
|
||||
|
||||
class NoPortraitFaceError(ValueError):
|
||||
"""The portrait landmarker could not find a face."""
|
||||
|
||||
|
||||
def prepare_portrait(photo):
|
||||
"""Shared interface-52 preprocessing, including the same landmark model/profile."""
|
||||
from .face_landmarks import get_landmarker
|
||||
landmarks = get_landmarker().detect(cv2.cvtColor(photo, cv2.COLOR_BGR2RGB), include_iris=True)
|
||||
if landmarks is None:
|
||||
raise NoPortraitFaceError("未检出人脸")
|
||||
return normalize_portrait(photo, landmarks)
|
||||
@@ -16,7 +16,7 @@ from . import comfyui
|
||||
|
||||
logger = logging.getLogger("hair.worker")
|
||||
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发"
|
||||
_DEFAULT_PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
_REPO = os.path.dirname(os.path.dirname(__file__))
|
||||
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
|
||||
|
||||
@@ -62,7 +62,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
|
||||
Args:
|
||||
image_bytes: 人物图片字节(JPG/PNG)
|
||||
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发"
|
||||
prompt: 提示词,None 用默认 "填充遮罩区域的头发,人物增加一点磨皮"
|
||||
timeout: ComfyUI 超时秒数
|
||||
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
|
||||
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
|
||||
"reference_image_url": "/static/framing_reference.png",
|
||||
"reference_size": {
|
||||
"width": 786,
|
||||
"height": 1048
|
||||
},
|
||||
"source_size": {
|
||||
"width": 792,
|
||||
"height": 1056
|
||||
},
|
||||
"brow_9": {
|
||||
"x": 385.1878369059212,
|
||||
"y": 360.65437126595765
|
||||
},
|
||||
"chin_152": {
|
||||
"x": 385.18783690592113,
|
||||
"y": 725.8251047982238
|
||||
},
|
||||
"face_axis_length_px": 365.1707335322661,
|
||||
"brow_x_ratio": 0.4900608612034621,
|
||||
"brow_y_ratio": 0.3441358504446161,
|
||||
"reference_width_per_face_length": 2.152417835890317,
|
||||
"reference_height_per_face_length": 2.8698904478537566,
|
||||
"width_per_face_length": 2.5,
|
||||
"height_per_face_length": 3.333333333333333,
|
||||
"rotation_degrees": 0.2596248408989324,
|
||||
"axis_x_error_px": 5.684341886080802e-14,
|
||||
"source_to_reference": [
|
||||
[
|
||||
0.9999897336401403,
|
||||
0.004531292786961665,
|
||||
-5.386196599797756
|
||||
],
|
||||
[
|
||||
-0.004531292786961665,
|
||||
0.9999897336401403,
|
||||
-2.202458197930582
|
||||
]
|
||||
],
|
||||
"landmark_source": "source_landmarks_transformed_without_redetection"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.3 KiB |
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,7 +27,7 @@ def prep_and_upload():
|
||||
|
||||
|
||||
def run_once(fname, model, dtype, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
wf["16"]["inputs"]["unet_name"] = model
|
||||
wf["16"]["inputs"]["weight_dtype"] = dtype
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -33,7 +33,7 @@ def upload(scale=1.0):
|
||||
|
||||
|
||||
def run(fname, steps):
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发")
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮")
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -30,7 +30,7 @@ SEED = 123456789
|
||||
imgs = []
|
||||
labels = []
|
||||
for steps in [2, 3, 4, 6]:
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发", seed=SEED)
|
||||
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮", seed=SEED)
|
||||
wf["16"]["inputs"]["unet_name"] = MODEL
|
||||
wf["16"]["inputs"]["weight_dtype"] = DTYPE
|
||||
wf["1"]["inputs"]["steps"] = steps
|
||||
|
||||
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
|
||||
</div>
|
||||
<div class="controls" style="margin-top:16px">
|
||||
<label>提示词:</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮">
|
||||
</div>
|
||||
<div style="text-align:center; margin-top:16px">
|
||||
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ resp = requests.post(
|
||||
"image": ("original.jpg", img_data, "image/jpeg"),
|
||||
"mask": ("mask.png", mask_data, "image/png"),
|
||||
},
|
||||
data={"prompt": "填充遮罩区域的头发"},
|
||||
data={"prompt": "填充遮罩区域的头发,人物增加一点磨皮"},
|
||||
timeout=600,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 网关内部转发可能耗时较长(worker 处理 + 排队)
|
||||
proxy_read_timeout 90s;
|
||||
proxy_read_timeout 660s;
|
||||
proxy_send_timeout 90s;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
(() => {
|
||||
const form = document.getElementById("client-auth");
|
||||
const message = document.getElementById("client-auth-status");
|
||||
const loginButton = form.querySelector('button[type="submit"]');
|
||||
const checkButton = document.getElementById("client-auth-check");
|
||||
const refreshButton = document.getElementById("client-auth-refresh");
|
||||
let accessToken;
|
||||
let refreshToken;
|
||||
let accessExpiry = 0;
|
||||
|
||||
async function requestToken(operation, data) {
|
||||
const response = await fetch("/api/v1/auth/" + operation, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.code !== 0) throw new Error(result.message || "鉴权失败");
|
||||
accessToken = result.data.access_token;
|
||||
accessExpiry = Date.now() + result.data.expires_in * 1000;
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function runAction(action) {
|
||||
loginButton.disabled = checkButton.disabled = refreshButton.disabled = true;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
message.textContent = error.message;
|
||||
} finally {
|
||||
loginButton.disabled = checkButton.disabled = false;
|
||||
refreshButton.disabled = !refreshToken;
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(form);
|
||||
form.elements.namedItem("password").value = "";
|
||||
runAction(async () => {
|
||||
const result = await requestToken("login", {
|
||||
username: data.get("username"),
|
||||
password: data.get("password"),
|
||||
});
|
||||
refreshToken = result.refresh_token;
|
||||
message.textContent = "已登录。访问令牌过期时会自动刷新。";
|
||||
});
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
if (!refreshToken) throw new Error("请先登录,再提交图片。");
|
||||
try {
|
||||
await requestToken("refresh", { refresh_token: refreshToken });
|
||||
} catch (error) {
|
||||
accessToken = refreshToken = undefined;
|
||||
refreshButton.disabled = true;
|
||||
message.textContent = "请重新登录。";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
checkButton.addEventListener("click", () => runAction(async () => {
|
||||
const path = "/api/v1/auth/check";
|
||||
const anonymous = await fetch(path);
|
||||
if (anonymous.status !== 401) throw new Error("无令牌检查返回 HTTP " + anonymous.status);
|
||||
if (!accessToken) {
|
||||
message.textContent = "无令牌已拒绝(HTTP 401)。登录后可验证访问令牌。";
|
||||
return;
|
||||
}
|
||||
const response = await fetch(path, { headers: { Authorization: "Bearer " + accessToken } });
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.code !== 0) throw new Error(result.message || "访问令牌检查失败");
|
||||
message.textContent = "鉴权通过:无令牌返回 HTTP 401,有效令牌返回 HTTP 200。";
|
||||
}));
|
||||
|
||||
refreshButton.addEventListener("click", () => runAction(async () => {
|
||||
await refresh();
|
||||
message.textContent = "访问令牌已刷新,有效期 15 分钟。";
|
||||
}));
|
||||
|
||||
window.hairClientFetch = async (path, options) => {
|
||||
if (Date.now() >= accessExpiry || !accessToken) await refresh();
|
||||
const send = () => {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Authorization", "Bearer " + accessToken);
|
||||
return fetch(path, { ...options, headers });
|
||||
};
|
||||
const response = await send();
|
||||
if (response.status !== 401) return response;
|
||||
await refresh();
|
||||
return send();
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 8px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
|
||||
|
||||
/* 上传区 */
|
||||
.upload-card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 24px; }
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.file-input { flex: 1; min-width: 200px; }
|
||||
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.btn-outline:hover { background: #f9fafb; }
|
||||
.upload-hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
|
||||
|
||||
/* 状态提示 */
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
/* 结果双栏 */
|
||||
.results { display: flex; gap: 24px; }
|
||||
.results > div { flex: 1; min-width: 0; background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden; }
|
||||
.results .panel-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
|
||||
|
||||
/* JSON 面板 */
|
||||
.json-panel { max-height: 600px; overflow: auto; }
|
||||
.json-content { padding: 16px 18px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||||
|
||||
/* 图片面板 — 叠加显示 */
|
||||
.img-panel { display: flex; align-items: center; justify-content: center; min-height: 300px; background: #222; position: relative; }
|
||||
.img-stack:not([hidden]) { max-width: 100%; position: relative; display: inline-block; line-height: 0; }
|
||||
.img-stack img:not([hidden]) { display: block; max-width: min(100%, 520px); max-height: 620px; object-fit: contain; }
|
||||
.img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
|
||||
.img-stack .layer-base[hidden] + .layer-anno { position: static; width: auto; height: auto; }
|
||||
.img-panel .placeholder { color: #9ca3af; font-size: 14px; }
|
||||
|
||||
/* 叠加控制 */
|
||||
.anno-controls { display: flex; gap: 12px; align-items: center; font-size: 12px; color: #9ca3af; }
|
||||
.anno-controls label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
||||
.anno-controls input[type=range] { width: 60px; }
|
||||
|
||||
/* 指标卡片 */
|
||||
.metrics { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.metric { background: #f9fafb; border-radius: 8px; padding: 12px 14px; }
|
||||
.metric .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.metric .value { font-size: 18px; font-weight: 700; color: #111827; margin-top: 2px; }
|
||||
.metric .unit { font-size: 12px; color: #6b7280; font-weight: 400; }
|
||||
|
||||
@media (max-width: 768px) { .results { flex-direction: column; } }
|
||||
.hidden { display: none !important; }
|
||||
.auth-fields { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
|
||||
.auth-fields label { display: grid; gap: 4px; font-size: 13px; }
|
||||
.auth-fields input { border: 1px solid #d1d5db; border-radius: 8px; padding: 8px 10px; }
|
||||
.auth-fields button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.diff-list { color: #6b7280; font-size: 13px; line-height: 1.7; margin: -14px 0 24px 18px; }
|
||||
#statusBar { margin: 12px 0 0; }
|
||||
@@ -0,0 +1,156 @@
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const form = $("measure-test");
|
||||
const { endpoint, totalHeightLabel } = form.dataset;
|
||||
const fileInput = $("imageFile");
|
||||
const submitButton = $("submitBtn");
|
||||
const clearButton = $("clearBtn");
|
||||
const copyButton = $("copyBtn");
|
||||
const metrics = $("metricsBar");
|
||||
const results = $("resultsArea");
|
||||
const jsonContent = $("jsonContent");
|
||||
const placeholder = $("imagePlaceholder");
|
||||
const imageStack = $("imageStack");
|
||||
const baseImage = $("baseImage");
|
||||
const annotationImage = $("annotationImage");
|
||||
let originalImageUrl;
|
||||
|
||||
function setStatus(text, type = "") {
|
||||
const status = $("statusBar");
|
||||
status.textContent = text;
|
||||
status.className = type ? "status " + type : "status";
|
||||
}
|
||||
|
||||
function resetResults() {
|
||||
results.classList.add("hidden");
|
||||
metrics.classList.add("hidden");
|
||||
jsonContent.textContent = "";
|
||||
imageStack.hidden = true;
|
||||
placeholder.hidden = false;
|
||||
placeholder.textContent = "提交后显示叠加效果";
|
||||
baseImage.removeAttribute("src");
|
||||
annotationImage.removeAttribute("src");
|
||||
if (originalImageUrl) {
|
||||
URL.revokeObjectURL(originalImageUrl);
|
||||
originalImageUrl = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function updateOverlay() {
|
||||
baseImage.hidden = !$("showBase").checked;
|
||||
annotationImage.hidden = !$("showAnno").checked;
|
||||
annotationImage.style.opacity = $("annoOpacity").value / 100;
|
||||
}
|
||||
|
||||
function renderMetrics(data) {
|
||||
const courts = data.four_courts || {};
|
||||
const eyes = data.seven_eyes || {};
|
||||
const items = [[totalHeightLabel, data.face_total_height_cm]];
|
||||
if (form.hasAttribute("data-include-top-court")) items.push(["顶庭", courts.top_court_cm]);
|
||||
items.push(
|
||||
["上庭", courts.upper_court_cm],
|
||||
["中庭", courts.middle_court_cm],
|
||||
["下庭", courts.lower_court_cm],
|
||||
["单眼宽度", eyes.eye_width_cm],
|
||||
["脸宽", eyes.face_width_cm],
|
||||
["两眼间距", eyes.inter_eye_distance_cm],
|
||||
);
|
||||
|
||||
metrics.replaceChildren();
|
||||
for (const [label, value] of items) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "metric";
|
||||
card.innerHTML = '<div class="label"></div><div class="value"><span></span> <span class="unit">cm</span></div>';
|
||||
card.querySelector(".label").textContent = label;
|
||||
card.querySelector(".value span").textContent = value ?? "—";
|
||||
metrics.append(card);
|
||||
}
|
||||
metrics.classList.remove("hidden");
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const selected = fileInput.files[0];
|
||||
if (!selected) {
|
||||
setStatus("请先选择一张图片", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
fileInput.disabled = submitButton.disabled = clearButton.disabled = true;
|
||||
submitButton.textContent = "⏳ 请求中...";
|
||||
resetResults();
|
||||
setStatus("正在请求 " + endpoint, "info");
|
||||
|
||||
try {
|
||||
const file = await window.downscaleImageFile(selected);
|
||||
const body = new FormData();
|
||||
body.append("image_file", file);
|
||||
const start = performance.now();
|
||||
const response = await window.hairClientFetch(endpoint, { method: "POST", body });
|
||||
if (!response.headers.get("Content-Type")?.includes("application/json")) {
|
||||
throw new Error("服务返回 HTTP " + response.status + ",响应格式异常");
|
||||
}
|
||||
const result = await response.json();
|
||||
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
|
||||
jsonContent.textContent = JSON.stringify(result, null, 2);
|
||||
results.classList.remove("hidden");
|
||||
|
||||
if (!response.ok || result.code !== 0) {
|
||||
setStatus(`请求失败 (${elapsed}s) · HTTP ${response.status} · code ${result.code} · ${result.message}`, "error");
|
||||
placeholder.textContent = "请求失败,无标注图";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const annotationSrc = window.resolveImgSrc(data.annotated_image_url, data.annotated_image_base64, "image/png");
|
||||
if (annotationSrc) {
|
||||
originalImageUrl = URL.createObjectURL(file);
|
||||
baseImage.src = originalImageUrl;
|
||||
annotationImage.src = annotationSrc;
|
||||
updateOverlay();
|
||||
imageStack.hidden = false;
|
||||
placeholder.hidden = true;
|
||||
} else {
|
||||
placeholder.textContent = "服务未返回标注图";
|
||||
}
|
||||
renderMetrics(data);
|
||||
setStatus(`请求成功 (${elapsed}s) · request_id ${result.request_id}`, "success");
|
||||
} catch (error) {
|
||||
setStatus(error.message, "error");
|
||||
} finally {
|
||||
fileInput.disabled = submitButton.disabled = clearButton.disabled = false;
|
||||
submitButton.textContent = "🚀 提交测试";
|
||||
}
|
||||
});
|
||||
|
||||
$("showBase").addEventListener("change", updateOverlay);
|
||||
$("showAnno").addEventListener("change", updateOverlay);
|
||||
$("annoOpacity").addEventListener("input", updateOverlay);
|
||||
clearButton.addEventListener("click", () => {
|
||||
resetResults();
|
||||
fileInput.value = "";
|
||||
setStatus("");
|
||||
});
|
||||
|
||||
copyButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonContent.textContent);
|
||||
copyButton.textContent = "✅ 已复制";
|
||||
setTimeout(() => { copyButton.textContent = "📋 复制"; }, 1500);
|
||||
} catch {
|
||||
setStatus("复制失败,请手动复制 JSON", "error");
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files.length) setStatus("已选择 " + fileInput.files[0].name, "info");
|
||||
});
|
||||
fileInput.addEventListener("dragover", (event) => event.preventDefault());
|
||||
fileInput.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer.files.length) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
fileInput.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
// Reference geometry is in input-image pixels; no screen-vertical assumptions.
|
||||
window.hairlineReferenceBox = function(points) {
|
||||
const p = id => {
|
||||
const value = points[String(id)];
|
||||
if (!value || !Number.isFinite(value.x) || !Number.isFinite(value.y)) throw new Error(`缺少 ${id} 号关键点`);
|
||||
return [value.x, value.y];
|
||||
};
|
||||
const add = (a,b) => [a[0]+b[0],a[1]+b[1]];
|
||||
const sub = (a,b) => [a[0]-b[0],a[1]-b[1]];
|
||||
const mul = (a,s) => [a[0]*s,a[1]*s];
|
||||
const dot = (a,b) => a[0]*b[0]+a[1]*b[1];
|
||||
const brow=p(9),chin=p(152),left=p(162),right=p(389);
|
||||
const delta=sub(chin,brow),length=Math.hypot(...delta);
|
||||
if(length<1)throw new Error("9→152 方向无法确定");
|
||||
const up=mul(delta,-1/length),horizontal=[-up[1],up[0]],height=.7*length;
|
||||
const sidePoint=(anchor,direction,level)=>{
|
||||
const denominator=dot(direction,up);
|
||||
if(Math.abs(denominator)<1e-6)throw new Error("参考框侧边与底边平行");
|
||||
return add(anchor,mul(direction,(level-dot(sub(anchor,brow),up))/denominator));
|
||||
};
|
||||
const corners=[sidePoint(left,up,0),sidePoint(right,up,0),
|
||||
sidePoint(right,up,height),sidePoint(left,up,height)];
|
||||
if(dot(sub(corners[1],corners[0]),horizontal)<=0)throw new Error("参考框左右位置异常");
|
||||
return {corners,height,faceLength:length,
|
||||
landmarks:{9:brow,152:chin,162:left,389:right}};
|
||||
};
|
||||
|
||||
window.clipHairlineToReferenceBox = function(image, points) {
|
||||
const box=window.hairlineReferenceBox(points);
|
||||
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
|
||||
if(!width||!height)throw new Error("发际线图片尚未加载");
|
||||
const canvas=document.createElement("canvas");canvas.width=width;canvas.height=height;
|
||||
const ctx=canvas.getContext("2d");
|
||||
ctx.beginPath();box.corners.forEach((p,i)=>i?ctx.lineTo(...p):ctx.moveTo(...p));ctx.closePath();
|
||||
ctx.clip();ctx.drawImage(image,0,0,width,height);
|
||||
return canvas;
|
||||
};
|
||||
|
||||
window.drawHairlineReferenceBox = function(ctx, points, crop, width, height) {
|
||||
const box=window.hairlineReferenceBox(points);
|
||||
const [x0,y0,x1,y1]=crop;
|
||||
const screen=p=>[(p[0]-x0)/(x1-x0)*width,(p[1]-y0)/(y1-y0)*height];
|
||||
const color="#e33399";
|
||||
ctx.save();ctx.strokeStyle=color;ctx.lineWidth=Math.max(2.5,width/280);ctx.lineJoin="round";
|
||||
ctx.beginPath();box.corners.forEach((p,i)=>{const q=screen(p);i?ctx.lineTo(...q):ctx.moveTo(...q);});ctx.closePath();ctx.stroke();
|
||||
ctx.lineWidth=1.4;ctx.setLineDash([7,6]);ctx.beginPath();ctx.moveTo(...screen(box.landmarks[9]));ctx.lineTo(...screen(box.landmarks[152]));ctx.stroke();ctx.setLineDash([]);
|
||||
const fontSize=Math.max(13,width/43);ctx.font=`600 ${fontSize}px sans-serif`;
|
||||
for(const id of [9,152,162,389]){
|
||||
const [x,y]=screen(box.landmarks[id]);
|
||||
if(x<0||x>width||y<0||y>height)continue;
|
||||
ctx.fillStyle=color;ctx.beginPath();ctx.arc(x,y,Math.max(3,width/160),0,Math.PI*2);ctx.fill();
|
||||
const label=String(id),tw=ctx.measureText(label).width;
|
||||
const tx=Math.max(3,Math.min(width-tw-8,x+7)),ty=Math.max(fontSize+4,Math.min(height-5,y-7));
|
||||
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(label,tx,ty);
|
||||
}
|
||||
const top=screen([(box.corners[2][0]+box.corners[3][0])/2,(box.corners[2][1]+box.corners[3][1])/2]);
|
||||
const text="70% · 9→152",tw=ctx.measureText(text).width;
|
||||
const tx=Math.max(4,Math.min(width-tw-6,top[0]-tw/2)),ty=Math.max(fontSize+4,top[1]-8);
|
||||
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(text,tx,ty);
|
||||
ctx.restore();
|
||||
};
|
||||
@@ -52,4 +52,34 @@
|
||||
}
|
||||
|
||||
window.downscaleImageFile = downscaleImageFile;
|
||||
/**
|
||||
* 将接口返回的图片字段规范成 <img src> 可用值。
|
||||
* 同时兼容:
|
||||
* - 网关改写后的 *_url(http/https/相对路径)
|
||||
* - worker 直连的 *_base64(原始 base64,或已带 data:image/...;base64, 前缀)
|
||||
* @param {string|null|undefined} url
|
||||
* @param {string|null|undefined} b64
|
||||
* @param {string} [mimeHint='image/jpeg'] 原始 base64 时的 MIME 提示(会按魔数再嗅探)
|
||||
* @returns {string}
|
||||
*/
|
||||
function resolveImgSrc(url, b64, mimeHint) {
|
||||
var v = url || b64 || '';
|
||||
if (!v || typeof v !== 'string') return '';
|
||||
v = v.trim();
|
||||
if (!v) return '';
|
||||
// 已是可用 URL / data URI / blob / 协议相对 URL
|
||||
if (/^(https?:|blob:|data:|\/\/)/i.test(v)) return v;
|
||||
// 同站相对路径(仅匹配明确的 /static/...;勿把 JPEG base64 的 /9j/ 前缀当路径)
|
||||
if (/^\/static\//.test(v)) return v;
|
||||
// 原始 base64:去掉可能残留的 data URI 前缀后再拼,避免双重前缀导致无法显示
|
||||
var raw = v.replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/i, '');
|
||||
if (!raw) return '';
|
||||
var mime = mimeHint || 'image/jpeg';
|
||||
if (raw.indexOf('iVBOR') === 0) mime = 'image/png';
|
||||
else if (raw.indexOf('/9j/') === 0) mime = 'image/jpeg';
|
||||
else if (raw.indexOf('R0lGOD') === 0) mime = 'image/gif';
|
||||
else if (raw.indexOf('UklGR') === 0) mime = 'image/webp';
|
||||
return 'data:' + mime + ';base64,' + raw;
|
||||
}
|
||||
window.resolveImgSrc = resolveImgSrc;
|
||||
})();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>旷视五接口 — 前端接入说明</title>
|
||||
<title>旷视接口 — 客户端接入说明</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f8f9fb; color: #1e293b; line-height: 1.6; }
|
||||
@@ -33,6 +33,10 @@
|
||||
.endpoint .url { font-size: 15px; font-weight: 700; margin-bottom: 6px; }
|
||||
.endpoint .desc { color: #64748b; font-size: 13px; margin-bottom: 10px; }
|
||||
.endpoint .params { font-size: 12px; color: #94a3b8; margin-bottom: 8px; }
|
||||
.guide-step { margin-top: 18px; }
|
||||
.guide-step h3 { font-size: 15px; margin-bottom: 5px; }
|
||||
.guide-step p, .guide-step pre { margin-bottom: 8px; }
|
||||
.guide-list { padding-left: 22px; margin-top: 8px; }
|
||||
|
||||
.link { color: #2563eb; text-decoration: none; font-size: 13px; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
@@ -46,17 +50,24 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>📡 旷视五接口 — 前端接入说明</h1>
|
||||
<h1>📡 旷视接口 — 客户端接入说明</h1>
|
||||
<p class="subtitle">Base URL: <code>https://hair.xiangsilian.com</code> | 在线文档: <a href="/docs" class="link">/docs</a> | 在线测试页见底部</p>
|
||||
|
||||
<div class="toc">
|
||||
<a href="#overview">通用约定</a>
|
||||
<a href="#client-auth">客户端鉴权</a>
|
||||
<a href="#if1">接口1</a>
|
||||
<a href="#if11">接口11</a>
|
||||
<a href="#if2">接口2</a>
|
||||
<a href="#if22">接口22</a>
|
||||
<a href="#if3">接口3</a>
|
||||
<a href="#if4">接口4</a>
|
||||
<a href="#if42">接口42</a>
|
||||
<a href="#if5">接口5</a>
|
||||
<a href="#if52">接口52</a>
|
||||
<a href="#if6">接口6</a>
|
||||
<a href="#if61">接口61</a>
|
||||
<a href="#if72">接口72</a>
|
||||
<a href="#errors">错误码</a>
|
||||
<a href="#test">在线测试</a>
|
||||
</div>
|
||||
@@ -67,11 +78,13 @@
|
||||
<div class="card-body">
|
||||
<table>
|
||||
<tr><td>协议</td><td>HTTPS</td></tr>
|
||||
<tr><td>请求方式</td><td>全部 <span class="badge post">POST</span></td></tr>
|
||||
<tr><td>请求方式</td><td>业务接口使用 POST。验证令牌使用 GET。</td></tr>
|
||||
<tr><td>编码</td><td>UTF-8</td></tr>
|
||||
<tr><td>Content-Type</td><td><code>multipart/form-data</code></td></tr>
|
||||
<tr><td>Content-Type</td><td>传图使用 <code>multipart/form-data</code>,登录和刷新使用 <code>application/json</code></td></tr>
|
||||
<tr><td>客户端鉴权</td><td>11、22、42、52、61、72 使用 <a class="link" href="#client-auth">Bearer 访问令牌</a>。</td></tr>
|
||||
<tr><td>图片参数</td><td>三选一:<code>image_file</code>(文件上传)/ <code>image_url</code>(URL)/ <code>image_base64</code>(base64 + 前缀)</td></tr>
|
||||
<tr><td>图片格式</td><td>JPG / PNG,单人正面照</td></tr>
|
||||
<tr><td>请求体上限</td><td><strong>2MB</strong>(nginx 层限制)。超限直接返回 HTTP <strong>413</strong>,不会进入业务逻辑。建议图片文件 ≤1MB;<code>image_base64</code> 传图会膨胀约 33%,编码前原图建议 ≤1.4MB。手机直拍照片通常 3~8MB,<strong>上传前务必先压缩</strong>(如最长边缩到 1500px、JPEG 质量 80)。<code>image_url</code> 方式图片在远端,不受此限制</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:14px;font-size:13px"><strong>统一响应结构</strong>:</p>
|
||||
@@ -84,6 +97,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="client-auth">
|
||||
<h2>客户端鉴权</h2>
|
||||
<div class="card-body">
|
||||
<p>客户端发送 <code>Authorization: Bearer <access_token></code>。账号为 <code>yonghe</code>,密码由对接人员提供。以下接口共用同一个访问令牌。</p>
|
||||
<p><a href="#if11">接口 11</a>、<a href="#if22">接口 22</a>、<a href="#if42">接口 42</a>、<a href="#if52">接口 52</a>、<a href="#if61">接口 61</a>、<a href="#if72">接口 72</a></p>
|
||||
|
||||
<div class="guide-step">
|
||||
<h3>1. 登录获取令牌</h3>
|
||||
<pre>BASE_URL=https://hair.xiangsilian.com
|
||||
curl -sS -X POST "$BASE_URL/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"yonghe","password":"<对接密码>"}'</pre>
|
||||
<p>成功返回 HTTP 200 且 <code>code=0</code>,<code>data</code> 的结构如下:</p>
|
||||
<pre>{
|
||||
"access_token": "<访问令牌>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"refresh_token": "<刷新令牌>",
|
||||
"refresh_expires_in": 604800
|
||||
}</pre>
|
||||
<p>有效期以秒为单位,访问令牌 15 分钟,刷新令牌 7 天。将响应里的令牌分别赋值给下方示例的 <code>ACCESS_TOKEN</code> 和 <code>REFRESH_TOKEN</code>。</p>
|
||||
</div>
|
||||
|
||||
<div class="guide-step">
|
||||
<h3>2. 验证令牌并发起业务请求</h3>
|
||||
<pre>curl -i "$BASE_URL/api/v1/auth/check" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN"</pre>
|
||||
<p>有效时返回 HTTP 200 和 <code>code=0</code>。缺少令牌、令牌无效或已过期时返回 HTTP 401 和 <code>code=1009</code>。业务请求使用同一个请求头,图片字段在 <code>image_file</code>、<code>image_url</code>、<code>image_base64</code> 中三选一。</p>
|
||||
</div>
|
||||
|
||||
<div class="guide-step">
|
||||
<h3>3. 刷新并重试</h3>
|
||||
<pre>curl -sS -X POST "$BASE_URL/api/v1/auth/refresh" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"refresh_token\":\"$REFRESH_TOKEN\"}"</pre>
|
||||
<ol class="guide-list">
|
||||
<li>访问令牌到期前可主动刷新。业务请求返回 HTTP 401 且 <code>code=1009</code> 时,也可刷新一次。</li>
|
||||
<li>刷新成功后,用返回的 <code>data.access_token</code> 和 <code>data.expires_in</code> 更新本地状态,再用新令牌重试原请求一次。</li>
|
||||
<li>刷新响应不含新刷新令牌,继续使用登录时的 <code>refresh_token</code>,直到它的原有效期结束。刷新返回 401 时,清除令牌并重新登录。</li>
|
||||
</ol>
|
||||
<p>登录或刷新返回 HTTP 503 且 <code>code=1007</code> 时,请联系网关维护人员检查令牌服务配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口1 ======== -->
|
||||
<div class="card" id="if1">
|
||||
<h2>1. 四庭七眼测量 <span class="badge post">POST</span> <code>/api/v1/face/measure</code></h2>
|
||||
@@ -106,6 +164,7 @@
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>标注图竖线坐标,与标注 PNG 同一套几何。原图像素:<code>y_top</code> / <code>y_bottom</code> 为竖线共用上下端(头顶之上到下巴之下,两端各伸出约短边的 3%,可能略超出图片);<code>lines</code> 从左到右,每项 <code>{ name, x }</code>。name:<code>left_head_edge</code>(人头最左,耳朵不可见则不出现,此时 eye1 为 null)、<code>left_cheek</code>、<code>left_eye_outer</code>、<code>left_eye_inner</code>、<code>right_eye_inner</code>、<code>right_eye_outer</code>、<code>right_cheek</code>、<code>right_head_edge</code>(人头最右,不可见则不出现,此时 eye7 为 null)</td></tr>
|
||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
|
||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
@@ -126,10 +185,22 @@ const { code, data } = await res.json();
|
||||
// data.annotated_image_url → 标注 PNG
|
||||
// data.face_total_height_cm → 全脸高度
|
||||
// data.four_courts.ratios → { top_court: 0.25, ... }
|
||||
// data.vertical_lines → { y_top, y_bottom, lines: [{ name, x }, ...] }
|
||||
// 前端叠加显示:原图 + annotated_image_url(absolute 叠加)</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if11">
|
||||
<h2>11. 四庭七眼测量(鉴权) <span class="badge post">POST</span> <code>/api/v1/face/measure-11</code></h2>
|
||||
<div class="card-body">
|
||||
<p>请求使用 <a href="#client-auth">Bearer 访问令牌</a>,图片参数和响应字段见<a href="#if1">接口 1</a>。返回四庭七眼测量数据和透明标注图 URL。</p>
|
||||
<pre>curl -sS -X POST "$BASE_URL/api/v1/face/measure-11" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@photo.jpg'</pre>
|
||||
<p><a href="/static/test_interface11.html" class="link">打开接口 11 测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口2 ======== -->
|
||||
<div class="card" id="if2">
|
||||
<h2>2. C端生发 <span class="badge post">POST</span> <code>/api/v1/hair/grow</code></h2>
|
||||
@@ -163,6 +234,61 @@ const { code, data } = await res.json();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if22">
|
||||
<h2>22. 生发密度与发际线高度 <span class="badge post">POST</span> <code>/api/v1/hair/grow-22</code></h2>
|
||||
<div class="card-body">
|
||||
<p>使用现有对接账号的 <a href="#client-auth">Bearer 访问令牌</a>,以 <code>multipart/form-data</code> 提交。</p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>说明</th></tr>
|
||||
<tr><td><code>image_file</code> / <code>image_url</code> / <code>image_base64</code></td><td>图片三选一。</td></tr>
|
||||
<tr><td><code>gender</code></td><td>必填,<code>female</code> 或 <code>male</code>。</td></tr>
|
||||
<tr><td><code>hair_style</code></td><td>必填,序号 1–7,多选用逗号分隔,如 <code>1,3</code>。</td></tr>
|
||||
<tr><td><code>hair_density</code></td><td>至少一档,<code>sparse</code> 稀疏、<code>normal</code> 正常、<code>dense</code> 浓密。多选时重复提交同名字段。</td></tr>
|
||||
<tr><td><code>hairline_height</code></td><td>默认 <code>0</code>,范围 <code>[-1, 1]</code>。API 负值上移,正值下移。端点移动眉心到下巴垂直像素距离的 10%。</td></tr>
|
||||
</table>
|
||||
<p>女性 1–7 依次为椭圆、花瓣、心形、直线、波浪、大花瓣、古典花瓣。男性 1–7 依次为椭圆、倒弧、M 形、直线、心形、柔和花瓣、波浪。</p>
|
||||
|
||||
<h3>前端请求示例</h3>
|
||||
<p>以下示例使用网关测试页的滑块约定,正值向上,所以提交时取反。<code>Content-Type</code> 由浏览器根据 <code>FormData</code> 自动设置。</p>
|
||||
<pre id="if22-js-example">async function grow22({ file, gender, styles, densities, slider = 0, accessToken }) {
|
||||
const body = new FormData();
|
||||
body.append("image_file", file);
|
||||
body.append("gender", gender);
|
||||
body.append("hair_style", styles.join(","));
|
||||
for (const density of densities) body.append("hair_density", density);
|
||||
body.append("hairline_height", String(-slider));
|
||||
|
||||
const response = await fetch("/api/v1/hair/grow-22", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
body,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.code !== 0) {
|
||||
throw new Error(result.message || `HTTP ${response.status}`);
|
||||
}
|
||||
return result.data.results;
|
||||
}
|
||||
|
||||
// 已登录后调用。选择 2 个发型和 2 档密度,共返回 4 项。
|
||||
// const results = await grow22({
|
||||
// file: input.files[0], gender: "female", styles: [1, 3],
|
||||
// densities: ["sparse", "dense"], slider: 0.5, accessToken,
|
||||
// });</pre>
|
||||
<p>请求返回 <code>401 / 1009</code> 时,按<a href="#client-auth">鉴权章节</a>刷新令牌后重试一次。多选高度时,按高度分别发起请求。</p>
|
||||
|
||||
<h3>展示结果与发际线预览</h3>
|
||||
<table>
|
||||
<tr><th><code>data.results[]</code> 字段</th><th>展示方式</th></tr>
|
||||
<tr><td><code>order</code>、<code>hairline_type</code>、<code>hair_density</code></td><td>标注发型和密度。多高度结果另外记录每次请求所用的高度。</td></tr>
|
||||
<tr><td><code>image_url</code></td><td>与输入照片同尺寸的透明发际线 PNG,可叠加在原图坐标的底图上。</td></tr>
|
||||
<tr><td><code>grown_image_url</code></td><td>生发图 URL。单项生成失败时返回 <code>grown_image_base64: null</code>,这一项仍保留发型和密度信息。</td></tr>
|
||||
</table>
|
||||
<p>如需复刻测试页,首次以高度 <code>0</code> 生图,出图后再用同一个 Bearer 令牌请求<a href="#if52">接口 52</a>,传 <code>normalize_image=true</code> 和 <code>generate_grow_image=false</code>。将各发型的 <code>image_middle_url</code> 叠在接口 52 的 <code>input_image_url</code> 上;如果底图改用首次生发图,先用 <code>framing.source_to_processed</code> 做同样的扶正裁切,再叠加。预览白线是设计参考线,实际出图边界以生发图为准。</p>
|
||||
<p><a href="/static/test_interface22.html" class="link">打开接口 22 测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口3 ======== -->
|
||||
<div class="card" id="if3">
|
||||
<h2>3. B端生发(医生端) <span class="badge post">POST</span> <code>/api/v1/hair/grow-b</code></h2>
|
||||
@@ -190,22 +316,23 @@ const { code, data } = await res.json();
|
||||
<div class="card" id="if4">
|
||||
<h2>4. 用户特征分析 <span class="badge post">POST</span> <code>/api/v1/face/features</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 6 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格)。</p>
|
||||
<p class="desc">上传照片 → 火山方舟豆包视觉模型分析 → 返回固定 7 项面部特征(脸型/眉形/面部年龄/动静类型/性别/基因风格/轮廓类型)。</p>
|
||||
|
||||
<p><strong>入参</strong>:image_file / image_url / image_base64 三选一。无其他参数。</p>
|
||||
|
||||
<p style="margin-top:12px"><strong>data 字段</strong></p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 6 个英文字段</strong></td></tr>
|
||||
<tr><td><code>features</code></td><td>string</td><td><strong>JSON 字符串</strong>(不是对象!客户端需 <code>JSON.parse()</code>)。解析后得到<strong>固定 7 个英文字段</strong></td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 6 个):</p>
|
||||
<p style="margin-top:8px"><strong>features 字段</strong>(固定返回 7 个):</p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>说明</th><th>字段</th><th>说明</th></tr>
|
||||
<tr><td>face_shape</td><td>脸型</td><td>eyebrow_shape</td><td>眉形</td></tr>
|
||||
<tr><td>facial_age</td><td>面部年龄区间</td><td>gender</td><td>性别</td></tr>
|
||||
<tr><td>dynamic_static_type</td><td>动静类型</td><td>gene_style</td><td>基因风格</td></tr>
|
||||
<tr><td>contour_type</td><td>轮廓类型(轮廓偏曲/轮廓适中/轮廓偏直)</td><td>—</td><td>—</td></tr>
|
||||
</table>
|
||||
|
||||
<pre>const res = await fetch('https://hair.xiangsilian.com/api/v1/face/features', {
|
||||
@@ -219,6 +346,26 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if42">
|
||||
<h2>42. 用户特征与两项判定 <span class="badge post">POST</span> <code>/api/v1/face/features-42</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">接收一张照片。先检查人脸是否完整及俯拍角度,再扶正裁切,返回接口 4 的特征与两项判定。需要 <a href="#client-auth">访问令牌</a>。</p>
|
||||
<pre>curl -sS "$BASE_URL/api/v1/face/features-42" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@portrait.jpg'</pre>
|
||||
<p><code>data.features</code> 是 JSON 字符串。成功后先执行 <code>JSON.parse(response.data.features)</code>,再读取以下字段:</p>
|
||||
<table>
|
||||
<tr><th>字段</th><th>说明</th></tr>
|
||||
<tr><td><code>face_shape</code>、<code>eyebrow_shape</code>、<code>facial_age</code>、<code>dynamic_static_type</code>、<code>gender</code>、<code>gene_style</code>、<code>contour_type</code></td><td>沿用接口 4 的面部特征</td></tr>
|
||||
<tr><td><code>zygoma_expansion</code></td><td>颧弓外扩</td></tr>
|
||||
<tr><td><code>temple_hollowing</code></td><td>太阳穴凹陷</td></tr>
|
||||
</table>
|
||||
<p>新增字段均为 <code>true</code>(命中)、<code>false</code>(未命中)或 <code>null</code>(无法测量)。两项在 <code>Ft ÷ Zy < 0.8</code> 时同时命中。</p>
|
||||
<p>未检测到人脸返回 <code>1001</code>。脸部不完整、俯拍过大或无法扶正裁切均返回 <code>1003</code>,具体重拍提示见 <code>message</code>。质检不通过时 <code>data</code> 为 <code>null</code>。</p>
|
||||
<p><a href="/static/test_interface42.html" class="link">42 号登录与特征测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口5 ======== -->
|
||||
<div class="card" id="if5">
|
||||
<h2>5. 发际线 PNG 生成 <span class="badge post">POST</span> <code>/api/v1/hairline/generate</code></h2>
|
||||
@@ -251,6 +398,7 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标,结构同接口1</td></tr>
|
||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
|
||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
@@ -261,6 +409,34 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if52">
|
||||
<h2>52. 发际线新规则与裁切 <span class="badge post">POST</span> <code>/api/v1/hairline/generate-52</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">按中庭与下庭总高度的 45% 定位 middle,支持扶正裁切与生发。需要 <a href="#client-auth">访问令牌</a>。以下示例开启扶正裁切,只获取预览:</p>
|
||||
<pre>curl -sS "$BASE_URL/api/v1/hairline/generate-52" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@portrait.jpg' \
|
||||
-F 'gender=female' \
|
||||
-F 'hair_style=1,3' \
|
||||
-F 'normalize_image=true' \
|
||||
-F 'generate_grow_image=false'</pre>
|
||||
<table>
|
||||
<tr><th>参数</th><th>说明</th></tr>
|
||||
<tr><td>图片字段</td><td>三选一,见通用约定。</td></tr>
|
||||
<tr><td><code>gender</code>、<code>hair_style</code></td><td>必填。性别为 <code>female</code> 或 <code>male</code>,发型序号为 1–7,支持逗号分隔。</td></tr>
|
||||
<tr><td><code>normalize_image</code></td><td>可选,默认 <code>false</code>。传 <code>true</code> 启用扶正裁切。</td></tr>
|
||||
<tr><td><code>generate_grow_image</code></td><td>可选,默认 <code>true</code>。只看设计曲线时传 <code>false</code>。</td></tr>
|
||||
<tr><td><code>use_mask</code>、<code>prompt</code></td><td>可选,沿用接口 5 的遮罩和提示词参数。</td></tr>
|
||||
</table>
|
||||
<ul class="guide-list">
|
||||
<li>用 <code>data.input_image_url</code> 显示当前底图,将每个发型的 <code>data.hairline_images[].image_middle_url</code> 按同尺寸叠加。中心点和 <code>guide_landmarks</code> 也使用当前底图坐标。</li>
|
||||
<li><code>original_image_url</code> 是处理前图片。需要映射坐标时,使用 <code>framing</code> 中的双向矩阵。</li>
|
||||
<li>生发成功时直接显示 <code>grown_image_url</code>。空图片保留为 <code>*_base64: null</code>,high/low 已停用。</li>
|
||||
</ul>
|
||||
<p style="margin-top:12px"><a href="/static/test_interface52.html" class="link">52 号登录与发际线测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口6 ======== -->
|
||||
<div class="card" id="if6">
|
||||
<h2>6. 四庭七眼测量 v2 <span class="badge post">POST</span> <code>/api/v1/face/measure-v2</code> <span class="badge warn">v2</span></h2>
|
||||
@@ -286,12 +462,42 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标。结构同接口1,但 <code>y_top</code> / <code>y_bottom</code> 为发际线到下巴尖(不伸出),<code>lines</code> 只有中间 6 条,无 <code>left_head_edge</code> / <code>right_head_edge</code></td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if61">
|
||||
<h2>61. 四庭七眼测量 v2(鉴权) <span class="badge post">POST</span> <code>/api/v1/face/measure-61</code></h2>
|
||||
<div class="card-body">
|
||||
<p>请求使用 <a href="#client-auth">Bearer 访问令牌</a>,图片参数和响应字段见<a href="#if6">接口 6</a>。返回去顶庭的测量数据和透明标注图 URL。</p>
|
||||
<pre>curl -sS -X POST "$BASE_URL/api/v1/face/measure-61" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@photo.jpg'</pre>
|
||||
<p><a href="/static/test_interface61.html" class="link">打开接口 61 测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if72">
|
||||
<h2>72. B 端五个关键点 <span class="badge post">POST</span> <code>/api/v1/face/b-end-points</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">接收一张正面照,返回扶正底图和透明标注层。标注包含 6 个白点、4 条虚线和 4 段厘米数。需要 <a href="#client-auth">访问令牌</a>。</p>
|
||||
<pre>curl -sS "$BASE_URL/api/v1/face/b-end-points" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-F 'image_file=@portrait.jpg'</pre>
|
||||
<table>
|
||||
<tr><th>data 字段</th><th>客户端用法</th></tr>
|
||||
<tr><td><code>input_image_url</code>、<code>annotated_image_url</code></td><td>分别为扶正裁切后的底图和透明 PNG 标注层。两图使用同一个容器、相同缩放和位置进行叠加。</td></tr>
|
||||
<tr><td><code>points</code></td><td>键为 <code>"1"</code> 到 <code>"6"</code>,值为 <code>{x, y}</code>。使用 <code>image_size.width</code> 和 <code>image_size.height</code> 缩放至显示尺寸。</td></tr>
|
||||
<tr><td><code>measurements_cm</code></td><td>键为 <code>2-1</code>、<code>2-3</code>、<code>4-6</code>、<code>5-6</code>。值为厘米数,界面使用一位小数。</td></tr>
|
||||
<tr><td><code>coordinate_space</code>、<code>px_per_cm</code></td><td>坐标系为 <code>processed_image</code>,<code>px_per_cm</code> 为每厘米估算像素数。</td></tr>
|
||||
</table>
|
||||
<p style="margin-top:12px"><a href="/static/test_interface72.html" class="link">72 号登录与标注测试页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 错误码 ======== -->
|
||||
<div class="card" id="errors">
|
||||
<h2>⚠ 错误码</h2>
|
||||
@@ -306,9 +512,10 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
|
||||
<tr><td>1007</td><td>图片参数错误 / 后端不可用</td><td>参数传错 / 服务繁忙请稍后重试</td></tr>
|
||||
<tr><td>1008</td><td>图片格式不支持</td><td>非 JPG/PNG / base64 解码失败</td></tr>
|
||||
<tr><td>1009</td><td>未授权</td><td>缺少或错误的 <code>X-Internal-Token</code>(<code>/api/*</code> 路径鉴权)</td></tr>
|
||||
<tr><td>1009</td><td>未授权</td><td>HTTP 401。登录凭据错误,或所需的访问令牌缺失、无效或过期。按 <a href="#client-auth">鉴权流程</a> 处理。</td></tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:8px">注:1004 仍在使用(接口2/5 的 gender 校验);接口7(grow-v2)已弃用,请改用接口2。</p>
|
||||
<p style="font-size:12px;color:#dc2626;margin-top:8px">先检查 HTTP 状态和 <code>Content-Type</code>,再解析 JSON。HTTP 200 仍需检查 <code>code</code>,只有 <code>0</code> 表示成功。HTTP 401 的 JSON 用于鉴权提示与刷新。上传超限可能返回 HTTP 413,网关异常可能返回 502/504,这些响应可能是 HTML。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -319,11 +526,20 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<table>
|
||||
<tr><th>接口</th><th>测试页</th><th>功能</th></tr>
|
||||
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
|
||||
<tr><td>11. 四庭七眼(鉴权)</td><td><a href="/static/test_interface11.html" class="link">/static/test_interface11.html</a></td><td>登录、测试鉴权、刷新令牌、四庭七眼测量</td></tr>
|
||||
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
|
||||
<tr><td>22. 生发密度与发际线高度</td><td><a href="/static/test_interface22.html" class="link">/static/test_interface22.html</a></td><td>登录后生成,支持密度多选和发际线高度预览</td></tr>
|
||||
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
|
||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
|
||||
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 7项面部特征 + 原始JSON</td></tr>
|
||||
<tr><td>42. 用户特征与两项判定</td><td><a href="/static/test_interface42.html" class="link">/static/test_interface42.html</a></td><td>登录、测试鉴权、刷新令牌、上传照片</td></tr>
|
||||
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
||||
<tr><td>52. 发际线新规则</td><td><a href="/static/test_interface52.html" class="link">/static/test_interface52.html</a></td><td>登录、测试鉴权、刷新令牌、发际线预览与生发</td></tr>
|
||||
<tr><td>72. B 端五个关键点</td><td><a href="/static/test_interface72.html" class="link">/static/test_interface72.html</a></td><td>登录、测试鉴权、刷新令牌、关键点和虚线标注</td></tr>
|
||||
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
|
||||
<tr><td>61. 四庭七眼 v2(鉴权)</td><td><a href="/static/test_interface61.html" class="link">/static/test_interface61.html</a></td><td>登录、测试鉴权、刷新令牌、三庭测量</td></tr>
|
||||
<tr><td>9. 头发遮罩</td><td><a href="/static/test_interface9.html" class="link">/static/test_interface9.html</a></td><td>上传照片 → 头发遮罩 + 分步可视化</td></tr>
|
||||
<tr><td>10. 外缘膨胀带</td><td><a href="/static/test_interface10.html" class="link">/static/test_interface10.html</a></td><td>上传照片 → 头部外缘带状遮罩 + 分步可视化</td></tr>
|
||||
<tr><td>99. 人脸特殊点</td><td><a href="/static/test_interface99.html" class="link">/static/test_interface99.html</a></td><td>上传照片 → 60 关键点 + 5 发际线点坐标(<strong>测试大图请先压缩到 2MB 内</strong>,见通用约定)</td></tr>
|
||||
</table>
|
||||
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
|
||||
完整 API 文档:<a href="/docs" class="link">/docs</a>(Swagger UI)
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const TOKEN = 'dev-shared-secret-2026';
|
||||
|
||||
const FEAT_LABELS = {
|
||||
face_height: '脸高 (px)',
|
||||
@@ -182,7 +181,6 @@ async function submitTest() {
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/debug/face-shape', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Internal-Token': TOKEN },
|
||||
body: fd,
|
||||
});
|
||||
const json = await r.json();
|
||||
|
||||
@@ -150,7 +150,7 @@ async function submitTest() {
|
||||
form.append('image_file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ async function submitTest() {
|
||||
form.append('dilate_cm', $('dilateCm').value || '2.0');
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
|
||||
const json = await resp.json();
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
|
||||
|
||||
@@ -3,413 +3,76 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>接口11 — 发际线生发 测试页</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 22px; margin-bottom: 8px; }
|
||||
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; line-height: 1.6; }
|
||||
h2 { font-size: 16px; margin: 24px 0 12px; }
|
||||
|
||||
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
|
||||
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
|
||||
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
|
||||
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
|
||||
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; line-height: 1.6; }
|
||||
|
||||
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; margin-top: 16px; }
|
||||
.pf { display: flex; flex-direction: column; gap: 4px; }
|
||||
.pf label { font-size: 13px; font-weight: 600; }
|
||||
.pf .desc { font-size: 11px; color: #9ca3af; font-weight: 400; }
|
||||
.pf select, .pf input[type=number], .pf input[type=text] { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
|
||||
.pf .row { display: flex; gap: 8px; align-items: center; }
|
||||
.pf .row input[type=number] { width: 80px; }
|
||||
.pf .row input[type=range] { flex: 1; }
|
||||
.chk { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
|
||||
.status.info { background: #dbeafe; color: #1e40af; display: block; }
|
||||
.status.error { background: #fee2e2; color: #991b1b; display: block; }
|
||||
.status.success { background: #d1fae5; color: #065f46; display: block; }
|
||||
|
||||
.metrics { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.metric { background: #f9fafb; border: 1px solid #eee; border-radius: 8px; padding: 10px 16px; min-width: 110px; }
|
||||
.metric .label { font-size: 12px; color: #888; }
|
||||
.metric .value { font-size: 17px; font-weight: 700; color: #111; }
|
||||
|
||||
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; }
|
||||
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; }
|
||||
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
|
||||
|
||||
.big img { max-height: 520px; object-fit: contain; }
|
||||
|
||||
.json-panel { max-height: 320px; overflow: auto; background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px; font: 12px/1.5 Consolas, Monaco, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||
.hidden { display: none; }
|
||||
|
||||
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
|
||||
.lightbox img { max-width: 95%; max-height: 95%; }
|
||||
</style>
|
||||
<script src="/static/img_downscale.js"></script>
|
||||
<title>接口11 — 四庭七眼测量 测试页</title>
|
||||
<link rel="stylesheet" href="/static/face_measure_auth_test.css">
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<script src="/static/img_downscale.js" defer></script>
|
||||
<script src="/static/face_measure_auth_test.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>接口11 — 发际线生发</h1>
|
||||
<div class="subtitle">
|
||||
接口9 头发遮罩 → change_hair 换发型(发际线类型 LoRA)→ 严格按遮罩贴回原图 → 接缝羽化融合。<br>
|
||||
输入:发际线较高 / 头发稀少的正脸图 + 发际线类型 ID。输出:同一个人同一发型、压低发际线后的图。每一步可视化,所有可调参数都在下面。
|
||||
</div>
|
||||
<h1>📐 四庭七眼测量 — 接口测试</h1>
|
||||
<p class="subtitle">POST /api/v1/face/measure-11 | 上传正面照 → 原图+标注叠加显示</p>
|
||||
|
||||
<div class="card">
|
||||
<form id="client-auth" class="upload-card" method="post" action="/api/v1/auth/login" aria-label="账号登录">
|
||||
<div class="auth-fields">
|
||||
<label>用户名 <input name="username" autocomplete="username" required></label>
|
||||
<label>密码 <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn btn-primary btn-sm" type="submit">登录</button>
|
||||
<button class="btn btn-outline btn-sm" id="client-auth-check" type="button">测试鉴权</button>
|
||||
<button class="btn btn-outline btn-sm" id="client-auth-refresh" type="button" disabled>刷新令牌</button>
|
||||
</div>
|
||||
<p id="client-auth-status" class="upload-hint" role="status">请使用对接账号登录后提交图片。</p>
|
||||
</form>
|
||||
|
||||
<!-- 上传区 -->
|
||||
<form class="upload-card" id="measure-test" data-endpoint="/api/v1/face/measure-11"
|
||||
data-total-height-label="全脸总高" data-include-top-court>
|
||||
<div class="upload-row">
|
||||
<input type="file" id="imageFile" accept="image/*">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
|
||||
<button class="btn btn-outline" onclick="clearResults()">清空</button>
|
||||
<div class="file-input">
|
||||
<input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="submitBtn" type="submit">🚀 提交测试</button>
|
||||
<button class="btn btn-outline btn-sm" id="clearBtn" type="button">清除结果</button>
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px; font-weight:600; white-space:nowrap">X-Internal-Token</label>
|
||||
<input type="text" id="token" placeholder="直连 worker 需填;走网关可留空" value="dev-shared-secret-2026"
|
||||
style="flex:1; min-width:200px; padding:8px; border:1px solid #ddd; border-radius:8px">
|
||||
<div class="upload-hint">
|
||||
支持 JPG / PNG 正面照 | 也可拖拽图片到文件选择框
|
||||
</div>
|
||||
<div id="statusBar" class="status" role="status"></div>
|
||||
</form>
|
||||
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
<label>gen_backend <span class="desc">生成后端</span></label>
|
||||
<select id="genBackend">
|
||||
<option value="swaphair">swaphair(换发型LoRA,应用发际线类型)</option>
|
||||
<option value="hairgrow">hairgrow(区域生发inpaint,压低发际线)</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 指标速览 -->
|
||||
<div id="metricsBar" class="metrics hidden"></div>
|
||||
|
||||
<div class="pf" data-show="gen:swaphair">
|
||||
<label>发际线类型 ID <span class="desc">= change_hair hair_id(仅swaphair)</span></label>
|
||||
<select id="hairlineId">
|
||||
<option value="chang_zhixian">chang_zhixian(直线)</option>
|
||||
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
|
||||
<option value="chang_bolang">chang_bolang(波浪)</option>
|
||||
<option value="chang_xinxing">chang_xinxing(心形)</option>
|
||||
<option value="chang_huaban">chang_huaban(花瓣)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pf" data-show="gen:swaphair">
|
||||
<label>swap_mode <span class="desc">换发型取图模式</span></label>
|
||||
<select id="swapMode">
|
||||
<option value="ext_mask">ext_mask(改造换发型,用接口9遮罩重绘)</option>
|
||||
<option value="as_is">as_is(不改换发型,贴回再裁)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>seg_model <span class="desc">头发分割模型</span></label>
|
||||
<select id="segModel">
|
||||
<option value="segformer">segformer(默认)</option>
|
||||
<option value="bisenet">bisenet</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>erode_cm <span class="desc">baseline参考内缩(cm)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="erodeCm" min="0" max="5" step="0.1" value="0.6">
|
||||
<input type="range" id="erodeRange" min="0" max="3" step="0.1" value="0.6">
|
||||
<!-- 结果双栏 -->
|
||||
<div class="results hidden" id="resultsArea">
|
||||
<!-- 左:图片叠加 -->
|
||||
<div>
|
||||
<div class="panel-header">
|
||||
<span>🖼️ 原图 + 标注叠加</span>
|
||||
<div class="anno-controls">
|
||||
<label><input type="checkbox" id="showBase" checked> 底图</label>
|
||||
<label><input type="checkbox" id="showAnno" checked> 标注层</label>
|
||||
<label>透明度 <input type="range" id="annoOpacity" min="0" max="100" value="100"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>hairline_push_cm <span class="desc">发际线外推距离(cm)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="1.0">
|
||||
<input type="range" id="hairlinePushRange" min="0" max="3" step="0.1" value="1.0">
|
||||
<div class="img-panel" id="imgPanel">
|
||||
<span id="imagePlaceholder" class="placeholder">提交后显示叠加效果</span>
|
||||
<div class="img-stack" id="imageStack" hidden>
|
||||
<img class="layer-base" id="baseImage" alt="原图">
|
||||
<img class="layer-anno" id="annotationImage" alt="标注层">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>hairline_edge <span class="desc">发际线提取方式</span></label>
|
||||
<select id="hairlineEdge">
|
||||
<option value="column">column(逐列最低点,稳定)</option>
|
||||
<option value="contour">contour(形态学轮廓,精确)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>mb_levels <span class="desc">多频段金字塔层数</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="mbLevels" min="2" max="6" step="1" value="5">
|
||||
<input type="range" id="mbLevelsRange" min="2" max="6" step="1" value="5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf">
|
||||
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="edgeErodePx" min="0" max="40" step="1" value="3">
|
||||
<input type="range" id="edgeErodeRange" min="0" max="30" step="1" value="3">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf" data-show="gen:swaphair">
|
||||
<label>denoising_strength <span class="desc">换发型重绘强度(仅swaphair)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="denoise" min="0.2" max="1.0" step="0.05" value="0.6">
|
||||
<input type="range" id="denoiseRange" min="0.2" max="1.0" step="0.05" value="0.6">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf" data-show="gen:hairgrow">
|
||||
<label>hairgrow_strength <span class="desc">区域生发强度(仅hairgrow)</span></label>
|
||||
<div class="row">
|
||||
<input type="number" id="hgStrength" min="0.2" max="1.0" step="0.05" value="0.75">
|
||||
<input type="range" id="hgStrengthRange" min="0.2" max="1.0" step="0.05" value="0.75">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf" data-show="gen:swaphair">
|
||||
<label>is_hr <span class="desc">高清模式</span></label>
|
||||
<div class="chk"><input type="checkbox" id="isHr"><span style="font-size:13px">1152×1536(否则 576×768)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">
|
||||
发际线类型 ID 必须是 change_hair 已训练的 hair_id(下拉为已有的 5 个)。参数改动后点「提交测试」重新计算,设置自动记住。<br>
|
||||
换发型走 GPU + SD img2img,单次约 10~15s;is_hr 更慢。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status hidden" id="statusBar"></div>
|
||||
|
||||
<div id="resultsArea" class="hidden">
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">🎯 最终结果对比</h2>
|
||||
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr))">
|
||||
<div class="step big"><div class="cap">输入原图</div><img id="finalInput"></div>
|
||||
<div class="step big"><div class="cap">最终结果 <small>按遮罩贴回 + 接缝融合</small></div><img id="finalOut"></div>
|
||||
<!-- 右:JSON -->
|
||||
<div>
|
||||
<div class="panel-header">
|
||||
<span>📋 JSON 响应</span>
|
||||
<button class="btn btn-outline btn-sm" id="copyBtn" type="button">📋 复制</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">📏 参数与指标</h2>
|
||||
<div class="metrics" id="metricsBar"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">🪜 分步可视化</h2>
|
||||
<div class="steps" id="stepsGrid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
|
||||
<span>原始 JSON</span>
|
||||
<button class="btn btn-outline" style="padding:6px 14px; font-size:13px" onclick="copyJson()">复制</button>
|
||||
</h2>
|
||||
<div class="json-panel" id="jsonContent"></div>
|
||||
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
|
||||
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const ENDPOINT = '/api/v1/hairline/grow';
|
||||
|
||||
// 分步展示的图(按算法文档 4 步;key 前缀、标题、副标题)
|
||||
const STEPS = [
|
||||
// 遮罩计算全过程(接口9 子步骤,蓝→红)
|
||||
{ key: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline(151中心点标红、其余标绿)' },
|
||||
{ key: 'upper_overlay', title: '①-b 分割线上半区', sub: '青=baseline 以上区域(裁剪范围)' },
|
||||
{ key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=分割模型原始头发像素(segformer/bisenet)' },
|
||||
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=头发向下填充含额头(延伸到图底,未裁剪)' },
|
||||
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=top_fill ∩ 上半区(头发+额头,底=基线)' },
|
||||
// pushed 模式专有(非 pushed 模式无图)
|
||||
{ key: 'hairline_overlay', title: '①-f 头发内轮廓线', sub: '绿=头发内轮廓(额头弧+两侧到下颌) + 黄=baseline(仅pushed)' },
|
||||
{ key: 'pushed_overlay', title: '①-g 外推发际线', sub: '青=外推线(进头发push_cm) + 红=外推遮罩(仅pushed)' },
|
||||
// 最终遮罩
|
||||
{ key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=遮罩区(含额头,外缘内缩)' },
|
||||
{ key: 'mask', title: '① 纯遮罩', sub: '白=生成/贴回区(传给换发型作遮罩 & 贴回)' },
|
||||
{ key: 'swap_raw', title: '② 生成全帧', sub: 'change_hair 生成,已对齐原图' },
|
||||
{ key: 'hard_paste', title: '③ 严格按遮罩贴回', sub: '遮罩内=生成,遮罩外=原图,无融合' },
|
||||
{ key: 'alpha', title: '④ 融合权重 alpha', sub: '羽化/渐变,白=用生成图' },
|
||||
{ key: 'final', title: '④ 接缝融合(最终)', sub: '遮罩边缘自然过渡' },
|
||||
];
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||
|
||||
// 网关把 *_base64 改写为 *_url;直连 worker 保留 *_base64。两者都兼容。
|
||||
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
|
||||
|
||||
function stepCard(title, sub, src) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'step';
|
||||
const img = src ? '<img src="' + src + '" alt="' + title + '" onclick="zoom(this.src)">'
|
||||
: '<div style="padding:30px;text-align:center;color:#bbb;font-size:13px">无图</div>';
|
||||
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub || '') + '</small></div>' + img;
|
||||
return div;
|
||||
}
|
||||
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
|
||||
|
||||
function renderMetrics(d) {
|
||||
const sz = d.image_size || {}, t = d.timings_ms || {};
|
||||
const items = [
|
||||
{ label: 'gen_backend', value: d.gen_backend },
|
||||
{ label: 'hairline_id', value: d.hairline_id },
|
||||
{ label: 'swap_mode', value: d.swap_mode },
|
||||
{ label: 'seg_model', value: d.seg_model },
|
||||
{ label: 'mask', value: d.mask_type },
|
||||
{ label: 'blend', value: d.blend_method },
|
||||
{ label: 'mb_levels', value: d.mb_levels },
|
||||
{ label: 'hairline_push', value: d.hairline_push_cm + 'cm' },
|
||||
{ label: 'hairline_edge', value: d.hairline_edge },
|
||||
{ label: 'denoise', value: d.denoising_strength },
|
||||
{ label: 'px_per_cm', value: d.px_per_cm },
|
||||
{ label: '内缩', value: d.erode_cm + 'cm/' + d.erode_px + 'px' },
|
||||
{ label: '遮罩像素', value: d.mask_pixels },
|
||||
{ label: '尺寸', value: (sz.width||'?') + '×' + (sz.height||'?') },
|
||||
{ label: '耗时(遮罩/换发/融合)', value: (t.mask||0) + '/' + (t.swap||0) + '/' + (t.blend||0) + 'ms' },
|
||||
];
|
||||
$('metricsBar').innerHTML = items.map(m =>
|
||||
'<div class="metric"><div class="label">' + m.label + '</div><div class="value">' + (m.value ?? '—') + '</div></div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderResult(d) {
|
||||
const s = d.steps || {};
|
||||
$('finalInput').src = pick(s, 'input') || '';
|
||||
$('finalInput').onclick = function(){ zoom(this.src); };
|
||||
$('finalOut').src = pick(s, 'final') || '';
|
||||
$('finalOut').onclick = function(){ zoom(this.src); };
|
||||
renderMetrics(d);
|
||||
const grid = $('stepsGrid'); grid.innerHTML = '';
|
||||
// 固定 pushed:只展示 pushed 相关步骤(不展示 top_fill/closed)
|
||||
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay',
|
||||
'hairline_overlay','pushed_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
|
||||
STEPS.filter(st => showKeys.has(st.key)).forEach(st =>
|
||||
grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key))));
|
||||
}
|
||||
|
||||
async function submitTest() {
|
||||
let file = $('imageFile').files[0];
|
||||
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
|
||||
file = await window.downscaleImageFile(file);
|
||||
const t0 = performance.now();
|
||||
const btn = $('submitBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 请求中...';
|
||||
setStatus('正在请求(换发型走 GPU,约 10~15s)...', 'info');
|
||||
$('resultsArea').classList.add('hidden');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('image_file', file);
|
||||
form.append('hairline_id', $('hairlineId').value.trim());
|
||||
form.append('gen_backend', $('genBackend').value);
|
||||
form.append('hairgrow_strength', $('hgStrength').value || '0.75');
|
||||
form.append('is_hr', $('isHr').checked ? 'true' : 'false');
|
||||
form.append('seg_model', $('segModel').value);
|
||||
form.append('erode_cm', $('erodeCm').value || '0.6');
|
||||
setStatus('正在请求(固定 pushed + multiband) hairline_push_cm=' + ($('hairlinePushCm').value||'1.0')
|
||||
+ ' hairline_edge=' + $('hairlineEdge').value
|
||||
+ '(换发型走 GPU,约 10~15s)...', 'info');
|
||||
form.append('swap_mode', $('swapMode').value);
|
||||
form.append('edge_erode_px', $('edgeErodePx').value || '3');
|
||||
form.append('denoising_strength', $('denoise').value || '0.6');
|
||||
form.append('mb_levels', $('mbLevels').value || '5');
|
||||
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
|
||||
form.append('hairline_edge', $('hairlineEdge').value);
|
||||
|
||||
try {
|
||||
const headers = {};
|
||||
const tok = $('token').value.trim();
|
||||
if (tok) headers['X-Internal-Token'] = tok;
|
||||
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers, body: form });
|
||||
const json = await resp.json();
|
||||
const dt = ((performance.now() - t0) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, (k, v) =>
|
||||
(typeof v === 'string' && v.length > 120) ? v.slice(0, 60) + '…(' + v.length + ')' : v, 2);
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
if (json.code === 0) {
|
||||
setStatus('✅ 成功 (' + dt + 's)', 'success');
|
||||
renderResult(json.data);
|
||||
} else {
|
||||
setStatus('❌ 业务错误 (' + dt + 's) — code ' + json.code + ':' + json.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('❌ 网络错误: ' + err.message, 'error');
|
||||
$('jsonContent').textContent = 'Error: ' + err.message;
|
||||
$('resultsArea').classList.remove('hidden');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '🚀 提交测试';
|
||||
}
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
$('resultsArea').classList.add('hidden');
|
||||
$('statusBar').className = 'status hidden';
|
||||
$('imageFile').value = '';
|
||||
$('jsonContent').textContent = '';
|
||||
}
|
||||
function copyJson() {
|
||||
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
|
||||
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 参数持久化 + 联动 ----
|
||||
const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','erodeCm','edgeErodePx','denoise','hgStrength','mbLevels','hairlinePushCm','hairlineEdge'];
|
||||
function saveField(id) { try { localStorage.setItem('if11_' + id, $(id).type === 'checkbox' ? ($(id).checked?'1':'0') : $(id).value); } catch(e){} }
|
||||
function linkNumRange(numId, rngId) {
|
||||
const num = $(numId), rng = $(rngId);
|
||||
function sync(from) {
|
||||
let v = parseFloat(from.value); if (isNaN(v)) v = 0;
|
||||
num.value = v; rng.value = Math.min(parseFloat(rng.max), Math.max(parseFloat(rng.min), v));
|
||||
saveField(numId);
|
||||
}
|
||||
num.addEventListener('input', () => sync(num));
|
||||
rng.addEventListener('input', () => sync(rng));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dz = $('imageFile');
|
||||
dz.addEventListener('change', () => { if (dz.files.length) setStatus('已选择: ' + dz.files[0].name, 'info'); });
|
||||
FIELDS.forEach(id => {
|
||||
try { const s = localStorage.getItem('if11_' + id); if (s !== null) $(id).value = s; } catch(e){}
|
||||
$(id).addEventListener('change', () => saveField(id));
|
||||
});
|
||||
try { $('isHr').checked = localStorage.getItem('if11_isHr') === '1'; } catch(e){}
|
||||
$('isHr').addEventListener('change', () => saveField('isHr'));
|
||||
linkNumRange('erodeCm','erodeRange');
|
||||
linkNumRange('edgeErodePx','edgeErodeRange');
|
||||
linkNumRange('denoise','denoiseRange');
|
||||
linkNumRange('hgStrength','hgStrengthRange');
|
||||
linkNumRange('mbLevels','mbLevelsRange');
|
||||
linkNumRange('hairlinePushCm','hairlinePushRange');
|
||||
// 初始化滑块值
|
||||
$('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||0.6);
|
||||
$('edgeErodeRange').value = Math.min(30, parseFloat($('edgeErodePx').value)||3);
|
||||
$('denoiseRange').value = Math.min(1.0, parseFloat($('denoise').value)||0.6);
|
||||
$('hgStrengthRange').value = Math.min(1.0, parseFloat($('hgStrength').value)||0.75);
|
||||
$('mbLevelsRange').value = Math.min(6, Math.max(2, parseFloat($('mbLevels').value)||5));
|
||||
$('hairlinePushRange').value = Math.min(3, parseFloat($('hairlinePushCm').value)||1.0);
|
||||
|
||||
// 参数联动:按 gen_backend 显隐无用参数(data-show 声明,遮罩/融合已固定无选项)
|
||||
function applyVisibility() {
|
||||
const gen = $('genBackend').value;
|
||||
document.querySelectorAll('.pf[data-show]').forEach(el => {
|
||||
const conds = el.dataset.show.split(',').map(s => s.trim());
|
||||
let vis = true;
|
||||
conds.forEach(c => {
|
||||
const [k, v] = c.split(':');
|
||||
if (k === 'gen' && v !== gen) vis = false;
|
||||
});
|
||||
el.style.display = vis ? '' : 'none';
|
||||
});
|
||||
}
|
||||
$('genBackend').addEventListener('change', applyVisibility);
|
||||
applyVisibility();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="0; url=/static/test_interface11.html">
|
||||
<title>接口 11 测试页</title>
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="/static/test_interface11.html">打开接口 11 测试页</a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||
<input type="text" id="token" value="dev-shared-secret-2026" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="token" value="" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
|
||||
@@ -69,11 +69,11 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
|
||||
<input type="text" id="token" value="dev-shared-secret-2026" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="token" value="" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,人物增加一点磨皮';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
flog('===== 调后端重绘 =====', 'info');
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div class="upload-row" style="margin-top:10px">
|
||||
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
|
||||
</div>
|
||||
<div class="params">
|
||||
<div class="pf">
|
||||
@@ -92,7 +92,6 @@
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const ENDPOINT = '/api/v1/hairline/grow_v2_final';
|
||||
const TOKEN = 'dev-shared-secret-2026';
|
||||
|
||||
// 缓存最近一次后端返回的 final(data URI)和纯红遮罩(data URI),供重绘使用
|
||||
let _finalDataUri = '';
|
||||
@@ -145,7 +144,6 @@ async function submitTest() {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Internal-Token': TOKEN },
|
||||
body: form,
|
||||
});
|
||||
const json = await resp.json();
|
||||
@@ -169,7 +167,7 @@ async function submitTest() {
|
||||
|
||||
async function runLocalRedraw() {
|
||||
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
|
||||
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,人物增加一点磨皮';
|
||||
const btn = $('redrawBtn');
|
||||
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
const ENDPOINT = '/api/v1/hairline/grow_v2_final_v2';
|
||||
const TOKEN = 'dev-shared-secret-2026';
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
|
||||
@@ -118,7 +117,6 @@ async function submitTest() {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Internal-Token': TOKEN },
|
||||
body: form,
|
||||
});
|
||||
const json = await resp.json();
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<div class="hint">JPG/PNG | 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
|
||||
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
|
||||
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
|
||||
</div>
|
||||
<div id="statusBar" class="status hidden"></div>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ async function submitTest() {
|
||||
fd.append('prompt', $('promptInput').value);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', headers:{ 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>接口 22 · 生发测试</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f3f5f8; color: #172334; font: 15px/1.5 system-ui, sans-serif; }
|
||||
main { max-width: 1100px; margin: 32px auto; padding: 0 16px; }
|
||||
h1 { margin: 0 0 4px; font-size: 28px; }
|
||||
h2 { margin: 0 0 12px; font-size: 18px; }
|
||||
.muted, figcaption { color: #526075; }
|
||||
.card { background: white; border: 1px solid #dce3eb; border-radius: 12px; padding: 18px; margin: 16px 0; }
|
||||
#client-auth { display: flex; flex-wrap: wrap; align-items: end; gap: 8px 12px; }
|
||||
#client-auth-status { flex-basis: 100%; margin: 0; }
|
||||
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
|
||||
.field { display: grid; gap: 5px; align-content: start; }
|
||||
input, select, button { font: inherit; }
|
||||
.field input, select { min-width: 0; width: 100%; padding: 6px 8px; }
|
||||
fieldset { margin: 16px 0; border: 1px solid #dce3eb; border-radius: 8px; }
|
||||
#sourceFields { margin-top: 0; padding: 0; border: 0; min-width: 0; }
|
||||
.choices { display: flex; flex-wrap: wrap; gap: 8px 18px; }
|
||||
.choices label { white-space: nowrap; }
|
||||
button { background: #1757ab; color: white; border: 0; border-radius: 8px; padding: 8px 16px; cursor: pointer; }
|
||||
button:disabled { opacity: .6; cursor: wait; }
|
||||
.status { margin-bottom: 0; }
|
||||
.status[data-error="true"] { color: #a02020; }
|
||||
.images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; }
|
||||
.images figure { max-width: 380px; }
|
||||
.result-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 122px), 1fr)); gap: 10px; }
|
||||
.result-tile { min-width: 0; overflow: hidden; background: white; border: 1px solid #dce3eb; border-radius: 8px; }
|
||||
.result-tile img { display: block; width: 100%; height: auto; }
|
||||
.result-tile figcaption { display: grid; gap: 2px; margin: 0; padding: 6px 8px; font-size: 12px; }
|
||||
.result-tile strong { color: #172334; }
|
||||
.preview-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; margin: 16px 0; }
|
||||
.preview-editor:has(#previews > figure:only-child) { grid-template-columns: minmax(0, 380px) auto; justify-content: start; }
|
||||
.height-controls { display: grid; gap: 6px; padding-top: 27px; }
|
||||
.height-scale { display: flex; gap: 6px; height: clamp(170px, 42vw, 300px); }
|
||||
#height { writing-mode: vertical-lr; direction: rtl; width: 24px; height: 100%; margin: 0; }
|
||||
.height-presets { display: flex; flex-direction: column; justify-content: space-between; }
|
||||
.height-presets label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
|
||||
figure { margin: 0; min-width: 0; }
|
||||
figcaption { margin-bottom: 6px; }
|
||||
.stack { position: relative; overflow: hidden; border-radius: 6px; }
|
||||
.stack img, .stack canvas { width: 100%; height: auto; display: block; border-radius: 6px; }
|
||||
.preview-layer, .preview-clip, .stack .overlay, .crop-border { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.preview-layer { transform: translateY(calc(-1 * var(--height-adjustment) * var(--max-shift))); }
|
||||
.crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
@media (max-width: 560px) { .images { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
<script src="/static/client_auth.js" defer></script>
|
||||
<script src="/static/img_downscale.js?v=2" defer></script>
|
||||
<script src="/static/hairline_reference_box.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>接口 22 生发测试</h1>
|
||||
<div class="muted"><code>POST /api/v1/hair/grow-22</code></div>
|
||||
<form id="client-auth" class="card" method="post" action="/api/v1/auth/login" aria-label="账号登录">
|
||||
<label class="field">用户名
|
||||
<input name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label class="field">密码
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit">登录</button>
|
||||
<button id="client-auth-check" type="button">测试鉴权</button>
|
||||
<button id="client-auth-refresh" type="button" disabled>刷新令牌</button>
|
||||
<p id="client-auth-status" class="muted" role="status">请使用对接账号登录后生图。</p>
|
||||
</form>
|
||||
<form id="generateRequest" class="card">
|
||||
<fieldset id="sourceFields" aria-label="照片与性别">
|
||||
<div class="fields">
|
||||
<label class="field">照片(JPG / PNG)
|
||||
<input type="file" name="image_file" accept="image/jpeg,image/png,.jpg,.jpeg,.png" required>
|
||||
</label>
|
||||
<label class="field">性别
|
||||
<select name="gender"><option value="female">女性</option><option value="male">男性</option></select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset id="styleFields">
|
||||
<legend>发际线形状(可多选)</legend>
|
||||
<div id="styles" class="choices"></div>
|
||||
<p id="generationPreviewStatus" class="status" role="status"></p>
|
||||
</fieldset>
|
||||
<p id="firstRunHint" class="muted">首次按默认高度 0 生图,出图后可预览和调整发际线高度。</p>
|
||||
<div id="heightEditor" hidden>
|
||||
<p class="muted">勾选右侧档位后按所选高度生图。未勾选时,使用滑块当前高度。</p>
|
||||
<div class="preview-editor">
|
||||
<div id="previews" class="images" aria-label="52 号裁切发际线预览"></div>
|
||||
<div class="height-controls">
|
||||
<label for="height">高度 <output id="heightValue" for="height">0</output></label>
|
||||
<div class="height-scale">
|
||||
<input type="range" id="height" name="hairline_height" min="-1" max="1" step="0.1" value="0">
|
||||
<div class="height-presets" role="group" aria-label="预设高度(可多选)">
|
||||
<label><input type="checkbox" name="height_preset" value="1"> +1</label>
|
||||
<label><input type="checkbox" name="height_preset" value="0.5"> +0.5</label>
|
||||
<label><input type="checkbox" name="height_preset" value="0"> 0</label>
|
||||
<label><input type="checkbox" name="height_preset" value="-0.5"> −0.5</label>
|
||||
<label><input type="checkbox" name="height_preset" value="-1"> −1</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend>生发密度(至少选择一档)</legend>
|
||||
<div class="choices">
|
||||
<label><input type="checkbox" name="hair_density" value="sparse"> 稀疏</label>
|
||||
<label><input type="checkbox" name="hair_density" value="normal" checked> 正常</label>
|
||||
<label><input type="checkbox" name="hair_density" value="dense"> 浓密</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<button id="submit" type="submit">生成生发效果</button>
|
||||
<p id="status" class="status" role="status"></p>
|
||||
</form>
|
||||
<section id="results" class="result-grid" aria-label="生发结果"></section>
|
||||
<details class="card" id="response" hidden>
|
||||
<summary>响应 JSON(省略图片 Base64)</summary>
|
||||
<pre id="json"></pre>
|
||||
</details>
|
||||
</main>
|
||||
<script>
|
||||
const generateForm = document.getElementById('generateRequest');
|
||||
const sourceFields = document.getElementById('sourceFields');
|
||||
const firstRunHint = document.getElementById('firstRunHint');
|
||||
const heightEditor = document.getElementById('heightEditor');
|
||||
const previews = document.getElementById('previews');
|
||||
const height = document.getElementById('height');
|
||||
const heightPresets = generateForm.querySelectorAll('[name="height_preset"]');
|
||||
const styles = document.getElementById('styles');
|
||||
const styleFields = document.getElementById('styleFields');
|
||||
const generationPreviewStatus = document.getElementById('generationPreviewStatus');
|
||||
const statusBar = document.getElementById('status');
|
||||
const results = document.getElementById('results');
|
||||
const response = document.getElementById('response');
|
||||
const submit = document.getElementById('submit');
|
||||
const densityLabels = { sparse: '稀疏', normal: '正常', dense: '浓密' };
|
||||
const styleNames = {
|
||||
female: ['椭圆', '花瓣', '心形', '直线', '波浪', '大花瓣', '古典花瓣'],
|
||||
male: ['椭圆', '倒弧', 'M 形', '直线', '心形', '柔和花瓣', '波浪'],
|
||||
};
|
||||
let prepared;
|
||||
let previewRevision = 0;
|
||||
|
||||
function renderStyleChoices(container, gender, selected) {
|
||||
container.replaceChildren(...styleNames[gender].map((name, index) => {
|
||||
const label = document.createElement('label');
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.name = 'hair_style';
|
||||
checkbox.value = index + 1;
|
||||
checkbox.checked = selected.includes(checkbox.value);
|
||||
label.append(checkbox, ' ' + (index + 1) + '. ' + name);
|
||||
return label;
|
||||
}));
|
||||
}
|
||||
|
||||
function imageSource(url, base64, mime) {
|
||||
if (url) return url;
|
||||
if (!base64) return '';
|
||||
return base64.startsWith('data:') ? base64 : 'data:' + mime + ';base64,' + base64;
|
||||
}
|
||||
|
||||
function imagePanel(title, src, preview) {
|
||||
const figure = document.createElement('figure');
|
||||
const caption = document.createElement('figcaption');
|
||||
caption.textContent = title;
|
||||
figure.append(caption);
|
||||
if (!src) {
|
||||
figure.append(preview?.error || '未返回图片');
|
||||
return figure;
|
||||
}
|
||||
const stack = document.createElement('div');
|
||||
stack.className = 'stack';
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
image.alt = title;
|
||||
stack.append(image);
|
||||
if (preview) {
|
||||
const movingLayer = document.createElement('div');
|
||||
movingLayer.className = 'preview-layer';
|
||||
movingLayer.style.setProperty('--max-shift', preview.maxShift + '%');
|
||||
const clip = document.createElement('div');
|
||||
clip.className = 'preview-clip';
|
||||
const polygonPoints = preview.corners.map(([x, y]) =>
|
||||
(100 * x / preview.width) + '% ' + (100 * y / preview.height) + '%');
|
||||
clip.style.clipPath = 'polygon(' + polygonPoints.join(', ') + ')';
|
||||
const layer = new Image();
|
||||
layer.src = preview.overlay;
|
||||
layer.alt = '发际线';
|
||||
layer.className = 'overlay';
|
||||
clip.append(layer);
|
||||
movingLayer.append(clip);
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
const border = document.createElementNS(ns, 'svg');
|
||||
border.classList.add('crop-border');
|
||||
border.setAttribute('viewBox', '0 0 ' + preview.width + ' ' + preview.height);
|
||||
border.setAttribute('aria-label', '发际线裁切边界');
|
||||
const polygon = document.createElementNS(ns, 'polygon');
|
||||
polygon.setAttribute('points', preview.corners.map(point => point.join(',')).join(' '));
|
||||
border.append(polygon);
|
||||
movingLayer.append(border);
|
||||
stack.append(movingLayer);
|
||||
}
|
||||
figure.append(stack);
|
||||
return figure;
|
||||
}
|
||||
|
||||
async function loadCroppedPreviews(data, selected) {
|
||||
const formData = new FormData();
|
||||
for (const [key, value] of data) formData.set(key, value);
|
||||
formData.set('hair_style', selected.join(','));
|
||||
formData.set('normalize_image', 'true');
|
||||
formData.set('generate_grow_image', 'false');
|
||||
const reply = await window.hairClientFetch('/api/v1/hairline/generate-52', { method: 'POST', body: formData });
|
||||
const body = await reply.json();
|
||||
if (!reply.ok || body.code !== 0) {
|
||||
throw new Error(body.message || 'HTTP ' + reply.status);
|
||||
}
|
||||
const preview = body.data;
|
||||
const base = imageSource(preview.input_image_url, preview.input_image_base64, 'image/jpeg');
|
||||
const { width, height } = preview;
|
||||
const { corners, landmarks } = window.hairlineReferenceBox(preview.guide_landmarks);
|
||||
// Convert the interface 22 scale (10% of brow-to-chin distance) to a percentage of the image height.
|
||||
const maxShift = 100 * 0.10 * Math.abs(landmarks[152][1] - landmarks[9][1]) / height;
|
||||
const items = preview.hairline_images.map(item => {
|
||||
const overlay = imageSource(item.image_middle_url, item.image_middle_base64, 'image/png');
|
||||
return { order: item.order, ...((base && overlay)
|
||||
? { base, corners, width, height, maxShift, overlay }
|
||||
: { error: '接口 52 未返回完整预览图片' }) };
|
||||
});
|
||||
return { items, framing: preview.framing };
|
||||
}
|
||||
|
||||
function makePreviewPanels(items, gender) {
|
||||
const names = styleNames[gender];
|
||||
return new Map(items.filter(item => item.base).map(item => [
|
||||
String(item.order), imagePanel('#' + item.order + ' · ' + names[item.order - 1], item.base, item),
|
||||
]));
|
||||
}
|
||||
|
||||
async function replacePreviewBases(images, panels, framing, fallback = false) {
|
||||
const byOrder = new Map(images.map(item => [String(item.order), item]));
|
||||
const targets = [...panels].flatMap(([order, panel]) => {
|
||||
const image = byOrder.get(order) || (fallback ? images[0] : undefined);
|
||||
return image ? [[image, panel]] : [];
|
||||
});
|
||||
if (!targets.length) return 0;
|
||||
const { source_to_processed: matrix, source_size: source, output_size: output } = framing || {};
|
||||
if (!matrix || !source || !output) return targets.length;
|
||||
const [[a, c, e], [b, d, f]] = matrix;
|
||||
const updated = await Promise.all(targets.map(async ([item, panel]) => {
|
||||
const base = panel.querySelector('.stack > img');
|
||||
if (!base) return false;
|
||||
try {
|
||||
const generated = new Image();
|
||||
generated.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
|
||||
await generated.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = output.width;
|
||||
canvas.height = output.height;
|
||||
canvas.setAttribute('role', 'img');
|
||||
canvas.setAttribute('aria-label', base.alt + ' · 默认高度生发底图');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
ctx.setTransform(a, b, c, d, e, f);
|
||||
ctx.drawImage(generated, 0, 0, source.width, source.height);
|
||||
base.replaceWith(canvas);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
return updated.filter(ok => !ok).length;
|
||||
}
|
||||
|
||||
function renderResult(item, submittedHeight, gender) {
|
||||
const tile = document.createElement('figure');
|
||||
tile.className = 'result-tile';
|
||||
const image = new Image();
|
||||
image.loading = 'lazy';
|
||||
image.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
|
||||
const name = styleNames[gender][item.order - 1] || item.hairline_type;
|
||||
const style = '#' + item.order + ' ' + name + ' · ' + (gender === 'male' ? '男性' : '女性');
|
||||
const details = (densityLabels[item.hair_density] || item.hair_density) + ' · 高度 ' + submittedHeight;
|
||||
image.alt = style + ',' + details;
|
||||
const caption = document.createElement('figcaption');
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = style;
|
||||
const parameters = document.createElement('span');
|
||||
parameters.textContent = details;
|
||||
caption.append(title, parameters);
|
||||
tile.append(image, caption);
|
||||
return tile;
|
||||
}
|
||||
|
||||
async function requestGrowth(data) {
|
||||
const reply = await window.hairClientFetch('/api/v1/hair/grow-22', { method: 'POST', body: data });
|
||||
const body = await reply.json();
|
||||
if (!reply.ok || body.code !== 0) {
|
||||
throw new Error(body.message || 'HTTP ' + reply.status);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function updatePreviewHeight() {
|
||||
document.getElementById('heightValue').value = height.value;
|
||||
previews.style.setProperty('--height-adjustment', height.value);
|
||||
}
|
||||
|
||||
height.addEventListener('input', updatePreviewHeight);
|
||||
heightPresets.forEach(preset => preset.addEventListener('change', () => {
|
||||
if (preset.checked) {
|
||||
height.value = preset.value;
|
||||
updatePreviewHeight();
|
||||
}
|
||||
}));
|
||||
updatePreviewHeight();
|
||||
function renderSourceStyles() {
|
||||
renderStyleChoices(styles, generateForm.elements.gender.value, ['1']);
|
||||
}
|
||||
|
||||
generateForm.elements.gender.addEventListener('change', renderSourceStyles);
|
||||
renderSourceStyles();
|
||||
|
||||
sourceFields.addEventListener('change', () => {
|
||||
prepared = undefined;
|
||||
heightEditor.hidden = true;
|
||||
firstRunHint.hidden = false;
|
||||
previews.replaceChildren();
|
||||
results.replaceChildren();
|
||||
response.hidden = true;
|
||||
statusBar.textContent = '';
|
||||
generationPreviewStatus.textContent = '';
|
||||
submit.disabled = false;
|
||||
height.value = '0';
|
||||
heightPresets.forEach(preset => { preset.checked = false; });
|
||||
updatePreviewHeight();
|
||||
});
|
||||
|
||||
async function refreshStylePreviews(selected) {
|
||||
const current = prepared;
|
||||
if (!current) return;
|
||||
const revision = ++previewRevision;
|
||||
const showSelected = () => previews.replaceChildren(...selected.flatMap(order =>
|
||||
current.panels.has(order) ? [current.panels.get(order)] : []));
|
||||
showSelected();
|
||||
generationPreviewStatus.dataset.error = 'false';
|
||||
generationPreviewStatus.textContent = selected.length ? '' : '请至少选择一种发际线形状。';
|
||||
const missing = selected.filter(order => !current.panels.has(order));
|
||||
submit.disabled = !selected.length || !!missing.length;
|
||||
if (!missing.length) return;
|
||||
generationPreviewStatus.textContent = '正在更新发际线预览…';
|
||||
try {
|
||||
const { items, framing } = await loadCroppedPreviews(current.data, missing);
|
||||
const panels = makePreviewPanels(items, current.data.get('gender'));
|
||||
const unmatched = await replacePreviewBases(current.defaultImages, panels, framing, true);
|
||||
if (current !== prepared || revision !== previewRevision) return;
|
||||
for (const [order, panel] of panels) current.panels.set(order, panel);
|
||||
showSelected();
|
||||
if (missing.some(order => !panels.has(order))) {
|
||||
throw new Error('接口 52 未返回全部所选形状。');
|
||||
}
|
||||
generationPreviewStatus.textContent = unmatched ? '部分生成图无法对齐,已显示对应的裁切原照。' : '';
|
||||
} catch (error) {
|
||||
if (current !== prepared || revision !== previewRevision) return;
|
||||
generationPreviewStatus.dataset.error = 'true';
|
||||
generationPreviewStatus.textContent = '更新发际线预览失败:' + error.message;
|
||||
} finally {
|
||||
if (current === prepared && revision === previewRevision) submit.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
styles.addEventListener('change', () => {
|
||||
refreshStylePreviews(new FormData(generateForm).getAll('hair_style'));
|
||||
});
|
||||
|
||||
generateForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled) return;
|
||||
const data = new FormData(generateForm);
|
||||
const selectedStyles = data.getAll('hair_style');
|
||||
if (!selectedStyles.length) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '请至少选择一种发际线形状。';
|
||||
return;
|
||||
}
|
||||
const densities = data.getAll('hair_density');
|
||||
if (!densities.length) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '请至少选择一档生发密度。';
|
||||
return;
|
||||
}
|
||||
const selectedHeights = data.getAll('height_preset');
|
||||
const firstRun = !prepared;
|
||||
const submittedHeights = firstRun ? ['0'] : (selectedHeights.length ? selectedHeights : [data.get('hairline_height')]);
|
||||
data.delete('height_preset');
|
||||
data.set('hair_style', selectedStyles.join(','));
|
||||
sourceFields.disabled = styleFields.disabled = submit.disabled = true;
|
||||
statusBar.dataset.error = 'false';
|
||||
results.replaceChildren();
|
||||
response.hidden = true;
|
||||
const reports = [];
|
||||
const failedHeights = [];
|
||||
const defaultImages = new Map();
|
||||
const planned = submittedHeights.length * densities.length * selectedStyles.length;
|
||||
let generated = 0;
|
||||
const start = performance.now();
|
||||
try {
|
||||
statusBar.textContent = '正在准备照片…';
|
||||
const file = firstRun
|
||||
? await window.downscaleImageFile(data.get('image_file'))
|
||||
: prepared.data.get('image_file');
|
||||
data.set('image_file', file);
|
||||
for (const [index, submittedHeight] of submittedHeights.entries()) {
|
||||
statusBar.textContent = '正在生成 ' + (index + 1) + '/' + submittedHeights.length + ' 档 · 高度 ' + submittedHeight + '…';
|
||||
// The slider is positive up, while the API uses positive image Y (down).
|
||||
data.set('hairline_height', String(-Number(submittedHeight)));
|
||||
try {
|
||||
const body = await requestGrowth(data);
|
||||
const images = body.data.results.filter(item => item.grown_image_url || item.grown_image_base64);
|
||||
results.append(...images.map(item => renderResult(item, submittedHeight, data.get('gender'))));
|
||||
generated += images.length;
|
||||
if (firstRun) {
|
||||
for (const item of images) {
|
||||
if (!defaultImages.has(item.order) || item.hair_density === 'normal') {
|
||||
const { order, grown_image_url, grown_image_base64 } = item;
|
||||
defaultImages.set(order, { order, grown_image_url, grown_image_base64 });
|
||||
}
|
||||
}
|
||||
}
|
||||
reports.push(JSON.stringify({ height: Number(submittedHeight), response: body }, (key, value) =>
|
||||
key.endsWith('_base64') && typeof value === 'string' ? '[图片已省略]' : value, 2));
|
||||
} catch (error) {
|
||||
failedHeights.push(submittedHeight);
|
||||
reports.push(JSON.stringify({ height: Number(submittedHeight), error: error.message }, null, 2));
|
||||
}
|
||||
}
|
||||
document.getElementById('json').textContent = '[\n' + reports.join(',\n') + '\n]';
|
||||
response.hidden = false;
|
||||
const seconds = ((performance.now() - start) / 1000).toFixed(1);
|
||||
statusBar.dataset.error = String(generated !== planned);
|
||||
statusBar.textContent = '生发成功 ' + generated + '/' + planned + ' 张 · ' + seconds + ' 秒'
|
||||
+ (failedHeights.length ? ' · 请求失败的高度 ' + failedHeights.join('、') : '');
|
||||
if (firstRun && generated) {
|
||||
const source = new FormData();
|
||||
source.set('image_file', file);
|
||||
source.set('gender', data.get('gender'));
|
||||
prepared = { data: source, panels: new Map(), defaultImages: [...defaultImages.values()] };
|
||||
heightEditor.hidden = false;
|
||||
firstRunHint.hidden = true;
|
||||
await refreshStylePreviews(selectedStyles);
|
||||
}
|
||||
} catch (error) {
|
||||
statusBar.dataset.error = 'true';
|
||||
statusBar.textContent = '生发失败:' + error.message;
|
||||
} finally {
|
||||
sourceFields.disabled = styleFields.disabled = submit.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<div class="upload-group" style="margin-top:14px">
|
||||
<div class="label">💬 提示词(prompt)</div>
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
|
||||
</div>
|
||||
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
|
||||
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
|
||||
@@ -162,7 +162,7 @@ async function submitTest() {
|
||||
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
|
||||
@@ -112,10 +112,10 @@
|
||||
<script>
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style'];
|
||||
const EN_KEYS = ['face_shape','eyebrow_shape','facial_age','dynamic_static_type','gender','gene_style','contour_type'];
|
||||
const EN_LABELS = {
|
||||
face_shape:'脸型', eyebrow_shape:'眉形', facial_age:'面部年龄',
|
||||
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格'
|
||||
dynamic_static_type:'动静类型', gender:'性别', gene_style:'基因风格', contour_type:'轮廓类型'
|
||||
};
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
@@ -133,7 +133,7 @@ async function submitTest() {
|
||||
const fd = new FormData(); fd.append('image_file', f);
|
||||
const _reqStart = performance.now();
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
|
||||
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', body:fd });
|
||||
const json = await r.json();
|
||||
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
|
||||
$('jsonContent').textContent = JSON.stringify(json, null, 2);
|
||||
|
||||