Author SHA1 Message Date
xsl 2cee2f7277 docs: 网关摘除隔离期实施方案(供网关机器参考实现,暂不在本仓库改 gateway 代码)
复盘 9·19 磁盘满事故网关未切换的三层原因;给出 main 分支 gateway/pool.py
的隔离期改动说明(WorkerState.offline_until + 被动摘除冷却 60s + 探测复活
守卫 + 删死代码)、配置项、单测建议、部署/验证/回滚步骤。worker 侧深检
(方案 A)已在本分支 14562d4 上线。
2026-09-20 21:49:25 +08:00
xsl 14562d4869 feat(health): /health 深检——磁盘水位 + 下游依赖 TCP 探测,异常返回 503 degraded
背景: 9·19 磁盘满事故中业务已瘫但 /health 仍 200(只看模型就绪标志),
网关持续把流量打到本机。深检让 worker 在磁盘不足(min_free_gb, 默认5G)或
ComfyUI/change_hair TCP 不通时主动报 503,网关探测两次失败即摘除。

- 配置: worker_config.json "health" 段,env HEALTH_MIN_FREE_GB/
  HEALTH_CHECK_DEPS/HEALTH_DEP_COMFYUI/HEALTH_DEPS_CACHE_SECONDS 可覆盖
- 依赖探测 0.8s 超时并发执行,结果缓存 5s;深检自身异常按就绪返回
- 测试: 深检各分支用例 + 旧用例改为 mock 深检(不依赖本机服务)
2026-09-20 21:46:18 +08:00
xsl 3f8d90d963 feat: ComfyUI 默认提示词统一改为「填充遮罩区域的头发,人物增加一点磨皮」
- app.py 接口2/3/5/12/重绘 各 Form 默认值与说明
- hairline/service.py _REDRAW_PROMPT 环境变量缺省值
- hairline/redraw.py _DEFAULT_PROMPT、face_analysis/hairline_grow.py 兜底值
- add_hair.json / 0716add-hair-api.json / 0716add-hair.json / hair_repaint.json
  工作流节点60内置文本(prompt=None 走工作流内置时生效)
- static 测试页、local_test、benchmark 脚本、接口文档同步
- 不动 add_hair2.json(备选工作流,自带独立提示词)
注意:新提示词首次请求会因 CLIP 缓存失效变慢一次,之后恢复
2026-09-16 00:12:23 +08:00
xsl a3587a2561 fix(接口2): _call_local_redraw 补 prompt 入参(0c7bcfc 漏改签名)
0c7bcfc 只在调用处补传 prompt,函数签名未加,运行期
'_call_local_redraw() got an unexpected keyword argument prompt'
导致 female 1..5 换发型分支重绘全部失败、生发图缺失。
与 origin/ubuntu3090_debug 上 59e01d3 的完整形态对齐。
2026-09-14 22:45:59 +08:00
xsl 0c7bcfc83d fix(接口2): female 1..5 换发型分支重绘透传 prompt(此前固定为默认值)
- generate_grow_results_swap 换发型分支 _call_local_redraw 补传 prompt,
  自定义提示词现可替换工作流节点60文本(与 male / female 6/7 行为一致);
  prompt 不进 change_hair(swapHair),仅作用于最后一步 ComfyUI 发际线带重绘
- 默认值不变仍为「填充遮罩区域的头发」,CLIP 缓存快路径不受影响;
  换词后首次请求因缓存失效重载明显变慢(实测 45s vs 命中 8s),后续恢复
- 文档同步:接口文档.md prompt 参数说明更新
2026-09-14 14:39:20 +08:00
xsl e0d1a5f628 docs(网关待改动§9): 补无GUI服务器系统库依赖 + torch CPU源安装说明 + 无GPU实测结论 2026-09-06 23:27:12 +08:00
xsl 6c611359bc feat(接口99): 网关改本机实现(纯CPU+单路排队锁),文档同步
- gateway/app.py: 接口99 从盲转发改为本机实现(同接口4 模式):
  显式 Form 三选一 + 惰性导入(缺依赖仅本接口报1007)+ run_in_threadpool
- 单路排队: _if99_lock 全局互斥——串行推理限制 CPU 峰值 +
  规避 MediaPipe 单例线程安全问题;实测 req2≈2×req1 确认串行
- 网关待改动 §9 重写: 本机实现部署步骤 + 依赖清单(mediapipe/opencv/
  numpy<2,torch CPU 版可选——不装则发际线5点null)+ 权重已入git说明
- 实现说明/接口文档/CLAUDE.md 同步: 接口4/99 网关本机实现不转发
- 实测: BiSeNet 13.3M参数/51MB;CPU单核 0.46-0.65s/张;网关路径
  70/70点;worker 路径保留(测试页直连);pytest 15 通过
2026-09-06 23:25:16 +08:00
xsl b215f81ca3 feat(接口99): 人脸特殊点检测 + 网关转发路由;文档同步至当前代码
- worker: POST /api/v1/face/special-points,60 个固定编号关键点(6组)
  + 5 个发际线特殊点(沿指定关键点方向射线步进,与 BiSeNet 头发 mask 首次相交;
  双射线取中点,left_left=mid(333→332, 298→284) 与右侧镜像)
- 网关: 同路径盲转发路由(调用频率低,维持转发不占网关依赖;
  响应无 *_base64 字段,改写逻辑天然跳过)
- 测试页 static/test_interface99.html:分组着色小点 + 点ID标注(可开关)
  + 发际线点大圆标签 + JSON 展示
- 文档: CLAUDE.md 重写至当前架构;实现说明/接口文档补接口 9~12/99 契约;
  网关待改动补 §7(9/10已加) §8(11/12待加) §9(接口99部署步骤)
2026-09-06 22:57:51 +08:00
xsl 3e4fc758ea 更新 发际线纹理 2026-08-14 21:11:57 +08:00
xslandClaude cace9d6cd4 feat: 接口1/6 发际线贴近头顶时改为修正而非弃用置null
顶庭(头顶→发际线)<0.7cm 时不再返回 null:
- 上庭 = 0.95×中庭 反推发际线;若越过头顶则顶庭固定 0.7cm(发际线钉在头顶下方)
- hairline_source 新增 "corrected";to_response 始终输出完整四庭
- 标注图始终正常画发际线横线与庭段
- 接口6 三庭逻辑同步,去 discarded 分支

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:48:24 +08:00
xslandCursor ee33632f44 feat: 男性发型追加波浪形(wave=7)
接口2/5 male hair_style 增至 1..7,贴图 man_wave.png 纳入四套纹理目录,同步测试页与接口文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 21:35:57 +08:00
69 changed files with 1578 additions and 279 deletions
+1 -1
View File
@@ -29,7 +29,7 @@
"60": {
"class_type": "JjkText",
"inputs": {
"text": "填充遮罩区域的头发"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
}
},
"22": {
+1 -1
View File
File diff suppressed because one or more lines are too long
+72 -52
View File
@@ -4,80 +4,100 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Overview
"旷视五接口" — a FastAPI facial analysis API service providing 5 endpoints for face measurement, hair growth simulation (C-end and B-end), user feature extraction, and hairline PNG generation. Currently a **Mock v1**: all endpoints return hardcoded sample data; real ML logic is pending.
旷视接口服务 — FastAPI 人像分析/生发服务,**一个仓库、两种部署角色**:
- **Base URL (prod)**: `https://hair.xiangsilian.com`
- **Swagger docs**: `https://hair.xiangsilian.com/docs`
- **Internal**: uvicorn on `127.0.0.1:8000` behind nginx reverse proxy
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真实算法(本地模型 + ComfyUI + change_hair),监听 `0.0.0.0:8187`。**本机(GPU 机)即 worker 角色**,不跑网关。
- **网关**(`gateway/`):外网机器上的薄反向代理,负责鉴权/健康检查/派发,并把 worker 返回的 `*_base64` 落盘改写为 `*_url`。接口4(豆包)和接口99(人脸特殊点,纯 CPU)在网关本机实现,不转发 worker。
> 历史包袱:`app.py` 的 FastAPI `description` 和 `docs/接口文档.md` 部分示例仍写「Mock 第一版」字样,实际全部接口已是真实算法实现。**架构以 `docs/实现说明.md` 为准,契约以 `docs/接口文档.md` 为准。**
## Commands
```bash
# Development server
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 --reload
# ---- worker(本机)----
./run_worker.sh # 后台启动(0.0.0.0:8187,日志 worker.log)
FOREGROUND=1 ./run_worker.sh # 前台调试
./stop_worker.sh # 停止
sudo systemctl restart hair-worker # systemd 方式(生产)
journalctl -u hair-worker -f
# Or via the start script
./start.sh
# ---- 依赖服务(worker 之前要先起)----
sudo systemctl start comfyui # ComfyUI 127.0.0.1:8188(Flux-2 生图)
cd /home/ubuntu/change_hair && bash start_all.sh start # 换发型三件套 8801/32678/57860
# Production (systemd)
sudo systemctl restart hair
sudo systemctl status hair
journalctl -u hair -f
# Install dependencies (uses Tencent Cloud PyPI mirror)
./venv/bin/pip install -r requirements.txt
# ---- 测试 ----
./venv/bin/pytest tests/ # 单元测试(conftest 会准备 fixtures)
./venv/bin/python -m hairline.comfyui tests/output/comfy_input.png # 手动跑一次生发工作流
```
依赖装在 `./venv`(Python 3.10,torch 2.7.1+cu128,RTX 4090 CUDA 可用)。`requirements.txt` 里的 torch 2.2.2/5090 注释是旧机器的记录,本机 venv 实际是 cu128 版。
## Architecture
**Single-file app** — `app.py` (~555 lines) contains the entire server: FastAPI app creation, Pydantic models, all 5 endpoint handlers, and health check routes. There are no separate routers, services, or middleware modules.
### 接口一览(worker 侧路由,`app.py`)
### Endpoints
| 接口 | 路径 | 实现位置 | 说明 |
|------|------|----------|------|
| 1 | `POST /api/v1/face/measure` | `face_analysis/` | 四庭七眼测量 + 标注 PNG |
| 6 | `POST /api/v1/face/measure-v2` | 同上(`variant="v6"`) | 接口1 去顶庭变体 |
| 2 | `POST /api/v1/hair/grow` | `hairline/service.py` | C端生发。**female 1..5** 走 change_hair swapHair + Flux-2 整帧重绘(接口12 final 管线);**female 6/7 与 male 全部**走 ComfyUI `add_hair.json` inpaint |
| 7 | `POST /api/v1/hair/grow-v2`(hidden/deprecated) | 同上 | 同接口2,用 `add_hair2.json` 工作流 |
| 3 | `POST /api/v1/hair/grow-b` | `hairline/marker_detect.py` | B端马克笔划线生发(黑帽响应 + Dijkstra) |
| 4 | `POST /api/v1/face/features` | worker 有实现;生产走**网关本机**(豆包) | 用户特征 |
| 5 | `POST /api/v1/hairline/generate` | `hairline/service.py` | 发际线 PNG 三档(middle/high/low)+ 生发 |
| 9 | `POST /api/v1/head/mask` | `face_analysis/head_mask.py` | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
| 10 | `POST /api/v1/head/band` | `face_analysis/head_band.py` | 头部外缘膨胀带遮罩 |
| 11 | `POST /api/v1/hairline/grow` | `face_analysis/hairline_grow.py` | 发际线生发(接口9 遮罩 + change_hair + 接缝融合),不含重绘 |
| 12 | `POST /api/v1/hairline/grow_v2` | 同上 | 接口11 final + 发际线重绘带 → Flux-2 保色重绘 |
| 12f | `POST /api/v1/hairline/grow_v2_final` | 同上(`_V2_FINAL_DEFAULTS`) | 精简版:仅图片 + hairline_id,取整帧重绘 |
| 12f2 | `POST /api/v1/hairline/grow_v2_final_v2` | 同上 | 同上,取 B 局部加发+全脸美颜 |
| 99 | `POST /api/v1/face/special-points` | `face_analysis/special_points.py`(网关直接 import 本机实现 + `_if99_lock` 单路排队;worker 保留同路径备份) | 固定编号关键点 + 发际线特殊点(射线求交头发 mask)像素坐标 |
| 代理 | `POST /api/swapHair/v1`、`/hairColor/v2` | httpx 转发 127.0.0.1:8801 | 解决测试页 CORS |
| 工具 | `POST /api/v1/redraw` | `hairline/redraw.py` | 直接 ComfyUI 重绘(图+遮罩+prompt) |
| 调试 | `POST /api/v1/debug/grow-timing`、`GET /api/v1/debug/hairline_log` | — | 免鉴权,分步计时/下载日志 |
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/api/v1/face/measure` | 四庭七眼 measurement + annotated PNG |
| POST | `/api/v1/hair/grow` | C-end hair growth (user-facing) |
| POST | `/api/v1/hair/grow-b` | B-end hair growth (doctor/operator, with marked image) |
| POST | `/api/v1/face/features` | User facial feature analysis |
| POST | `/api/v1/hairline/generate` | Hairline PNG generation |
| GET | `/health` | Health check (excluded from OpenAPI schema) |
| GET | `/` | Service info (excluded from OpenAPI schema) |
配套测试页在 `static/test_interface{1..12}*.html`,经 `/static` 挂载。
### Image input convention (all endpoints)
### 关键模式
Every endpoint accepts images via **exactly one** of three mutually exclusive fields:
- `image_file` — multipart file upload (≤ 1 MB)
- `image_url` — HTTP/HTTPS URL string
- `image_base64` — base64 string with `data:image/...;base64,` prefix
- **统一信封**:`ok(data)` / `err(code, message)` 构造 `{code, message, request_id, data}`;业务错误 HTTP 一律 200。错误码:1001 无人像 / 1002 分辨率低 / 1003 非正面 / 1004 gender 非法 / 1005 多人 / 1006 >1MB / 1007 参数或处理失败 / 1008 格式不支持。
- **图片三选一**:每个接口 `image_file`(multipart) / `image_url` / `image_base64`(带 `data:` 前缀)严格互斥,由 `resolve_image_bytes()` 统一处理(0 个或多个 → 1007)。接口3 是 `marked_image_*`。
- **阻塞算法进线程池**:所有推理 handler 用 `run_in_threadpool(...)`,避免卡事件循环。
- **内网鉴权**:HTTP 中间件校验 `/api/*` 的 `X-Internal-Token` 头,密码来自 `worker_config.json` 的 `accept_passwords`(或环境变量 `WORKER_ACCEPT_PASSWORDS`)。豁免前缀见 `_AUTH_EXEMPT`(health/docs/static/debug/redraw/swapHair/hairColor)。
- **发型序号映射**:`hairline/service.py` 的 `_HAIRSTYLE_ORDER` 显式定义 1..7 顺序(不靠文件名字母序);female 1..5 → change_hair `chang_*` 映射在 `_FEMALE_KEY_TO_CHANG`。
- **接口12 固化参数**:`_V2_FINAL_DEFAULTS`(app.py)是生产默认值(push=0.8cm / two_stage / color_match=关 等),接口2 female 重绘也复用它。
Endpoint 3 (B-end) has **two** sets of image params: `marked_image_*` (the marked-up image) and `original_image_*` (the original photo). Each set follows the same three-way exclusive rule independently.
### 外部依赖(worker 视角)
### Unified response structure
| 依赖 | 地址 | 用途 | 配置 |
|------|------|------|------|
| ComfyUI | `http://127.0.0.1:8188`(`COMFYUI_URL`) | Flux-2 生图/重绘,走 HTTP API(`hairline/comfyui.py`) | Basic Auth `admin`,密码:`COMFYUI_PASSWORD` → `worker_config.json.comfyui_password` → `password.txt` |
| change_hair | `http://127.0.0.1:8801`(`SWAP_HAIR_URL`/`HAIR_GROW_URL`/`CHANGE_HAIR_BASE`) | 换发型(SD WebUI + LoRA)、hairGrow | 服务目录 `/home/ubuntu/change_hair`,`start_all.sh` 启停 |
```json
{"code": 0, "message": "success", "request_id": "mock-request-id", "data": {...}}
```
ComfyUI 工作流 JSON 在仓库根:`add_hair.json`(接口2/3/5 生发)、`add_hair2.json`(接口7)、`0716add-hair-api.json`(接口12/2女 重绘)、`hair_repaint.json`。节点约定:**26** 输入图 / **6** seed / **60** 提示词 / **16** unet(.gguf 自动切 UnetLoaderGGUF)/ **61** CLIP(4b↔9b 自动配套)。
Error codes: 1001–1008 (see `app.py` docstring or `docs/接口文档.md`).
### 性能/显存相关的坑
### Key code patterns
- 三个生发接口共用**同一提示词字符串**以命中 ComfyUI 的 CLIP 文本条件缓存——prompt 变了会触发 Flux(7.7G)+qwen CLIP(3.9G) 互相挤显存重载(每次 +4s)。
- 接口2 女性重绘管线默认把输入长边压到 `REDRAW_MAX_SIDE`(env,默认 1024)再放大回原尺寸;`redraw_max_side=0` 表示不缩图。
- `comfyui.run(front=True)` 可插队(接口2 对时延敏感);超时 `COMFY_TIMEOUT`(默认 600s)。
- `ok(data)` / `err(code, msg)` — helper functions at module level that build the standard response envelope
- `ImageJsonBody` — Pydantic model for JSON-based image params (URL/base64); form-upload endpoints use FastAPI `Form()` / `File()` parameters directly
- `SAMPLE_IMAGE_URL` — `https://hair.xiangsilian.com/static/sample.jpg`, returned as the image URL in all mock responses
- Static files (`/static/*`) are mounted via `app.mount("/static", StaticFiles(directory="static"), name="static")`
- All 5 POST endpoints use `multipart/form-data` with both `File` and `Form` fields — there are no JSON-body POST endpoints despite `ImageJsonBody` being defined (it exists for the OpenAPI schema but the actual handlers use `Form()` for all params)
## Deployment(本机 = gpu_worker)
### Deployment
systemd 单元(仓库根有副本,实装在 `/etc/systemd/system/`):
- **Process manager**: systemd (`hair.service` → `ExecStart` runs uvicorn directly from venv)
- **Reverse proxy**: nginx (`nginx/hair.conf`) proxies port 80 → `127.0.0.1:8000` for `hair.xiangsilian.com`
- **Python**: venv at `./venv`, dependencies in `requirements.txt`
- **PyPI mirror**: Tencent Cloud (`mirrors.cloud.tencent.com`), configured in `pip.conf`
| 服务 | 端口 | 说明 |
|------|------|------|
| `comfyui.service` | 127.0.0.1:8188 | `ComfyUI/venv` 的 `main.py --cache-classic --fast` |
| `hair-worker.service` | 0.0.0.0:8187 | 本仓库 `app:app`(Wants comfyui + change_hair-hair) |
| `change_hair-{webui,photo,hair}.service` | 57860 / 32678 / 8801 | conda envs `sdwebui`/`py310`/`my_hair`,依赖顺序 webui→photo→hair |
### Documentation
网关机另行部署 `gateway/`(config:`workers` 列表 + `shared_password` + `ark` key),见 `gateway/config.example.json` 与 `docs/网关待改动.md`。
- `docs/接口文档.md` — complete API documentation (inputs, outputs, examples, error codes)
- `docs/旷视具体需求.md` — original requirements spec (converted from `.docx`)
- The FastAPI app's `description` docstring in `app.py` is an additional source of API docs visible in Swagger UI
## Documentation
- `docs/接口文档.md` — 对外 API 契约(唯一权威)
- `docs/实现说明.md` — 架构总览 + 各接口实现简述 + 部署要点
- `docs/发际线增强算法.md`、`docs/发际线生发遮罩算法_pushed模式.md` — 接口11/12 算法推导
- `docs/gpu-worker改动说明.md`、`docs/网关待改动.md` — worker↔网关 协同变更记录
- `OFFLINE_ASSETS.md` — 离线模型权重/字体清单
+1 -1
View File
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
},
"class_type": "JjkText",
"_meta": {
+217 -48
View File
@@ -2,15 +2,20 @@
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验
(深检:磁盘水位 + 下游依赖 TCP 探测,异常返回 503 degraded,见 _load_health_cfg)。
"""
import asyncio
import base64
import json
import logging
import os
import shutil
import time
from contextlib import asynccontextmanager
from io import BytesIO
from typing import Any, List, Optional
from urllib.parse import urlsplit
import cv2
import numpy as np
@@ -401,36 +406,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 有。
@@ -446,10 +437,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],
@@ -726,15 +716,15 @@ async def face_measure_v2(
---
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 7 张 / male 6 张)。
- **gender**(必填):`male` / `female`。决定返回的贴图集合(female 7 张 / male 7 张)。
非法或缺失返回 `1004`。
- **hair_style**(必填):发型序号,**逗号分隔多选**(如 `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。越界/非法返回 `1007`。
`male`:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。越界/非法返回 `1007`。
- **beauty_enabled**:本期保留但不生效。
`hairline_type` 取值:`ellipse` / `flower` / `heart` / `straight` / `wave` / `bigflower` / `clasicalflower`(female),
`ellipse` / `m` / `straight` / `inverse_arc` / `heart` / `Softpetal`(male)。
`ellipse` / `m` / `straight` / `inverse_arc` / `heart` / `Softpetal` / `wave`(male)。
""",
responses={
200: {
@@ -773,10 +763,10 @@ async def hair_grow(
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
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-6"),
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的文本"),
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(切换模型用)。None=工作流默认;如 flux-2-klein-9b-Q5_K_M.gguf / flux-2-klein-9b-Q4_K_M.gguf / flux2.0/flux-2-klein-9b-fp8.safetensors"),
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
):
@@ -785,7 +775,7 @@ async def hair_grow(
return err(1004, "gender 必填且只能为 male / female")
# 2. hair_style 必填校验(解析逗号分隔,越界 → 1007)
max_styles = {"female": 7, "male": 6}[gender]
max_styles = {"female": 7, "male": 7}[gender]
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
@@ -848,7 +838,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女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
@@ -1034,7 +1024,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的文本"),
):
# 划线图三选一取图(只需这一张)
marked_raw, e = await resolve_image_bytes(marked_image_file, marked_image_url, marked_image_base64)
@@ -1160,10 +1150,10 @@ async def face_features(
---
**入参**(同接口2:先选性别,再多选发型):
- 必填 `gender`(`male`/`female`),决定发型集合(female 7 / male 6)。
- 必填 `gender`(`male`/`female`),决定发型集合(female 7 / male 7)。
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
`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。
`male`:1=ellipse,2=inverse_arc,3=m,4=straight,5=heart,6=Softpetal,7=wave。
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数(仅 male 路径生效)。
注:生发黑模板固定取 `hairline_texture_black/`(middle 档),即三档叠图分别用各自贴图、但生发目标固定 middle。
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
@@ -1247,9 +1237,9 @@ async def hairline_generate(
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
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-6"),
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,仅 male 路径生效)"),
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词(同接口2,仅 male 路径生效),会替换工作流节点60的文本"),
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词(同接口2,仅 male 路径生效),会替换工作流节点60的文本"),
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
flux_model: Optional[str] = Form(default=None, description="Flux 模型文件名(同接口2,切换模型用)。None=工作流默认"),
redraw_max_side: Optional[int] = Form(default=None, description="重绘压图长边像素(同接口2,仅 female 路径生效)。None=默认896;0=不缩图(原图直送);其他如 768/640/1024"),
@@ -1258,7 +1248,7 @@ async def hairline_generate(
return err(1004, "gender 必填且只能为 male / female")
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
max_styles = {"female": 7, "male": 6}[gender]
max_styles = {"female": 7, "male": 7}[gender]
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
@@ -1491,6 +1481,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 换发型 + 按遮罩羽化贴回)
# ---------------------------------------------------------------------------
@@ -1647,7 +1701,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"),
@@ -1813,7 +1867,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()
@@ -1862,12 +1916,127 @@ async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
# 健康检查
# ---------------------------------------------------------------------------
def _load_health_cfg() -> dict:
"""深健康检查配置:环境变量优先,回退 worker_config.json 的 "health" 段。
min_free_gb=0 关闭磁盘检查;check_deps=false 关闭依赖探测(均退化为旧行为,
即只看模型就绪标志)。
"""
cfg_path = os.path.join(os.path.dirname(__file__), "worker_config.json")
file_cfg: dict = {}
if os.path.isfile(cfg_path):
try:
with open(cfg_path, encoding="utf-8") as f:
file_cfg = json.load(f).get("health") or {}
except Exception as e: # noqa: BLE001
logger.warning("读取 worker_config.json health 段失败:%s", e)
def _get(env_key, file_key, default):
raw = os.getenv(env_key)
return raw if raw not in (None, "") else file_cfg.get(file_key, default)
def _as_float(value, default):
try:
return float(value)
except (TypeError, ValueError):
return default
return {
"min_free_gb": _as_float(_get("HEALTH_MIN_FREE_GB", "min_free_gb", 5.0), 5.0),
"check_deps": str(_get("HEALTH_CHECK_DEPS", "check_deps", True)).strip().lower()
not in ("0", "false", "no", "off"),
"dep_comfyui": str(_get("HEALTH_DEP_COMFYUI", "dep_comfyui", "127.0.0.1:8188")),
"deps_cache_seconds": max(0.0, _as_float(
_get("HEALTH_DEPS_CACHE_SECONDS", "deps_cache_seconds", 5.0), 5.0)),
}
_HEALTH_CFG = _load_health_cfg()
def _disk_free_gb() -> float:
"""worker 所在文件系统的剩余空间(GB)。"""
st = shutil.disk_usage(os.path.dirname(os.path.abspath(__file__)))
return st.free / (1 << 30)
def _parse_host_port(addr: str, default_port: int):
"""解析 "host:port"(仅支持 IPv4/主机名形式)。"""
host, _, port = str(addr).partition(":")
try:
return host or "127.0.0.1", int(port) if port else default_port
except ValueError:
return host or "127.0.0.1", default_port
def _change_hair_addr():
"""从 _CHANGE_HAIR_BASE 解析 (host, port),失败返回 None。"""
try:
u = urlsplit(_CHANGE_HAIR_BASE)
if u.hostname:
return u.hostname, u.port or 80
except ValueError:
pass
return None
async def _tcp_probe(host: str, port: int, timeout: float = 0.8) -> bool:
"""TCP 连通性探测:只判下游进程死活,不过 HTTP 认证、不受业务繁忙影响。"""
try:
_, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout)
writer.close()
return True
except Exception: # noqa: BLE001
return False
_DEP_CACHE = {"ts": -1.0, "detail": None}
async def _check_deps():
"""并发探测下游依赖(ComfyUI、change_hair),结果缓存 deps_cache_seconds 秒。"""
now = time.monotonic()
detail = _DEP_CACHE["detail"]
if detail is None or now - _DEP_CACHE["ts"] >= _HEALTH_CFG["deps_cache_seconds"]:
probes = {"comfyui": _tcp_probe(*_parse_host_port(_HEALTH_CFG["dep_comfyui"], 8188))}
ch = _change_hair_addr()
if ch:
probes["change_hair"] = _tcp_probe(*ch)
names = list(probes)
results = await asyncio.gather(*probes.values())
detail = dict(zip(names, results))
_DEP_CACHE["ts"], _DEP_CACHE["detail"] = now, detail
return all(detail.values()), detail
@app.get("/health", include_in_schema=False)
async def health():
# 模型就绪后才返回 200,供网关探测;未就绪返回 503。
"""网关探测入口。
模型未就绪 → 503 starting;磁盘剩余低于阈值 / 下游依赖 TCP 不通 → 503 degraded;
正常 → 200(附 disk_free_gb 与 deps 明细,供人工排查)。深检自身异常时按就绪返回
(健康检查不能自己成为故障点)。
"""
if not _STATE["ready"]:
return JSONResponse(status_code=503, content={"status": "starting"})
return {"status": "ok"}
try:
free_gb = _disk_free_gb()
body = {"status": "ok", "disk_free_gb": round(free_gb, 1)}
min_free = _HEALTH_CFG["min_free_gb"]
if min_free > 0 and free_gb < min_free:
body.update(status="degraded", reason=f"disk_free_below_{min_free}g")
return JSONResponse(status_code=503, content=body)
if _HEALTH_CFG["check_deps"]:
deps_ok, detail = await _check_deps()
body["deps"] = detail
if not deps_ok:
down = ",".join(k for k, v in detail.items() if not v)
body.update(status="degraded", reason=f"dep_down:{down}")
return JSONResponse(status_code=503, content=body)
return body
except Exception: # noqa: BLE001
logger.exception("深健康检查异常,按就绪返回")
return {"status": "ok"}
@app.get("/", include_in_schema=False)
+1 -1
View File
@@ -55,7 +55,7 @@ def gpu_used():
def call(img_path, hair_num, model_file, res_val):
fd = {"gender": "female", "hair_style": str(hair_num), "use_mask": "true",
"prompt": "填充遮罩区域的头发"}
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"}
if model_file:
fd["flux_model"] = model_file
if res_val != "":
+1 -1
View File
@@ -54,7 +54,7 @@ def call(img_path, model_file, res_val):
"gender": "female",
"hair_style": "2", # 花瓣形
"use_mask": "true",
"prompt": "填充遮罩区域的头发",
"prompt": "填充遮罩区域的头发,人物增加一点磨皮",
}
if model_file:
fd["flux_model"] = model_file
+5 -5
View File
@@ -16,7 +16,7 @@
Handler 签名新增:
```python
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"),
```
透传链路:
@@ -31,7 +31,7 @@ handler → generate_grow_results(image, gender, use_mask, prompt)
Handler 签名新增:
```python
prompt: str = Form(default="补充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"),
```
透传链路:
@@ -80,17 +80,17 @@ if prompt is not None:
| 2 | `image_base64` | string | — | 同上 |
| 2 | `gender` | string | `female` | `male` / `female` |
| 2 | `use_mask` | bool | `true` | inpaint 遮罩开关 |
| 2 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
| 2 | `prompt` | string | `填充遮罩区域的头发,人物增加一点磨皮` | ComfyUI 提示词 |
| 3 | `marked_image_file` | file | — | 划线图(三选一) |
| 3 | `marked_image_url` | string | — | 同上 |
| 3 | `marked_image_base64` | string | — | 同上 |
| 3 | `use_mask` | bool | `true` | 画发际线开关 |
| 3 | `prompt` | string | `补充遮罩区域的头发` | ComfyUI 提示词 |
| 3 | `prompt` | string | `填充遮罩区域的头发,人物增加一点磨皮` | ComfyUI 提示词 |
## 3. 当前工作流提示词节点
`add_hair.json` 中节点 **60**(`JjkText`)为提示词节点,当前硬编码文本:
> 补充遮罩区补充遮罩区域内的头发,头发填满遮罩区域。发际线往下挡住额头
> 填充遮罩区域的头发,人物增加一点磨皮
`comfyui.run()` 收到 `prompt` 参数后将替换此文本。
+55 -19
View File
@@ -12,15 +12,18 @@
```
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI)
└ 接口4 本机直接调豆包(不转发) └ 接口1/2/3/5/6/7
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI/change_hair)
└ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/6/7/9/10/11/12 + 工具路由
```
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,**纯本地、无外网依赖**。
对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**(`./start.sh` 控制开关,`./run_worker.sh` 热重载)。
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机
回环外无外网依赖。对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**(systemd `hair-worker.service`,也可 `./run_worker.sh` 手动起)。
另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、
`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免鉴权调试)。
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
唯一例外是**接口4 在网关本机直接实现**(调外网豆包,不转发 worker)。
网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测,
直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 一律 200)。
@@ -33,7 +36,10 @@
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
| 7 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` |
| 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` |
| 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` |
| 12 | `steps.{input,final,redraw_band_overlay,redraw_full,redraw_band,redraw_c}_base64` | `steps.*_url` |
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
@@ -47,7 +53,7 @@
---
## 2. 五个接口实现简述
## 2. 各接口实现简述
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
@@ -65,10 +71,10 @@
### 接口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。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 组预览+生发图)。
@@ -92,20 +98,50 @@
- **做什么**:入参同接口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。
### 接口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 兼容)。
+202 -18
View File
@@ -265,10 +265,12 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| 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。越界/非法返回 `1007` |
| 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)
@@ -278,10 +280,11 @@
|------|------|------|
| image_url | string | 发际线曲线**透明 PNG** URL(仅白色发际线曲线,透明底,**不含人物**,需前端叠加原图显示) |
| grown_image_url | string | **生发后图片** URL(ComfyUI/Flux「植发 3 个月」效果图,完整人像照片) |
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
| 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`。
>
@@ -405,8 +408,8 @@
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 6)。缺失/非法返回 `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。缺失/越界/非法返回 `1007` |
| 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 的文本 |
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回三档发际线叠图与中心点,可大幅降低耗时 |
@@ -427,7 +430,7 @@
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`(male) |
| 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(仅曲线,透明底,**不含人物**,需叠加原图显示) |
| image_high_url | string | high 档发际线曲线**透明 PNG** URL(同上,high 档曲线) |
| image_low_url | string | low 档发际线曲线**透明 PNG** URL(同上,low 档曲线) |
@@ -446,7 +449,7 @@
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算) |
| 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 +518,197 @@
---
## 接口 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` | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格),网关本机实现 |
| 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 +718,7 @@
- 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。
- 所有坐标基准:**原图像素**。
- 接口 2:提供「美颜」开关 `beauty_enabled`。
- 接口 3:需同时上传原图,需返回发际线类型。
- 接口 3:仅传**划线图**(`marked_image_*` 三选一),需返回发际线类型(`custom`)。
- 接口 4:`data` 返回**一个 JSON 字符串**,字段不固定,可随时调整。
- 接口 5:发际线 PNG 数量 **N 不固定**,需按合适度**排序**。
- 英文字段命名由本文档约定(见各接口表格)。
+99 -1
View File
@@ -70,7 +70,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 +135,104 @@ 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 章节。
---
## 已经做好、无需再动的
- **接口4 在网关本机实现**(调豆包,不转发 worker)——已完成;config 里配 `ark`。
+234
View File
@@ -0,0 +1,234 @@
# 网关摘除隔离期实施方案(方案 B)
> 目标读者:网关机器的部署/维护者。本文档给出基于 **main 分支** `gateway/` 代码的
> 完整改动说明,可直接照做。**不需要改 worker 侧任何代码。**
## 1. 背景与事故复盘
2026-09-19 晚,GPU worker 机器(ubuntu3090_debug_4090 所在主机)磁盘写满,业务接口
全部失败,但网关**持续把请求打到该机器近 16 个小时**(21:30 故障发生 → 次日 13:36
人工重启),没有切换到其他 worker。复盘结论是三层防线同时失效:
| 层 | 问题 | 后果 |
|---|---|---|
| 1. worker `/health` 太浅 | 只返回内存里的"模型就绪"标志,磁盘满/下游 ComfyUI 挂掉不影响它 | 探测一直 200 |
| 2. 业务错误不触发摘除 | 下游出错经包装后以 HTTP 200/400 + 错误码返回,`forward.py` 只对 连接异常/超时/≥500/401 摘除 | 请求失败但 worker 被视为健康 |
| 3. 摘除后 8 秒即复活(**本方案要修的**) | `mark_worker_unhealthy()` 下线后,下一轮探测(8s 后)`/health` 返回 200,`healthy_threshold=1` 使其**一次成功立即重新上线** | 即使被摘除也会反复横跳,流量持续打入 |
worker 侧的第 1 层已经修复(方案 A:`/health` 深检磁盘水位 + 下游依赖 TCP 探测,
异常返回 503,代码在分支 `ubuntu3090_debug_4090`,提交 `14562d4`)。本方案修复
第 3 层:**被动摘除后进入隔离期,隔离期内即使探测成功也不复活**。两层配合后:
worker 磁盘满 → `/health` 503 → 网关 2 次探测失败摘除 → 隔离期内不再接流量。
> 第 2 层(错误码语义化,涉及调用方兼容)暂不处理,单独立项。
## 2. 现状代码行为(main 分支 gateway/pool.py)
- `mark_worker_unhealthy(w)`:被 `forward.py` 在转发失败(连接异常/超时/5xx/401)时
调用,置 `w.online = False`。**没有任何冷却机制**。
- `_health_check_loop(cfg)`:每 `interval_seconds`(默认 8s)探测所有 worker 的
`/health`;返回 200 时 `consecutive_successes += 1`,达到 `healthy_threshold`
(默认 **1**)且当前 offline → 立即 `online = True`。
- 因此被动摘除最多 8 秒后就被一次探测成功拉回——如果 worker 是"浅健康"或故障
间歇性表现,就会陷入 摘除→复活→失败→摘除 的横跳。
- 另外 58-96 行有一段**从未被调用的死代码** `_check_worker_health()` /
`_mark_failure()`(写了一半的旧实现),本次顺带删除,避免后人改错地方。
## 3. 代码改动(共 2 个文件 + 1 个配置)
### 3.1 `gateway/pool.py`
**(1) `WorkerState` 增加一个字段:**
```python
@dataclass
class WorkerState:
"""单个 worker 的运行时状态。"""
url: str
online: bool = False # 初始 offline,等健康检查通过后上线
busy: bool = False
consecutive_failures: int = 0
consecutive_successes: int = 0
offline_until: float = 0.0 # 被动摘除后的隔离截止时刻(time.monotonic() 基准)
```
**(2) 模块顶部(`logger = ...` 之后)加一个配置读取 helper:**
```python
def _cooldown_seconds() -> float:
"""被动摘除后的隔离期时长(秒),配置 health_check.cooldown_seconds,默认 60。"""
try:
from gateway.config import get_config
return float(get_config()["health_check"].get("cooldown_seconds", 60))
except Exception: # noqa: BLE001
return 60.0
```
(函数内 import 是为了不引入模块级循环依赖;`gateway/config.py` 不依赖 pool,顶层
import 也可以。)
**(3) `mark_worker_unhealthy()` 摘除时同时设置隔离期:**
```python
async def mark_worker_unhealthy(w: WorkerState) -> None:
"""被动标记:转发失败时立即将该 worker 下线,并进入隔离期。
隔离期内即使 /health 探测成功也不复活(由 _health_check_loop 守卫),
避免"摘除→8 秒复活→再失败"的横跳。
"""
async with _pool_condition:
if w.online:
w.online = False
w.consecutive_failures += 1
w.offline_until = time.monotonic() + _cooldown_seconds()
logger.warning("⚠ Worker 被动下线: %s(转发失败,隔离 %.0fs)",
w.url, _cooldown_seconds())
```
**(4) `_health_check_loop()` 的探测成功分支加隔离期守卫。** 找到这段:
```python
if resp.status_code == 200:
w.consecutive_failures = 0
w.consecutive_successes += 1
if w.consecutive_successes >= healthy_threshold and not w.online:
w.online = True
logger.info("✅ Worker 上线: %s", w.url)
```
改为:
```python
if resp.status_code == 200:
w.consecutive_failures = 0
w.consecutive_successes += 1
if w.consecutive_successes >= healthy_threshold and not w.online:
if time.monotonic() < w.offline_until:
logger.debug("Worker %s 探测成功但处于隔离期,暂不复活", w.url)
else:
w.online = True
logger.info("✅ Worker 上线: %s", w.url)
```
注意:`except`(连接异常)分支只累计失败、不涉及复活,无需改动;
`init_pool()` 启动时的首轮探测同理无需改动(进程刚启动时 `offline_until` 为 0,
不构成隔离)。
**(5) 删除死代码**:`_check_worker_health()` 与
`_mark_failure()` 两个函数整体删除(约 58-96 行,以函数名为准,别删到
`_health_check_loop`)。删除前可确认无调用方:
`grep -rn "_check_worker_health\|_mark_failure" .` 应只剩定义处。
### 3.2 `gateway/config.py`
`DEFAULTS["health_check"]` 增加一个 key:
```python
"health_check": {
"path": "/health",
"interval_seconds": 8,
"timeout_seconds": 3,
"unhealthy_threshold": 2,
"healthy_threshold": 1,
"cooldown_seconds": 60, # ← 新增:被动摘除后的隔离期(秒)
},
```
`config.example.json` 同步加这个 key。网关机器的实际 `config.json` **不加也不会报错**
(代码里有 60 的默认兜底),但建议显式加上,便于运维感知。
### 3.3 建议单测:`tests/test_gateway_pool.py`(新文件)
gateway 子包目前没有任何测试。隔离期是纯逻辑,建议至少覆盖以下三个用例
(不依赖真实网络):
```python
"""gateway/pool.py 隔离期逻辑单测。"""
import asyncio
import time
from unittest.mock import patch
from gateway import pool
from gateway.pool import WorkerState
def _drain(coro):
return asyncio.run(coro)
def test_passive_ejection_sets_cooldown():
w = WorkerState(url="http://127.0.0.1:9000", online=True)
async def run():
pool._pool_condition = asyncio.Condition()
with patch.object(pool, "_cooldown_seconds", return_value=60.0):
await pool.mark_worker_unhealthy(w)
_drain(run())
assert w.online is False
assert w.offline_until > time.monotonic()
def test_probe_success_revives_after_cooldown():
w = WorkerState(url="http://127.0.0.1:9000") # offline
w.offline_until = time.monotonic() - 1 # 隔离期已过
# 直接验证状态迁移逻辑(若按 3.1(4) 内联实现,可复制该分支逻辑做断言,
# 或将判断抽成 _try_revive(w, healthy_threshold) 小函数后单测)
...
def test_probe_success_blocked_during_cooldown():
w = WorkerState(url="http://127.0.0.1:9000")
w.offline_until = time.monotonic() + 60
... # 断言保持 offline
```
> 提示:如果按 3.1(4) 原样内联修改,后两个用例不好直接驱动,可以把"是否允许复活"
> 抽成一个 6 行的纯函数 `_try_revive(w, healthy_threshold) -> bool` 再测,主循环
> 调用它即可——实现方式自定,覆盖语义即可。
## 4. 部署步骤(网关机器)
1. `git pull` main 分支(含本方案代码改动)。
2. (可选但建议)`config.json` 的 `health_check` 段加 `"cooldown_seconds": 60`。
3. 重启网关服务(`hair-gateway.service` 或等价的 uvicorn 进程)。重启期间网关
本身短暂不可用,选低峰执行。
4. 观察启动日志:`健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=N`
正常出现。
## 5. 验证
**功能验证(低峰演练):**
1. 人工把主 worker 摘除一次最直接的方法:临时在 worker 机器
`systemctl stop hair-worker`(或临时设 `HEALTH_MIN_FREE_GB=999` 重启)。
2. 观察网关日志:
- 连续 2 次探测失败 → `⚠ Worker 下线`(原有行为);
- **转发失败触发的** `⚠ Worker 被动下线: ...(转发失败,隔离 60s)`(新日志);
- 恢复 worker 后,若刚被被动摘除过,即使 `/health` 恢复 200,也应看到 60 秒内
**不出现** `✅ Worker 上线`,隔离期满后下一次探测成功才上线。
3. `GET /gateway-health` 观察 `workers_healthy` 的变化节奏。
**回归确认:** 正常场景 worker 上下线不受影响(隔离期只对"被动摘除"生效;
探测失败导致的下线,恢复后仍按 `healthy_threshold` 立即上线)。
## 6. 回滚
- 代码回滚:`git revert` 对应提交即可,无数据/配置迁移。
- 紧急关闭隔离期:网关机器 `config.json` 设 `"cooldown_seconds": 0`
(等价于旧行为——摘除后下一次探测成功即复活),无需回代码。
## 7. 附:worker 侧方案 A 的行为(已上线,供联调参考)
worker(分支 `ubuntu3090_debug_4090`,提交 `14562d4`)的 `/health` 现在会在以下
情况返回 **503**(网关 2 次探测失败即摘除):
- 模型加载中:`503 {"status":"starting"}`(原有行为不变)
- 磁盘剩余低于阈值:`503 {"status":"degraded","reason":"disk_free_below_5.0g","disk_free_gb":...}`
- 下游依赖(ComfyUI 8188 / change_hair 8801)TCP 不通:
`503 {"status":"degraded","reason":"dep_down:comfyui,...","deps":{...}}`
正常时返回 `200 {"status":"ok","disk_free_gb":24.6,"deps":{"comfyui":true,"change_hair":true}}`。
worker 侧配置(`worker_config.json` 的 `health` 段,环境变量可覆盖):
`min_free_gb`(默认 5,0=关闭)、`check_deps`(默认 true)、`dep_comfyui`(默认
127.0.0.1:8188)、`deps_cache_seconds`(默认 5)。**其他 worker 机器如果也想启用
深检,cherry-pick 该提交并补一份 worker_config.json 的 health 段即可。**
+6 -14
View File
@@ -212,11 +212,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"]
@@ -243,7 +241,7 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
face_cx = (fx0 + fx1) / 2
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1(含发际线弃用)= 头顶→下巴尖并两端超出一点
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over
@@ -281,14 +279,8 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
draw.text((x, ys[i]), text, fill=LINE_COLOR, font=font, anchor="lm")
# --- 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
+1 -1
View File
@@ -1107,7 +1107,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
"hairline_id": hairline_id,
"blend_method": blend_method,
"hairline_push_cm": round(float(hairline_push_cm), 2),
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发",
"comfyui_prompt": comfyui_prompt or "填充遮罩区域的头发,人物增加一点磨皮",
"beauty_alpha": beauty_alpha,
"px_per_cm": round(float(px_per_cm), 4),
"mask_pixels": mask_viz["mask_pixels"],
+61 -82
View File
@@ -153,10 +153,12 @@ def pt_or_none(vertical, name):
class MeasureResult:
"""测量结果,提供 to_response() 输出与接口文档同构的 data 字段。"""
# 发际线弃用阈值:发际线离头顶(顶庭)< 此值时判定分割不可靠,弃用发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮),说明发际线
# 定位无意义 → 顶/上庭置 null、标注图不画头顶/发际线。
HAIRLINE_DISCARD_TOP_CM = 0.7
# 发际线修正阈值/固定值:发际线离头顶(顶庭)< 此值时判定分割不可靠,需修正发际线。
# hairline 与 hair_top 几乎重合(如稀疏头发中轴漏检只剩一小撮)→ 上庭改取
# 0.95×中庭反推发际线;若反推后越过头顶,则顶庭固定为 0.7cm(发际线钉在头顶下方
# 0.7cm),上庭取眉心→发际线剩余距离。修正后始终返回完整四庭,不再置 null。
HAIRLINE_CORRECT_TOP_CM = 0.7
UPPER_TO_MIDDLE_RATIO = 0.95 # 贴近头顶时,上庭 = 中庭 × 此比例
def __init__(self, vertical, eyes, px_per_cm, hairline_source, head_pose,
landmarks=None, image_width=None, image_height=None):
@@ -170,19 +172,32 @@ class MeasureResult:
self.w = image_width
self.h = image_height
# 各庭厘米
# 发际线修正:顶庭(头顶→发际线)< 阈值视为发际线贴近头顶、不可靠。
# 改写:上庭 = 0.95×中庭 → 由眉心反推发际线 y;若发际线越过头顶
# (hairline_y < hair_top_y),顶庭固定为 0.7cm(发际线钉在头顶下方 0.7cm)。
# 回写 vertical,使 to_response/标注图/v6 复用全部拿到修正后的发际线。
self.detected_top_cm = vertical["top_court_px"] / px_per_cm
self.hairline_corrected = self.detected_top_cm < self.HAIRLINE_CORRECT_TOP_CM
if self.hairline_corrected:
brow_x, brow_y = vertical["brow_center"]
hair_top_y = vertical["hair_top"][1]
new_upper_px = vertical["middle_court_px"] * self.UPPER_TO_MIDDLE_RATIO
hairline_y = brow_y - new_upper_px
if hairline_y < hair_top_y: # 反推发际线越过头顶 → 顶庭固定 0.7cm
hairline_y = hair_top_y + self.HAIRLINE_CORRECT_TOP_CM * px_per_cm
new_upper_px = brow_y - hairline_y
vertical["hairline"] = (brow_x, float(hairline_y))
vertical["upper_court_px"] = new_upper_px
vertical["top_court_px"] = hairline_y - hair_top_y
self.hairline_source = "corrected"
# 各庭厘米(修正后)
self.top_cm = vertical["top_court_px"] / px_per_cm
self.upper_cm = vertical["upper_court_px"] / px_per_cm
self.middle_cm = vertical["middle_court_px"] / px_per_cm
self.lower_cm = vertical["lower_court_px"] / px_per_cm
# 发际线弃用判定:顶庭(头顶→发际线)过小视为发际线贴近头顶、不可靠。
# 弃用时 hairline_source 改为 "discarded",face_total 只算中庭+下庭。
self.hairline_discarded = self.top_cm < self.HAIRLINE_DISCARD_TOP_CM
if self.hairline_discarded:
self.hairline_source = "discarded"
self.face_total_cm = self.middle_cm + self.lower_cm
else:
self.face_total_cm = self.top_cm + self.upper_cm + self.middle_cm + self.lower_cm
self.face_total_cm = (self.top_cm + self.upper_cm
+ self.middle_cm + self.lower_cm)
# 七眼厘米
self.eye_width_cm = eyes["eye_width_px"] / px_per_cm
@@ -190,77 +205,41 @@ class MeasureResult:
self.inter_eye_cm = eyes["inter_eye_distance_px"] / px_per_cm
def to_response(self):
# 发际线弃用:顶/上庭相关字段置 null(保留键),ratio 分母只算中下庭;
# landmarks.hair_top/hairline 置 null。否则按四庭正常输出。
if self.hairline_discarded:
base_px = (self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": None,
"upper_court_cm": None,
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": None,
"upper_court": None,
"middle_court": round(self.vertical["middle_court_px"] / base_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / base_px, 3),
},
# 四庭完整输出(发际线贴近头顶时已在 __init__ 修正 vertical,无 null 分支)。
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
"landmarks": {
"hair_top": None,
"hairline": None,
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
else:
total_px = (self.vertical["top_court_px"] + self.vertical["upper_court_px"]
+ self.vertical["middle_court_px"] + self.vertical["lower_court_px"])
data = {
"face_total_height_cm": round(self.face_total_cm, 2),
"four_courts": {
"top_court_cm": round(self.top_cm, 2),
"upper_court_cm": round(self.upper_cm, 2),
"middle_court_cm": round(self.middle_cm, 2),
"lower_court_cm": round(self.lower_cm, 2),
"ratios": {
"top_court": round(self.vertical["top_court_px"] / total_px, 3),
"upper_court": round(self.vertical["upper_court_px"] / total_px, 3),
"middle_court": round(self.vertical["middle_court_px"] / total_px, 3),
"lower_court": round(self.vertical["lower_court_px"] / total_px, 3),
},
},
"seven_eyes": {
"eye_width_cm": round(self.eye_width_cm, 2),
"face_width_cm": round(self.face_width_cm, 2),
"inter_eye_distance_cm": round(self.inter_eye_cm, 2),
"ratios": {
"eye_width": round(self.eyes["eye_width_px"] / self.eyes["face_width_px"], 3),
"inter_eye_distance": round(self.eyes["inter_eye_distance_px"] / self.eyes["face_width_px"], 3),
},
},
"landmarks": {
"hair_top": pt_or_none(self.vertical, "hair_top"),
"hairline": pt_or_none(self.vertical, "hairline"),
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
},
"landmarks": {
"hair_top": pt_or_none(self.vertical, "hair_top"),
"hairline": pt_or_none(self.vertical, "hairline"),
"brow_center": pt_or_none(self.vertical, "brow_center"),
"nose_bottom": pt_or_none(self.vertical, "nose_bottom"),
"chin_tip": pt_or_none(self.vertical, "chin_tip"),
},
"hairline_source": self.hairline_source,
}
# left/right_position:mediapipe 21/251 号定位点(原图像素,与 landmarks 同坐标系)。
# landmarks 缺省(如测试直构 MeasureResult)时不输出,保持向后兼容。
if self.landmarks is not None and self.w and self.h:
+166
View File
@@ -0,0 +1,166 @@
"""接口99:人脸特殊点检测。
返回 MediaPipe Face Mesh 关键点中一组固定编号的像素坐标(英文组名_点ID 为字段名),
外加 5 个发际线特殊点:从指定关键点沿指定方向射线步进,与 BiSeNet 头发分割 mask
首次相交处即发际线点(参考接口1 方案B 的发际线定位方式,见 hair_segmenter.py)。
组与编号(需求方给定):
- eyebrow 107 66 105 63 70 336 296 334 293 300
- eye 189 222 224 113 226 413 442 444 342 446
- mouth 0 37 40 67 91 84 17 405 375 409 269
- nose_midline 9 168 197 5 4 194 278 48 437 217
- cheek 101 117 123 187 205 50 330 346 352 280 425
- face_edge 162 234 58 136 149 152 378 365 288 401 366 389 251
发际线点(字段名需求方给定;A/B 两射线时取中点,仅一条命中则返回该点,均未命中为 null):
- hair_line_center 151 → 10 方向与发际线交点
- hair_line_right mid(108→109 交点A, 69→67 交点B)
- hair_line_right_right mid(104→103 交点A, 68→54 交点B)
- hair_line_left mid(337→338 交点A, 299→297 交点B)
- hair_line_left_left mid(333→332 交点A, 298→284 交点B) # 与 right_right 镜像对称
"""
import math
import numpy as np
# 固定关键点组:组英文名 → MediaPipe 点 ID 列表(顺序即需求方给定顺序)
GROUPS = {
"eyebrow": [107, 66, 105, 63, 70, 336, 296, 334, 293, 300],
"eye": [189, 222, 224, 113, 226, 413, 442, 444, 342, 446],
"mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269],
"nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217],
"cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425],
"face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251],
}
# 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点,
# 双射线取两交点中点。
HAIRLINE_RAYS = {
"hair_line_center": [(151, 10)],
"hair_line_right": [(108, 109), (69, 67)],
"hair_line_right_right": [(104, 103), (68, 54)],
"hair_line_left": [(337, 338), (299, 297)],
"hair_line_left_left": [(333, 332), (298, 284)],
}
class NoFaceError(Exception):
"""未检出人脸。"""
class NonFrontalError(Exception):
"""非正面照(复用接口1 姿态阈值)。"""
def ray_hairline_intersection(p1, p2, hair_mask):
"""从 p1 沿 p1→p2 方向逐像素步进,返回首次进入头发 mask 的整数像素坐标。
p1/p2: (x, y) 像素坐标(float)。起点本身已在头发内时直接返回起点(已到发际线)。
步进直到出图或步数上限(3×图对角线,防极端死循环);未命中返回 None。
"""
mask = np.asarray(hair_mask).astype(bool)
if mask.size == 0:
return None
h, w = mask.shape
x0, y0 = float(p1[0]), float(p1[1])
dx, dy = float(p2[0]) - x0, float(p2[1]) - y0
length = math.hypot(dx, dy)
if length < 1e-6:
return None
ux, uy = dx / length, dy / length
max_steps = int(3 * math.hypot(h, w))
def _hit(x, y):
xi, yi = int(round(x)), int(round(y))
if 0 <= xi < w and 0 <= yi < h:
return mask[yi, xi]
return False
x, y = x0, y0
for _ in range(max_steps):
if _hit(x, y):
return int(round(x)), int(round(y))
x += ux
y += uy
if not (0 <= int(round(x)) < w and 0 <= int(round(y)) < h):
return None # 已步出图像,方向上无头发
return None
def detect_special_points(image_bgr):
"""主入口:图片 → {字段名: {"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 = 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))
+76
View File
@@ -607,3 +607,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})
+1 -1
View File
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
},
"class_type": "JjkText",
"_meta": {
+2 -2
View File
@@ -16,7 +16,7 @@ from . import comfyui
logger = logging.getLogger("hair.worker")
_DEFAULT_PROMPT = "填充遮罩区域的头发"
_DEFAULT_PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
_REPO = os.path.dirname(os.path.dirname(__file__))
_REPAINT_WORKFLOW = os.path.join(_REPO, "0716add-hair-api.json")
@@ -62,7 +62,7 @@ def run_redraw(image_bytes: bytes, mask_bytes: bytes,
Args:
image_bytes: 人物图片字节(JPG/PNG)
mask_bytes: 遮罩图片字节(支持红/白/alpha 遮罩格式)
prompt: 提示词,None 用默认 "填充遮罩区域的头发"
prompt: 提示词,None 用默认 "填充遮罩区域的头发,人物增加一点磨皮"
timeout: ComfyUI 超时秒数
front: True 时任务插到 ComfyUI 队列最前(接口2 时延敏感路径用)
unet_name: 非 None 时切换 Flux 模型(如 flux-2-klein-9b-Q5_K_M.gguf),None 用工作流默认
+14 -7
View File
@@ -46,7 +46,8 @@ _FEMALE_KEY_TO_CHANG = {
_HAIRSTYLE_ORDER = {
"female": ["ellipse", "flower", "heart", "straight", "wave",
"bigflower", "clasicalflower"], # 1..7
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"], # 1..6
"male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal",
"wave"], # 1..7
}
_REPO = os.path.dirname(os.path.dirname(__file__))
@@ -57,7 +58,7 @@ _BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")
# 关键:ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻,靠缓存 CLIP 文本条件避免重载。
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux(每次 +4s)。三接口用同一字符串即可全程命中。
# 与 app.py 接口2/接口3 的默认 prompt 保持一致;可用 REDRAW_PROMPT 覆盖。
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发")
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发,人物增加一点磨皮")
# 接口2 女重绘整条管线(swapHair + ComfyUI)送模型前限边。真实照片常达 1257x1495:
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。
@@ -67,7 +68,7 @@ _REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发")
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "1024"))
def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
max_side=None, unet_name=None):
max_side=None, unet_name=None, prompt=None):
"""直接调 ComfyUI 重绘(替代原 local_test HTTP 服务)。
传 final 图 + 纯红遮罩 PNG,返回重绘后的 PNG bytes。
@@ -75,6 +76,7 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
max_side:送 ComfyUI 前长边压到多少像素,None 用全局默认 _REDRAW_MAX_SIDE。
unet_name:非 None 时切换 Flux 模型,None 用工作流内置默认。
prompt:None 用默认 _REDRAW_PROMPT,否则用传入的提示词。
"""
from .redraw import run_redraw
eff_side = _REDRAW_MAX_SIDE if max_side is None else max_side
@@ -96,7 +98,8 @@ def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0,
orig_w, orig_h, nw, nh, eff_side)
# front=True:接口2 时延敏感,插到 ComfyUI 队列最前,避免排在接口3/5 的批量任务后面
out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
prompt=_REDRAW_PROMPT, front=True, unet_name=unet_name)
prompt=prompt if prompt is not None else _REDRAW_PROMPT,
front=True, unet_name=unet_name)
if scale < 1.0 and out:
out = _upscale_png_to(out, orig_w, orig_h)
return out
@@ -226,7 +229,7 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
unet_name: str | None = None):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..6。
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..7。
为 None 时生成全部(兼容旧调用)。
use_mask(默认 True):是否启用 inpaint 遮罩,用于测试对比(同接口3)。
False 时用**干净原图 + 空遮罩**送 ComfyUI(不烧黑色模板线)。
@@ -313,7 +316,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/默认「填充遮罩区域的头发,人物增加一点磨皮」时与全局 _REDRAW_PROMPT 一致,
不破坏 CLIP 缓存命中的快路径;传不同提示词会使缓存失效并重载 CLIP/Flux
(实测换词后首次请求重绘 45s vs 缓存命中 8s),后续相同提示词恢复快路径。
Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)};
无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None,不抛异常。
"""
@@ -387,7 +393,8 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
_tr0 = _t.perf_counter()
grown_png = _call_local_redraw(final_bytes, mask_bytes,
max_side=redraw_max_side,
unet_name=unet_name)
unet_name=unet_name,
prompt=prompt)
_tr1 = _t.perf_counter()
_tm = data.get("timings_ms") or {}
logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+3 -3
View File
@@ -48,7 +48,7 @@ cd /home/ubuntu/hair/local_test
|--------|------|------|------|
| image | File | 是 | 人物图片(支持 jpg, png 等常见格式) |
| mask | File | 是 | 遮罩图片(支持 jpg, png,遮罩区域可用红色/白色/alpha 通道标识) |
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,皮肤加一点磨皮" |
| prompt | String | 否 | 提示词,默认值:"填充遮罩区域的头发,人物增加一点磨皮" |
#### 遮罩图片格式说明
@@ -70,7 +70,7 @@ cd /home/ubuntu/hair/local_test
curl -X POST http://127.0.0.1:8899/api/generate \
-F "image=@/path/to/person.jpg" \
-F "mask=@/path/to/mask.png" \
-F "prompt=填充遮罩区域的头发,皮肤加一点磨皮" \
-F "prompt=填充遮罩区域的头发,人物增加一点磨皮" \
--output result.png
```
@@ -85,7 +85,7 @@ files = {
"mask": open("mask.png", "rb"),
}
data = {
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮"
"prompt": "填充遮罩区域的头发,人物增加一点磨皮"
}
resp = requests.post(url, files=files, data=data, timeout=600)
+1 -1
View File
@@ -206,7 +206,7 @@ def generate():
return jsonify({"error": msg}), 400
image_file = request.files["image"]
mask_file = request.files["mask"]
prompt_text = request.form.get("prompt", "填充遮罩区域的头发")
prompt_text = request.form.get("prompt", "填充遮罩区域的头发,人物增加一点磨皮")
log.info(
"收到请求: image=%s mask=%s prompt=%r",
image_file.filename, mask_file.filename, prompt_text,
+1 -1
View File
@@ -27,7 +27,7 @@ def prep_and_upload():
def run_once(fname, model, dtype, steps):
wf = A.build_workflow(fname, "填充遮罩区域的头发")
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮")
wf["16"]["inputs"]["unet_name"] = model
wf["16"]["inputs"]["weight_dtype"] = dtype
wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -33,7 +33,7 @@ def upload(scale=1.0):
def run(fname, steps):
wf = A.build_workflow(fname, "填充遮罩区域的头发")
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮")
wf["16"]["inputs"]["unet_name"] = MODEL
wf["16"]["inputs"]["weight_dtype"] = DTYPE
wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -30,7 +30,7 @@ SEED = 123456789
imgs = []
labels = []
for steps in [2, 3, 4, 6]:
wf = A.build_workflow(fname, "填充遮罩区域的头发", seed=SEED)
wf = A.build_workflow(fname, "填充遮罩区域的头发,人物增加一点磨皮", seed=SEED)
wf["16"]["inputs"]["unet_name"] = MODEL
wf["16"]["inputs"]["weight_dtype"] = DTYPE
wf["1"]["inputs"]["steps"] = steps
+1 -1
View File
@@ -55,7 +55,7 @@ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer;
</div>
<div class="controls" style="margin-top:16px">
<label>提示词:</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发">
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮">
</div>
<div style="text-align:center; margin-top:16px">
<button class="btn-generate" id="generateBtn" disabled>🚀 生成</button>
+1 -1
View File
@@ -25,7 +25,7 @@ resp = requests.post(
"image": ("original.jpg", img_data, "image/jpeg"),
"mask": ("mask.png", mask_data, "image/png"),
},
data={"prompt": "填充遮罩区域的头发"},
data={"prompt": "填充遮罩区域的头发,人物增加一点磨皮"},
timeout=600,
)
+2 -2
View File
@@ -69,7 +69,7 @@
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">X-Internal-Token</label>
@@ -354,7 +354,7 @@ function dataUriToBlob(dataUri) {
async function runLocalRedraw() {
if (!_finalDataUri || !_maskDataUri) { setStatus('缺少 final 或遮罩,请先生成', 'error'); return; }
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发';
const prompt = $('localTestPrompt').value.trim() || '填充遮罩区域的头发,人物增加一点磨皮';
const btn = $('redrawBtn');
btn.disabled = true; btn.textContent = '⏳ 重绘中...';
flog('===== 调后端重绘 =====', 'info');
+2 -2
View File
@@ -56,7 +56,7 @@
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px;font-weight:600;white-space:nowrap">重绘提示词</label>
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
<input type="text" id="localTestPrompt" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;min-width:200px;padding:8px;border:1px solid #ddd;border-radius:8px">
</div>
<div class="params">
<div class="pf">
@@ -169,7 +169,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 = '⏳ 重绘中...';
+3 -2
View File
@@ -117,7 +117,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</div>
<div id="statusBar" class="status hidden"></div>
</div>
@@ -144,7 +144,7 @@ const API_BASE = window.location.origin;
let _origUrl = '';
const TYPE_LABELS = {
ellipse:'椭圆形', flower:'花瓣形', heart:'心形', straight:'直线形', wave:'波浪形', m:'M形', inverse_arc:'倒弧形'
ellipse:'椭圆形', flower:'花瓣形', heart:'心形', straight:'直线形', wave:'波浪形', m:'M形', inverse_arc:'倒弧形', Softpetal:'柔和花瓣形', bigflower:'大花瓣型', clasicalflower:'古典花瓣型'
};
function $(id) { return document.getElementById(id); }
@@ -262,6 +262,7 @@ const HAIR_STYLES = {
{ value:'4', label:'straight 直线形' },
{ value:'5', label:'heart 桃心形' },
{ value:'6', label:'Softpetal 柔和花瓣形' },
{ value:'7', label:'wave 波浪形' },
]
};
+1 -1
View File
@@ -94,7 +94,7 @@
</div>
<div class="upload-group" style="margin-top:14px">
<div class="label">💬 提示词(prompt)</div>
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="width:100%;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:14px;max-width:500px">
</div>
<div style="margin-top:14px;display:flex;gap:12px;align-items:center">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
+3 -2
View File
@@ -101,7 +101,7 @@
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<div class="form-group">
<label>性别</label>
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(6种)</option></select>
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(7种)</option></select>
</div>
<div class="form-group" style="align-items:flex-start">
<label style="padding-top:3px">发型 *</label>
@@ -122,7 +122,7 @@
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div>
<div class="hint">JPG/PNG 正面照 &nbsp;|&nbsp; Female: 椭圆/花瓣/心形/直线/波浪 &nbsp;|&nbsp; Male: 椭圆/倒弧/M形/直线 &nbsp;|&nbsp; 每个发型返回 middle/high/low 三档 + 生发图</div>
<div class="hint">JPG/PNG 正面照 &nbsp;|&nbsp; Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 &nbsp;|&nbsp; Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 &nbsp;|&nbsp; 每个发型返回 middle/high/low 三档 + 生发图</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
@@ -184,6 +184,7 @@ const HAIR_STYLES = {
{ value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. heart 桃心形' },
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
{ value:'7', label:'7. wave 波浪形' },
]
};
+1 -1
View File
@@ -104,7 +104,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待 &nbsp;|&nbsp; 工作流: add_hair2.json</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</div>
<div id="statusBar" class="status hidden"></div>
</div>
+266
View File
@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口99 — 人脸特殊点检测 测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 8px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
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; }
.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; }
.hidden { display: none; }
.canvas-wrap { position: relative; display: inline-block; max-width: 100%; border-radius: 8px; overflow: hidden; }
canvas { max-width: 100%; display: block; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 12px; font-size: 13px; }
.legend .item { display: flex; align-items: center; gap: 6px; }
.legend .dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.json-panel { background: #1e293b; color: #e2e8f0; font-family: "SF Mono", Consolas, monospace; font-size: 12px; line-height: 1.6; padding: 16px; border-radius: 8px; max-height: 480px; overflow: auto; white-space: pre; }
</style>
</head>
<body>
<div class="container">
<h1>接口99 — 人脸特殊点检测</h1>
<div class="subtitle">POST /api/v1/face/special-points · 固定编号 MediaPipe 关键点 + 5 个发际线特殊点(射线与头发分割 mask 交点),返回原图像素坐标</div>
<div class="card">
<div class="upload-row">
<input type="file" id="imageFile" accept="image/jpeg,image/png">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
<button class="btn btn-outline" onclick="clearResults()">清空</button>
</div>
<div class="hint">支持 JPG / PNG;单人正面照效果最佳。发际线点依赖头发分割,秃顶/遮挡时对应字段为 null。</div>
<div class="status" id="statusBar"></div>
</div>
<div id="resultsArea" class="hidden">
<div class="card">
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
<span>可视化结果(点击图片可放大)</span>
<label style="font-size:13px; font-weight:400; display:flex; align-items:center; gap:6px; cursor:pointer">
<input type="checkbox" id="showIds" checked onchange="redraw()"> 显示点ID
</label>
</h2>
<div class="canvas-wrap"><canvas id="resultCanvas"></canvas></div>
<div class="legend" id="legendBar"></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>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.85); z-index:99; cursor:zoom-out; align-items:center; justify-content:center">
<img id="lightboxImg" style="max-width:96vw; max-height:96vh" alt="">
</div>
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/face/special-points';
// 组 → 颜色(与下方 legend 一致)
const GROUP_COLORS = {
eyebrow: '#ef4444', // 红
eye: '#3b82f6', // 蓝
mouth: '#22c55e', // 绿
nose_midline: '#a855f7', // 紫
cheek: '#f97316', // 橙
face_edge: '#06b6d4', // 青
hairline: '#e11d48', // 玫红(发际线特殊点,大圆+标签)
};
const GROUP_LABELS = {
eyebrow: '眉毛', eye: '眼睛', mouth: '嘴', nose_midline: '中线鼻',
cheek: '面颊', face_edge: '边线', hairline: '发际线特殊点',
};
const HAIRLINE_KEYS = ['hair_line_center', 'hair_line_right', 'hair_line_right_right',
'hair_line_left', 'hair_line_left_left'];
let lastImage = null; // 提交的原图(用于画布底图)
let lastJson = null;
let lastData = null; // 最近一次成功的 data(供「显示点ID」切换时重画)
function $(id) { return document.getElementById(id); }
function setStatus(text, type) {
const bar = $('statusBar');
bar.textContent = text;
bar.className = 'status ' + type;
}
async function submitTest() {
let file = $('imageFile').files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
const t0 = performance.now();
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '⏳ 请求中...';
setStatus('正在请求 ' + API_BASE + ENDPOINT + ' ...', 'info');
$('resultsArea').classList.add('hidden');
// 本地预览原图(原图直送,不缩放,坐标即原图像素)
lastImage = await loadImage(await file.arrayBuffer());
const form = new FormData();
form.append('image_file', file);
try {
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form });
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
lastJson = json;
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
const n = Object.values(json.data.points || {}).filter(Boolean).length;
setStatus('✅ 请求成功 (' + dt + 's) — request_id: ' + json.request_id
+ ',有效点 ' + n + '/' + Object.keys(json.data.points || {}).length
+ ',hairline_source=' + json.data.hairline_source, 'success');
lastData = json.data;
renderCanvas(json.data);
renderLegend();
} else {
setStatus('❌ 业务错误 (' + dt + 's) — code: ' + json.code + ' message: ' + 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 loadImage(buf) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(new Blob([buf]));
});
}
function renderCanvas(data) {
const canvas = $('resultCanvas');
const ctx = canvas.getContext('2d');
const img = lastImage;
const scale = Math.min(1, 900 / img.naturalWidth); // 画布显示尺寸(坐标按比例缩放)
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const r = Math.max(1.5, 1.6 * scale * 2); // 点半径随图缩小适度收缩
const font = Math.max(9, Math.round(9 * scale * 2)); // 发际线标签字号
const idFont = Math.max(8, Math.round(6.5 * scale * 2)); // 关键点ID字号
const showIds = $('showIds').checked;
// 1) 固定关键点:小实心圆 + 白描边 + 点ID
for (const [key, pt] of Object.entries(data.points || {})) {
if (!pt || HAIRLINE_KEYS.includes(key)) continue;
const group = key.split('_')[0];
const color = GROUP_COLORS[group] || '#888';
const x = pt.x * scale, y = pt.y * scale;
drawDot(ctx, x, y, r, color);
if (showIds) {
const id = key.split('_').pop(); // 如 eyebrow_107 → 107
ctx.font = idFont + 'px sans-serif';
ctx.textBaseline = 'alphabetic';
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(0,0,0,.75)'; // 黑描边保证任意底色可读
ctx.strokeText(id, x + r + 1, y + 3);
ctx.fillStyle = '#fff';
ctx.fillText(id, x + r + 1, y + 3);
}
}
// 2) 发际线特殊点:大空心圆 + 字段名标签
for (const key of HAIRLINE_KEYS) {
const pt = (data.points || {})[key];
if (!pt) continue;
const x = pt.x * scale, y = pt.y * scale;
ctx.beginPath();
ctx.arc(x, y, r * 2, 0, Math.PI * 2);
ctx.strokeStyle = GROUP_COLORS.hairline;
ctx.lineWidth = Math.max(1.5, r * 0.55);
ctx.stroke();
drawDot(ctx, x, y, r, GROUP_COLORS.hairline);
ctx.font = '600 ' + font + 'px sans-serif';
ctx.textBaseline = 'bottom';
const label = key.replace('hair_line_', 'HL ');
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillRect(x + r * 2.4, y - font - 4, tw + 8, font + 6);
ctx.fillStyle = '#fecdd3';
ctx.fillText(label, x + r * 2.6 + 4, y - 2);
}
}
function drawDot(ctx, x, y, r, color) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = Math.max(1, r * 0.35);
ctx.strokeStyle = 'rgba(255,255,255,.9)';
ctx.stroke();
}
function renderLegend() {
const bar = $('legendBar');
bar.innerHTML = Object.entries(GROUP_LABELS)
.map(([g, label]) => `<span class="item"><span class="dot" style="background:${GROUP_COLORS[g]}"></span>${label}</span>`)
.join('');
}
function clearResults() {
$('resultsArea').classList.add('hidden');
$('statusBar').className = 'status';
$('imageFile').value = '';
lastImage = null; lastJson = null; lastData = null;
}
function redraw() {
if (lastData && lastImage) renderCanvas(lastData);
}
function copyJson() {
if (!lastJson) return;
navigator.clipboard.writeText(JSON.stringify(lastJson, null, 2))
.then(() => setStatus('✅ JSON 已复制到剪贴板', 'success'));
}
// 点击画布放大
$('resultCanvas').addEventListener('click', () => {
const lb = $('lightbox');
$('lightboxImg').src = $('resultCanvas').toDataURL('image/png');
lb.style.display = 'flex';
});
</script>
</body>
</html>
+67 -3
View File
@@ -36,12 +36,76 @@ def test_auth_missing_token_401(client):
assert r.status_code == 401
def test_health_no_token_200(client):
def _patch_health_ok(monkeypatch):
"""让 /health 深检确定性通过:不依赖本机磁盘水位与 8188/8801 服务存活。"""
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
**app_module._HEALTH_CFG, "min_free_gb": 0.0, "check_deps": True})
async def _deps_ok():
return True, {"comfyui": True, "change_hair": True}
monkeypatch.setattr(app_module, "_check_deps", _deps_ok)
def test_health_no_token_200(client, monkeypatch):
_patch_health_ok(monkeypatch)
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_health_disk_low_503(client, monkeypatch):
# 阈值调到不可能的 99999G → 必然触发磁盘水位 503
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
**app_module._HEALTH_CFG, "min_free_gb": 99999.0, "check_deps": False})
r = client.get("/health")
assert r.status_code == 503
body = r.json()
assert body["status"] == "degraded"
assert body["reason"].startswith("disk_free_below")
def test_health_dep_down_503(client, monkeypatch):
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
**app_module._HEALTH_CFG, "min_free_gb": 0.0, "check_deps": True})
async def _deps_down():
return False, {"comfyui": False, "change_hair": True}
monkeypatch.setattr(app_module, "_check_deps", _deps_down)
r = client.get("/health")
assert r.status_code == 503
body = r.json()
assert body["status"] == "degraded"
assert "comfyui" in body["reason"]
def test_health_ok_carries_detail(client, monkeypatch):
_patch_health_ok(monkeypatch)
r = client.get("/health")
assert r.status_code == 200
body = r.json()
assert "disk_free_gb" in body
assert body["deps"] == {"comfyui": True, "change_hair": True}
def test_tcp_probe_real_socket():
# 用进程内真实 socket 验证探测本身(曾因 open_connection 返回元组误判全部不可达)
import asyncio
async def scenario():
server = await asyncio.start_server(lambda r, w: None, host="127.0.0.1", port=0)
port = server.sockets[0].getsockname()[1]
up = await app_module._tcp_probe("127.0.0.1", port)
server.close()
await server.wait_closed()
# 端口 1 无监听,本机 connect 立即被拒
down = await app_module._tcp_probe("127.0.0.1", 1)
return up, down
up, down = asyncio.run(scenario())
assert up is True
assert down is False
def test_param_none_provided_1007(client):
r = client.post(URL, headers=H)
assert r.json()["code"] == 1007
@@ -173,7 +237,7 @@ def test_hairline_gen_female(client, monkeypatch):
assert set([f"eye{i}" for i in range(1, 8)]).issubset(fm["seven_eyes"].keys())
assert set(["hair_top", "hairline", "brow_center",
"nose_bottom", "chin_tip"]).issubset(fm["landmarks"].keys())
assert fm["hairline_source"] in ("segmentation", "estimated")
assert fm["hairline_source"] in ("segmentation", "estimated", "corrected")
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
@@ -200,7 +264,7 @@ def test_success_structure(client):
assert data["seven_eyes"]["eye3"] is not None
assert data["seven_eyes"]["eye4"] is not None
assert data["seven_eyes"]["eye5"] is not None
assert data["hairline_source"] in ("segmentation", "estimated")
assert data["hairline_source"] in ("segmentation", "estimated", "corrected")
# base64 解码为合法 PNG(非 URL)
assert "annotated_image_url" not in data
png = base64.b64decode(data["annotated_image_base64"])
+8 -2
View File
@@ -1,5 +1,11 @@
{
"_comment": "复制为 worker_config.json(不入 git)。accept_passwords:worker 内网鉴权密码列表,网关带 X-Internal-Token: <其中之一>(也可用环境变量 WORKER_ACCEPT_PASSWORDS 覆盖)。comfyui_password:本机 ComfyUI(8188) Basic Auth 密码(user 默认 admin;也可放 password.txt 或环境变量 COMFYUI_PASSWORD)。注:接口4 火山方舟 ark_api_key 已迁到网关配置。",
"_comment": "复制为 worker_config.json(不入 git)。accept_passwords:worker 内网鉴权密码列表,网关带 X-Internal-Token: <其中之一>(也可用环境变量 WORKER_ACCEPT_PASSWORDS 覆盖)。comfyui_password:本机 ComfyUI(8188) Basic Auth 密码(user 默认 admin;也可放 password.txt 或环境变量 COMFYUI_PASSWORD)。health:深健康检查(/health 磁盘水位+下游依赖 TCP 探测,均可用环境变量 HEALTH_MIN_FREE_GB / HEALTH_CHECK_DEPS / HEALTH_DEP_COMFYUI / HEALTH_DEPS_CACHE_SECONDS 覆盖)。注:接口4 火山方舟 ark_api_key 已迁到网关配置。",
"accept_passwords": ["change-me-to-a-strong-secret"],
"comfyui_password": "your-comfyui-basic-auth-password"
"comfyui_password": "your-comfyui-basic-auth-password",
"health": {
"min_free_gb": 5,
"check_deps": true,
"dep_comfyui": "127.0.0.1:8188",
"deps_cache_seconds": 5
}
}