Author SHA1 Message Date
xslandCursor ccb9c142c4 docs: 接入页补充接口1/6 的竖线坐标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:29:59 +08:00
xslandCursor b64313dc73 feat(测量): 接口1/6 返回与标注图一致的竖线坐标
前端需要按标注图重绘竖线,坐标与画线共用同一套几何。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:18:12 +08:00
xsl 1c51f3eaab fix: 将52号接口范围限定为GPU worker
撤回52号网关路由、图片字段改名和mock改动,gateway目录恢复a3fddbb。worker保持base64和null响应,更新接口说明和回归测试。

验证:worker API、IP限制与网关兼容性共54项测试通过。
2026-11-04 10:45:16 +08:00
xsl 5b2b73f5d5 feat: 新增52号发际线接口并限制GPU worker来源IP
保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。

worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。

验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
2026-11-04 10:20:29 +08:00
xsl a3fddbbc34 fix(发际线): 修正不对称刘海导致的模板叠加倾斜
按 MediaPipe 左右对应额头点协调模板延伸距离,保留人脸姿态与头部轮廓钳制,统一预览白线和生发遮罩的几何。补充对齐回归测试并更新贴图顺序测试及实现说明。

验证:13 项相关测试通过;两张用户图片完成前后对比,新图男性 7 型接口与生发图均成功返回。
2026-11-01 15:14:29 +08:00
xsl 8554850d55 docs: 网关摘除隔离期实施方案(供网关机器参考实现,暂不在本仓库改 gateway 代码)
复盘 9·19 磁盘满事故网关未切换的三层原因;给出 main 分支 gateway/pool.py
的隔离期改动说明(WorkerState.offline_until + 被动摘除冷却 60s + 探测复活
守卫 + 删死代码)、配置项、单测建议、部署/验证/回滚步骤。worker 侧深检
(方案 A)已在本分支 14562d4 上线。
2026-09-20 22:07:48 +08:00
xsl 6b098c0e15 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 22:07:48 +08:00
xsl 752630e4f5 feat: ComfyUI 默认提示词统一改为「填充遮罩区域的头发,人物增加一点磨皮」
- app.py 各接口 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内置文本
- static 测试页、local_test、benchmark 脚本、接口文档同步
注意:新提示词首次请求会因 CLIP 缓存失效变慢一次,之后恢复
2026-09-16 00:27:33 +08:00
xsl 59e01d3230 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:37:51 +08:00
xsl 10eecba2f0 docs(网关待改动§9): 补无GUI服务器系统库依赖 + torch CPU源安装说明 + 无GPU实测结论 2026-09-06 23:32:24 +08:00
xsl 27c402c259 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:32:24 +08:00
xsl 0b02ad1693 feat(接口99): 人脸特殊点检测 + 网关转发路由;文档同步至当前代码
- worker: POST /api/v1/face/special-points,60 个固定编号关键点(6组)
  + 5 个发际线特殊点(沿指定关键点方向射线步进,与 BiSeNet 头发 mask 首次相交;
  双射线取中点,left_left=mid(333→332, 298→284) 与右侧镜像)
- 网关: 同路径盲转发路由(调用频率低,维持转发不占网关依赖;
  响应无 *_base64 字段,改写逻辑天然跳过)
- 测试页 static/test_interface99.html:分组着色小点 + 点ID标注(可开关)
  + 发际线点大圆标签 + JSON 展示
- 文档: CLAUDE.md 重写至当前架构;实现说明/接口文档补接口 9~12/99 契约;
  网关待改动补 §7(9/10已加) §8(11/12待加) §9(接口99部署步骤)
2026-09-06 23:32:24 +08:00
68 changed files with 4680 additions and 196 deletions
+1
View File
@@ -9,6 +9,7 @@ gateway/config.json
# worker 配置(含鉴权密码,不入 git)
worker_config.json
ip_access.json
# ComfyUI Basic Auth 密码(不入 git)
password.txt
+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": {
+520 -28
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
@@ -189,6 +194,10 @@ async def internal_token_auth(request: Request, call_next):
本地起步),生产务必通过 worker_config.json / 环境变量配置密码。
"""
path = request.url.path
from ip_access import rejection
denied = rejection(request)
if denied is not None:
return denied
if path == "/" or path.startswith(_AUTH_EXEMPT):
return await call_next(request)
if path.startswith("/api/") and ACCEPT_PASSWORDS:
@@ -365,22 +374,22 @@ class ImageJsonBody(BaseModel):
# ---------------------------------------------------------------------------
def _run_face_measure_data(image, variant="v1"):
def _run_face_measure_data(image, variant="v1", landmarks_override=None):
"""接口1测量数值核心:detect → 姿态校验 → 头发/耳朵分割 → measure_face → eye1~eye7。
返回 (data_dict, result, hair_mask, ear_mask),或检测/姿态失败时返回 None。
不含标注图(annotated_image_*),供接口5 容错复用;接口1 调用后再自行生成标注图。
任何分割/七眼计算异常均内部吞掉(七眼相关字段不出现),不影响主测量结果。
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)。
variant="v6"(接口6):去顶庭、不画端线、无 eye1~eye7。
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)+ vertical_lines。
variant="v6"(接口6):去顶庭、不画端线、无 eye1/eye7,vertical_lines 仅 6 条内线。
"""
h, w = image.shape[:2]
from face_analysis.detector import detector
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face
landmarks = detector.detect(image)
landmarks = landmarks_override if landmarks_override is not None else detector.detect(image)
if landmarks is None:
return None
if not check_frontal_face(landmarks, w, h):
@@ -419,30 +428,29 @@ def _run_face_measure_data(image, variant="v1"):
# 占比、标注图仍按三庭处理,显示效果不变。
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 用竖线坐标里的人头端线,仅接口1 有。
# vertical_lines 与标注图竖线同一套几何。
try:
from face_analysis.annotation import vertical_line_geometry
epts = result.eyes["points"]
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
pc = result.px_per_cm
inner_xs = [lcx, epts["left_outer"][0], epts["left_inner"][0],
epts["right_inner"][0], epts["right_outer"][0], rcx]
geom = vertical_line_geometry(
result, w, h, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
data["vertical_lines"] = geom
inner_xs = [epts["left_cheek"][0], epts["left_outer"][0], epts["left_inner"][0],
epts["right_inner"][0], epts["right_outer"][0], epts["right_cheek"][0]]
for i in range(5):
a, b = inner_xs[i], inner_xs[i + 1]
data["seven_eyes"][f"eye{i + 2}"] = (
None if (a is None or b is None) else round((b - a) / pc, 2))
if variant != "v6":
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
# 竖向范围上界用头顶(hair_top 始终可用)。
from face_analysis.annotation import _ear_edges_from_mask
top_y = vd["hair_top"][1]
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask,
top_y, vd["chin_tip"][1],
lcx, rcx, (lcx + rcx) / 2)
by_name = {line["name"]: line["x"] for line in geom["lines"]}
head_l = by_name.get("left_head_edge")
head_r = by_name.get("right_head_edge")
data["seven_eyes"]["eye1"] = (
None if (head_l is None) else round((lcx - head_l) / pc, 2))
None if head_l is None else round((by_name["left_cheek"] - head_l) / pc, 2))
data["seven_eyes"]["eye7"] = (
None if (head_r is None) else round((head_r - rcx) / pc, 2))
None if head_r is None else round((head_r - by_name["right_cheek"]) / pc, 2))
except Exception as seg_e: # noqa: BLE001
logger.warning("七眼段宽度计算失败:%s", seg_e)
@@ -892,6 +900,7 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
- 四庭(顶庭/上庭/中庭/下庭)各段**厘米数值及占比**
- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比**
- 五个关键分界点的**原图像素坐标**(头顶/发际线/眉心/鼻翼下缘/下巴尖)
- 竖线坐标 `vertical_lines`(与标注图同一套:共用 y 范围 + 从左到右的 x)
{_image_fields_desc}
@@ -947,6 +956,19 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
"vertical_lines": {
"y_top": 98, "y_bottom": 1410,
"lines": [
{"name": "left_head_edge", "x": 120},
{"name": "left_cheek", "x": 210},
{"name": "left_eye_outer", "x": 310},
{"name": "left_eye_inner", "x": 420},
{"name": "right_eye_inner", "x": 560},
{"name": "right_eye_outer", "x": 670},
{"name": "right_cheek", "x": 780},
{"name": "right_head_edge", "x": 890},
],
},
},
}
}
@@ -1023,6 +1045,7 @@ async def face_measure_debug(
- 三庭(上庭/中庭/下庭)各段**厘米数值及占比**(**不含顶庭**)
- 五眼段宽(eye2~eye6:左脸颊/左眼/两眼间距/右眼/右脸颊)**厘米数值**,另含眼宽/脸宽/两眼间距
- 四个关键分界点的**原图像素坐标**(发际线/眉心/鼻翼下缘/下巴尖)
- 竖线坐标 `vertical_lines`(发际线→下巴尖,仅 6 条内线,无人头两端)
基于接口1 的变体,与接口1 的差异:
- **去顶庭**:不画头顶横线、不返回顶庭数据;`face_total_height_cm` 为三庭之和
@@ -1080,6 +1103,17 @@ async def face_measure_debug(
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
"vertical_lines": {
"y_top": 430, "y_bottom": 1360,
"lines": [
{"name": "left_cheek", "x": 210},
{"name": "left_eye_outer", "x": 310},
{"name": "left_eye_inner", "x": 420},
{"name": "right_eye_inner", "x": 560},
{"name": "right_eye_outer", "x": 670},
{"name": "right_cheek", "x": 780},
],
},
},
}
}
@@ -1186,7 +1220,7 @@ async def hair_grow(
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填),如 1,2,3。female:1-7 male:1-7"),
beauty_enabled: bool = Form(default=False, description="是否开启美颜(本期不生效)"),
use_mask: bool = Form(default=True, description="是否启用 inpaint 遮罩(测试对比用)。false 时用干净原图生成(空遮罩,不烧模板线)"),
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词,会替换工作流节点60的文本"),
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词,会替换工作流节点60的文本"),
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"),
):
@@ -1258,7 +1292,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女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
@@ -1444,7 +1478,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)
@@ -1552,7 +1586,7 @@ async def face_features(
# ---------------------------------------------------------------------------
# 接口 5:发际线 PNG 生成
# 接口 5:发际线 PNG 生成(恢复 a3fddbb 历史版本)
# ---------------------------------------------------------------------------
@app.post(
@@ -1655,7 +1689,7 @@ async def hairline_generate(
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-7"),
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
prompt: str = Form(default="填充遮罩区域的头发", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图(ComfyUI 生发,最耗时)。默认 true 出图;false 时跳过生发,各发型 grown_image 恒为 null,仅返回三档发际线叠图与中心点"),
):
if gender not in ("male", "female"):
@@ -1722,6 +1756,285 @@ async def hairline_generate(
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 52:中下庭规则、扶正裁切与矩形叠加预览
# ---------------------------------------------------------------------------
async def _generate_hairline52_data(image, gender, hair_styles, use_mask=True,
prompt=None, generate_grow_image=False,
compare_rules=False, normalize_image=True):
"""Share measurement coordinates between interface 52 and its comparison page."""
from fastapi.concurrency import run_in_threadpool
from hairline.service import generate_hairline_pngs_52, get_landmarker
original = image
framing, normalized_landmarks, landmarks_override = None, None, None
if normalize_image:
from hairline.framing import normalize_portrait
from mediapipe.framework.formats import landmark_pb2
landmarks = await run_in_threadpool(
get_landmarker().detect, cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True)
if landmarks is None:
return None, None
image, normalized_landmarks, framing = await run_in_threadpool(normalize_portrait, image, landmarks)
landmarks_override = landmark_pb2.NormalizedLandmarkList()
for point in normalized_landmarks:
landmarks_override.landmark.add(x=float(point[0]), y=float(point[1]), z=float(point[2]))
fm, reference = None, None
try:
measured = await run_in_threadpool(_run_face_measure_data, image, landmarks_override=landmarks_override)
if measured is not None:
fm, result = measured[:2]
reference = {key: result.vertical[key]
for key in ("brow_center", "nose_bottom", "chin_tip")}
reference["px_per_cm"] = result.px_per_cm
except Exception as ex:
logger.warning("接口52 当前底图测量失败,使用发际线关键点计算中下庭:%s", ex)
res = await run_in_threadpool(
generate_hairline_pngs_52, image, gender, hair_styles, use_mask, prompt,
generate_grow_image=generate_grow_image, height_reference=reference,
compare_rules=compare_rules,
precomputed_landmarks=normalized_landmarks[:468] if normalized_landmarks is not None else None)
if res is not None:
res.update(processed_bgr=image, original_bgr=original, framing=framing)
return res, fm
@app.post(
"/api/v1/hairline/generate-52",
summary="接口52 发际线PNG生成",
tags=["人脸分析"],
description=f"""
输入用户照片 + 性别 + 多选发型,各发型 middle 中点统一对齐到眉心上方(中庭+下庭)/2。
返回设计曲线、生发图、目标高度与定位误差;high/low 旧字段保留为 null。
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
---
**入参**(同接口2:先选性别,再多选发型):
- 必填 `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,7=wave。
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
生发遮罩直接使用新位置的 middle 曲线,与预览保持一致。
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
`false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回设计叠图与中心点。
- 新增可选 `normalize_image`(默认 `false`):显式传 `true` 时扶正并裁切,长图采用3:4。
不传时保留旧调用的原图尺寸与坐标。测试页显式开启此参数,保留实际像素、不放大图片。
**返回说明**:
- `hairline_images`:**选中发型**的列表,数量 = 所选发型数,`order` = 发型序号。每项含:
- `image_middle_base64`:新高度规则的透明发际线 PNG;`image_high_base64` / `image_low_base64` 固定 null。
- `height_placement`:旧/新中心坐标、移动距离和目标误差(像素)。
- `grown_image_base64`:该发型的**生发图**(生发失败时为 `null`)。
- `hairline_type`:发际线类型 key。
本接口仅部署在GPU worker,图片始终返回 `*_base64`;空图片保留字段为 null。
- `best_hairline_center_point`:**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标,
以**当前底图像素**为基准(左上角为原点,x 向右,y 向下)。
- `high_hairline_center_point` / `low_hairline_center_point`:旧字段,固定 null。
- `height_rule`:中庭、下庭、目标上庭高度、目标中心与测量来源;`face_measure` 为当前底图测量。
- `input_image_base64`:处理后底图,须用它叠加发际线;`original_image_base64`:处理前图片。
- `coordinate_space`:默认 `original_image`,开启预处理后为 `processed_image`。
- `framing`:是否标准裁切、旋转角、前后尺寸及双向坐标变换;关闭预处理时为 null。
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"hairline_images": [
{"hairline_type": "ellipse", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": None, "image_low_base64": None, "grown_image_base64": "iVBORw0KGgo...", "order": 1},
{"hairline_type": "flower", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": None, "image_low_base64": None, "grown_image_base64": None, "order": 2},
],
"best_hairline_center_point": {"x": 540, "y": 430},
"high_hairline_center_point": None,
"low_hairline_center_point": None,
"face_measure": {
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44, "upper_court_cm": 3.44,
"middle_court_cm": 3.44, "lower_court_cm": 3.44,
"ratios": {"top_court": 0.25, "upper_court": 0.25,
"middle_court": 0.25, "lower_court": 0.25},
},
"seven_eyes": {
"eye_width_cm": 3.44, "face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44,
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
"hairline_source": "segmentation",
"head_pose": {"yaw": -1.39, "pitch": 2.49, "roll": -0.06},
},
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
}
},
},
},
)
async def hairline_generate_52(
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, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-7"),
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图。false 时仅返回新规则发际线叠图与中心点"),
normalize_image: bool = Form(default=False, description="新增可选开关,默认false保留原图尺寸和坐标;true先扶正再裁切,长图采用3:4。测试页显式开启"),
):
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
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}")
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
from hairline.framing import decode_portrait
image = decode_portrait(raw)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
res, fm = await _generate_hairline52_data(
image, gender, hair_styles, use_mask, prompt,
generate_grow_image=generate_grow_image, normalize_image=normalize_image)
if res is None:
return err(1001, "无法识别人像")
hairline_images = []
for it in res["images"]:
ov = it["overlays"]
hairline_images.append({
"hairline_type": it["hairline_type"],
"image_middle_base64": _rgba_png_b64(ov["middle"]),
"image_high_base64": None,
"image_low_base64": None,
"grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null)
if it.get("grown_png") else None),
"order": it["order"],
"height_placement": it["height_placement"],
})
c = res.get("best_centers") or {}
def _pt(p):
return ({"x": p[0], "y": p[1]} if p else None)
data = {
"hairline_images": hairline_images,
"best_hairline_center_point": _pt(c.get("middle")),
"high_hairline_center_point": _pt(c.get("high")),
"low_hairline_center_point": _pt(c.get("low")),
"height_rule": res["height_rule"],
"guide_landmarks": res["guide_landmarks"],
"face_measure": fm,
"framing": res["framing"],
"input_image_base64": _jpg_b64(res["processed_bgr"]),
"original_image_base64": _jpg_b64(res["original_bgr"]),
"coordinate_space": "processed_image" if normalize_image else "original_image",
"width": res["processed_bgr"].shape[1], "height": res["processed_bgr"].shape[0],
}
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口52 处理异常")
return err(1007, f"处理失败:{ex}")
@app.get("/api/v1/debug/hairline-height-cases", include_in_schema=False)
async def hairline_height_cases():
path = os.path.join(os.path.dirname(__file__), "tests", "fixtures", "hairline_height_cases.json")
with open(path, encoding="utf-8") as f:
return ok(json.load(f))
@app.get("/api/v1/debug/hairline-framing-reference", include_in_schema=False)
async def hairline_framing_reference():
from hairline.framing import load_profile
return ok(load_profile())
@app.post("/api/v1/debug/hairline-height-compare", include_in_schema=False)
async def hairline_height_compare(
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
gender: str = Form(default="female"),
hair_style: str = Form(default="1,2,3,4,5,6,7"),
generate_grow_image: bool = Form(default=False),
normalize_image: bool = Form(default=True),
):
if gender not in ("female", "male"):
return err(1004, "gender 必须为 male / female")
styles = _parse_hair_styles(hair_style, 7)
if styles is None:
return err(1007, "hair_style 必须为 1..7 的整数,逗号分隔")
raw, error = await resolve_image_bytes(image_file, image_url, image_base64)
if error is not None:
return error
from hairline.framing import decode_portrait
photo = decode_portrait(raw)
if photo is None:
return err(1008, "图片格式不支持")
try:
res, fm = await _generate_hairline52_data(
photo, gender, styles, prompt="填充遮罩区域的头发,人物增加一点磨皮",
generate_grow_image=generate_grow_image, compare_rules=True,
normalize_image=normalize_image)
if res is None:
return err(1001, "无法识别人像")
h, w = res["processed_bgr"].shape[:2]
return ok({
"gender": gender, "width": w, "height": h,
"input_base64": _jpg_b64(res["processed_bgr"]),
"original_base64": _jpg_b64(res["original_bgr"]),
"framing": res["framing"],
"coordinate_space": "processed_image" if normalize_image else "original_image",
"height_rule": res["height_rule"], "face_measure": fm,
"guide_landmarks": res["guide_landmarks"],
"results": [{
"hairline_type": item["hairline_type"], "order": item["order"],
"old_overlay_base64": _rgba_png_b64(item["legacy_overlay"]),
"new_overlay_base64": _rgba_png_b64(item["overlays"]["middle"]),
"height_placement": item["height_placement"],
"grown_image_base64": _png_to_jpg_b64(item["grown_png"]) if item["grown_png"] else None,
} for item in res["images"]],
})
except Exception as ex:
logger.exception("接口52 高度规则对比失败")
return err(1007, str(ex))
# ---------------------------------------------------------------------------
# 接口 9:头发遮罩生成
# ---------------------------------------------------------------------------
@@ -1893,6 +2206,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 换发型 + 按遮罩羽化贴回)
# ---------------------------------------------------------------------------
@@ -2049,7 +2426,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"),
@@ -2215,7 +2592,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()
@@ -2264,12 +2641,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)
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""快速测试:3张图×花瓣发型×896分辨率,新提示词"填充遮罩区域的头发"。
"""快速测试:3张图×花瓣发型×896分辨率,新提示词"填充遮罩区域的头发,人物增加一点磨皮"。
预热1次+正式1次。
"""
import base64, json, os, time
@@ -9,7 +9,7 @@ import requests
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
TOKEN = "dev-shared-secret-2026"
PROMPT = "填充遮罩区域的头发"
PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
OUT = Path("/home/ubuntu/hair/benchmark_out/bench6")
OUT.mkdir(parents=True, exist_ok=True)
+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
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""分辨率对比测试(新提示词版):4图×5发型=20行,每行4种分辨率,steps=15。
提示词固定为 "填充遮罩区域的头发"。
提示词固定为 "填充遮罩区域的头发,人物增加一点磨皮"。
热数据:预热1次+正式1次。
"""
import base64
@@ -14,7 +14,7 @@ import requests
API = "http://127.0.0.1:8187/api/v1/debug/grow-timing"
TOKEN = "dev-shared-secret-2026"
PROMPT = "填充遮罩区域的头发"
PROMPT = "填充遮罩区域的头发,人物增加一点磨皮"
OUT = Path("/home/ubuntu/hair/benchmark_out/bench7")
OUT.mkdir(parents=True, exist_ok=True)
+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` 参数后将替换此文本。
+74 -24
View File
@@ -12,19 +12,23 @@
```
客户端 ──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/52/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/*`(免Token调试,仍受IP限制)。
所有动态路径先校验直连来源IP:允许本机与网关`49.232.51.171`,其余返回HTTP403/code1010;`/static/`除外。配置见[`接口IP白名单.md`](接口IP白名单.md),网关侧不增加IP限制。
- **网关**(`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)。
- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 200);IP拒绝为HTTP403/code1010,Token错误为HTTP401/code1009,依赖健康异常为HTTP503。
### base64 → URL 映射(网关落盘改写,递归进数组、可空保留 null)
### base64 → URL 映射(网关落盘改写,递归进数组)
| 接口 | worker 字段(内部) | 对外字段 |
|------|--------------------|----------|
@@ -33,24 +37,29 @@
| 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` |
| 52 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`、`input_image_base64` / `original_image_base64` | 仅GPU worker,本次不新增网关路由;直接返回`*_base64` |
| 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`,自动覆盖嵌套/新增字段。
> 兼容约定:网关保持原样,有图片时落盘为`*_url`,空图片保留`*_base64: null`。接口52仅在worker返回base64,与网关的图片改名无关。
> **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。
> 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。
### 错误码
`1001` 无法识别人像 | `1002` 分辨率过低 | `1003` 非正面 | `1004` gender 必填/非法(接口2/5/7)|
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持。
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持 | `1009` Token鉴权失败 | `1010` worker来源IP不允许或白名单配置无效。
---
## 2. 五个接口实现简述
## 2. 各接口实现简述
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、竖线坐标 `vertical_lines`、一张透明底标注 PNG。
- **怎么实现**(`face_analysis/`):MediaPipe Face Mesh 468+虹膜点 → solvePnP 姿态校验(非正面 1003) →
虹膜直径法定标(px→cm) → **眉心以下实测**;**眉心以上**用 BiSeNet 头发分割取真实发际线/头顶(方案B),
失败回退比例推算(方案A,`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`。
@@ -59,16 +68,17 @@
### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`(worker)—— 接口1 的去顶庭变体
- **做什么**:基于接口 1,**去顶庭**:不画头顶横线、不返回顶庭数据(`four_courts` 仅上/中/下庭,`landmarks` 无 `hair_top`,`face_total_height_cm` 为三庭之和)。
- **与接口1 的标注差异**(`create_annotated_image(variant="v6")`):①竖线范围改为发际线→下巴尖;②不画人头最左/最右端线(仅七眼 6 点 5 段,接口1 为 8 线 7 段);③左侧只标上/中/下庭。箭头/虚线/字体等与接口1 一致。
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。`vertical_lines` 与标注图共用 `vertical_line_geometry`:接口 6 的 y 范围是发际线到下巴尖,`lines` 只有 6 条内线。
- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
- **做什么**:正面照 + `gender`(必填) + `hair_style`(必填,逗号分隔多选,如 `1,2,3`) → 指定发际线类型 **N 组**:**预览图**(发际线叠在照片上) + **生发后图**(植发3个月效果)。
- **怎么实现**(`hairline/`):移植 head3d——MediaPipe(Tasks) + SegFormer 分割 + 17 锚点射线检测 → 502 点 mesh,
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8182** 的
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
返回 `results[].image_base64` + `grown_image_base64`。
- `hair_style` 映射:female 1=ellipse 2=flower 3=heart 4=straight 5=wave 6=bigflower 7=clasicalflower;male 1=ellipse 2=inverse_arc 3=m 4=straight 5=heart 6=Softpetal 7=wave。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线;female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。
- 模板对齐:左右刘海边界高低不同时,按 MediaPipe 左右对应额头点协调沿人脸向上方向的延伸距离,避免把 M 型等设计模板拉歪;保留人脸姿态,之后仍做头部轮廓钳制。预览白线与生发黑线/遮罩共用修正后的 502 点几何,不改变真实发际线测量算法。
- `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 +102,60 @@
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。
### 接口52 发际线PNG生成 `/api/v1/hairline/generate-52`(worker)
- **预处理**:`hairline/framing.py`先扶正9→152,再按高(3÷0.9)L、宽2.5L(3:4)做原像素裁切,人物相对原3L规则缩小约10%。L为9→152距离;眉心位置沿用已扶正参考图比例。长图的标准窗口不足时,求原图内最大的3:4窗口,通过可行区域约束保留全部人脸关键点,并尽量贴近目标眉心位置;无法保留人脸时回退仅扶正。其余图片空间不足时仍仅扶正、去除旋转空角。参考图与参数由`scripts/calibrate_hairline_framing.py`生成。公开接口为兼容旧调用默认关闭预处理,`normalize_image=true`开启;规则页显式开启,对比调试页默认开启。返回当前底图、原图及双向坐标矩阵。
- **目标高度**:`上庭=(中庭+下庭)/2`,用当前底图测量的未取整像素坐标(眉心9、鼻翼下缘94、下巴152)算目标中心。测量失败时用发际线管线同编号点计算并标注来源。
- **模板定位**:原有502点/贴图生成形状,`hairline/height_rule.py` 将每种 middle 曲线沿面部向上方向平移、校准中心 Y,保留曲线形状与倾斜。此规则仅接口52使用,接口2保持原逻辑。
- **生发**:移动后的预览曲线直接生成黑线和重绘遮罩,再送ComfyUI,避免预览与黑模板位置不一致。
- **裁切叠加预览**:测试页新增“裁切发际线叠加”。`static/hairline_reference_box.js`共享同一个162/389/9/152参考框几何,按原图像素裁切middle透明图层,再叠加到处理后人像;四栏顺序为原图、middle、裁切发际线叠加、生发图。
- **兼容**:high/low不再生成,图片与中心点字段保留为null。接口52始终返回`*_base64`,空图片保留字段并返回null。本次不修改网关路由、落盘、字段改名或IP策略。`face_measure`为当前底图测量;新增`height_rule`与各项`height_placement`说明设计目标与实际误差。
- **测试**:规则页`static/test_interface52.html`,对比页`static/test_interface52_compare.html`;运行`scripts/compare_hairline_height.py`默认保存全部30张×男女各7型,共420组对比。每人一行,顺序为未裁切原图、单张旧规则图、全部新规则样式,支持位置对照、额头特写、同图叠线和上传新图。新图可叠加162/389两侧、底边经过9、高度为9→152距离70%的参考框。原图与叠图按需加载。
### 接口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 角色)**
- 启动使用`./run_worker.sh`或`hair-worker.service`,均显式传`--no-proxy-headers`以保留直连来源IP。部署时复制`ip_access.example.json`为`ip_access.json`并核对网关IP;运行配置不入Git,改动即时生效。旧启动方式也应补上此参数,详见[`接口IP白名单.md`](接口IP白名单.md)。
- 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 兼容)。
+120
View File
@@ -0,0 +1,120 @@
# 接口52上传图片裁切与扶正规划
状态:2026-11-04 在原3L规则上将人物显示缩小约10%,裁切高度改为(3÷0.9)L≈3.3333L、宽度2.5L,已接入接口52与对比页。参考图先扶正后重新标定,以下旧预检数值仅为历史记录,当前标准以 `hairline/reference/framing_profile.json` 为准。
## 接口拆分
- 接口5已按11月2日前最新提交`a3fddbb`恢复:`POST /api/v1/hairline/generate`,返回middle/high/low三档,测试页`/static/test_interface5.html`。
- 当前新规则完整迁至接口52:`POST /api/v1/hairline/generate-52`,测试页`/static/test_interface52.html`,30案例对比`/static/test_interface52_compare.html`。接口52保留迁移前所有参数和返回字段,页面显式开启`normalize_image=true`。
- 原`test_interface5_compare.html`书签跳转至52号对比页;5号与52号的worker处理路由和生成函数独立;52号仅部署在GPU worker,本次不新增网关转发。
## 已确定的要求
- 仅用于接口52与当前旧/新规则对比页。
- 先扶正:变换后的9号点与152号点保持同一X坐标,9在上、152在下。
- 原图空间够时按目标人脸比例和位置裁切;标准窗口不足时,偏长图片在保留人脸的前提下裁为3:4,其余仍仅扶正。3:4无法保留完整人脸时不强裁。
- 输出保留实际像素尺寸,不为了凑参考图尺寸而放大小图。
- 当前展示:全部30个案例,每个案例一行,未裁切原图放最前,旧规则图仅一张,新规则男女各7型横排,人物图片完整显示。选择新样式时旧图同步对应形状;失败案例仍保留原图并显示原因。
## 已扶正标准(当前生效)
- 标准图:`static/framing_reference.png`,786×1048,3:4。
- 两点X均为385.18783691;9号Y为360.65437127,152号Y为725.82510480。
- 相对位置:9号点 x=49.00608612%,y=34.41358504%。
- 当前裁切高/L=3÷0.9≈3.3333、宽/L=2.5(3:4),L为9→152长度。参考图自身测得的宽/L=2.15241783589、高/L=2.86989044785作为原始标定记录保留;眉心在画面中的位置比例继续使用参考图数值。
- 校准旋转约0.25962484°,变换后横向误差约5.7e-14像素。参数取自扶正并去空角后的图,不再使用未扶正原图的比例。
- 重标定脚本:`./venv/bin/python scripts/calibrate_hairline_framing.py <参考图路径>`。
- 使用同一套原始检测点作刚体变换与后续测量,避免二次检测抖动破坏轴线竖直。
- 公开接口新增`normalize_image`默认false,旧请求保持原图尺寸和坐标;显式true开启预处理。规则页主动传true,对比调试页默认开启。响应明确返回`coordinate_space`、当前底图和坐标变换。
## 原始参考构图(旧测量)
[用户指定参考图](https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg)
测量采用当前发际线管线的MediaPipe FaceLandmarker:
| 指标 | 测量值 |
|---|---|
| 画面 | 792×1056,3:4 |
| 9号点 | (388.93, 364.62) |
| 152号点 | (387.27, 729.79) |
| 9→152长度L | 365.17像素 |
| 面部轴线距竖直方向 | 约0.26° |
| 9号点画面相对位置 | x约49.1%,y约34.5% |
| 152号点画面相对高度 | y约69.1% |
| 画面宽度/L | 约2.16885 |
| 画面高度/L | 约2.89180 |
参考图接近竖直。以9号点为定位锚点,用9→152长度作为缩放无关的构图标尺。这里的L仅为眉心到下巴的距离,不是头顶到下巴的全头高度。
## 实施流程
1. 读取上传原图、处理EXIF方向,再检测人脸关键点。
2. 用9→152方向计算二维旋转,使转换后两点竖直。不改变脸部比例。
3. 按本人的L算目标裁切尺寸:高(3÷0.9)L、宽2.5L;令9号点位于裁切画面约(49.0061%,34.4136%)处。高度取最接近的4的倍数,宽=高×3/4,不改变原图像素尺度。
4. 将裁切窗口四角反算回原图,四角均在有效图片区域内才执行标准构图。采用一次仿射变换合并旋转与裁切,降低重复插值。
5. 标准窗口空间不足时,偏长图片采用原图内最大的3:4有效窗口,约束所有人脸关键点在框内,并尽量靠近目标眉心位置。若无法保留完整人脸则仅扶正并给出原因。其余图片仍仅扶正,最小去除旋转空角。
6. 后续测量、分割、旧/新发际线和70%参考框使用同一个处理后底图与坐标系。保留原图及原图/处理后坐标转换关系,防止叠图错位。
7. 页面展示原图、处理后图、旋转角、是否标准裁切、跳过原因与前后尺寸。旧/新规则对比使用同一处理后底图,避免构图变化混入高度规则差异。
## 当前:长图与矩形发际线预览
- 长图即使放不下标准人脸占比窗口,也尝试在原图有效区域裁出最大的3:4窗口;通过可行区域约束保留468个人脸关键点,并尽量贴近参考眉心位置。无法完整保留人脸时回退仅扶正,响应说明原因。
- 案例7从1080×1920裁为1062×1416,保持9→152竖直。长图7、20、21、22、50、51的男女各7型已更新,84组均成功;对比页30张、378组结果统一使用当前规则。
- 规则页展示原图、middle、裁切发际线叠加、生发图。新增预览使用共享矩形几何裁切透明发际线,矩形外清空透明,底图仍是完整处理后人像。
## 人物缩小10%的验证(长图规则加入前)
- 裁切高=(3÷0.9)L、宽=2.5L,使同样展示尺寸下的人物线性尺寸约为原3L规则的90%。
- 案例1由828×1104改为918×1224,人脸相对大小约90.20%,差异来自原像素窗口取整;底图、发际线和实际生发图均为918×1224。
- 对比页保留全部30张。重新计算了1、3、42、43、44、45;其余48组结果验证新窗口仍超出有效范围,且只扶正后的矩阵和尺寸完全一致,复用原定位图并更新裁切参数。
- 当前生成378组对比,43、44、45的男女组仍显示原图及定位失败原因。布局、眉心比例和9→152扶正方向保持一致。
## 历史:3L规则的五个案例验证
2026-11-04 按3L规则重跑男女各7型,共70组,失败0组。保留原始像素,不缩放人脸;实际尺寸如下。
| 案例 | 原尺寸 | 处理后尺寸 | 实际处理 |
|---|---|---|---|
| 1 | 1088×1406 | 828×1104 | 按3L规则裁切 |
| 2 | 1088×1406 | 1042×1346 | 空间不足,仅扶正并去除旋转空角 |
| 3 | 1088×1406 | 810×1080 | 按3L规则裁切 |
| 4 | 1088×1406 | 1042×1346 | 空间不足,仅扶正并去除旋转空角 |
| 5 | 1088×1367 | 1071×1346 | 空间不足,仅扶正并去除旋转空角 |
## 历史几何预检(未扶正参考图)
以下为修改前的预检记录,当前生效尺寸见上表。
| 案例 | 原尺寸 | 所需裁切尺寸(约) | 预计处理 |
|---|---|---|---|
| 1 | 1088×1406 | 797×1063 | 可按参考构图裁切并扶正 |
| 2 | 1088×1406 | 1442×1923 | 空间不足,仅扶正 |
| 3 | 1088×1406 | 782×1042 | 可按参考构图裁切并扶正 |
| 4 | 1088×1406 | 1465×1953 | 空间不足,仅扶正 |
| 5 | 1088×1367 | 1463×1950 | 空间不足,仅扶正 |
## 历史:3L规则的全部30张验证
- 按3L规则运行全部30张、男女各7型,共60次请求;55组请求成功,生成385组旧/新规则对比。27张男女均成功,案例43额外生成男性7型。
- 案例43女性:模板在面部中轴处没有有效曲线;案例44、45男女:扶正后无法保留9/152参考点。三个案例均在对应行显示完整原图和未生成组合的原因,不隐藏失败案例。
- 页面顺序为未裁切原图、旧规则、全部新规则;案例筛选包括失败图片,重跑按钮运行完整30张。
- 页面筛选、旧图切换、末尾图片加载及移动端横向浏览均通过;3L调参后再次检查全部输出尺寸与混合成功/失败案例的筛选。
## 验收重点
- 处理后的9、152号点X差不超过1像素,保证9在152上方。
- 标准裁切成功时,画面比例及两点位置接近参考构图,保留实际像素,不放大小图。
- 空间不足的案例明确提示,仍完成扶正。
- 旧/新规则叠图、参考框与底图坐标一致,原图坐标可通过逆变换还原。
- 全部30案例均可查看,成功案例显示男女各7型,失败案例显示原图与原因;沿用每人一行、单张旧规则图的展示。
## 2026-11-04 提交前 Review 与验证
- 接口5的请求处理函数、三档PNG生成函数与`a3fddbb`逐项AST比较一致,测试页逐字节一致;新高度规则、扶正裁切和矩形叠加均在接口52。
- 范围修正:接口52仅属于GPU worker,返回`*_base64`及null;网关路由、落盘、字段改名、mock worker均恢复`a3fddbb`,没有“仅52号改名”的特殊逻辑。测试确认旧网关行为保持原样且未暴露52号路由。
- 范围修正后的回归检查:`./venv/bin/pytest tests/test_ip_access.py tests/test_gateway_hairline_routes.py tests/test_gateway_nullable_images.py tests/test_api.py -q`,54项通过。
- GPU worker所有动态接口启用直连IP白名单,本机和网关`49.232.51.171`可访问;网关侧不启用IP限制。配置与部署见[接口IP白名单](接口IP白名单.md)。
- 上一轮`./venv/bin/pytest -q`:113项通过、1项已有失败(撤回网关新增功能前的测试数)。`tests/test_pipeline.py::test_numeric_regression`期望全脸总高24.01cm,当前实测22.47cm;从未修改的`a3fddbb`导出代码、夹具后独立运行,同样失败。此次保留历史断言,未通过修改基线掩盖差异。
- 浏览器实测:接口5返回middle/high/low且保持868×1166;接口52长图案例7返回1062×1416、`status=aspect_crop`,四栏图和裁切边框正常;对比页显示30个案例、378组结果,旧书签可跳转52,未出现页面脚本错误。定位验证均关闭ComfyUI生发,本轮未重复运行耗时的真实生发。
- 批量结果与上传图位于被Git忽略的`static/annotations/`及`tests/output/`,不随提交分发;新环境执行`scripts/compare_hairline_height.py`重建。420是计划组合数,378为当前成功结果,失败组及原因保留在报告中。
+29
View File
@@ -0,0 +1,29 @@
# GPU worker 接口 IP白名单
仅GPU worker使用项目根目录的`ip_access.json`,不存在时仅允许本机。网关不启用IP限制。配置每次请求重新读取,修改后立即生效。现有`X-Internal-Token`鉴权继续有效。
```json
{
"allowed_ips": ["49.232.51.171"]
}
```
当前已将网关IP `49.232.51.171`加入白名单;本机回环地址和网卡自身地址自动允许,其余来源返回HTTP403、`code=1010`。支持IPv4、IPv6及CIDR,不自动允许整个内网。
worker的所有动态路径统一保护,包括5号、52号、其他业务接口、调试接口、代理接口、健康检查和文档接口。`/static/`下的测试页和图片可正常访问,但页面发出的API请求仍需通过IP限制。
启动GPU worker时使用`uvicorn ... --no-proxy-headers`,保留真实连接来源。`run_worker.sh`与`hair-worker.service`已配置该选项。来源被服务器提前改写、地址格式错误或白名单配置无效时拒绝访问。
判断依据是直接连接worker的IP,忽略`X-Forwarded-For`、`X-Real-IP`和`Forwarded`,这些请求头不能用于绕过限制。由网关转发的请求按网关IP判断,网关后面的最终用户无需加入worker白名单。网关保持原有鉴权和转发流程,无需部署此配置。
`ip_access.json`不入Git;配置格式参考`ip_access.example.json`。本机网卡地址在请求时自动获取。
## 部署与维护
首次部署复制`ip_access.example.json`为`ip_access.json`,示例已包含网关`49.232.51.171`;已有配置不覆盖。该操作仅在GPU worker执行,网关服务和启动参数保持原样。
修改`allowed_ips`后保存为有效JSON即可生效,无需重启;建议写入同目录临时文件再原子替换,避免编辑过程中出现半份JSON。文件不存在时仅允许本机,内容损坏时动态接口一律拒绝。更新程序时保留本机配置文件。
IP检查先于Token检查。白名单内请求仍需原来的`X-Internal-Token`;`/health`和`/api/v1/debug/*`等原本免Token的路径仍需通过IP检查。静态页可打开不代表该来源可以调用API。
验证命令:`./venv/bin/pytest tests/test_ip_access.py -q`,覆盖本机、精确白名单、CIDR、IPv6、转发头伪造、热更新、全接口拦截与网关不受限制。
+402 -16
View File
@@ -20,11 +20,14 @@
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` |
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
| 52 发际线新规则与裁切 | POST | `/api/v1/hairline/generate-52` |
---
## 通用约定
GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.json`白名单(当前为网关IP `49.232.51.171`),其他来源返回HTTP403、code=1010;静态页面与图片除外。按直接连接worker的IP判断,网关不启用此限制,已有鉴权继续有效。
以下约定适用于全部 5 个接口(最终以联调为准)。
### 请求
@@ -35,7 +38,7 @@
| 请求方式 | `POST` |
| 图片传参方式 | 三选一,三种都支持:① `multipart/form-data` 上传文件、② 图片 URL、③ base64 字符串 |
| 字符编码 | UTF-8 |
| 坐标基准 | 所有返回的坐标均以**原图像素**为基准(原点为图片左上角,x 向右、y 向下) |
| 坐标基准 | 默认以**原图像素**为基准;接口52开启预处理时使用响应中的处理后底图坐标(原点为左上角,x向右、y向下) |
图片传参字段(三者**严格互斥**,必须且只能传其一;传了 0 个或 ≥2 个均报错):
@@ -81,12 +84,13 @@
| 1001 | 无法识别人像 | 图片中未检测到人脸 |
| 1002 | 人像分辨率过低 | 低于最低分辨率要求 |
| 1003 | 角度问题,非正面照 | 非正面 / 角度过大 |
| 1004 | gender 必填/非法 | 接口2/5 的 `gender` 缺失或非 `male`/`female` |
| 1004 | gender 必填/非法 | 接口2/5/52/7 的 `gender` 缺失或非 `male`/`female` |
| 1005 | 检测到多张人脸 | 默认仅支持单人,检测到 2 人或以上时返回 |
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
| 1008 | 图片格式不支持 | 非 JPG / PNG |
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
| 1010 | 来源IP不允许 | 仅GPU worker:直连IP不在本机/白名单内,或来源/白名单配置无效;HTTP403 |
---
@@ -109,6 +113,7 @@
| four_courts | object | 四庭数据,见下表 |
| seven_eyes | object | 七眼数据,见下表 |
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
| vertical_lines | object | 标注图竖线坐标,见下表。与标注 PNG 上的竖线同一套几何 |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
@@ -138,7 +143,28 @@
| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 |
| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null |
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。`eye1`/`eye7` 的厘米数由 `vertical_lines` 里对应端点的 x 相减得到。
`vertical_lines`(竖线,原图像素,原点左上,x 向右,y 向下):
| 字段 | 类型 | 说明 |
|------|------|------|
| y_top | int | 竖线共用上端。接口 1 在头顶之上,伸出 `max(6, round(短边 × 0.03))` 像素,可能略小于 0 |
| y_bottom | int | 竖线共用下端。接口 1 在下巴尖之下,伸出同样像素,可能略大于图高 |
| lines | array | 从左到右实际画出的竖线。每项 `{ "name": string, "x": int }` |
`lines[].name` 从左到右:
| name | 竖线 |
|------|------|
| left_head_edge | 人头最左(耳朵外缘)。该侧耳朵不可见时不出现,此时 `eye1` 为 `null` |
| left_cheek | 左脸颊 |
| left_eye_outer | 左眼外角 |
| left_eye_inner | 左眼内角 |
| right_eye_inner | 右眼内角 |
| right_eye_outer | 右眼外角 |
| right_cheek | 右脸颊 |
| right_head_edge | 人头最右(耳朵外缘)。该侧耳朵不可见时不出现,此时 `eye7` 为 `null` |
### 标注图片(UI)规范
@@ -182,6 +208,19 @@
"brow_center": { "x": 540, "y": 740 },
"nose_bottom": { "x": 540, "y": 1050 },
"chin_tip": { "x": 540, "y": 1360 }
},
"vertical_lines": {
"y_top": 98, "y_bottom": 1410,
"lines": [
{ "name": "left_head_edge", "x": 120 },
{ "name": "left_cheek", "x": 210 },
{ "name": "left_eye_outer", "x": 310 },
{ "name": "left_eye_inner", "x": 420 },
{ "name": "right_eye_inner", "x": 560 },
{ "name": "right_eye_outer", "x": 670 },
{ "name": "right_cheek", "x": 780 },
{ "name": "right_head_edge", "x": 890 }
]
}
}
}
@@ -213,6 +252,7 @@
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
| vertical_lines | object | 标注图竖线坐标。结构同接口 1,但 `y_top`/`y_bottom` 为发际线到下巴尖(不伸出),`lines` 只有中间 6 条,无 `left_head_edge` / `right_head_edge` |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
@@ -242,6 +282,17 @@
"brow_center": { "x": 540, "y": 740 },
"nose_bottom": { "x": 540, "y": 1050 },
"chin_tip": { "x": 540, "y": 1360 }
},
"vertical_lines": {
"y_top": 430, "y_bottom": 1360,
"lines": [
{ "name": "left_cheek", "x": 210 },
{ "name": "left_eye_outer", "x": 310 },
{ "name": "left_eye_inner", "x": 420 },
{ "name": "right_eye_inner", "x": 560 },
{ "name": "right_eye_outer", "x": 670 },
{ "name": "right_cheek", "x": 780 }
]
}
}
}
@@ -267,8 +318,10 @@
| gender | string | **是** | 性别:`male` / `female`。决定使用的发际线贴图集合 |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),最多不超过该性别的预设数。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。越界/非法返回 `1007` |
| beauty_enabled | bool | 否 | 生发图是否带美颜效果,默认 false(当前阶段不生效) |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「补充遮罩区域的头发,加一点美颜」,会替换工作流节点 60 的文本 |
| use_mask | bool | 否 | 是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比。仅对原生发(male / female 6/7)管线生效 |
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本。所有分支生效(male / female 6/7 原生生发,female 1..5 换发型后的发际线带重绘;不进 change_hair)。注:非默认提示词会使 ComfyUI 的 CLIP 条件缓存失效,换词后首次请求明显变慢(实测 45s vs 命中缓存 8s),相同提示词的后续请求恢复正常 |
| flux_model | string | 否 | Flux 模型文件名(如 `flux-2-klein-9b-Q5_K_M.gguf`)。缺省用工作流默认(9B-Q4_K_M);`.gguf` 自动切 UnetLoaderGGUF 并同步配套 qwen CLIP |
| redraw_max_side | int | 否 | female 1..5 重绘管线送模型前的长边上限像素。缺省=服务端默认(`REDRAW_MAX_SIDE`,当前 1024);`0`=不缩图(原图直送,更慢) |
### 输出(data)
@@ -281,7 +334,8 @@
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
| order | int | 排序序号(当前阶段固定 `1..N`,按贴图顺序,暂不计算合适度) |
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8182)生成,**一次请求生成指定发型的 1 张、同步返回**。
> ⚠️ 生发图由本机 ComfyUI(Flux-2,端口 8188)生成,**一次请求生成指定发型的 1 张、同步返回**;
> female 1..5 的生发图另依赖 change_hair 换发型服务(:8801)。
> worker 侧返回 `image_base64` / `grown_image_base64`,
> 网关落盘后改写为上表的 `image_url` / `grown_image_url`。
>
@@ -446,6 +500,7 @@
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
| vertical_lines | object | 竖线坐标,结构同接口1 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
@@ -515,16 +570,347 @@
---
## 接口 52:发际线 PNG 生成接口
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
**请求**:`POST /api/v1/hairline/generate-52`(仅GPU worker,端口8187;本次不在网关新增路由)
**IP限制**:GPU worker的所有动态接口统一仅允许本机回环地址、本机网卡地址及白名单;目前白名单含网关IP `49.232.51.171`,其他直连来源需加入`ip_access.json`的`allowed_ips`。该文件支持单个IP与CIDR,修改后立即生效;拒绝时返回HTTP403、code=1010。网关不启用此限制,已有鉴权仍保留。详见[接口IP白名单](接口IP白名单.md)。
### 图片扶正与标准构图
显式传`normalize_image=true`时,先扶正9→152,再按**高(3÷0.9)×L≈3.3333×L、宽2.5×L(3:4)**裁切,L为9→152的直线像素距离。相对原3L规则,裁切框宽高均扩大约11.11%,同样显示尺寸下人物缩小约10%。高度取最接近的4的倍数,宽度取高度的3/4。眉心定位沿用已扶正参考图:9号点相对位置约(49.0061%,34.4136%)。参考图仍为786×1048,两点X均为385.18784。
目标窗口完全在原图有效区域内才进行标准裁切。标准窗口不足时,偏长图片(宽/高<3/4)采用原图有效范围内最大的3:4窗口,尽量贴近眉心目标位置并保留完整人脸,`status=aspect_crop`;人脸放不进3:4窗口时回退仅扶正并返回原因。其余图片空间不足时仍仅扶正、去除旋转空角。不放大小图。**所有叠图与坐标使用响应中的处理后底图 `input_image_*`**,调用方应使用该底图叠加;`normalize_image=false` 可关闭此预处理。
`framing`包含处理前后尺寸、旋转角、是否标准裁切、跳过原因、`source_to_processed`/`processed_to_source`仿射矩阵。`status`区分`reference_crop`(标准窗口)、`aspect_crop`(长图3:4裁切)和`rotation_only`(仅扶正);`standard_crop=false`时仍可能有`aspect_crop=true`。`aspect_crop_skip_reason`说明长图无法保留人脸时的回退原因。`original_image_*`保留EXIF方向修正后的上传图,原图坐标可通过逆矩阵还原。
### 输入
图片参数见「通用约定 → 图片传参字段」。专属参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 7)。缺失/非法返回 `1004` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。缺失/越界/非法返回 `1007` |
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本 |
| normalize_image | bool | 否 | 新增可选开关,默认false:保留原图尺寸与坐标。传true开启扶正和裁切;规则测试页显式传true,对比调试页默认开启 |
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回设计叠图、中心点与高度规则数据,可大幅降低耗时 |
> 生发划线与遮罩直接取新位置的 middle 曲线;旧规则的头发边界与模板用于保留形状和倾斜,不决定新中心高度。计算使用未取整的像素高度,与四庭测量采用同一 Y 方向;测量主流程失败时用发际线管线的同编号关键点计算,`measurement_source` 标明来源。目标越出图片或无法对齐时返回 1007,不静默回退旧规则。
### 高度规则与对比
- **参数与字段**:原有8个请求参数、响应外层结构和所有旧返回字段均保留。`use_mask=true`、默认prompt、`generate_grow_image=true`保持原值。新功能只新增可选参数和返回字段;旧请求未传`normalize_image`时保留原图坐标,high/low按已约定保留null,worker始终返回`*_base64`,不负责保存图片、生成公网URL或改名;网关保持原样。
- `height_rule`:`middle_height_px`、`lower_height_px`、`target_upper_height_px`、`target_upper_height_cm`(可空)、`target_center`、`brow_center`、`nose_bottom`、`chin_tip`、`measurement_source`。
- 每项 `height_placement`:`old_center`、`new_center`、`shift_x_px`、`shift_y_px`(正=下移)、`target_error_px`。浮点坐标保留精度,旧中心点字段继续返回取整像素。
- `face_measure` 为处理后底图的实际测量值,未被设计目标覆盖。
- `guide_landmarks`:9、150、152、162、251、389 号 MediaPipe 点的图片像素坐标,供对比页绘制参考框。参考框左/右边分别经过162/389,底边经过9,高度为9→152距离的70%;两侧均固定平行9→152。
- 规则页:`/static/test_interface52.html`;旧/新规则对比:`/static/test_interface52_compare.html`。
- 规则页每个发型依次展示未裁切原图、middle、裁切发际线叠加、生发图。“裁切发际线叠加”由页面按`guide_landmarks`生成:在相同处理后底图上叠加矩形内的middle PNG,矩形外保持透明;左/右边过162/389,底边过9,高度0.7×9→152,左右边平行9→152。
- 对比调试接口:`POST /api/v1/debug/hairline-height-compare`,图片三选一、gender、hair_style;默认只出旧/新曲线,可选 `generate_grow_image=true` 额外验证新规则生发。
- 测试清单:`tests/fixtures/hairline_height_cases.json`(用户指定30张);批量运行:`./venv/bin/python scripts/compare_hairline_height.py`,默认全部30张、男女各7型,共420组。每人一行,未裁切原图放在最前,后接一张可切换样式的旧规则图和全部新规则样式。可用 `--cases 1,2,3,4,5` 指定部分案例。
### 输出(data)
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,当前底图像素:`{ "x": number, "y": number }` |
| high_hairline_center_point | object \| null | 已停用,固定 `null` |
| low_hairline_center_point | object \| null | 已停用,固定 `null` |
| input_image_base64 | string | 当前底图的JPEG base64,发际线叠图、参考点和测量值均使用该图的坐标系 |
| original_image_base64 | string | EXIF方向修正后的上传原图JPEG base64 |
| width / height | int | 当前底图的实际像素宽 / 高 |
| coordinate_space | string | 开启预处理时为 `processed_image`,关闭时为 `original_image` |
| framing | object \| null | 裁切状态、旋转角、前后尺寸、轴线误差及双向仿射矩阵;关闭预处理时为 `null` |
| height_rule | object | 中庭/下庭、目标上庭高度、目标中心及测量来源,详见“高度规则与对比” |
| guide_landmarks | object | 9、150、152、162、251、389 号点的当前底图像素坐标;参考框实际使用9、152、162、389 |
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
`hairline_images` 元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
| image_middle_base64 | string | middle 档发际线曲线**透明 PNG** base64(仅曲线,透明底,**不含人物**,需叠加 `input_image_base64` 显示) |
| image_high_base64 | null | 已停用,保留字段为 `null` |
| image_low_base64 | null | 已停用,保留字段为 `null` |
| grown_image_base64 | string \| null | **生发后图片** JPEG base64(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
| order | int | 发型序号(= 传入的 hair_style 值) |
| height_placement | object | 旧/新中心、XY移动距离、目标误差,均为未取整的像素值 |
> 上表为GPU worker的实际响应字段;所有图片使用base64,空图片保留原字段并返回null。网关的落盘与URL改写属于网关职责,本次不修改。
>
> 前端将base64转为data URI后显示。middle透明图叠加到`input_image_base64`,生发图直接显示。GPU worker本机测试页为`http://localhost:8187/static/test_interface52.html`。
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
| 字段 | 类型 | 说明 |
|------|------|------|
| face_total_height_cm | number | 全脸总高度(cm)= 四庭之和 |
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
| vertical_lines | object | 竖线坐标,结构同接口1 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),当前底图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),当前底图像素:`{ "x": int, "y": int }` |
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
### GPU worker响应结构示例(normalize_image=false)
此例未开启裁切,坐标基于原图;`face_measure=null`时仍可按发际线关键点计算目标。图片内容以缩略base64字符串示意,空图片字段返回null,字段名不变。
```json
{
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"hairline_images": [
{
"hairline_type": "ellipse",
"image_middle_base64": "iVBORw0KGgo...",
"image_high_base64": null,
"image_low_base64": null,
"grown_image_base64": "/9j/4AAQ...",
"order": 1,
"height_placement": {
"old_center": {"x": 540.0, "y": 450.0},
"new_center": {"x": 540.0, "y": 430.0},
"shift_x_px": 0.0, "shift_y_px": -20.0, "target_error_px": 0.0
}
}
],
"best_hairline_center_point": {"x": 540, "y": 430},
"high_hairline_center_point": null,
"low_hairline_center_point": null,
"input_image_base64": "/9j/4AAQ...",
"original_image_base64": "/9j/4AAQ...",
"width": 1080, "height": 1440,
"coordinate_space": "original_image",
"framing": null,
"height_rule": {
"name": "court_average", "formula": "上庭 = (中庭 + 下庭) / 2",
"measurement_source": "hairline_landmarks",
"brow_center": {"x": 540.0, "y": 740.0},
"nose_bottom": {"x": 540.0, "y": 1050.0},
"chin_tip": {"x": 540.0, "y": 1360.0},
"middle_height_px": 310.0, "lower_height_px": 310.0,
"target_upper_height_px": 310.0, "target_upper_height_cm": null,
"target_center": {"x": 540.0, "y": 430.0}
},
"guide_landmarks": {
"9": {"x": 540.0, "y": 740.0}, "150": {"x": 470.0, "y": 1340.0},
"152": {"x": 540.0, "y": 1360.0}, "162": {"x": 300.0, "y": 680.0},
"251": {"x": 770.0, "y": 630.0}, "389": {"x": 780.0, "y": 680.0}
},
"face_measure": null
}
}
```
开启`normalize_image=true`后,`coordinate_space=processed_image`,`framing`包含前述裁切状态和坐标变换,所有发际线图片须叠加到`input_image_base64`。原始图片仅用于展示和坐标回溯。
---
## 接口 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 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
| 52 发际线新规则 | `/api/v1/hairline/generate-52` | 用户照片 + gender + hair_style(多选) | 每个选中发型的新规则 middle 设计叠图 + 生发图 + 目标中心坐标;high/low 旧字段为 null |
| 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/*`。
---
@@ -532,9 +918,9 @@
- 图片传参:**文件 / URL / base64 三种都支持,严格互斥**(必须且只能传一个,base64 需带 `data:image/...;base64,` 前缀)。
- 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。
- 所有坐标基准:**原图像素**。
- 坐标基准:默认**原图像素**;接口52开启预处理时使用响应中的**处理后底图像素**。
- 接口 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 段即可。**
+59 -16
View File
@@ -180,6 +180,60 @@ def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_
return head_l, head_r
# 返回给接口的竖线名 → eyes["points"] 里的键。从左到右的解剖顺序。
_VERTICAL_INNER_LINES = (
("left_cheek", "left_cheek"),
("left_eye_outer", "left_outer"),
("left_eye_inner", "left_inner"),
("right_eye_inner", "right_inner"),
("right_eye_outer", "right_outer"),
("right_cheek", "right_cheek"),
)
def vertical_line_geometry(measure_result, image_width, image_height,
ear_mask=None, hair_mask=None, variant="v1"):
"""标注图竖线的像素几何,供画线与接口返回共用。
返回 {"y_top", "y_bottom", "lines": [{"name", "x"}, ...]}。
x / y 均为原图像素整数(四舍五入)。lines 从左到右,只含实际画出的线。
variant="v1":竖线从头顶上方延伸到下巴下方;有耳朵外缘时含
left_head_edge / right_head_edge。
variant="v6":竖线从发际线到下巴尖,不含人头两端。
"""
v = measure_result.vertical
s = min(image_width, image_height)
if variant == "v6":
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
else:
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
ys = [v[name][1] for name in order]
pts = measure_result.eyes["points"]
named = [(public, float(pts[internal][0])) for public, internal in _VERTICAL_INNER_LINES]
if variant != "v6":
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
if head_l is not None:
named.append(("left_head_edge", float(head_l)))
if head_r is not None:
named.append(("right_head_edge", float(head_r)))
named.sort(key=lambda item: item[1])
fy0, fy1 = ys[0], ys[-1]
over = max(6, round(s * 0.030))
if variant == "v6":
y_top, y_bottom = fy0, fy1
else:
y_top, y_bottom = fy0 - over, fy1 + over
return {
"y_top": int(round(y_top)),
"y_bottom": int(round(y_bottom)),
"lines": [{"name": name, "x": int(round(x))} for name, x in named],
}
def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None,
variant="v1"):
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
@@ -221,19 +275,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
ys = [v[name][1] for name in order]
pts = measure_result.eyes["points"]
seven_keys = ["left_cheek", "left_outer", "left_inner",
"right_inner", "right_outer", "right_cheek"]
base_xs = [pts[k][0] for k in seven_keys]
if variant == "v6":
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
else:
# 人头最左/最右:取自耳朵分割掩膜外缘(方案B,类7/8),脸纵向范围内统计。
# 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
head_l, head_r = _ear_edges_from_mask(
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
head_xs = [x for x in (head_l, head_r) if x is not None]
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
# 竖线 x 与纵向范围与接口返回的 vertical_lines 同一份几何。
geom = vertical_line_geometry(
measure_result, w, h, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
xs = [line["x"] for line in geom["lines"]]
v_top, v_bot = geom["y_top"], geom["y_bottom"]
# 人脸/人头包围盒
fx0, fx1 = xs[0], xs[-1]
@@ -241,9 +287,6 @@ 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 = 头顶→下巴尖并两端超出一点
v_top = fy0 if variant == "v6" else fy0 - over
v_bot = fy1 if variant == "v6" else fy1 + over
# --- 1. 横向分界线(渐变,覆盖头宽并超出一点) ---
for cy in ys:
+1 -1
View File
@@ -1110,7 +1110,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"],
+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
@@ -7,7 +7,7 @@ Wants=comfyui.service change_hair-hair.service
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/hair
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187 --no-proxy-headers
Restart=on-failure
RestartSec=5
+1 -1
View File
@@ -410,7 +410,7 @@
},
"60": {
"inputs": {
"text": "填充遮罩区域的头发"
"text": "填充遮罩区域的头发,人物增加一点磨皮"
},
"class_type": "JjkText",
"_meta": {
+7
View File
@@ -20,6 +20,13 @@ MP_TOP_ANCHORS: list[int] = [
]
N_ANCHORS = len(MP_TOP_ANCHORS) # 17
# Anatomical left/right pairs, in MediaPipe indices. The anchor list is
# NOT mirror-ordered at its ends: 127 pairs with 356, 234 with 454.
MP_TOP_ANCHOR_PAIRS = [
(127, 356), (234, 454), (162, 389), (21, 251),
(54, 284), (103, 332), (67, 297), (109, 338),
]
# Vertex ID layout in the extended array of length 468 + 2*N_ANCHORS.
# [0 .. 468) : MediaPipe canonical landmarks
# [468 .. 468+N) : middle row (between MP boundary and hairline)
+6 -6
View File
@@ -44,18 +44,18 @@ class FaceLandmarker:
self._detector = vision.FaceLandmarker.create_from_options(options)
self._mp = mp
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
"""Returns (468, 3) float32 of normalized x, y and relative z, or None.
def detect(self, image_rgb: np.ndarray, include_iris: bool = False) -> np.ndarray | None:
"""Returns (468, 3), or (478, 3) with iris, normalized float32 points.
The task model produces 478 landmarks (468 face + 10 iris); we return
only the first 468 to match the canonical FaceMesh topology used by
the SDK's OBJ file.
The task model produces 478 landmarks (468 face + 10 iris). By default
only the first 468 match the SDK's canonical FaceMesh topology;
include_iris=True retains all 478 for face-measurement calibration.
"""
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb)
result = self._detector.detect(mp_image)
if not result.face_landmarks:
return None
lm = result.face_landmarks[0][:468]
lm = result.face_landmarks[0] if include_iris else result.face_landmarks[0][:468]
arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
return arr
+218
View File
@@ -0,0 +1,218 @@
"""Interface 52: upright face axis, then native-resolution reference framing."""
from __future__ import annotations
from functools import lru_cache
from io import BytesIO
import json
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageOps
PROFILE_PATH = Path(__file__).parent / "reference/framing_profile.json"
def decode_portrait(raw):
"""Respect phone EXIF orientation before detecting or transforming points."""
try:
with Image.open(BytesIO(raw)) as image:
return cv2.cvtColor(np.array(ImageOps.exif_transpose(image).convert("RGB")), cv2.COLOR_RGB2BGR)
except Exception:
return None
@lru_cache(maxsize=1)
def load_profile():
return json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
def transform_points(points, matrix):
return np.asarray(points, np.float64) @ matrix[:, :2].T + matrix[:, 2]
def _rotation(landmarks, width, height):
xy = landmarks[:, :2].astype(np.float64) * [width, height]
delta = xy[152] - xy[9]
length = float(np.linalg.norm(delta))
if not np.isfinite(xy).all() or length < 2 or delta[1] <= 0:
raise ValueError("9号点与152号点无效,无法扶正人脸")
down = delta / length
rotation = np.array([[down[1], -down[0]], [down[0], down[1]]], np.float64)
return xy, rotation, length
def _matrix(rotation, source_anchor, target_anchor):
return np.column_stack((rotation, np.asarray(target_anchor) - rotation @ source_anchor))
def crop_fits(matrix, source_size, output_size):
w, h = output_size
corners = transform_points([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]], cv2.invertAffineTransform(matrix))
sw, sh = source_size
return bool((corners[:, 0] >= -1e-7).all() and (corners[:, 0] <= sw-1+1e-7).all()
and (corners[:, 1] >= -1e-7).all() and (corners[:, 1] <= sh-1+1e-7).all())
def _inscribed_frame(rotation, source_size, aspect=None):
"""Largest centred valid rectangle: no generated background or upscaling."""
sw, sh = source_size
ratio = sw / sh if aspect is None else aspect
source_center = np.array([(sw-1)/2, (sh-1)/2])
low, high = 2.0, min(float(sh), sw/ratio)
for _ in range(48):
h = (low+high)/2
w = h*ratio
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
if crop_fits(matrix, source_size, (w, h)):
low = h
else:
high = h
if aspect == .75:
h = max(4, int(low+1e-7)//4*4)
w = h//4*3
else:
h, w = max(2, int(low+1e-7)), max(2, int(low*ratio+1e-7))
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
if not crop_fits(matrix, source_size, (w, h)):
raise ValueError("图片边缘空间不足,无法得到有效扶正画面")
return matrix, (w, h)
def _tall_portrait_frame(xy, rotation, source_size, profile):
"""Largest native 3:4 frame, positioned to retain the entire face."""
_, size = _inscribed_frame(rotation, source_size, aspect=.75)
half = (np.array(size, np.float64)-1)/2
extent = np.abs(rotation.T) @ half
low, high = extent, np.array(source_size)-1-extent
polygon = [np.array(p) for p in ((low[0], low[1]), (high[0], low[1]),
(high[0], high[1]), (low[0], high[1]))]
face = xy[:468] @ rotation.T
# The crop centre must satisfy both the source boundaries and the face
# bounds. Clip the feasible polygon in double precision to avoid padding.
face_low, face_high = face.max(axis=0)-half, face.min(axis=0)+half
for axis in range(2):
for normal, bound in ((rotation[axis], face_high[axis]),
(-rotation[axis], -face_low[axis])):
clipped = []
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
a, b = float(normal @ start-bound-1e-8), float(normal @ end-bound-1e-8)
if (a <= 0) != (b <= 0):
clipped.append(start+(end-start)*(a/(a-b)))
if b <= 0:
clipped.append(end)
polygon = clipped
if not polygon:
raise ValueError("3:4裁切窗口无法保留完整人脸")
target = np.array([profile["brow_x_ratio"]*size[0], profile["brow_y_ratio"]*size[1]])
preferred = xy[9]-rotation.T @ (target-half)
# Euclidean projection keeps the brow as close as possible to its target.
candidates = []
if (preferred >= low-1e-8).all() and (preferred <= high+1e-8).all():
upright = rotation @ preferred
if (upright >= face_low-1e-8).all() and (upright <= face_high+1e-8).all():
candidates.append(preferred)
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
edge = end-start
length2 = float(edge @ edge)
t = np.clip(float((preferred-start) @ edge)/length2, 0, 1) if length2 > 1e-16 else 0
candidates.append(start+t*edge)
centre = min(candidates, key=lambda p: float(np.sum((p-preferred)**2)))
matrix = _matrix(rotation, centre, half)
if not crop_fits(matrix, source_size, size):
raise ValueError("3:4裁切窗口超出原图有效范围")
return matrix, size
def _apply(photo, landmarks, matrix, size):
h, w = photo.shape[:2]
ow, oh = size
moved = np.asarray(landmarks, np.float64).copy()
moved[:, :2] = transform_points(landmarks[:, :2]*[w, h], matrix) / [ow, oh]
# MediaPipe Z is measured in normalized image-width units.
moved[:, 2] *= w/ow
result = cv2.warpAffine(photo, matrix, size, flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))
return result, moved
def calibrate_reference(photo, landmarks):
"""First straighten the actual reference; derive ratios from its final frame."""
h, w = photo.shape[:2]
_xy, rotation, length = _rotation(landmarks, w, h)
matrix, size = _inscribed_frame(rotation, (w, h), aspect=.75)
image, moved = _apply(photo, landmarks, matrix, size)
ow, oh = size
b, c = moved[[9, 152], :2]*[ow, oh]
profile = {
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
"reference_image_url": "/static/framing_reference.png",
"reference_size": {"width": ow, "height": oh},
"source_size": {"width": w, "height": h},
"brow_9": {"x": float(b[0]), "y": float(b[1])},
"chin_152": {"x": float(c[0]), "y": float(c[1])},
"face_axis_length_px": length,
"brow_x_ratio": float(b[0]/ow), "brow_y_ratio": float(b[1]/oh),
"reference_width_per_face_length": ow/length,
"reference_height_per_face_length": oh/length,
"width_per_face_length": 2.5, "height_per_face_length": 3.0/.9,
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
"axis_x_error_px": float(abs(b[0]-c[0])),
"source_to_reference": matrix.tolist(),
"landmark_source": "source_landmarks_transformed_without_redetection",
}
return image, moved, profile
def normalize_portrait(photo, landmarks, profile=None):
profile = load_profile() if profile is None else profile
h, w = photo.shape[:2]
xy, rotation, length = _rotation(landmarks, w, h)
# Round to an exact 3:4 frame without changing pixel scale.
target_h = max(4, round(profile["height_per_face_length"]*length/4)*4)
target_w = target_h//4*3
matrix = _matrix(rotation, xy[9], [profile["brow_x_ratio"]*target_w,
profile["brow_y_ratio"]*target_h])
cropped = crop_fits(matrix, (w, h), (target_w, target_h))
aspect_cropped = False
aspect_skip_reason = None
if cropped:
size = (target_w, target_h)
elif h*3 > w*4:
try:
matrix, size = _tall_portrait_frame(xy, rotation, (w, h), profile)
aspect_cropped = True
except ValueError as ex:
aspect_skip_reason = str(ex)
matrix, size = _inscribed_frame(rotation, (w, h))
else:
matrix, size = _inscribed_frame(rotation, (w, h))
image, moved = _apply(photo, landmarks, matrix, size)
ow, oh = size
axis = moved[[9, 152], :2]*[ow, oh]
if not ((axis[:, 0] >= 0).all() and (axis[:, 0] < ow).all()
and (axis[:, 1] >= 0).all() and (axis[:, 1] < oh).all()):
raise ValueError("人脸过于贴近边缘,扶正后无法保留9号点与152号点")
metadata = {
"enabled": True, "standard_crop": cropped, "aspect_crop": aspect_cropped,
"aspect_crop_skip_reason": aspect_skip_reason,
"status": "reference_crop" if cropped else ("aspect_crop" if aspect_cropped else "rotation_only"),
"skip_reason": None if cropped else "insufficient_space",
"message": ("已扶正并按参考图裁切" if cropped else
"原图偏长,已扶正并裁成3:4,保留完整人脸" if aspect_cropped else
"3:4窗口无法完整保留人脸,已仅扶正" if aspect_skip_reason else
"空间不足,仅扶正,未按参考比例裁切"),
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
"source_size": {"width": w, "height": h}, "output_size": {"width": ow, "height": oh},
"requested_crop_size": {"width": target_w, "height": target_h},
"face_axis_length_px": length,
"height_per_face_length": profile["height_per_face_length"],
"width_per_face_length": profile["height_per_face_length"]*.75,
"source_to_processed": matrix.tolist(),
"processed_to_source": cv2.invertAffineTransform(matrix).tolist(),
"axis_x_error_px": float(abs(axis[0, 0]-axis[1, 0])),
"profile_version": profile["version"],
"reference_image_url": profile["reference_image_url"],
}
return image, moved, metadata
+40
View File
@@ -124,6 +124,46 @@ def build_hairline_mask(parse_map: np.ndarray) -> np.ndarray:
return build_hair_mask(parse_map)
def align_hairline_for_template(
landmarks_norm: np.ndarray,
hairline_norm: np.ndarray,
valid: np.ndarray,
image_shape: tuple[int, int],
) -> np.ndarray:
"""Keep a designed template aligned with the face rather than the fringe.
Uneven bangs can make the detected hair boundary much higher on one
side, shearing an otherwise balanced template. Average the upward
extension of each anatomical anchor pair, measured in image pixels.
Keep the face anchors and the perpendicular component unchanged so
facial roll/perspective remain visible; do not level the curve in the
image. Missing ray hits retain their fallback and never influence a
valid partner. Call silhouette clamping afterwards to bound the result.
This is for template placement only, not measurement of the real hairline.
"""
h, w = image_shape
scale = np.array([w, h], dtype=np.float64)
up = face_up_vector(landmarks_norm) * scale
length = np.linalg.norm(up)
if length < 1e-9:
return hairline_norm.copy()
up /= length
anchors = landmarks_norm[C.MP_TOP_ANCHORS, :2] * scale
points = hairline_norm * scale
distances = (points - anchors) @ up
indices = {mp_idx: i for i, mp_idx in enumerate(C.MP_TOP_ANCHORS)}
for left, right in C.MP_TOP_ANCHOR_PAIRS:
i, j = indices[left], indices[right]
if not (valid[i] and valid[j]):
continue
mean_distance = (distances[i] + distances[j]) / 2.0
for k in (i, j):
points[k] += (mean_distance - distances[k]) * up
return (points / scale).astype(np.float32)
def smooth_hairline(
hairline_norm: np.ndarray,
valid: np.ndarray,
+117
View File
@@ -0,0 +1,117 @@
"""接口52设计高度:上庭 = (中庭 + 下庭) / 2;不改变接口2/5的几何。"""
from __future__ import annotations
import cv2
import numpy as np
from .mask import mask_from_curve
def height_target(landmarks, width, height, reference=None):
"""Use unrounded measurement coordinates; fall back to the same MP indices.
Distances use image Y, matching face_analysis.measure's court heights.
No rounded centimetre values or existing-hairline estimates enter the rule.
"""
scale = np.array([width, height], dtype=np.float64)
if reference is None:
brow, nose, chin = landmarks[[9, 94, 152], :2] * scale
source = "hairline_landmarks"
px_per_cm = None
else:
brow = np.asarray(reference["brow_center"], dtype=np.float64)
nose = np.asarray(reference["nose_bottom"], dtype=np.float64)
chin = np.asarray(reference["chin_tip"], dtype=np.float64)
source = "face_measure"
px_per_cm = reference.get("px_per_cm")
if not np.isfinite([brow, nose, chin]).all() or not brow[1] < nose[1] < chin[1]:
raise ValueError("眉心、鼻翼下缘与下巴坐标异常,无法计算目标上庭高度")
middle, lower = float(nose[1] - brow[1]), float(chin[1] - nose[1])
upper = (middle + lower) / 2.0
target_y = float(brow[1] - upper)
if not (0 <= brow[0] < width and 1 <= target_y < height - 1):
raise ValueError("按中下庭均值计算的目标发际线超出图片范围,请使用完整正面照")
def pt(p):
return {"x": float(p[0]), "y": float(p[1])}
return {
"name": "court_average",
"formula": "上庭 = (中庭 + 下庭) / 2",
"measurement_source": source,
"brow_center": pt(brow), "nose_bottom": pt(nose), "chin_tip": pt(chin),
"middle_height_px": middle, "lower_height_px": lower,
"target_upper_height_px": upper,
"target_upper_height_cm": upper / px_per_cm if px_per_cm else None,
"target_center": {"x": float(brow[0]), "y": target_y},
}
def clean_curve(overlay):
"""Keep the white design line; discard opaque black texture-border pixels."""
out = np.zeros_like(overlay, dtype=np.float32)
out[:, :, :3] = 255
out[:, :, 3] = overlay[:, :, 3] * (overlay[:, :, :3].mean(axis=2) > 128)
return out
def curve_center(overlay, center_x):
"""Alpha-weighted centre of the line at the facial midline, in image pixels."""
h, w = overlay.shape[:2]
column = int(round(center_x))
if not 0 <= column < w:
raise ValueError("面部中轴超出图片范围")
alpha = overlay[:, column, 3].astype(np.float64)
alpha[alpha <= 1] = 0
if not alpha.any():
# Rasterized endpoints may leave a 1px gap, but distant sideburns
# must never be silently substituted for a missing central curve.
columns = np.flatnonzero((overlay[:, :, 3] > 1).any(axis=0))
if not columns.size or np.min(np.abs(columns - column)) > 2:
raise ValueError("发际线模板在面部中轴处没有有效曲线")
column = int(columns[np.argmin(np.abs(columns - column))])
alpha = overlay[:, column, 3].astype(np.float64)
alpha[alpha <= 1] = 0
return {"x": float(center_x), "y": float(np.dot(np.arange(h), alpha) / alpha.sum())}
def place_curve(overlay, target, landmarks):
"""Rigidly shift a rendered template along face-up to meet the target Y.
Keeping the curve rigid preserves roll and the template's shape. Pixel Y
remains the height convention, even for a rolled face. A short correction
compensates for the curve's slope as the local-axis shift also changes X.
"""
curve = clean_curve(overlay)
h, w = curve.shape[:2]
center_x, target_y = target["x"], target["y"]
before = curve_center(curve, center_x)
up = (landmarks[10, :2] - landmarks[152, :2]) * [w, h]
if abs(up[1]) < 1e-6:
raise ValueError("无法确定人脸向上方向")
x_per_y = float(np.clip(up[0] / up[1], -1.0, 1.0))
dy = target_y - before["y"]
moved = curve
for _ in range(8):
transform = np.array([[1, 0, dy * x_per_y], [0, 1, dy]], np.float32)
moved = cv2.warpAffine(curve, transform, (w, h), flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
center = curve_center(moved, center_x)
residual = target_y - center["y"]
if abs(residual) <= 0.15:
break
dy += residual
if abs(center["y"] - target_y) > 1.0:
raise ValueError("发际线中心无法对齐目标高度,请检查照片姿态与裁剪范围")
return moved, {
"old_center": before, "new_center": center,
"shift_x_px": float(dy * x_per_y), "shift_y_px": float(dy),
"target_error_px": float(abs(center["y"] - target_y)),
}
def inpaint_from_curve(photo, landmarks, parse_map, overlay):
"""The preview line is also the exact boundary used for the black line/mask."""
alpha = np.clip(overlay[:, :, 3:4] / 255.0, 0, 1)
marked = np.clip(photo * (1.0 - alpha), 0, 255).astype(np.uint8)
curve = (overlay[:, :, 3] > 40).astype(np.uint8) * 255
mask = mask_from_curve(curve, landmarks, parse_map)
return marked, mask
+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 用工作流默认
+43
View File
@@ -0,0 +1,43 @@
{
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
"reference_image_url": "/static/framing_reference.png",
"reference_size": {
"width": 786,
"height": 1048
},
"source_size": {
"width": 792,
"height": 1056
},
"brow_9": {
"x": 385.1878369059212,
"y": 360.65437126595765
},
"chin_152": {
"x": 385.18783690592113,
"y": 725.8251047982238
},
"face_axis_length_px": 365.1707335322661,
"brow_x_ratio": 0.4900608612034621,
"brow_y_ratio": 0.3441358504446161,
"reference_width_per_face_length": 2.152417835890317,
"reference_height_per_face_length": 2.8698904478537566,
"width_per_face_length": 2.5,
"height_per_face_length": 3.333333333333333,
"rotation_degrees": 0.2596248408989324,
"axis_x_error_px": 5.684341886080802e-14,
"source_to_reference": [
[
0.9999897336401403,
0.004531292786961665,
-5.386196599797756
],
[
-0.004531292786961665,
0.9999897336401403,
-2.202458197930582
]
],
"landmark_source": "source_landmarks_transformed_without_redetection"
}
+93 -9
View File
@@ -15,12 +15,14 @@ from . import comfyui
from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
from .hairline_2d import (
smooth_hairline, sample_hairline_clamped,
smooth_hairline, sample_hairline, align_hairline_for_template,
clamp_hairline_to_silhouette,
)
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
from .marker_detect import detect_marker_hairline, path_to_curve_mask
from .height_rule import height_target, clean_curve, place_curve, inpaint_from_curve
import base64
import io
@@ -58,7 +60,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 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
@@ -177,20 +179,25 @@ def extract_502(image_bgr: np.ndarray):
return ctx["points"], ctx["valid"]
def extract_context(image_bgr: np.ndarray):
def extract_context(image_bgr: np.ndarray, landmarks: np.ndarray | None = None):
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制:
左右刘海高低不同时,协调对应锚点的延伸距离,避免把设计模板拉歪;
保留人脸本身的倾斜。预览白线与生发黑线/遮罩共用同一套 points。
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。
fallback 点不会再跑出头部轮廓。
"""
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
return None
parse_map = get_parser().parse(rgb)
hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
hairline_2d, valid = sample_hairline(landmarks, parse_map)
hairline_2d = align_hairline_for_template(landmarks, hairline_2d, valid, parse_map.shape)
hairline_2d = clamp_hairline_to_silhouette(hairline_2d, parse_map)
hairline_2d = smooth_hairline(hairline_2d, valid)
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
middle_3d = build_middle_row(landmarks, hairline_3d)
@@ -315,7 +322,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,不抛异常。
"""
@@ -389,7 +399,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",
@@ -529,6 +540,79 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
return {"images": images, "best_centers": best_centers}
def generate_hairline_pngs_52(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None,
generate_grow_image: bool = True,
height_reference: dict | None = None,
compare_rules: bool = False,
precomputed_landmarks: np.ndarray | None = None):
"""接口52:每种 middle 曲线中点对齐到眉心上方 (中庭+下庭)/2。
只读取 middle 贴图,high/low 保留空值。生发直接复用移动后的预览曲线,
不再使用位置不同的黑模板。compare_rules 仅为调试页附带旧规则叠图。
接口2仍使用原有管线。
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
if not hair_styles:
raise ValueError("hair_styles 必填且不能为空")
ctx = extract_context(image_bgr, landmarks=precomputed_landmarks)
if ctx is None:
return None
h, w = image_bgr.shape[:2]
uv, ext_faces = load_ext_mesh()
rule = height_target(ctx["landmarks"], w, h, height_reference)
textures = get_texture_map()[gender]
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
# generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None
shared_grown = None
if generate_grow_image and not use_mask:
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
images, best_centers = [], None
for s in hair_styles: # s = 1-indexed 发型序号
key, mid_path = textures[s - 1]
old = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, load_texture_rgba(mid_path))
overlay, placement = place_curve(old, rule["target_center"], ctx["landmarks"])
overlays = {"middle": overlay, "high": None, "low": None}
if not generate_grow_image:
grown_png = None
elif not use_mask:
grown_png = shared_grown
else:
grown_png = None
try:
marked, mask = inpaint_from_curve(
image_bgr, ctx["landmarks"], ctx["parse_map"], overlay)
buf = io.BytesIO()
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
except Exception as ex: # 单张生发失败不丢弃设计曲线
logger.warning("接口52 生发失败 type=%s:%s", key, ex)
# Flux may round dimensions to a multiple of its latent stride.
# Restore the input size so the preview and generated photo align.
if grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
item = {"hairline_type": key, "order": s, "overlays": overlays,
"grown_png": grown_png, "height_placement": placement}
if compare_rules:
item["legacy_overlay"] = clean_curve(old)
images.append(item)
if best_centers is None:
center = placement["new_center"]
best_centers = {"middle": (round(center["x"]), round(center["y"])),
"high": None, "low": None}
guide_landmarks = {
str(index): {"x": float(ctx["landmarks"][index, 0] * w),
"y": float(ctx["landmarks"][index, 1] * h)}
for index in (9, 150, 152, 162, 251, 389)
}
return {"images": images, "best_centers": best_centers, "height_rule": rule,
"guide_landmarks": guide_landmarks}
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))
+3
View File
@@ -0,0 +1,3 @@
{
"allowed_ips": ["49.232.51.171"]
}
+77
View File
@@ -0,0 +1,77 @@
"""Direct-peer IP access policy for the GPU worker's dynamic endpoints."""
import ipaddress
import json
from pathlib import Path
import socket
CONFIG_PATH = Path(__file__).resolve().parent / "ip_access.json"
def _address(value):
address = ipaddress.ip_address(value)
return address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped else address
def local_addresses():
addresses = {_address("127.0.0.1"), _address("::1")}
try:
addresses.update(_address(item[4][0].split("%", 1)[0])
for item in socket.getaddrinfo(socket.gethostname(), None))
except (OSError, ValueError):
pass
# Include all assigned Linux interface addresses, not entire private ranges.
try:
import fcntl
import struct
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
for _, name in socket.if_nameindex():
try:
data = fcntl.ioctl(sock.fileno(), 0x8915, struct.pack("256s", name.encode()[:15]))
addresses.add(_address(socket.inet_ntoa(data[20:24])))
except OSError:
continue
path = Path("/proc/net/if_inet6")
if path.exists():
addresses.update(ipaddress.IPv6Address(line.split()[0]) for line in path.read_text().splitlines())
except (ImportError, OSError, ValueError):
pass
return addresses
def _networks(values):
if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
raise ValueError("IP配置必须是字符串列表")
return [ipaddress.ip_network(value, strict=False) for value in values]
def _matches(address, networks):
return any(address.version == network.version and address in network for network in networks)
def check_access(request):
"""Return (allowed, reason), re-reading the whitelist for each request."""
try:
config = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
allowed = _networks(config.get("allowed_ips", []))
if request.client is None or request.client.port == 0:
# Uvicorn proxy middleware has discarded the socket peer at port 0.
# Reject rather than treating an unverified forwarded IP as local.
return False, "无法确认直连来源,请使用 --no-proxy-headers 启动服务"
peer = _address(request.client.host)
# Authorize the gateway's socket IP, never its end-user forwarding headers.
if peer.is_unspecified:
return False, "来源地址无效"
return (True, "") if peer.is_loopback or peer in local_addresses() or _matches(peer, allowed) else (False, "来源IP不在接口白名单")
except (OSError, ValueError, TypeError, AttributeError):
return False, "IP白名单配置或来源地址无效"
def rejection(request):
from fastapi.responses import JSONResponse
if request.url.path == "/static" or request.url.path.startswith("/static/"):
return None
allowed, reason = check_access(request)
if allowed:
return None
return JSONResponse(status_code=403, content={"code": 1010, "message": reason,
"request_id": "ip-access", "data": None})
+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
@@ -31,11 +31,11 @@ fi
if [ "${FOREGROUND:-0}" = "1" ]; then
echo ">> 前台运行 worker: ${HOST}:${PORT}"
exec "$VENV_UVICORN" app:app --host "$HOST" --port "$PORT"
exec "$VENV_UVICORN" app:app --host "$HOST" --port "$PORT" --no-proxy-headers
fi
echo ">> 后台启动 worker: ${HOST}:${PORT}"
nohup "$VENV_UVICORN" app:app --host "$HOST" --port "$PORT" >> "$LOG_FILE" 2>&1 &
nohup "$VENV_UVICORN" app:app --host "$HOST" --port "$PORT" --no-proxy-headers >> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
sleep 1
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Straighten the specified reference first, then recalibrate framing ratios."""
import json
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import cv2
from hairline.framing import calibrate_reference, decode_portrait, PROFILE_PATH
from hairline.service import get_landmarker
if __name__ == "__main__":
source = Path(sys.argv[1]) if len(sys.argv)>1 else ROOT/"tests/output/upload_framing/reference.jpg"
photo = decode_portrait(source.read_bytes())
if photo is None:
raise ValueError("Reference image cannot be decoded")
landmarks = get_landmarker().detect(cv2.cvtColor(photo, cv2.COLOR_BGR2RGB), include_iris=True)
if landmarks is None:
raise ValueError("Reference face not detected")
image, _, profile = calibrate_reference(photo, landmarks)
cv2.imwrite(str(ROOT/"static/framing_reference.png"), image)
PROFILE_PATH.write_text(json.dumps(profile, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
print(json.dumps(profile, ensure_ascii=False, indent=2))
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""保存接口52旧/新高度规则对比,供 static/test_interface52_compare.html 展示。
默认验证用户清单全部30张,男女各7型,共420组;只跑定位,不调用ComfyUI。
用法:./venv/bin/python scripts/compare_hairline_height.py
可用 --cases 1,2,3,4,5 只验证指定案例。
"""
from __future__ import annotations
import argparse
import base64
from datetime import datetime
import json
import os
from pathlib import Path
import time
from zoneinfo import ZoneInfo
import cv2
import httpx
import numpy as np
ROOT = Path(__file__).resolve().parent.parent
def run(args):
manifest = json.loads((ROOT / "tests/fixtures/hairline_height_cases.json").read_text())
available = {c["image_number"]: c for c in manifest["cases"]}
requested = list(available) if args.cases == "all" else [int(n) for n in args.cases.split(",")]
cases = [available[n] for n in requested]
inputs = ROOT / "tests/output/hairline_height_compare/inputs"
inputs.mkdir(parents=True, exist_ok=True)
output = ROOT / "static/annotations/hairline_height_compare"
output.mkdir(parents=True, exist_ok=True)
report = {
"created_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
"case_numbers": requested, "groups": [], "errors": [],
}
started = time.monotonic()
with httpx.Client(timeout=180, follow_redirects=True) as client:
for case in cases:
number = case["image_number"]
source = inputs / f"case_{number}.png"
if not source.exists():
response = client.get(case["image_url"])
response.raise_for_status()
source.write_bytes(response.content)
photo = cv2.imdecode(np.frombuffer(source.read_bytes(), np.uint8), cv2.IMREAD_COLOR)
if photo is None:
raise ValueError(f"案例 {number} 无法解码")
# Keep the source photo available even when positioning fails.
original_path = output / f"case_{number}_original.jpg"
cv2.imwrite(str(original_path), photo, [cv2.IMWRITE_JPEG_QUALITY, 95])
original_url = "/static/annotations/hairline_height_compare/" + original_path.name
# Interface 52 frames native-resolution input before any size changes.
content = cv2.imencode(".png", photo)[1].tobytes()
for gender in args.genders.split(","):
step = time.monotonic()
try:
response = client.post(args.base_url.rstrip("/") + "/api/v1/debug/hairline-height-compare",
files={"image_file": (f"case_{number}.png", content, "image/png")},
data={"gender": gender, "hair_style": "1,2,3,4,5,6,7"})
response.raise_for_status()
body = response.json()
if body["code"] != 0:
raise ValueError(body["message"])
group = body["data"]
group.update(case_number=number, source_url=case["image_url"],
elapsed_seconds=round(time.monotonic() - step, 2))
def save_image(obj, field, suffix):
value = obj.pop(field + "_base64", None)
if not value:
return
# Both gender sets use the same source and processed
# photos; share their URLs and browser image cache.
prefix = f"case_{number}" if obj is group else f"case_{number}_{gender}"
path = output / f"{prefix}_{suffix}"
path.write_bytes(base64.b64decode(value.split(",", 1)[-1]))
obj[field + "_url"] = "/static/annotations/hairline_height_compare/" + path.name
save_image(group, "input", "input.jpg")
save_image(group, "original", "original.jpg")
for result in group["results"]:
save_image(result, "old_overlay", f"{result['order']}_old.png")
save_image(result, "new_overlay", f"{result['order']}_new.png")
save_image(result, "grown_image", f"{result['order']}_grown.jpg")
if result["height_placement"]["target_error_px"] > 1:
raise ValueError(f"{result['hairline_type']} 中心误差超过1像素")
if group["framing"]["axis_x_error_px"] > 1:
raise ValueError("扶正后的9/152横向误差超过1像素")
report["groups"].append(group)
print(f"case {number} / {gender}: {len(group['results'])} shapes OK ({group['elapsed_seconds']}s)", flush=True)
except Exception as ex:
report["errors"].append({"case_number": number, "gender": gender, "message": str(ex),
"source_url": case["image_url"], "original_url": original_url,
"width": photo.shape[1], "height": photo.shape[0]})
print(f"case {number} / {gender}: FAILED {ex}", flush=True)
# Atomically expose partial results to the page while running.
temporary = output / "latest.json.tmp"
temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2))
os.replace(temporary, output / "latest.json")
results = [r for g in report["groups"] for r in g["results"]]
summary = {
"cases": len(cases), "successful_groups": len(report["groups"]),
"comparisons": len(results), "failures": len(report["errors"]),
"max_center_error_px": max((r["height_placement"]["target_error_px"] for r in results), default=None),
"elapsed_seconds": round(time.monotonic() - started, 2),
}
report["summary"] = summary
temporary = output / "latest.json.tmp"
temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2))
os.replace(temporary, output / "latest.json")
print(json.dumps(summary, ensure_ascii=False), flush=True)
if report["errors"]:
raise SystemExit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--cases", default="all", help="all 或逗号分隔的图片编号")
parser.add_argument("--genders", default="female,male")
parser.add_argument("--base-url", default="http://127.0.0.1:8187")
run(parser.parse_args())
Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

File diff suppressed because one or more lines are too long
+254
View File
@@ -0,0 +1,254 @@
"use strict";
const $ = id => document.getElementById(id);
const styleNames = {
female: ["椭圆形", "花瓣形", "心形", "直线形", "波浪形", "大花瓣形", "古典花瓣形"],
male: ["椭圆形", "倒弧形", "M 型", "直线形", "心形", "柔和花瓣形", "波浪形"]
};
let dataset = {groups: [], errors: []}, mode = "side", drawVersion = 0, busy = false, cardObserver = null;
const inputImages = new Map();
const rowSelections = new Map();
const esc = value => String(value).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const fixed = (value, n = 2) => Number(value).toFixed(n);
const imageSource = (object, field, mime = "image/png") => object[field + "_url"] || window.resolveImgSrc(null, object[field + "_base64"], mime);
function status(text, error = false) { $("status").textContent = text; $("status").classList.toggle("error", error); }
function loadImage(src) {
return new Promise((resolve, reject) => {
const im = new Image();
im.onload = () => resolve(im); im.onerror = () => reject(new Error("无法读取对比图片,请重新运行案例")); im.src = src;
});
}
function inputImage(group) {
const src = imageSource(group, "input", "image/jpeg");
if (!inputImages.has(src)) inputImages.set(src, loadImage(src));
return inputImages.get(src);
}
function originalCard(group,caseName){
const src=imageSource(group,"original","image/jpeg")||group.source_url||imageSource(group,"input","image/jpeg");
const size=group.framing?.source_size||{width:group.width,height:group.height};
const dimensions=size.width&&size.height?`width="${size.width}" height="${size.height}"`:"";
const sizeLabel=size.width&&size.height?`原尺寸 ${size.width}×${size.height}`:"完整上传图片";
const panel=document.createElement("article");panel.className="original-reference-card";
panel.innerHTML=`<div class="case-header"><div><span class="case-number original-reference-label">未裁切原图</span><h3>${esc(caseName)} · 上传原图</h3><p>处理前完整图片</p></div></div><div class="metric-line">${sizeLabel}</div><div class="image-pair overlay"><figure><figcaption>未扶正、未裁切</figcaption><a href="${esc(src)}" target="_blank" rel="noopener"><img src="${esc(src)}" ${dimensions} alt="${esc(caseName)}未裁切原图" loading="lazy" decoding="async"></a></figure></div><div class="case-bottom"><span>保留完整画面</span><a href="${esc(src)}" target="_blank" rel="noopener">查看原图 ↗</a></div>`;
return panel;
}
function failureCard(errors){
const panel=document.createElement("article");panel.className="failed-reference-card";
panel.innerHTML='<h3>定位失败,未生成对比图</h3><p>左侧保留完整上传原图。</p>';
for(const error of errors){
const message=document.createElement("p");
message.textContent=`${error.gender==="female"?"女性":"男性"}7型:${error.message}`;
panel.append(message);
}
return panel;
}
function refreshStyleOptions() {
const old = $("styleFilter").value || "all", gender = $("genderFilter").value;
$("styleFilter").replaceChildren();
for (let i = 0; i < 7; i++) {
const label = gender === "all" ? `${i+1}. 女:${styleNames.female[i]} / 男:${styleNames.male[i]}` : `${i+1}. ${styleNames[gender][i]}`;
$("styleFilter").add(new Option(label, String(i+1)));
}
$("styleFilter").add(new Option("全部 7 种形状", "all"));
$("styleFilter").value = old;
}
function refreshCases() {
const selected = $("caseFilter").value;
const cases = [...new Set([...dataset.groups,...dataset.errors].map(g => String(g.case_number)))].sort((a,b)=>a.localeCompare(b,"en",{numeric:true}));
$("caseFilter").replaceChildren(new Option(`全部 ${cases.length} 个案例`, "all"));
for (const key of cases) $("caseFilter").add(new Option(key.startsWith("custom") ? "自定义图片" : `案例 ${key}`, key));
if (cases.includes(selected)) $("caseFilter").value = selected;
}
function refreshStats() {
const results = dataset.groups.flatMap(g => g.results);
const errors = results.map(r => r.height_placement.target_error_px);
$("statCases").textContent = new Set([...dataset.groups,...dataset.errors].map(g => g.case_number)).size;
$("statComparisons").textContent = results.length;
$("statError").textContent = errors.length ? `${fixed(Math.max(...errors))} px` : "—";
$("statStatus").textContent = busy ? "运行中" : (dataset.errors.length ? "有异常" : (results.length ? "已完成" : "待运行"));
$("runTime").textContent = dataset.created_at ? `更新于 ${new Date(dataset.created_at).toLocaleString("zh-CN", {timeZone:"Asia/Shanghai",hour12:false})}` : "尚无保存结果";
}
function cropBox(group) {
if (!$("zoomFace").checked) return [0, 0, group.width, group.height];
if (group.crop_box) return group.crop_box;
const rule = group.height_rule, upper = rule.target_upper_height_px, center = rule.target_center;
const oldY = group.results.map(r => r.height_placement.old_center.y);
const left = Math.max(0, center.x - 1.7 * upper), top = Math.max(0, Math.min(center.y, ...oldY) - .55 * upper);
const right = Math.min(group.width, center.x + 1.7 * upper), bottom = Math.min(group.height, rule.brow_center.y + .75 * upper);
return [left, top, right, bottom];
}
function paint(canvas, photo, oldLine, newLine, group, kind) {
const [x0,y0,x1,y1] = cropBox(group), cw = x1-x0, ch = y1-y0;
canvas.width = kind === "overlay" ? 1000 : 650;
canvas.height = Math.round(canvas.width * ch / cw);
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height;
ctx.drawImage(photo, x0,y0,cw,ch,0,0,W,H);
const tint = (line, color) => {
const layer = document.createElement("canvas"); layer.width=W; layer.height=H;
const ink=layer.getContext("2d"); ink.drawImage(line,x0,y0,cw,ch,0,0,W,H);
ink.globalCompositeOperation="source-in";ink.fillStyle=color;ink.fillRect(0,0,W,H);ctx.drawImage(layer,0,0);
};
if (kind !== "new") tint(oldLine, "#f3a338");
if (kind !== "old") tint(newLine, "#10c6b6");
if ($("showTarget").checked) {
const target = group.height_rule.target_center, tx=(target.x-x0)/cw*W, ty=(target.y-y0)/ch*H;
ctx.save();ctx.strokeStyle="rgba(255,255,255,.85)";ctx.lineWidth=1.7;ctx.setLineDash([7,6]);
ctx.beginPath();ctx.moveTo(0,ty);ctx.lineTo(W,ty);ctx.stroke();ctx.setLineDash([]);
ctx.fillStyle="#fff";ctx.beginPath();ctx.arc(tx,ty,4,0,2*Math.PI);ctx.fill();ctx.restore();
}
if(kind!=="old"&&$("showBox").checked&&group.guide_landmarks){
window.drawHairlineReferenceBox(ctx,group.guide_landmarks,[x0,y0,x1,y1],W,H);
}
}
async function render() {
if(cardObserver)cardObserver.disconnect();
const version=++drawVersion,gender=$("genderFilter").value,number=$("caseFilter").value,style=$("styleFilter").value;
const cases=new Map();
for(const group of dataset.groups){
if(gender!=="all"&&group.gender!==gender)continue;
if(number!=="all"&&String(group.case_number)!==number)continue;
for(const result of group.results){
if(style!=="all"&&result.order!==Number(style))continue;
if(!cases.has(group.case_number))cases.set(group.case_number,[]);
cases.get(group.case_number).push({group,result});
}
}
const visibleErrors=dataset.errors.filter(error=>(gender==="all"||error.gender===gender)&&(number==="all"||String(error.case_number)===number));
for(const error of visibleErrors)if(!cases.has(error.case_number))cases.set(error.case_number,[]);
$("results").replaceChildren();
let drawErrors=0,total=0;
const rowUpdates=[],drawJobs=new WeakMap();
cardObserver=new IntersectionObserver(entries=>{
for(const entry of entries)if(entry.isIntersecting){
cardObserver.unobserve(entry.target);drawJobs.get(entry.target)?.();
}
},{rootMargin:"600px 0px"});
for(const [caseNumber,cards] of [...cases].sort((a,b)=>String(a[0]).localeCompare(String(b[0]),"en",{numeric:true}))){
total+=cards.length;
const row=document.createElement("section");row.className="person-row";
const caseName=String(caseNumber).startsWith("custom")?"自定义图片":`案例 ${caseNumber}`;
const caseErrors=visibleErrors.filter(error=>error.case_number===caseNumber);
if(!cards.length){
row.classList.add("failed-case");
row.innerHTML=`<div class="person-row-header"><h2 class="case-group-heading">${esc(caseName)} · 定位失败</h2></div><div class="person-body"><div class="person-styles" role="region" tabindex="0" aria-label="${esc(caseName)}的原图与定位结果"></div></div>`;
row.querySelector(".person-styles").append(originalCard(caseErrors[0],caseName),failureCard(caseErrors));
$("results").append(row);continue;
}
row.innerHTML=`<div class="person-row-header"><h2 class="case-group-heading">${esc(caseName)} · ${cards.length} 种发际线样式</h2><div class="row-controls"><span>点击样式,左侧旧图同步切换</span><button type="button" aria-label="查看前面的样式">←</button><button type="button" aria-label="查看后面的样式">→</button></div></div><div class="person-body"><article class="old-reference-card"><div class="case-header"><div><span class="case-number old-reference-label"><span class="dot old"></span>旧规则 · 头发边界定位</span><h3 class="old-style-name">正在加载…</h3><p>与右侧选中的形状对应</p></div></div><div class="metric-line old-style-metrics"></div><div class="image-pair overlay"><figure><figcaption>当前选中样式的原位置</figcaption><canvas aria-label="旧规则参考图"></canvas></figure></div><div class="case-bottom">本行仅显示一次 · 点击右侧样式切换</div></article><div class="person-styles" role="region" tabindex="0" aria-label="${esc(caseName)}的全部新规则样式"></div></div>`;
$("results").append(row);
const groupForRow=cards[0].group,framing=groupForRow.framing;
if(framing){
const info=document.createElement("div");info.className="framing-info";
info.innerHTML=`${esc(framing.message)} · 旋转 ${fixed(framing.rotation_degrees,3)}° · ${framing.source_size.width}×${framing.source_size.height} → ${framing.output_size.width}×${framing.output_size.height} · 9/152 横向差 ${fixed(framing.axis_x_error_px,3)} px <a target="_blank" rel="noopener" href="${esc(imageSource(groupForRow,"original","image/jpeg")||groupForRow.source_url)}">上传原图 ↗</a> <a target="_blank" rel="noopener" href="${esc(imageSource(groupForRow,"input","image/jpeg"))}">处理后底图 ↗</a>`;
row.insertBefore(info,row.querySelector(".person-body"));
}
const strip=row.querySelector(".person-styles"),oldPanel=row.querySelector(".old-reference-card"),oldCanvas=oldPanel.querySelector("canvas");
// One horizontal row keeps the unprocessed photo first on narrow screens
// too, without squeezing the new styles into a zero-width column.
strip.append(originalCard(groupForRow,caseName),oldPanel);
if(caseErrors.length)strip.append(failureCard(caseErrors));
const [previous,next]=row.querySelectorAll(".row-controls button");
previous.addEventListener("click",()=>strip.scrollBy({left:-strip.clientWidth*.85,behavior:"smooth"}));
next.addEventListener("click",()=>strip.scrollBy({left:strip.clientWidth*.85,behavior:"smooth"}));
const updateArrows=()=>{previous.disabled=strip.scrollLeft<=2;next.disabled=strip.scrollWidth-strip.clientWidth-strip.scrollLeft<=2;};
strip.addEventListener("scroll",updateArrows,{passive:true});rowUpdates.push(updateArrows);
let selectionVersion=0;
const selectOld=async(group,result,drawNow=true)=>{
const selected=++selectionVersion,key=`${group.gender}:${result.order}`;
oldPanel.dataset.drawn="false";
rowSelections.set(caseNumber,key);row.dataset.selectedStyle=key;
for(const card of strip.querySelectorAll(".case-card")){
const active=card.dataset.styleKey===key;card.classList.toggle("selected",active);
card.setAttribute("aria-current",String(active));card.querySelector(".select-style").textContent=active?"已对照":"对照此样式";
}
const label=styleNames[group.gender][result.order-1];
oldPanel.querySelector(".old-style-name").textContent=`${group.gender==="female"?"女性":"男性"} · ${label}`;
oldPanel.querySelector(".old-style-metrics").textContent=`原中心 y ${fixed(result.height_placement.old_center.y)} · 目标 y ${fixed(group.height_rule.target_center.y)}`;
const box=cropBox(group);oldCanvas.width=650;oldCanvas.height=Math.round(650*(box[3]-box[1])/(box[2]-box[0]));
const draw=async()=>{
try{
const [photo,line]=await Promise.all([inputImage(group),loadImage(imageSource(result,"old_overlay"))]);
if(version!==drawVersion||selected!==selectionVersion)return;
paint(oldCanvas,photo,line,null,group,"old");oldPanel.dataset.drawn="true";
}catch(error){if(version===drawVersion)status(error.message,true);}
};
if(drawNow)await draw();
else{drawJobs.set(oldPanel,draw);cardObserver.observe(oldPanel);}
};
for(const {group,result} of cards){
const rule=group.height_rule,p=result.height_placement,label=styleNames[group.gender][result.order-1];
const card=document.createElement("article");card.className="case-card";card.tabIndex=0;
card.dataset.styleKey=`${group.gender}:${result.order}`;
card.innerHTML=`<div class="case-header"><div><span class="case-number">${group.gender==="female"?"女性":"男性"}发际线</span><h3>${esc(label)} <span style="font-size:11px;color:#7c8c88;font-weight:400">${esc(result.hairline_type)}</span></h3><p>目标上庭 (${fixed(rule.middle_height_px)} + ${fixed(rule.lower_height_px)}) ÷ 2 = ${fixed(rule.target_upper_height_px)} px</p></div></div><div class="metric-line"><span>新 y <b>${fixed(p.new_center.y)}</b></span><span><b>${p.shift_y_px<0?"上移":"下移"} ${fixed(Math.abs(p.shift_y_px))} px</b></span></div><div class="image-pair overlay"><figure><figcaption class="new"><span class="dot new"></span>${mode==="overlay"?"橙色旧线 / 青色新线":"新规则 · 中下庭均值"}</figcaption><canvas aria-label="新规则发际线"></canvas></figure></div><div class="case-bottom"><span>中心误差 ${fixed(p.target_error_px)} px</span><button class="select-style" type="button">对照此样式</button><a href="${esc(group.source_url||imageSource(group,"input","image/jpeg"))}" target="_blank" rel="noopener">原图 ↗</a></div>`;
strip.append(card);
card.addEventListener("click",event=>{if(!event.target.closest("a"))selectOld(group,result);});
card.addEventListener("keydown",event=>{if(event.target===card&&(event.key==="Enter"||event.key===" ")){event.preventDefault();selectOld(group,result);}});
const canvas=card.querySelector("canvas"),box=cropBox(group);canvas.width=650;canvas.height=Math.round(650*(box[3]-box[1])/(box[2]-box[0]));
drawJobs.set(card,async()=>{
try{
const [photo,line,oldLine]=await Promise.all([inputImage(group),loadImage(imageSource(result,"new_overlay")),mode==="overlay"?loadImage(imageSource(result,"old_overlay")):Promise.resolve(null)]);
if(version!==drawVersion)return;
paint(canvas,photo,oldLine,line,group,mode==="overlay"?"overlay":"new");card.dataset.drawn="true";
}catch(error){drawErrors++;const message=document.createElement("p");message.className="error-card";message.textContent=error.message;card.append(message);status(`有 ${drawErrors} 组图片加载失败,可重跑案例。`,true);}
});
cardObserver.observe(card);
}
const active=cards.find(c=>`${c.group.gender}:${c.result.order}`===rowSelections.get(caseNumber))||cards[0];
selectOld(active.group,active.result,false);
}
if(!cases.size&&!dataset.errors.length)$("results").innerHTML='<div class="empty">暂无对比结果。可上传图片或点击“重跑全部30个案例”。</div>';
requestAnimationFrame(()=>rowUpdates.forEach(update=>update()));
if(version===drawVersion&&!busy){
const failures=new Set(visibleErrors.map(error=>error.case_number)).size;
status(`显示 ${cases.size} 个案例、${total} 种新规则样式${failures?`,${failures} 个案例存在定位失败`:""}。每人一行,最前面为未裁切原图,旧图仅一次;左右滑动查看全部样式。`,failures>0);
}
}
function setBusy(value) {busy=value;$("rerunBatch").disabled=value;$("customRun").disabled=value;refreshStats();}
async function compare(form) {
const response=await fetch("/api/v1/debug/hairline-height-compare",{method:"POST",body:form});
if(!response.ok)throw new Error(`请求失败:HTTP ${response.status}`);
const json=await response.json();if(json.code!==0)throw new Error(json.message);return json.data;
}
async function rerun() {
if(busy)return;setBusy(true);let failure=null;
try {
const response=await fetch("/api/v1/debug/hairline-height-cases");const json=await response.json();
if(json.code!==0)throw new Error(json.message);
dataset={groups:[],errors:[],created_at:new Date().toISOString()};inputImages.clear();
const cases=json.data.cases,total=cases.length*2;let done=0;
for(const test of cases)for(const gender of ["female","male"]){
status(`正在运行案例 ${test.image_number} / ${gender==="female"?"女性":"男性"} 7 型(${done}/${total})…`);
const form=new FormData();form.append("image_url",test.image_url);form.append("gender",gender);form.append("hair_style","1,2,3,4,5,6,7");
try{const data=await compare(form);dataset.groups.push({...data,case_number:test.image_number,source_url:test.image_url});}
catch(error){dataset.errors.push({case_number:test.image_number,gender,message:error.message,source_url:test.image_url,original_url:test.image_url});}
done++;refreshCases();refreshStats();await render();
}
}catch(error){failure=error.message;}finally{setBusy(false);await render();if(failure)status(failure,true);}
}
async function custom(event) {
event.preventDefault();if(busy)return;const file=$("customFile").files[0];if(!file)return;setBusy(true);status("正在计算自定义图片的旧 / 新位置…");let failure=null;
try {
const photo=file,form=new FormData(),gender=$("customGender").value;
form.append("image_file",photo);form.append("gender",gender);form.append("hair_style","1,2,3,4,5,6,7");
const data=await compare(form),number=`custom-${Date.now()}`;
dataset.groups.push({...data,case_number:number});dataset.created_at=new Date().toISOString();
$("genderFilter").value=gender;refreshStyleOptions();refreshCases();$("caseFilter").value=number;
}catch(error){failure=error.message;}finally{setBusy(false);await render();if(failure)status(failure,true);}
}
async function init() {
fetch("/api/v1/debug/hairline-framing-reference").then(r=>r.json()).then(json=>{
if(json.code!==0)throw new Error(json.message);
const p=json.data;
$("framingStandard").innerHTML=`<img src="${esc(p.reference_image_url)}" alt="9号与152号点已竖直对齐的参考图" loading="lazy"><div><strong>已扶正参考图:${p.reference_size.width}×${p.reference_size.height}(3:4)</strong><p>参考图9号点 (${fixed(p.brow_9.x)}, ${fixed(p.brow_9.y)})<br>参考图152号点 (${fixed(p.chin_152.x)}, ${fixed(p.chin_152.y)})<br>两点X相同;旋转 ${fixed(p.rotation_degrees,3)}°</p><p><strong>当前裁切:高约 ${Number(fixed(p.height_per_face_length,4))}×L,宽 ${Number(fixed(p.width_per_face_length,4))}×L</strong>L 为9号点到152号点的像素距离。<br>眉心位置:横向 ${fixed(p.brow_x_ratio*100,2)}%,纵向 ${fixed(p.brow_y_ratio*100,2)}%</p><p>用户图先扶正,有空间时按上述尺寸裁切;长图保留人脸裁为3:4,其余空间不足时仅扶正。保留实际像素,不放大图片。</p></div>`;
}).catch(error=>{$("framingStandard").textContent=error.message;});
refreshStyleOptions();
$("genderFilter").addEventListener("change",()=>{refreshStyleOptions();render();});
for(const id of ["styleFilter","caseFilter","zoomFace","showTarget","showBox"])$(id).addEventListener("change",render);
for(const [id,value] of [["sideMode","side"],["overlayMode","overlay"]])$(id).addEventListener("click",()=>{mode=value;$("sideMode").classList.toggle("active",value==="side");$("overlayMode").classList.toggle("active",value==="overlay");render();});
$("customForm").addEventListener("submit",custom);$("rerunBatch").addEventListener("click",rerun);
try {
const response=await fetch("/static/annotations/hairline_height_compare/latest.json",{cache:"no-store"});
if(!response.ok)throw new Error("尚无保存的批量结果,可上传图片或重跑全部30个案例。");
dataset=await response.json();dataset.errors ||= [];refreshCases();refreshStats();await render();
}catch(error){refreshStats();status(error.message,true);}
}
document.addEventListener("DOMContentLoaded",init);
+62
View File
@@ -0,0 +1,62 @@
"use strict";
// Reference geometry is in input-image pixels; no screen-vertical assumptions.
window.hairlineReferenceBox = function(points) {
const p = id => {
const value = points[String(id)];
if (!value || !Number.isFinite(value.x) || !Number.isFinite(value.y)) throw new Error(`缺少 ${id} 号关键点`);
return [value.x, value.y];
};
const add = (a,b) => [a[0]+b[0],a[1]+b[1]];
const sub = (a,b) => [a[0]-b[0],a[1]-b[1]];
const mul = (a,s) => [a[0]*s,a[1]*s];
const dot = (a,b) => a[0]*b[0]+a[1]*b[1];
const brow=p(9),chin=p(152),left=p(162),right=p(389);
const delta=sub(chin,brow),length=Math.hypot(...delta);
if(length<1)throw new Error("9→152 方向无法确定");
const up=mul(delta,-1/length),horizontal=[-up[1],up[0]],height=.7*length;
const sidePoint=(anchor,direction,level)=>{
const denominator=dot(direction,up);
if(Math.abs(denominator)<1e-6)throw new Error("参考框侧边与底边平行");
return add(anchor,mul(direction,(level-dot(sub(anchor,brow),up))/denominator));
};
const corners=[sidePoint(left,up,0),sidePoint(right,up,0),
sidePoint(right,up,height),sidePoint(left,up,height)];
if(dot(sub(corners[1],corners[0]),horizontal)<=0)throw new Error("参考框左右位置异常");
return {corners,height,faceLength:length,
landmarks:{9:brow,152:chin,162:left,389:right}};
};
window.clipHairlineToReferenceBox = function(image, points) {
const box=window.hairlineReferenceBox(points);
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
if(!width||!height)throw new Error("发际线图片尚未加载");
const canvas=document.createElement("canvas");canvas.width=width;canvas.height=height;
const ctx=canvas.getContext("2d");
ctx.beginPath();box.corners.forEach((p,i)=>i?ctx.lineTo(...p):ctx.moveTo(...p));ctx.closePath();
ctx.clip();ctx.drawImage(image,0,0,width,height);
return canvas;
};
window.drawHairlineReferenceBox = function(ctx, points, crop, width, height) {
const box=window.hairlineReferenceBox(points);
const [x0,y0,x1,y1]=crop;
const screen=p=>[(p[0]-x0)/(x1-x0)*width,(p[1]-y0)/(y1-y0)*height];
const color="#e33399";
ctx.save();ctx.strokeStyle=color;ctx.lineWidth=Math.max(2.5,width/280);ctx.lineJoin="round";
ctx.beginPath();box.corners.forEach((p,i)=>{const q=screen(p);i?ctx.lineTo(...q):ctx.moveTo(...q);});ctx.closePath();ctx.stroke();
ctx.lineWidth=1.4;ctx.setLineDash([7,6]);ctx.beginPath();ctx.moveTo(...screen(box.landmarks[9]));ctx.lineTo(...screen(box.landmarks[152]));ctx.stroke();ctx.setLineDash([]);
const fontSize=Math.max(13,width/43);ctx.font=`600 ${fontSize}px sans-serif`;
for(const id of [9,152,162,389]){
const [x,y]=screen(box.landmarks[id]);
if(x<0||x>width||y<0||y>height)continue;
ctx.fillStyle=color;ctx.beginPath();ctx.arc(x,y,Math.max(3,width/160),0,Math.PI*2);ctx.fill();
const label=String(id),tw=ctx.measureText(label).width;
const tx=Math.max(3,Math.min(width-tw-8,x+7)),ty=Math.max(fontSize+4,Math.min(height-5,y-7));
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(label,tx,ty);
}
const top=screen([(box.corners[2][0]+box.corners[3][0])/2,(box.corners[2][1]+box.corners[3][1])/2]);
const text="70% · 9→152",tw=ctx.measureText(text).width;
const tx=Math.max(4,Math.min(width-tw-6,top[0]-tw/2)),ty=Math.max(fontSize+4,top[1]-8);
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(text,tx,ty);
ctx.restore();
};
+17
View File
@@ -56,6 +56,7 @@
<a href="#if3">接口3</a>
<a href="#if4">接口4</a>
<a href="#if5">接口5</a>
<a href="#if52">接口52</a>
<a href="#if6">接口6</a>
<a href="#errors">错误码</a>
<a href="#test">在线测试</a>
@@ -106,6 +107,7 @@
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
<tr><td><code>vertical_lines</code></td><td>object</td><td>标注图竖线坐标,与标注 PNG 同一套几何。原图像素:<code>y_top</code> / <code>y_bottom</code> 为竖线共用上下端(头顶之上到下巴之下,两端各伸出约短边的 3%,可能略超出图片);<code>lines</code> 从左到右,每项 <code>{ name, x }</code>。name:<code>left_head_edge</code>(人头最左,耳朵不可见则不出现,此时 eye1 为 null)、<code>left_cheek</code>、<code>left_eye_outer</code>、<code>left_eye_inner</code>、<code>right_eye_inner</code>、<code>right_eye_outer</code>、<code>right_cheek</code>、<code>right_head_edge</code>(人头最右,不可见则不出现,此时 eye7 为 null)</td></tr>
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
@@ -126,6 +128,7 @@ const { code, data } = await res.json();
// data.annotated_image_url → 标注 PNG
// data.face_total_height_cm → 全脸高度
// data.four_courts.ratios → { top_court: 0.25, ... }
// data.vertical_lines → { y_top, y_bottom, lines: [{ name, x }, ...] }
// 前端叠加显示:原图 + annotated_image_url(absolute 叠加)</pre>
</div>
</div>
@@ -251,6 +254,7 @@ console.log(features.gene_style); // "自然型"</pre>
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标,结构同接口1</td></tr>
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
@@ -261,6 +265,16 @@ console.log(features.gene_style); // "自然型"</pre>
</div>
</div>
<div class="card" id="if52">
<h2>52. 发际线新规则与裁切 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hairline/generate-52</code></h2>
<div class="card-body">
<p class="desc">仅部署在GPU worker(8187),本次不新增网关路由。中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。</p>
<p>基础图片参数、<code>gender</code>、<code>hair_style</code>、<code>use_mask</code>、<code>prompt</code>、<code>generate_grow_image</code>沿用接口5。新增可选<code>normalize_image</code>,默认false,传true开启扶正与裁切;测试页显式开启。</p>
<p style="margin-top:12px">图片返回<code>*_base64</code>,空图片保留字段并返回null。另有<code>height_rule</code>、<code>framing</code>、<code>guide_landmarks</code>、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。</p>
<p style="margin-top:12px"><a href="http://localhost:8187/static/test_interface52.html" class="link">52号规则测试页</a> · <a href="http://localhost:8187/static/test_interface52_compare.html" class="link">52号30案例对比页</a></p>
</div>
</div>
<!-- ======== 接口6 ======== -->
<div class="card" id="if6">
<h2>6. 四庭七眼测量 v2 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/face/measure-v2</code> &nbsp;<span class="badge warn">v2</span></h2>
@@ -286,6 +300,7 @@ console.log(features.gene_style); // "自然型"</pre>
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标。结构同接口1,但 <code>y_top</code> / <code>y_bottom</code> 为发际线到下巴尖(不伸出),<code>lines</code> 只有中间 6 条,无 <code>left_head_edge</code> / <code>right_head_edge</code></td></tr>
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
</table>
@@ -323,6 +338,8 @@ console.log(features.gene_style); // "自然型"</pre>
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
<tr><td>52. 发际线新规则</td><td><a href="http://localhost:8187/static/test_interface52.html" class="link">/static/test_interface52.html</a></td><td>中下庭定位、扶正裁切、矩形发际线叠加及生发</td></tr>
<tr><td>52. 规则对比</td><td><a href="http://localhost:8187/static/test_interface52_compare.html" class="link">/static/test_interface52_compare.html</a></td><td>30个案例,原图与旧/新规则样式对照</td></tr>
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
</table>
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
+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 = '⏳ 重绘中...';
+1 -1
View File
@@ -107,7 +107,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</div>
<div id="statusBar" class="status hidden"></div>
</div>
+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>
+418
View File
@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口52 — 发际线PNG生成 测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 6px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 18px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.form-group { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.form-group label { font-size: 13px; font-weight: 600; color: #374151; }
.form-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
.hair-checkboxes { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; }
.hair-checkboxes label { font-size: 13px; font-weight: 500; color: #374151; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
.hair-checkboxes label input[type=checkbox] { cursor: pointer; }
.checkbox-actions { display: flex; gap: 6px; margin-left: 4px; }
.checkbox-actions button { font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid #d1d5db; background: #f9fafb; cursor: pointer; }
.checkbox-actions button:hover { background: #e5e7eb; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.results-layout { display: flex; gap: 24px; }
.col-main { flex: 1.5; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
/* 每个发型区块 */
.hair-block { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
.hair-block:first-child { padding-top: 4px; }
.hair-block:last-child { border-bottom: none; padding-bottom: 4px; }
.hair-block-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 10px; }
/* 未裁切原图 + middle + 矩形裁切发际线叠加 + 生发图 */
.level-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; align-items: start; }
.level-cell { background: #222; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
.level-cell .level-label { font-size: 12px; font-weight: 600; color: #fff; background: rgba(0,0,0,.55); padding: 4px 8px; min-height: 38px; display: flex; align-items: center; }
.level-cell img { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .level-none { color: #9ca3af; font-size: 13px; padding: 28px 0; text-align: center; }
/* 处理后底图 + 同尺寸透明设计曲线叠加 */
.level-cell .img-stack { position: relative; line-height: 0; }
.level-cell .img-stack .layer-base { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
.hairline-crop-border { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
.hairline-crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
@media (max-width: 700px) { .level-row { grid-template-columns: repeat(2, 1fr); } }
/* 坐标高亮 */
.coord-box { background: #f9fafb; border-radius: 8px; padding: 12px 16px; margin-top: 10px; font-size: 14px; }
.coord-box .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
.coord-box .value { font-weight: 700; font-size: 18px; color: #111827; }
/* 四庭七眼测量(face_measure) */
.fm-section { margin-bottom: 18px; }
.fm-section:last-child { margin-bottom: 0; }
.fm-title { font-size: 13px; font-weight: 700; color: #374151; margin-bottom: 8px; }
.fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.fm-cell { background: #f9fafb; border-radius: 6px; padding: 8px 10px; border: 1px solid #f0f0f0; }
.fm-cell .k { font-size: 11px; color: #9ca3af; margin-bottom: 2px; }
.fm-cell .v { font-size: 15px; font-weight: 700; color: #111827; }
.fm-cell .v .sub { font-size: 11px; font-weight: 500; color: #6b7280; margin-left: 4px; }
.fm-cell.muted .v { color: #9ca3af; font-weight: 600; }
.fm-tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; font-weight: 600; }
.fm-tag.ok { background: #d1fae5; color: #065f46; }
.fm-tag.warn { background: #fef3c7; color: #92400e; }
.json-panel { max-height: 550px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
.rule-banner { background:#ecfdf5; border:1px solid #a7f3d0; border-radius:12px; padding:18px; margin-bottom:20px; line-height:1.8; }
.rule-banner strong { color:#065f46; font-size:19px; }
.rule-banner a { color:#065f46; margin-left:18px; font-weight:600; }
.placement { font-size:12px; color:#64748b; font-weight:400; margin:6px 0 10px; }
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js?v=2"></script>
<script src="/static/hairline_reference_box.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>💈 接口52 — 发际线 PNG 生成 测试</h1>
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p>
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)÷ 2</strong><a href="/static/test_interface52_compare.html">查看旧 / 新规则对比 →</a><br>上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。</div>
<div class="card">
<div class="card-body">
<div class="upload-row">
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<div class="form-group">
<label>性别</label>
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(7种)</option></select>
</div>
<div class="form-group" style="align-items:flex-start">
<label style="padding-top:3px">发型 *</label>
<div>
<div class="hair-checkboxes" id="hairStyleGroup"></div>
<div class="checkbox-actions" style="margin-top:4px">
<button type="button" onclick="selectAllHair(true)">全选</button>
<button type="button" onclick="selectAllHair(false)">全不选</button>
</div>
</div>
</div>
<div class="form-group">
<label>生发效果图</label>
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
<input type="checkbox" id="genGrowImg" checked> 同时生成生发效果(关闭后只查看设计曲线,速度更快)
</label>
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div>
<div class="hint">JPG/PNG 正面照 &nbsp;|&nbsp; Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 &nbsp;|&nbsp; Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 &nbsp;|&nbsp; 每个发型返回一张设计曲线,生发遮罩同步到新位置</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<!-- 四庭七眼测量(face_measure,复用接口1数值) -->
<div class="card">
<div class="card-header"><span>📐 处理后图像的四庭七眼测量</span><span style="font-weight:400;font-size:12px;color:#9ca3af">与当前显示底图使用同一坐标系</span></div>
<div class="card-body"><div id="faceMeasureArea"><span style="color:#9ca3af">—</span></div></div>
</div>
<!-- 所有发型平铺 -->
<div class="card">
<div class="card-header"><span>🎯 未裁切原图 + middle + 裁切发际线叠加 + 生发图</span><span style="font-weight:400;font-size:12px;color:#9ca3af" id="resultCount">共 0 个发型</span></div>
<div class="card-body">
<div class="coord-box" style="margin-top:0;margin-bottom:16px">
<div class="label">📍 设计中心点 — 图片像素坐标</div>
<div class="value">
<span id="centerPoint">—</span>
</div>
</div>
<div id="ruleDetails" style="font-size:13px;color:#475569;margin-bottom:16px;line-height:1.8"></div>
<div id="resultsGrid"></div>
</div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-header"><span>📋 JSON</span><button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button></div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let _images = [], _uploadUrl = '', _originalSrc = '', _processedSrc = '', _center = null, _guideLandmarks = null, _renderVersion = 0;
function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
// 发型列表(同接口2)
const HAIR_STYLES = {
female: [
{ value:'1', label:'1. ellipse 椭圆' },
{ value:'2', label:'2. flower 花瓣' },
{ value:'3', label:'3. heart 心形' },
{ value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. wave 波浪' },
{ value:'6', label:'6. bigflower 大花瓣型' },
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
],
male: [
{ value:'1', label:'1. ellipse 椭圆' },
{ value:'2', label:'2. inverse_arc 倒弧' },
{ value:'3', label:'3. m M形' },
{ value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. heart 桃心形' },
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
{ value:'7', label:'7. wave 波浪形' },
]
};
function onGenderChange() {
const g = $('gender').value;
const div = $('hairStyleGroup');
div.innerHTML = '';
HAIR_STYLES[g].forEach(s => {
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.value = s.value; cb.id = 'hs_' + s.value;
const lb = document.createElement('label');
lb.htmlFor = cb.id; lb.appendChild(cb);
lb.appendChild(document.createTextNode(' ' + s.label));
div.appendChild(lb);
});
}
function selectAllHair(select) {
document.querySelectorAll('#hairStyleGroup input[type=checkbox]').forEach(cb => { cb.checked = select; });
}
async function submitTest() {
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
if (!checked.length) { setStatus('请至少选择一个发型', 'error'); return; }
// Preserve native pixels: the worker frames and straightens the image first.
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
_uploadUrl = URL.createObjectURL(f);
_originalSrc = _uploadUrl; _processedSrc = ''; _images = []; _guideLandmarks = null; _renderVersion++;
$('resultsGrid').innerHTML = '';
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false'); fd.append('normalize_image', 'true');
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
_images = json.data.hairline_images || [];
_originalSrc = resolveImgSrc(json.data.original_image_url, json.data.original_image_base64, 'image/jpeg') || _uploadUrl;
_processedSrc = resolveImgSrc(json.data.input_image_url, json.data.input_image_base64, 'image/jpeg');
_guideLandmarks = json.data.guide_landmarks;
if (_originalSrc !== _uploadUrl) { URL.revokeObjectURL(_uploadUrl); _uploadUrl = ''; }
_center = json.data.best_hairline_center_point;
const _fmtPt = p => p ? '(' + p.x + ', ' + p.y + ')' : '—';
$('centerPoint').textContent = _fmtPt(json.data.best_hairline_center_point);
const rule = json.data.height_rule;
$('ruleDetails').textContent = rule
? '中庭 ' + rule.middle_height_px.toFixed(2) + ' px + 下庭 ' + rule.lower_height_px.toFixed(2) + ' px → 目标上庭 ' + rule.target_upper_height_px.toFixed(2) + ' px' + (rule.target_upper_height_cm == null ? '' : '(约 ' + rule.target_upper_height_cm.toFixed(2) + ' cm)') + ';目标 y = ' + rule.target_center.y.toFixed(2) + '。测量来源:' + (rule.measurement_source === 'face_measure' ? '当前底图测量' : '发际线关键点')
: '—';
if (json.data.framing) { const f = json.data.framing; $('ruleDetails').textContent += ';' + f.message + ',旋转 ' + f.rotation_degrees.toFixed(3) + '°,底图 ' + f.output_size.width + '×' + f.output_size.height + '。'; }
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
setStatus('✅ ' + _images.length + ' 个发型已按新规则定位 (' + _elapsed + 's)', 'success');
await renderGrid();
renderFaceMeasure(json.data.face_measure);
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}
async function buildClippedOverlay(src, points) {
const image = await new Promise((resolve, reject) => {
const img = new Image(); img.crossOrigin = 'anonymous';
img.onload = () => resolve(img); img.onerror = () => reject(new Error('无法读取发际线图片'));
img.src = src;
});
const canvas = window.clipHairlineToReferenceBox(image, points);
const corners = window.hairlineReferenceBox(points).corners.map(p => p.join(',')).join(' ');
const result = {
src: canvas.toDataURL('image/png'),
border: '<svg class="hairline-crop-border" viewBox="0 0 '+canvas.width+' '+canvas.height+'" role="img" aria-label="粉色虚线:发际线裁切边界"><polygon points="'+corners+'"></polygon></svg>'
};
canvas.width = 0; canvas.height = 0;
return result;
}
async function renderGrid() {
const version = ++_renderVersion;
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
const images = _images, originalSrc = _originalSrc, processedSrc = _processedSrc, guideLandmarks = _guideLandmarks;
const escapeText = text => String(text).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// The overlay uses the server's processed image, with matching coordinates.
const overlayCell = (label, src, className, emptyText = '暂无处理后图片', border = '') => (src && processedSrc)
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="img-stack">'+
'<img class="layer-base" src="'+processedSrc+'" alt="处理后底图">'+
'<img class="layer-anno" src="'+src+'" alt="'+label+'">'+border+'</div></div>'
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">'+escapeText(emptyText)+'</div></div>';
const photoCell = (label, src, className) => src
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><img src="'+src+'" alt="'+label+'"></div>'
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
const blocks = await Promise.all(images.map(async function(it) {
// 兼容网关 *_url 与 worker *_base64
const mid = resolveImgSrc(it.image_middle_url, it.image_middle_base64, 'image/png');
const grown = resolveImgSrc(it.grown_image_url, it.grown_image_base64, 'image/jpeg');
let clipped = '', clipBorder = '', clipError = '缺少发际线图片或参考点';
if (mid && processedSrc && guideLandmarks) {
try { const preview = await buildClippedOverlay(mid, guideLandmarks); clipped = preview.src; clipBorder = preview.border; }
catch (error) { clipError = error.message; }
}
return '<div class="hair-block">' +
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
(grown ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
(it.height_placement ? '<div class="placement">中心 y:' + it.height_placement.new_center.y.toFixed(2) + ' · 相对旧规则 ' + (it.height_placement.shift_y_px >= 0 ? '下移 ' : '上移 ') + Math.abs(it.height_placement.shift_y_px).toFixed(2) + ' px · 目标误差 ' + it.height_placement.target_error_px.toFixed(2) + ' px</div>' : '') +
'<div class="level-row">' +
photoCell('未裁切原图', originalSrc, 'original-cell') +
overlayCell('middle · 发际线叠加', mid, 'middle-cell') +
overlayCell('裁切发际线叠加', clipped, 'clipped-cell', clipError, clipBorder) +
photoCell('生发图', grown, 'grown-cell') +
'</div>' +
'</div>';
}));
if (version === _renderVersion) $('resultsGrid').innerHTML = blocks.join('');
}
function clearResults() {
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
_images=[]; _uploadUrl=''; _originalSrc=''; _processedSrc=''; _center=null; _guideLandmarks=null; _renderVersion++;
$('ruleDetails').textContent = '';
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
$('centerPoint').textContent='—';
$('resultCount').textContent = '共 0 个发型';
$('faceMeasureArea').innerHTML = '<span style="color:#9ca3af">—</span>';
}
// 四庭七眼测量渲染(face_measure,接口1数值)
const _PCT = r => (r == null ? '' : '(' + (r * 100).toFixed(1) + '%)');
const _FMT = v => (v == null ? '—' : Number(v).toFixed(2));
function renderFaceMeasure(fm) {
const box = $('faceMeasureArea');
if (!fm) {
box.innerHTML = '<div style="padding:24px;text-align:center;color:#9ca3af;font-size:14px">⚠️ face_measure = null(测量失败或未启用)</div>';
return;
}
// 发际线来源 + 头部姿态
const src = fm.hairline_source;
const srcTag = src === 'segmentation'
? '<span class="fm-tag ok">segmentation 真实分割</span>'
: (src === 'estimated' ? '<span class="fm-tag warn">estimated 比例估算</span>' : '');
let poseHtml = '—';
if (fm.head_pose) {
const p = fm.head_pose;
poseHtml = 'yaw ' + _FMT(p.yaw) + '° &nbsp; pitch ' + _FMT(p.pitch) + '° &nbsp; roll ' + _FMT(p.roll) + '°';
}
let h = '<div class="coord-box" style="margin-top:0;margin-bottom:16px;display:flex;gap:32px;flex-wrap:wrap;align-items:center">'
+ '<div><div class="label">脸总高</div><div class="value">' + _FMT(fm.face_total_height_cm) + ' cm</div></div>'
+ '<div><div class="label">发际线来源</div><div class="value" style="font-size:15px">' + (src || '—') + srcTag + '</div></div>'
+ '<div><div class="label">头部姿态</div><div class="value" style="font-size:14px">' + poseHtml + '</div></div>'
+ '</div>';
// 四庭
const fc = fm.four_courts || {};
const fr = (fc.ratios || {});
const courts = [
['顶庭', fc.top_court_cm, fr.top_court],
['上庭', fc.upper_court_cm, fr.upper_court],
['中庭', fc.middle_court_cm, fr.middle_court],
['下庭', fc.lower_court_cm, fr.lower_court],
];
h += '<div class="fm-section"><div class="fm-title">📏 四庭(纵向,自上而下)</div><div class="fm-grid">';
courts.forEach(c => {
h += '<div class="fm-cell"><div class="k">' + c[0] + '</div><div class="v">' + _FMT(c[1]) + ' cm<span class="sub">' + _PCT(c[2]).replace(/[()]/g, '') + '</span></div></div>';
});
h += '</div></div>';
// 七眼
const se = fm.seven_eyes || {};
const ser = (se.ratios || {});
h += '<div class="fm-section"><div class="fm-title">👁 七眼(横向汇总)</div><div class="fm-grid">';
[['单眼宽', se.eye_width_cm, ser.eye_width], ['脸宽', se.face_width_cm, null], ['两眼间距', se.inter_eye_distance_cm, ser.inter_eye_distance]].forEach(e => {
h += '<div class="fm-cell"><div class="k">' + e[0] + '</div><div class="v">' + _FMT(e[1]) + ' cm' + (e[2] != null ? '<span class="sub">' + (e[2] * 100).toFixed(1) + '%</span>' : '') + '</div></div>';
});
h += '</div></div>';
// 七眼 7 段(从左到右)
const segLabels = ['eye1 左耳外段', 'eye2 左脸颊段', 'eye3 左眼', 'eye4 两眼间距', 'eye5 右眼', 'eye6 右脸颊段', 'eye7 右耳外段'];
h += '<div class="fm-section"><div class="fm-title">↔️ 七眼 7 段宽度(从左到右,cm)</div><div class="fm-grid" style="grid-template-columns:repeat(7,1fr)">';
segLabels.forEach((lab, i) => {
const v = se['eye' + (i + 1)];
const muted = v == null ? ' muted' : '';
h += '<div class="fm-cell' + muted + '"><div class="k">' + lab + '</div><div class="v">' + (v == null ? '—' : _FMT(v)) + '</div></div>';
});
h += '</div></div>';
// 关键点坐标
const lm = fm.landmarks || {};
const lmLabels = { hair_top: '头顶', hairline: '发际线', brow_center: '眉心', nose_bottom: '鼻翼下缘', chin_tip: '下巴尖' };
h += '<div class="fm-section"><div class="fm-title">📍 关键分界点(当前底图像素坐标)</div><div class="fm-grid">';
Object.keys(lmLabels).forEach(k => {
const p = lm[k];
h += '<div class="fm-cell"><div class="k">' + lmLabels[k] + '</div><div class="v" style="font-size:14px">' + (p ? '(' + p.x + ', ' + p.y + ')' : '—') + '</div></div>';
});
h += '</div></div>';
box.innerHTML = h;
}
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
document.addEventListener('DOMContentLoaded', () => {
onGenderChange(); // 初始填充发型列表
const dz = $('imageFile');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
});
</script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>接口 52 · 发际线高度规则对比</title>
<link rel="stylesheet" href="/static/hairline_height_compare.css">
<script src="/static/img_downscale.js?v=2" defer></script>
<script src="/static/hairline_reference_box.js" defer></script>
<script src="/static/hairline_height_compare.js" defer></script>
</head>
<body>
<main>
<header class="page-header">
<div><div class="eyebrow">接口 52 / 规则验证</div><h1>发际线高度规则对比</h1><p>用同一张照片、同一个形状,查看旧规则与中下庭均值规则的位置差异。</p></div>
<a class="button secondary" href="/static/test_interface52.html">打开规则测试页 ↗</a>
</header>
<section class="rule-card" aria-label="规则说明">
<div><span class="pill">新规则</span><h2>目标上庭 =(中庭 + 下庭)÷ 2</h2><p>从眉心向上定位,所有形状的中点对齐同一高度。各发型保留自身轮廓,叠加在扶正后的底图上。</p></div>
<div class="rule-note"><span class="dot old"></span>旧规则:根据头发边界和模板定位<br><span class="dot new"></span>新规则:根据中庭、下庭定位<br><span class="dash"></span>虚线:新规则的目标高度</div>
</section>
<details class="framing-standard"><summary>查看已扶正的标准图与裁切参数</summary><div id="framingStandard">正在加载标准图参数…</div></details>
<section class="stats" aria-label="验证概况">
<div><span>已验证图片</span><strong id="statCases">—</strong><small>独立测试图片</small></div>
<div><span>发际线对比</span><strong id="statComparisons">—</strong><small>男性 / 女性各 7 型</small></div>
<div><span>最大中心误差</span><strong id="statError">—</strong><small>新中心与目标的像素差</small></div>
<div><span>验证状态</span><strong id="statStatus">加载中</strong><small id="runTime">正在读取已保存结果</small></div>
</section>
<section class="toolbar" aria-label="显示设置">
<label>发型集合<select id="genderFilter"><option value="all" selected>全部集合(男女各 7 型)</option><option value="female">女性发际线</option><option value="male">男性发际线</option></select></label>
<label>形状<select id="styleFilter"></select></label>
<label>图片<select id="caseFilter"><option value="all">全部 30 个案例</option></select></label>
<div class="segmented" aria-label="对比方式"><button id="sideMode" class="active" type="button">左右对比</button><button id="overlayMode" type="button">同图叠线</button></div>
<label class="check"><input type="checkbox" id="zoomFace">额头特写</label>
<label class="check"><input type="checkbox" id="showTarget" checked>目标参考线</label>
<label class="check"><input type="checkbox" id="showBox" checked>新图参考框</label>
</section>
<p id="status" class="status" role="status" aria-live="polite">正在加载30个案例的对比结果…</p>
<section id="results" class="results" aria-label="案例对比"></section>
<section class="tools-card">
<div class="tools-heading"><div><h2>继续验证</h2><p>上传新图片,或重新运行清单全部30张。每人先显示未裁切原图,再显示旧、新规则;对比只生成定位曲线,无需等待生发。</p></div><button id="rerunBatch" class="button secondary" type="button">重跑全部30个案例</button></div>
<form id="customForm" class="custom-form">
<label>选择图片<input id="customFile" type="file" accept="image/png,image/jpeg" required></label>
<label>发型集合<select id="customGender"><option value="female">女性 · 7 型</option><option value="male">男性 · 7 型</option></select></label>
<button class="button primary" id="customRun" type="submit">运行自定义对比</button>
</form>
<p class="footnote">high、low 已停用;接口旧字段保留为 null。当前页的“旧规则”是本次高度修改前的定位逻辑。旧、新规则使用同一张处理后底图。</p>
</section>
<footer>旧线 <span class="dot old"></span> 橙色 · 新线 <span class="dot new"></span> 青色 · 粉色参考框:两侧均平行 9→152,左边经过 162、右边经过 389、底边经过 9;高度为 9→152 距离的 70%<br>向上移动为负,向下移动为正 · 所有距离基于接口实际输入图片</footer>
</main>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>接口52 · 规则对比</title><meta http-equiv="refresh" content="0;url=/static/test_interface52_compare.html"></head>
<body><p>新规则对比已迁移至接口52:<a href="/static/test_interface52_compare.html">打开接口52对比页</a></p><script>location.replace('/static/test_interface52_compare.html'+location.search+location.hash);</script></body></html>
+1 -1
View File
@@ -104,7 +104,7 @@
<div class="hint">JPG/PNG &nbsp;|&nbsp; 生发图生成较慢(数十秒~数分钟),请耐心等待 &nbsp;|&nbsp; 工作流: add_hair2.json</div>
<div style="margin-top:10px;display:flex;align-items:center;gap:8px">
<label style="font-size:13px;font-weight:600;color:#374151;white-space:nowrap">💬 提示词</label>
<input type="text" id="promptInput" value="填充遮罩区域的头发" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
<input type="text" id="promptInput" value="填充遮罩区域的头发,人物增加一点磨皮" style="flex:1;padding:8px 12px;border:1px solid #d1d5db;border-radius:6px;font-size:13px;max-width:500px">
</div>
<div id="statusBar" class="status hidden"></div>
</div>
+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>
+38
View File
@@ -0,0 +1,38 @@
{
"description": "用户指定的接口 52 发际线高度规则回归验证图片清单",
"recorded_on": "2026-11-03",
"endpoint": "/api/v1/hairline/generate-52",
"total": 30,
"cases": [
{"image_number": 1, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%201.png"},
{"image_number": 2, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%202.png"},
{"image_number": 3, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%203.png"},
{"image_number": 4, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%204.png"},
{"image_number": 5, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%205.png"},
{"image_number": 6, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%206.png"},
{"image_number": 7, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%207.png"},
{"image_number": 20, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2020.png"},
{"image_number": 21, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2021.png"},
{"image_number": 22, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2022.png"},
{"image_number": 23, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2023.png"},
{"image_number": 27, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2027.png"},
{"image_number": 32, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2032.png"},
{"image_number": 33, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2033.png"},
{"image_number": 34, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2034.png"},
{"image_number": 35, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2035.png"},
{"image_number": 36, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2036.png"},
{"image_number": 37, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2037.png"},
{"image_number": 40, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2040.png"},
{"image_number": 41, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2041.png"},
{"image_number": 42, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2042.png"},
{"image_number": 43, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2043.png"},
{"image_number": 44, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2044.png"},
{"image_number": 45, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2045.png"},
{"image_number": 46, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2046.png"},
{"image_number": 47, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2047.png"},
{"image_number": 50, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2050.png"},
{"image_number": 51, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2051.png"},
{"image_number": 52, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2052.png"},
{"image_number": 53, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2053.png"}
]
}
+313 -3
View File
@@ -18,7 +18,7 @@ H = {"X-Internal-Token": "testpass"}
@pytest.fixture(scope="module")
def client():
# with 触发 lifespan:加载模型单例(detector + 尽力加载 segmenter)
with TestClient(app_module.app) as c:
with TestClient(app_module.app, client=("127.0.0.1", 50000)) as c:
yield c
@@ -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
@@ -125,6 +189,40 @@ def test_growb_success(client, monkeypatch):
HLGEN = "/api/v1/hairline/generate"
def test_hairline5_restored_schema_has_only_historical_parameters(client):
schema = client.get("/openapi.json", headers=H).json()
ref = schema["paths"][HLGEN]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"]
fields = schema["components"]["schemas"][ref.rsplit("/", 1)[-1]]["properties"]
assert set(fields) == {"image_file", "image_url", "image_base64", "gender", "hair_style",
"use_mask", "prompt", "generate_grow_image"}
assert fields["use_mask"]["default"] is True
assert fields["generate_grow_image"]["default"] is True
assert fields["prompt"]["default"] == "填充遮罩区域的头发,人物增加一点磨皮"
@pytest.mark.parametrize("gender", ["female", "male"])
def test_hairline5_restored_three_levels_and_original_coordinate_contract(client, gender):
from pathlib import Path
photo = Path(fixture("frontal.jpg")).read_bytes()
body = client.post(HLGEN, headers=H, files={"image_file": ("frontal.jpg", photo, "image/jpeg")},
data={"gender": gender, "hair_style": "1,3", "generate_grow_image": "false"}).json()
assert body["code"] == 0, body
data = body["data"]
assert set(data) == {"hairline_images", "best_hairline_center_point", "high_hairline_center_point",
"low_hairline_center_point", "face_measure"}
expected = _cv2.imdecode(_np.frombuffer(photo, _np.uint8), _cv2.IMREAD_COLOR).shape[:2]
for key in ("best_hairline_center_point", "high_hairline_center_point", "low_hairline_center_point"):
assert set(data[key]) == {"x", "y"}
assert isinstance(data[key]["x"], int) and isinstance(data[key]["y"], int)
for item in data["hairline_images"]:
assert set(item) == {"hairline_type", "image_middle_base64", "image_high_base64", "image_low_base64",
"grown_image_base64", "order"}
assert item["grown_image_base64"] is None
for level in ("middle", "high", "low"):
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(item[f"image_{level}_base64"]), _np.uint8), _cv2.IMREAD_UNCHANGED)
assert image.shape == (*expected, 4)
def test_hairline_gen_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004
@@ -177,6 +275,181 @@ def test_hairline_gen_female(client, monkeypatch):
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
HLGEN52 = "/api/v1/hairline/generate-52"
def test_hairline52_legacy_form_parameters_and_defaults_are_preserved(client):
schema = client.get("/openapi.json", headers=H).json()
body = schema["paths"][HLGEN52]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]
body = schema["components"]["schemas"][body["$ref"].rsplit("/", 1)[-1]]
fields = body["properties"]
legacy = {"image_file", "image_url", "image_base64", "gender", "hair_style",
"use_mask", "prompt", "generate_grow_image"}
assert legacy.issubset(fields)
assert not set(body.get("required", [])) - legacy
for key in ("image_file", "image_url", "image_base64", "gender", "hair_style"):
assert any(choice.get("type") == "string" for choice in fields[key].get("anyOf", [fields[key]]))
assert fields["use_mask"]["type"] == "boolean" and fields["use_mask"]["default"] is True
assert fields["generate_grow_image"]["type"] == "boolean" and fields["generate_grow_image"]["default"] is True
assert fields["prompt"]["default"] == "填充遮罩区域的头发,人物增加一点磨皮"
assert fields["normalize_image"]["default"] is False
@pytest.mark.parametrize("image_kind", ["file", "base64", "url"])
def test_hairline52_legacy_request_keeps_fields_and_original_coordinates(client, monkeypatch, image_kind):
import contextlib
from pathlib import Path
from types import SimpleNamespace
import httpx
import hairline.comfyui as comfy
raw = Path(fixture("frontal.jpg")).read_bytes()
calls = []
def generate(*args, **kwargs):
calls.append(kwargs)
return _PNG_1x1
monkeypatch.setattr(comfy, "run", generate)
form = {"gender": "male", "hair_style": "1", "use_mask": "false", "prompt": "legacy prompt"}
kwargs = {"headers": H, "data": form}
if image_kind == "file":
kwargs["files"] = {"image_file": ("frontal.jpg", raw, "image/jpeg")}
elif image_kind == "base64":
form["image_base64"] = "data:image/jpeg;base64," + base64.b64encode(raw).decode()
else:
url = "https://legacy.example.test/frontal.jpg"
form["image_url"] = url
response = httpx.Response(200, content=raw, request=httpx.Request("GET", url))
def get_image(actual_url):
assert actual_url == url
return response
monkeypatch.setattr(httpx, "Client", lambda **kw: contextlib.nullcontext(SimpleNamespace(get=get_image)))
# No new parameters: old clients keep their original-size overlays.
body = client.post(HLGEN52, **kwargs).json()
assert body["code"] == 0, body
assert {"code", "message", "request_id", "data"}.issubset(body)
data = body["data"]
assert {"hairline_images", "best_hairline_center_point", "high_hairline_center_point",
"low_hairline_center_point", "face_measure"}.issubset(data)
assert data["coordinate_space"] == "original_image" and data["framing"] is None
assert data["input_image_base64"] == data["original_image_base64"]
original = _cv2.imdecode(_np.frombuffer(raw, _np.uint8), _cv2.IMREAD_COLOR)
expected = original.shape[:2]
assert (data["height"], data["width"]) == expected
item = data["hairline_images"][0]
assert {"hairline_type", "image_middle_base64", "image_high_base64", "image_low_base64",
"grown_image_base64", "order"}.issubset(item)
assert item["image_high_base64"] is None and item["image_low_base64"] is None
assert data["high_hairline_center_point"] is None and data["low_hairline_center_point"] is None
for key in ("image_middle_base64", "grown_image_base64"):
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(item[key]), _np.uint8), _cv2.IMREAD_UNCHANGED)
assert image.shape[:2] == expected
assert len(calls) == 1 and calls[0]["prompt"] == "legacy prompt"
@pytest.mark.parametrize("normalize_image", [True, False])
def test_hairline52_height_compare_uses_same_new_rule(client, monkeypatch, normalize_image):
import hairline.comfyui as comfy
def unexpected_generation(*args, **kwargs):
pytest.fail("Position comparison must not invoke ComfyUI by default")
monkeypatch.setattr(comfy, "run", unexpected_generation)
with open(fixture("frontal.jpg"), "rb") as photo:
response = client.post("/api/v1/debug/hairline-height-compare",
files={"image_file": ("frontal.jpg", photo, "image/jpeg")},
data={"gender": "male", "hair_style": "1,3", "normalize_image": str(normalize_image).lower()})
data = response.json()
assert data["code"] == 0, data
data = data["data"]
assert [r["order"] for r in data["results"]] == [1, 3]
assert set(data["guide_landmarks"]) == {"9", "150", "152", "162", "251", "389"}
assert data["guide_landmarks"]["9"]["y"] < data["guide_landmarks"]["152"]["y"]
expected_size = (data["height"], data["width"])
base = _cv2.imdecode(_np.frombuffer(base64.b64decode(data["input_base64"]), _np.uint8), _cv2.IMREAD_COLOR)
assert base.shape[:2] == expected_size
assert data["coordinate_space"] == ("processed_image" if normalize_image else "original_image")
if normalize_image:
assert data["framing"]["enabled"] is True
assert data["framing"]["axis_x_error_px"] < 1e-8
assert data["guide_landmarks"]["9"]["x"] == pytest.approx(data["guide_landmarks"]["152"]["x"], abs=1e-8)
else:
assert data["framing"] is None
original = _cv2.imread(fixture("frontal.jpg"))
assert base.shape == original.shape
for result in data["results"]:
assert result["height_placement"]["target_error_px"] <= 1
assert result["grown_image_base64"] is None
for key in ("old_overlay_base64", "new_overlay_base64"):
assert base64.b64decode(result[key])[:8] == b"\x89PNG\r\n\x1a\n"
overlay = _cv2.imdecode(_np.frombuffer(base64.b64decode(result[key]), _np.uint8), _cv2.IMREAD_UNCHANGED)
assert overlay.shape[:2] == expected_size
def test_hairline52_gen_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
assert client.post(HLGEN52, headers=H, files=files).json()["code"] == 1004
def test_hairline52_gen_missing_hairstyle_1007(client):
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
assert client.post(HLGEN52, headers=H, files=files, data={"gender": "female"}).json()["code"] == 1007
def test_hairline52_gen_female(client, monkeypatch):
# mock ComfyUI:验证处理后底图、发际线和生发图使用相同尺寸与坐标。
import hairline.comfyui as comfy
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post(HLGEN52, headers=H, files=files,
data={"gender": "female", "hair_style": "1,3", "normalize_image": "true"}).json()
assert body["code"] == 0, body
d = body["data"]
assert d["coordinate_space"] == "processed_image"
assert d["framing"]["enabled"] is True
assert d["framing"]["axis_x_error_px"] < 1e-8
expected_size = (d["height"], d["width"])
for value in (d["input_image_base64"], d["hairline_images"][0]["image_middle_base64"],
d["hairline_images"][0]["grown_image_base64"]):
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(value), _np.uint8), _cv2.IMREAD_UNCHANGED)
assert image.shape[:2] == expected_size
imgs = d["hairline_images"]
# 只返回选中发型,order = 发型序号
assert [x["order"] for x in imgs] == [1, 3]
assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"]
# Only the designed middle curve is generated; deprecated fields stay null.
assert base64.b64decode(imgs[0]["image_middle_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert all(x["image_high_base64"] is None and x["image_low_base64"] is None for x in imgs)
assert d["high_hairline_center_point"] is None and d["low_hairline_center_point"] is None
rule = d["height_rule"]
assert rule["target_upper_height_px"] == pytest.approx(
(rule["middle_height_px"] + rule["lower_height_px"]) / 2)
for x in imgs:
assert x["height_placement"]["new_center"]["y"] == pytest.approx(rule["target_center"]["y"], abs=1)
assert base64.b64decode(imgs[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff"
assert "image_middle_url" not in imgs[0]
c = d["best_hairline_center_point"]
assert 0 <= c["x"] < d["width"] and 0 <= c["y"] < d["height"]
# face_measure:复用接口1测量数值(容错,失败为 null;本用例正常 → 必须有完整结构)
fm = d["face_measure"]
assert fm is not None, "face_measure 不应为 null(正常正面照)"
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
"landmarks", "hairline_source", "head_pose"]).issubset(fm.keys())
# 不应含标注图字段(接口52 只要数值,不要画线图)
assert "annotated_image_base64" not in fm
assert "annotated_image_url" not in fm
# 子结构
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
"lower_court_cm", "ratios"]).issubset(fm["four_courts"].keys())
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
"ratios"]).issubset(fm["seven_eyes"].keys())
# 七眼 eye1~eye7 键必须存在(eye1/eye7 在耳朵不可见时可为 null)
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", "corrected")
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
# 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker;
# 其测试随实现一起在网关侧做,worker 这边不再覆盖。
@@ -188,7 +461,7 @@ def test_success_structure(client):
data = body["data"]
# 业务字段对齐文档
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
"landmarks", "hairline_source", "head_pose",
"landmarks", "hairline_source", "head_pose", "vertical_lines",
"annotated_image_base64"]).issubset(data.keys())
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
"lower_court_cm", "ratios"]).issubset(data["four_courts"].keys())
@@ -205,3 +478,40 @@ def test_success_structure(client):
assert "annotated_image_url" not in data
png = base64.b64decode(data["annotated_image_base64"])
assert png[:8] == b"\x89PNG\r\n\x1a\n"
_assert_vertical_lines(data, "v1")
_INNER_VLINES = [
"left_cheek", "left_eye_outer", "left_eye_inner",
"right_eye_inner", "right_eye_outer", "right_cheek",
]
def _assert_vertical_lines(data, variant):
vl = data["vertical_lines"]
assert isinstance(vl["y_top"], int) and isinstance(vl["y_bottom"], int)
assert vl["y_top"] < vl["y_bottom"]
names = [line["name"] for line in vl["lines"]]
xs = [line["x"] for line in vl["lines"]]
assert xs == sorted(xs)
assert all(isinstance(x, int) for x in xs)
if variant == "v6":
assert names == _INNER_VLINES
assert vl["y_top"] == data["landmarks"]["hairline"]["y"]
assert vl["y_bottom"] == data["landmarks"]["chin_tip"]["y"]
assert "eye1" not in data["seven_eyes"]
assert "eye7" not in data["seven_eyes"]
return
assert set(_INNER_VLINES).issubset(names)
assert names[0] in ("left_head_edge", "left_cheek")
assert names[-1] in ("right_head_edge", "right_cheek")
by_name = {line["name"]: line["x"] for line in vl["lines"]}
assert ("left_head_edge" in by_name) == (data["seven_eyes"]["eye1"] is not None)
assert ("right_head_edge" in by_name) == (data["seven_eyes"]["eye7"] is not None)
def test_measure_v2_vertical_lines_have_no_head_edges(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post("/api/v1/face/measure-v2", headers=H, files=files).json()
assert body["code"] == 0, body
_assert_vertical_lines(body["data"], "v6")
+122
View File
@@ -0,0 +1,122 @@
"""Framing must align real pixel coordinates without scaling or invented pixels."""
import io
import cv2
import numpy as np
from PIL import Image
import pytest
from hairline.framing import calibrate_reference, crop_fits, decode_portrait, normalize_portrait, transform_points
PROFILE = {"version": "test", "reference_image_url": "/reference.png",
"width_per_face_length": 2.1, "height_per_face_length": 2.8,
"brow_x_ratio": .49, "brow_y_ratio": .34}
def landmarks(w, h, brow, length=100, angle=0):
lm = np.zeros((478, 3), np.float64)
down = np.array([np.sin(np.deg2rad(angle)), np.cos(np.deg2rad(angle))])
lm[:, :2] = np.array(brow)/[w, h]
lm[152, :2] = (np.array(brow)+down*length)/[w, h]
lm[:, 2] = .01
return lm
@pytest.mark.parametrize("angle", [-22, 0, 17])
def test_native_scale_upright_and_inverse_mapping(angle):
photo = np.full((700, 600, 3), 127, np.uint8)
lm = landmarks(600, 700, (300, 280), angle=angle)
result, moved, metadata = normalize_portrait(photo, lm, PROFILE)
h, w = result.shape[:2]
assert metadata["standard_crop"] is True
assert w/h == .75
xy = moved[[9, 152], :2]*[w, h]
assert abs(xy[0, 0]-xy[1, 0]) < 1e-9
assert xy[1, 1]-xy[0, 1] == pytest.approx(100)
assert xy[0, 0]/w == pytest.approx(PROFILE["brow_x_ratio"])
assert xy[0, 1]/h == pytest.approx(PROFILE["brow_y_ratio"])
matrix = np.array(metadata["source_to_processed"])
np.testing.assert_allclose(np.linalg.svd(matrix[:, :2])[1], [1, 1], atol=1e-10)
restored = transform_points(moved[:, :2]*[w, h], np.array(metadata["processed_to_source"]))
np.testing.assert_allclose(restored, lm[:, :2]*[600, 700], atol=1e-9)
assert result.min() == 127 # no black triangles / padded background
def test_insufficient_space_keeps_upright_original_size_when_no_rotation_needed():
photo = np.full((300, 230, 3), 127, np.uint8)
result, moved, metadata = normalize_portrait(photo, landmarks(230, 300, (115, 80), 150), PROFILE)
assert metadata["standard_crop"] is False
assert metadata["skip_reason"] == "insufficient_space"
assert result.shape == photo.shape
np.testing.assert_array_equal(result, photo)
assert abs(moved[9, 0]-moved[152, 0]) < 1e-10
def test_rotated_fallback_has_no_empty_corners():
photo = np.full((400, 300, 3), 127, np.uint8)
result, moved, metadata = normalize_portrait(photo, landmarks(300, 400, (150, 130), 160, 12), PROFILE)
assert metadata["standard_crop"] is False
assert result.min() == 127
assert metadata["axis_x_error_px"] < 1e-10
assert crop_fits(np.array(metadata["source_to_processed"]), (300, 400), result.shape[1::-1])
@pytest.mark.parametrize("brow,angle", [((150, 330), 0), ((150, 330), 8), ((150, 70), -8)])
def test_tall_fallback_is_three_by_four_and_retains_face(brow, angle):
photo = np.full((700, 300, 3), 127, np.uint8)
lm = landmarks(300, 700, brow, length=200, angle=angle)
image, moved, metadata = normalize_portrait(photo, lm, PROFILE)
h, w = image.shape[:2]
assert metadata["standard_crop"] is False
assert metadata["aspect_crop"] is True
assert metadata["status"] == "aspect_crop"
assert w*4 == h*3
xy = moved[:, :2]*[w, h]
assert (xy >= -1e-7).all() and (xy <= [w-1+1e-7, h-1+1e-7]).all()
assert abs(xy[9, 0]-xy[152, 0]) < 1e-9
assert np.linalg.norm(xy[152]-xy[9]) == pytest.approx(200)
assert image.min() == 127
assert crop_fits(np.array(metadata["source_to_processed"]), (300, 700), (w, h))
def test_tall_fallback_does_not_cut_face_that_cannot_fit_three_by_four():
photo = np.full((700, 300, 3), 127, np.uint8)
image, moved, metadata = normalize_portrait(photo, landmarks(300, 700, (150, 100), length=450), PROFILE)
assert metadata["aspect_crop"] is False
assert metadata["status"] == "rotation_only"
assert image.shape == photo.shape
def test_reference_parameters_are_measured_after_straightening():
photo = np.full((400, 300, 3), 127, np.uint8)
image, moved, profile = calibrate_reference(photo, landmarks(300, 400, (145, 140), angle=8))
h, w = image.shape[:2]
assert w/h == .75 and w < 300 and h < 400
assert profile["brow_9"]["x"] == pytest.approx(profile["chin_152"]["x"], abs=1e-9)
assert profile["brow_x_ratio"] == pytest.approx(moved[9, 0])
assert profile["brow_y_ratio"] == pytest.approx(moved[9, 1])
assert profile["reference_width_per_face_length"] == pytest.approx(w/100)
assert profile["reference_height_per_face_length"] == pytest.approx(h/100)
assert profile["width_per_face_length"] == 2.5
assert profile["height_per_face_length"] == pytest.approx(3/.9)
assert image.min() == 127
def test_default_crop_makes_face_ten_percent_smaller_than_three_l():
photo = np.full((700, 600, 3), 127, np.uint8)
result, moved, metadata = normalize_portrait(photo, landmarks(600, 700, (300, 280), length=120))
assert metadata["standard_crop"] is True
assert result.shape[:2] == (400, 300)
assert metadata["face_axis_length_px"] == pytest.approx(120)
assert metadata["height_per_face_length"] == pytest.approx(3/.9)
assert metadata["width_per_face_length"] == 2.5
old_height = 3*120
assert old_height/result.shape[0] == pytest.approx(.9)
def test_phone_orientation_is_applied_before_landmarks():
image = Image.new("RGB", (120, 80), "white")
exif = Image.Exif(); exif[274] = 6
buf = io.BytesIO(); image.save(buf, format="JPEG", exif=exif)
assert decode_portrait(buf.getvalue()).shape[:2] == (120, 80)
+29
View File
@@ -0,0 +1,29 @@
"""Interface 52 belongs to the GPU worker; the gateway keeps its legacy routes."""
import asyncio
import importlib
from starlette.requests import Request
def test_legacy_hairline_forwarding(monkeypatch):
name, path = "hairline_generate", "/api/v1/hairline/generate"
gateway = importlib.import_module("gateway.app")
request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
calls = []
async def proxy(actual_request, actual_path):
assert actual_request is request
calls.append(actual_path)
return {"forwarded_to": actual_path}
monkeypatch.setattr(gateway, "_proxy", proxy)
endpoint = getattr(gateway, name)
assert next(route for route in gateway.app.routes if getattr(route, "path", None) == path).endpoint is endpoint
assert asyncio.run(endpoint(request)) == {"forwarded_to": path}
assert calls == [path]
def test_interface52_is_not_exposed_by_gateway():
gateway = importlib.import_module("gateway.app")
assert not any(getattr(route, "path", None) == "/api/v1/hairline/generate-52"
for route in gateway.app.routes)
+62
View File
@@ -0,0 +1,62 @@
"""Keep the gateway's historical image-field conversion unchanged."""
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
from starlette.requests import Request
from gateway.forward import rewrite_base64_to_url
def test_nullable_image_fields_keep_original_names(tmp_path):
value = {"hairline_images": [{"image_high_base64": None, "image_low_base64": None,
"grown_image_base64": None}],
"high_hairline_center_point": None, "face_measure": None}
actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path))
assert actual == value
assert not list(tmp_path.iterdir())
def test_nonempty_images_still_rewrite_alongside_null_fields(tmp_path):
import base64
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGBA", (2, 2), (255, 255, 255, 0)).save(buf, format="PNG")
value = {"image_middle_base64": base64.b64encode(buf.getvalue()).decode(),
"image_high_base64": None}
actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path))
assert actual["image_middle_url"].startswith("https://example.test/static/annotations/")
assert actual["image_high_base64"] is None
assert "image_high_url" not in actual
assert len(list(tmp_path.iterdir())) == 1
@pytest.mark.parametrize("path,expected_key", [
("/api/v1/hairline/generate", "grown_image_base64"),
("/api/v1/hair/grow", "grown_image_base64"),
])
def test_proxy_keeps_original_null_image_fields(tmp_path, monkeypatch, path, expected_key):
import gateway.config as config
import gateway.forward as forward
monkeypatch.setattr(config, "get_config", lambda: {
"dispatch": {"request_timeout_seconds": 10}, "shared_password": "test-token",
"public_base_url": "https://example.test", "static_dir": str(tmp_path),
})
monkeypatch.setattr(forward, "acquire_worker", AsyncMock(return_value=SimpleNamespace(url="http://worker")))
monkeypatch.setattr(forward, "release_worker", AsyncMock())
worker_response = httpx.Response(200, json={"code": 0, "data": {"hairline_images": [
{"grown_image_base64": None}]}})
monkeypatch.setattr(forward, "get_client", lambda: SimpleNamespace(request=AsyncMock(return_value=worker_response)))
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
request = Request({"type": "http", "method": "POST", "path": path, "headers": []}, receive)
response = asyncio.run(forward.proxy_request(request, path))
assert response.status_code == 200
assert json.loads(response.body)["data"]["hairline_images"] == [{expected_key: None}]
+6 -2
View File
@@ -27,5 +27,9 @@ def test_mp_to_obj_anchor_mapping():
def test_texture_map():
m = get_texture_map()
assert [k for k, _ in m["female"]] == ["ellipse", "flower", "heart", "straight", "wave"]
assert [k for k, _ in m["male"]] == ["ellipse", "inverse_arc", "m", "straight"]
assert [k for k, _ in m["female"]] == [
"ellipse", "flower", "heart", "straight", "wave", "bigflower", "clasicalflower",
]
assert [k for k, _ in m["male"]] == [
"ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal", "wave",
]
+82
View File
@@ -0,0 +1,82 @@
"""Template placement must follow face pose, not unequal left/right bangs."""
import numpy as np
import pytest
from hairline import constants as C
from hairline.hairline_2d import (
align_hairline_for_template,
clamp_hairline_to_silhouette,
)
def _scene(angle=0.0, shape=(600, 900)):
h, w = shape
scale = np.array([w, h])
lm = np.zeros((468, 3), np.float32)
xy = np.full((468, 2), [450.0, 300.0])
for i, (left, right) in enumerate(C.MP_TOP_ANCHOR_PAIRS):
xy[left] = [300.0 + i * 12, 210.0 - i * 8]
xy[right] = [600.0 - i * 12, 210.0 - i * 8]
xy[10] = [450, 150]
xy[152] = [450, 450]
theta = np.deg2rad(angle)
rotation = np.array([[np.cos(theta), -np.sin(theta)],
[np.sin(theta), np.cos(theta)]])
xy = (xy - [450, 300]) @ rotation.T + [450, 300]
lm[:, :2] = xy / scale
up = rotation @ [0.0, -1.0]
anchors = lm[C.MP_TOP_ANCHORS, :2] * scale
distances = np.full(C.N_ANCHORS, 50.0)
indices = {k: i for i, k in enumerate(C.MP_TOP_ANCHORS)}
distances[indices[67]] = 80.0
distances[indices[297]] = 20.0
hairline = ((anchors + distances[:, None] * up) / scale).astype(np.float32)
return lm, hairline, np.ones(C.N_ANCHORS, bool), anchors, up
@pytest.mark.parametrize("angle,shape", [(0, (600, 900)), (23, (600, 900)),
(-17, (1200, 900))])
def test_uneven_bangs_do_not_shear_template(angle, shape):
lm, raw, valid, anchors, up = _scene(angle, shape)
raw_copy, lm_copy = raw.copy(), lm.copy()
aligned = align_hairline_for_template(lm, raw, valid, shape)
displacement = aligned * shape[::-1] - anchors
# Every anchor extends 50 px along the rotated face axis, even when
# one side's hair boundary is 60 px higher than its counterpart.
np.testing.assert_allclose(displacement, np.tile(50 * up, (C.N_ANCHORS, 1)), atol=1e-4)
np.testing.assert_array_equal(raw, raw_copy)
np.testing.assert_array_equal(lm, lm_copy)
def test_anatomical_pairs_are_not_reversed_anchor_order():
shape = (600, 900)
lm, raw, valid, anchors, up = _scene(shape=shape)
# Endpoints of MP_TOP_ANCHORS are not mirror-ordered.
distances = {127: 10, 356: 30, 234: 60, 454: 100}
for mp_idx, distance in distances.items():
i = C.MP_TOP_ANCHORS.index(mp_idx)
raw[i] = (anchors[i] + distance * up) / shape[::-1]
aligned = align_hairline_for_template(lm, raw, valid, shape)
actual = (aligned * shape[::-1] - anchors) @ up
for mp_idx, expected in [(127, 20), (356, 20), (234, 80), (454, 80)]:
assert actual[C.MP_TOP_ANCHORS.index(mp_idx)] == pytest.approx(expected, abs=1e-4)
def test_missing_ray_hit_does_not_pull_valid_partner():
shape = (600, 900)
lm, raw, valid, _anchors, _up = _scene(shape=shape)
left, right = C.MP_TOP_ANCHORS.index(67), C.MP_TOP_ANCHORS.index(297)
valid[right] = False
raw[right, 1] = -0.5 # An extrapolated fallback outside the head.
aligned = align_hairline_for_template(lm, raw, valid, shape)
np.testing.assert_allclose(aligned[[left, right]], raw[[left, right]], atol=1e-7)
def test_template_alignment_still_allows_silhouette_clamping():
shape = (600, 900)
lm, raw, valid, _anchors, _up = _scene(shape=shape)
aligned = align_hairline_for_template(lm, raw, valid, shape)
parse_map = np.zeros(shape, np.int32)
parse_map[140:450, 250:650] = C.PARSE_SKIN
clamped = clamp_hairline_to_silhouette(aligned, parse_map)
assert np.min(clamped[:, 1] * shape[0]) >= 142 - 1e-4
+91
View File
@@ -0,0 +1,91 @@
"""Court-based design placement, including preview/inpaint consistency."""
import cv2
import numpy as np
import pytest
from hairline.height_rule import (
height_target, curve_center, place_curve, inpaint_from_curve,
)
def _landmarks(width=320, height=400):
lm = np.zeros((468, 3), np.float32)
for index, xy in {9: (160, 180), 94: (160, 260), 152: (160, 320),
10: (160, 140), 21: (70, 170), 251: (250, 170)}.items():
lm[index, :2] = np.array(xy) / [width, height]
return lm
def test_target_uses_mean_courts_without_old_estimation_ratio():
rule = height_target(_landmarks(), 320, 400)
assert rule["middle_height_px"] == pytest.approx(80, abs=1e-4)
assert rule["lower_height_px"] == pytest.approx(60, abs=1e-4)
assert rule["target_upper_height_px"] == pytest.approx(70, abs=1e-4)
assert rule["target_center"]["y"] == pytest.approx(110, abs=1e-4)
def test_reference_uses_unrounded_measurement_and_scale():
ref = {"brow_center": (161.4, 181.3), "nose_bottom": (160.0, 261.9),
"chin_tip": (160.0, 321.5), "px_per_cm": 20}
rule = height_target(_landmarks(), 320, 400, ref)
assert rule["target_upper_height_px"] == pytest.approx(70.1)
assert rule["target_center"]["y"] == pytest.approx(111.2)
assert rule["target_upper_height_cm"] == pytest.approx(3.505)
assert rule["measurement_source"] == "face_measure"
@pytest.mark.parametrize("scale", [1, 2])
@pytest.mark.parametrize("center_y", [90, 125, 150])
def test_all_shapes_align_to_same_height_at_multiple_scales(scale, center_y):
h, w = 400 * scale, 320 * scale
lm = _landmarks()
overlay = np.zeros((h, w, 4), np.float32)
# A non-horizontal design, preserving a relative 20px arc after movement.
pts = np.array([[70, center_y + 20], [160, center_y], [250, center_y + 20]]) * scale
cv2.polylines(overlay, [pts.astype(np.int32)], False, (255, 255, 255, 255), 3 * scale)
original = overlay.copy()
target = height_target(lm, w, h)["target_center"]
moved, placement = place_curve(overlay, target, lm)
assert abs(curve_center(moved, target["x"])["y"] - target["y"]) < .2
assert placement["target_error_px"] < .2
# Shape's relative heights stay constant after translation.
before_delta = curve_center(original, 100 * scale)["y"] - curve_center(original, 160 * scale)["y"]
after_delta = curve_center(moved, 100 * scale)["y"] - curve_center(moved, 160 * scale)["y"]
assert after_delta == pytest.approx(before_delta, abs=.1)
np.testing.assert_array_equal(overlay, original)
def test_rolled_face_preserves_curve_angle():
lm = _landmarks()
lm[10, 0] -= .08
overlay = np.zeros((400, 320, 4), np.float32)
cv2.line(overlay, (60, 95), (260, 135), (255, 255, 255, 255), 3)
moved, _ = place_curve(overlay, {"x": 160, "y": 150}, lm)
original_slope = curve_center(overlay, 200)["y"] - curve_center(overlay, 120)["y"]
moved_slope = curve_center(moved, 200)["y"] - curve_center(moved, 120)["y"]
assert moved_slope == pytest.approx(original_slope, abs=.2)
def test_inpaint_mask_follows_new_line_not_old_black_template():
lm = _landmarks()
photo = np.full((400, 320, 3), 200, np.uint8)
parsing = np.ones((400, 320), np.int32)
overlay = np.zeros((400, 320, 4), np.float32)
cv2.line(overlay, (20, 140), (300, 140), (255, 255, 255, 255), 3)
moved, _ = place_curve(overlay, {"x": 160, "y": 100}, lm)
marked, mask = inpaint_from_curve(photo, lm, parsing, moved)
assert marked[100, 160, 0] == 0
assert marked[140, 160, 0] == 200
assert mask[90, 160] > 240
assert mask[130, 160] < 10
def test_out_of_frame_target_is_reported_instead_of_silent_clamping():
ref = {"brow_center": (160, 50), "nose_bottom": (160, 200), "chin_tip": (160, 350)}
with pytest.raises(ValueError, match="超出图片"):
height_target(_landmarks(), 320, 400, ref)
def test_missing_midline_curve_is_reported():
with pytest.raises(ValueError, match="有效曲线"):
place_curve(np.zeros((400, 320, 4), np.float32), {"x": 160, "y": 100}, _landmarks())
+101
View File
@@ -0,0 +1,101 @@
import json
from ipaddress import ip_address
import pytest
from fastapi.testclient import TestClient
from starlette.requests import Request
import ip_access as access
@pytest.fixture
def policy(tmp_path, monkeypatch):
path = tmp_path / "access.json"
path.write_text(json.dumps({"allowed_ips": []}))
monkeypatch.setattr(access, "CONFIG_PATH", path)
monkeypatch.setattr(access, "local_addresses", lambda: {ip_address("192.168.10.2"), ip_address("::1")})
return path
def request(host, headers=None, port=50000):
return Request({"type": "http", "path": "/api/v1/hairline/generate-52", "scheme": "http",
"client": (host, port), "headers": [(k.encode(), v.encode()) for k, v in (headers or {}).items()]})
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "::ffff:127.0.0.1", "192.168.10.2"])
def test_only_actual_local_addresses_allowed_by_default(policy, host):
assert access.check_access(request(host))[0]
@pytest.mark.parametrize("host", ["203.0.113.9", "192.168.10.3", "0.0.0.0", "invalid"])
def test_other_addresses_denied(policy, host):
assert not access.check_access(request(host))[0]
def test_whitelist_ipv4_ipv6_cidr_and_hot_reload(policy):
policy.write_text(json.dumps({"allowed_ips": ["203.0.113.9", "198.51.100.0/24", "2001:db8::/64"]}))
for host in ["203.0.113.9", "::ffff:203.0.113.9", "198.51.100.12", "2001:db8::12"]:
assert access.check_access(request(host))[0]
policy.write_text('{"allowed_ips": []}')
assert not access.check_access(request("203.0.113.9"))[0]
def test_configured_public_whitelist_is_exact(policy):
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
assert access.check_access(request("49.232.51.171"))[0]
assert not access.check_access(request("49.232.51.172"))[0]
@pytest.mark.parametrize("header", ["x-forwarded-for", "x-real-ip", "forwarded"])
def test_forwarding_headers_cannot_override_socket_peer(policy, header):
assert not access.check_access(request("203.0.113.9", {header: "127.0.0.1"}))[0]
assert access.check_access(request("127.0.0.1", {header: "203.0.113.9"}))[0]
def test_gateway_is_allowed_independently_of_end_user_ip(policy):
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
assert access.check_access(request("49.232.51.171", {"x-forwarded-for": "203.0.113.9"}))[0]
assert not access.check_access(request("203.0.113.9", {"x-forwarded-for": "49.232.51.171"}))[0]
def test_rewritten_peer_or_invalid_policy_fail_closed(policy):
assert not access.check_access(request("127.0.0.1", port=0))[0]
policy.write_text('{"allowed_ips": ["not-an-ip"]}')
assert not access.check_access(request("127.0.0.1"))[0]
policy.write_text('invalid JSON')
assert not access.check_access(request("127.0.0.1"))[0]
def test_worker_guard_covers_all_interfaces_before_auth(policy, monkeypatch):
import app as worker
monkeypatch.setattr(worker, "ACCEPT_PASSWORDS", ["access-test"])
headers = {"X-Internal-Token": "access-test"}
with TestClient(worker.app, client=("203.0.113.9", 50000)) as client:
assert client.post('/api/v1/hairline/generate-52').status_code == 403
assert client.post('/api/v1/hairline/generate-52/', headers=headers).status_code == 403
assert client.post('/api/v1/debug/hairline-height-compare').status_code == 403
assert client.post('/api/v1/hairline/generate', headers=headers).status_code == 403
assert client.post('/api/v1/face/measure', headers=headers).status_code == 403
assert client.post('/api/v1/redraw', headers=headers).status_code == 403
assert client.get('/health').status_code == 403
assert client.get('/openapi.json').status_code == 403
policy.write_text('{"allowed_ips": ["203.0.113.9"]}')
assert client.post('/api/v1/hairline/generate-52', headers=headers).json()['code'] == 1004
assert client.post('/api/v1/hairline/generate-52').status_code == 401
def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
import importlib
gateway = importlib.import_module('gateway.app')
calls = []
async def proxy(request, path):
calls.append(path)
return {'code': 0}
monkeypatch.setattr(gateway, '_proxy', proxy)
# Deliberately skip lifespan: no workers or configuration are needed.
client = TestClient(gateway.app, client=('203.0.113.9', 50000))
assert client.post('/api/v1/hairline/generate-52').status_code == 404
assert client.post('/api/v1/hairline/generate').json()['code'] == 0
assert client.post('/api/v1/hair/grow').json()['code'] == 0
assert client.get('/static/test_interface52.html').status_code == 200
assert calls == ['/api/v1/hairline/generate', '/api/v1/hair/grow']
+83
View File
@@ -0,0 +1,83 @@
"""竖线坐标:与标注图同一套几何,接口1/6 的返回字段由此而来。"""
import numpy as np
from face_analysis.measure import MeasureResult
from face_analysis.annotation import vertical_line_geometry
def _result():
"""已知像素坐标的测量结果。顶庭 2cm,不会触发发际线修正。"""
vertical = {
"hair_top": (500.0, 100.0),
"hairline": (500.0, 200.2),
"brow_center": (500.0, 350.0),
"nose_bottom": (500.0, 500.0),
"chin_tip": (500.0, 700.4),
"top_court_px": 100.0,
"upper_court_px": 150.0,
"middle_court_px": 150.0,
"lower_court_px": 200.0,
}
eyes = {
"eye_width_px": 80.0,
"face_width_px": 400.0,
"inter_eye_distance_px": 90.0,
"points": {
"left_cheek": (300.4, 400.0),
"left_outer": (360.6, 400.0),
"left_inner": (440.2, 400.0),
"right_inner": (530.8, 400.0),
"right_outer": (610.4, 400.0),
"right_cheek": (700.7, 400.0),
},
}
return MeasureResult(vertical, eyes, 50.0, "estimated", None)
_INNER = [
"left_cheek", "left_eye_outer", "left_eye_inner",
"right_eye_inner", "right_eye_outer", "right_cheek",
]
def test_v1_inner_lines_and_extended_span():
geom = vertical_line_geometry(_result(), 1000, 800, variant="v1")
# 短边 800,伸出 max(6, round(800*0.03)) = 24
assert geom["y_top"] == 76
assert geom["y_bottom"] == 724
assert [line["name"] for line in geom["lines"]] == _INNER
assert [line["x"] for line in geom["lines"]] == [300, 361, 440, 531, 610, 701]
def test_v6_span_is_hairline_to_chin_without_head_edges():
ear = np.zeros((800, 1000), dtype=np.uint8)
ear[120:680, 250] = 255
ear[120:680, 760] = 255
geom = vertical_line_geometry(
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v6")
assert geom["y_top"] == 200
assert geom["y_bottom"] == 700
assert [line["name"] for line in geom["lines"]] == _INNER
def test_v1_includes_ear_edges_left_to_right():
ear = np.zeros((800, 1000), dtype=np.uint8)
ear[120:680, 250] = 255
ear[120:680, 760] = 255
geom = vertical_line_geometry(
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v1")
names = [line["name"] for line in geom["lines"]]
xs = [line["x"] for line in geom["lines"]]
assert names == ["left_head_edge", *_INNER, "right_head_edge"]
assert xs[0] == 250 and xs[-1] == 760
assert xs == sorted(xs)
def test_missing_ear_omits_that_edge():
ear = np.zeros((800, 1000), dtype=np.uint8)
ear[120:680, 250] = 255
geom = vertical_line_geometry(
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v1")
names = [line["name"] for line in geom["lines"]]
assert names[0] == "left_head_edge"
assert "right_head_edge" not in names
+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
}
}