feat: 新增接口22生发控制与测试页

This commit is contained in:
xsl
2026-11-09 21:19:39 +08:00
parent c800822d54
commit b84a506b9b
21 changed files with 770 additions and 175 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ cd /home/ubuntu/change_hair && bash start_all.sh start # 换发型三件套 88
- **统一信封**:`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)。
- **Worker 访问控制**使用 `ip_access.json` 检查动态路由的直连来源 IP。
- **发型序号映射**:`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 重绘也复用它。
+61 -61
View File
@@ -2,8 +2,8 @@
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验
(深检:磁盘水位 + 下游依赖 TCP 探测,异常返回 503 degraded,见 _load_health_cfg)。
Worker 通过 IP 白名单控制动态路由访问。
`/health` 检查磁盘水位和下游依赖,异常返回 503 degraded。
"""
import asyncio
import base64
@@ -14,7 +14,7 @@ import shutil
import time
from contextlib import asynccontextmanager
from io import BytesIO
from typing import Any, List, Optional
from typing import Any, List, Literal, Optional
from urllib.parse import urlsplit
import cv2
@@ -40,25 +40,6 @@ SAMPLE_IMAGE_URL = f"{BASE_URL}/static/sample.jpg"
_STATE = {"ready": False}
def _load_accept_passwords() -> List[str]:
"""worker 鉴权密码来源:环境变量 WORKER_ACCEPT_PASSWORDS(逗号分隔)优先,
否则读 worker_config.json 的 accept_passwords 列表。"""
env = os.getenv("WORKER_ACCEPT_PASSWORDS")
if env:
return [p.strip() for p in env.split(",") if p.strip()]
cfg_path = os.path.join(os.path.dirname(__file__), "worker_config.json")
if os.path.isfile(cfg_path):
try:
with open(cfg_path, encoding="utf-8") as f:
return list(json.load(f).get("accept_passwords", []))
except Exception as e: # noqa: BLE001
logger.warning("读取 worker_config.json 失败:%s", e)
return []
ACCEPT_PASSWORDS = _load_accept_passwords()
@asynccontextmanager
async def lifespan(_app: FastAPI):
"""启动时加载模型单例(detector 必需,segmenter 尽力而为),就绪后才放行 /health。"""
@@ -142,12 +123,6 @@ app = FastAPI(
app.mount("/static", StaticFiles(directory="static"), name="static")
# 不校验鉴权的路径前缀(供网关探测 / 文档 / 静态)
_AUTH_EXEMPT = ("/health", "/docs", "/openapi.json", "/redoc", "/static",
"/api/v1/debug", "/api/v1/redraw",
"/api/swapHair", "/hairColor")
# ---------------------------------------------------------------------------
# change_hair 代理路由(解决 CORS 问题)
# ---------------------------------------------------------------------------
@@ -187,28 +162,12 @@ async def proxy_hair_color(request: Request):
@app.middleware("http")
async def internal_token_auth(request: Request, call_next):
"""worker 内网鉴权:/api/* 必须带正确的 X-Internal-Token,否则 401。
密码列表来自 worker 配置(accept_passwords)。未配置任何密码时放行(仅便于
本地起步),生产务必通过 worker_config.json / 环境变量配置密码。
"""
path = request.url.path
async def enforce_ip_allowlist(request: Request, call_next):
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:
token = request.headers.get("X-Internal-Token")
if token not in ACCEPT_PASSWORDS:
return JSONResponse(status_code=401, content={
"code": 1009,
"message": "未授权:缺少或错误的 X-Internal-Token",
"request_id": "worker",
"data": None,
})
return await call_next(request)
@@ -293,11 +252,13 @@ def _rgba_png_b64(rgba) -> str:
return base64.b64encode(buf.getvalue()).decode()
def _png_to_jpg_b64(png_bytes) -> str:
"""ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。"""
def _png_to_jpg_b64(png_bytes, output_size: tuple[int, int] | None = None) -> str:
"""把 ComfyUI 结果编码为 JPG,可选对齐到 (宽, 高)。"""
img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return base64.b64encode(png_bytes).decode()
if output_size is not None and img.shape[1::-1] != output_size:
img = cv2.resize(img, output_size, interpolation=cv2.INTER_LINEAR)
return _jpg_b64(img)
@@ -1224,6 +1185,35 @@ async def hair_grow(
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"),
):
return await _hair_grow_impl(image_file, image_url, image_base64, gender, hair_style,
use_mask, prompt, flux_model, redraw_max_side)
@app.post("/api/v1/hair/grow-22", summary="接口22 生发密度与发际线高度", tags=["生发"])
async def hair_grow_22(
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
gender: Optional[str] = Form(default=None),
hair_style: Optional[str] = Form(default=None),
beauty_enabled: bool = Form(default=False, description="保留接口2兼容字段"),
use_mask: bool = Form(default=True),
hair_density: list[Literal["dense", "sparse", "normal"]] = Form(
..., min_length=1, description="至少选一档,多选时重复提交 hair_density 字段"),
hairline_height: float = Form(default=0.0, ge=-1, le=1, description="-1 最高,0 正常,1 最低"),
flux_model: Optional[str] = Form(default=None),
redraw_max_side: Optional[int] = Form(default=None),
):
if hairline_height and not use_mask:
return err(1007, "调整发际线高度时必须启用 use_mask")
return await _hair_grow_impl(image_file, image_url, image_base64, gender, hair_style,
use_mask, None, flux_model, redraw_max_side, hairline_height,
hair_densities=hair_density)
async def _hair_grow_impl(image_file, image_url, image_base64, gender, hair_style,
use_mask, prompt, flux_model, redraw_max_side, hairline_height=None,
hair_densities=None):
# 1. gender 必填校验(非法/缺失 → 1004)
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
@@ -1246,30 +1236,40 @@ async def hair_grow(
try:
from fastapi.concurrency import run_in_threadpool
# 预览 + 生发/换发型 都是阻塞且较慢,放线程池避免卡住事件循环。
# female:换发型 + Flux-2 整帧重绘(= 接口12 final 管线);male:仍走原生发管线。
results = []
output_size = image.shape[1::-1] if hairline_height is not None else None
density_labels = {"dense": "适中偏多", "sparse": "稀疏", "normal": "正常"}
densities = dict.fromkeys(hair_densities) if hair_densities is not None else [None]
for density in densities:
density_prompt = prompt
if density is not None:
density_prompt = f"填充遮罩区域的头发,发量{density_labels[density]},人物增加一点磨皮"
# 生发和换发型在工作线程运行,避免阻塞事件循环。
if gender == "female":
from hairline.service import generate_grow_results_swap
items = await run_in_threadpool(
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
redraw_max_side=redraw_max_side, unet_name=flux_model, prompt=prompt)
redraw_max_side=redraw_max_side, unet_name=flux_model, prompt=density_prompt,
hairline_height=hairline_height)
else:
from hairline.service import generate_grow_results
items = await run_in_threadpool(
generate_grow_results, image, gender, use_mask, prompt, hair_styles,
unet_name=flux_model)
generate_grow_results, image, gender, use_mask, density_prompt, hair_styles,
unet_name=flux_model, hairline_height=hairline_height)
if items is None:
return err(1001, "无法识别人像")
results = []
for p in items:
results.append({
"image_base64": _rgba_png_b64(p["overlay"]), # 发际线曲线透明 PNG
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
if p["grown_png"] else None),
result = {
"image_base64": _rgba_png_b64(p["overlay"]),
"grown_image_base64": _png_to_jpg_b64(p["grown_png"], output_size) if p["grown_png"] else None,
"hairline_type": p["hairline_type"],
"order": p["order"],
})
}
if density is not None:
result["hair_density"] = density
results.append(result)
return ok({"results": results})
except Exception as ex: # noqa: BLE001
logger.exception("接口2 处理异常")
@@ -1846,7 +1846,7 @@ async def _generate_hairline52_data(image, gender, hair_styles, use_mask=True,
summary="接口52 发际线PNG生成",
tags=["人脸分析"],
description=f"""
输入用户照片 + 性别 + 多选发型,各发型 middle 中点统一对齐到眉心上方(中庭+下庭)/2。
输入用户照片 + 性别 + 多选发型,各发型 middle 中点统一对齐到眉心上方(中庭+下庭)× 0.45。
返回设计曲线、生发图、目标高度与定位误差;high/low 旧字段保留为 null。
{_image_fields_desc}
@@ -1897,7 +1897,7 @@ async def _generate_hairline52_data(image, gender, hair_styles, use_mask=True,
{"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},
"best_hairline_center_point": {"x": 540, "y": 461},
"high_hairline_center_point": None,
"low_hairline_center_point": None,
"face_measure": {
+7 -7
View File
@@ -17,16 +17,16 @@
```
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机
回环外无外网依赖。对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
回环外无外网依赖。动态路由检查直连来源 IP;
`/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限制)。
`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(调试,受 IP 白名单限制)。
所有动态路径先校验直连来源IP:允许本机与网关`49.232.51.171`,其余返回HTTP403/code1010;`/static/`除外。配置见[`接口IP白名单.md`](接口IP白名单.md),网关侧不增加IP限制。
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
网关本机实现的接口(不转发 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);IP拒绝为HTTP403/code1010,Token错误为HTTP401/code1009,依赖健康异常为HTTP503。
- **响应信封**使用 `{code, message, request_id, data}`。业务错误用 `code`(HTTP 200),Worker 拒绝来源 IP 时返回 HTTP 403 / code 1010,依赖健康异常时返回 HTTP 503。
### base64 → URL 映射(网关落盘改写,递归进数组)
@@ -34,7 +34,7 @@
|------|--------------------|----------|
| 1 | `annotated_image_base64` | `annotated_image_url` |
| 6 | `annotated_image_base64` | `annotated_image_url` |
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 2、22 | `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` |
| 52 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`、`input_image_base64` / `original_image_base64` | 仅GPU worker,本次不新增网关路由;直接返回`*_base64` |
@@ -52,7 +52,7 @@
### 错误码
`1001` 无法识别人像 | `1002` 分辨率过低 | `1003` 非正面 | `1004` gender 必填/非法(接口2/5/7)|
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持 | `1009` Token鉴权失败 | `1010` worker来源IP不允许或白名单配置无效。
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持 | `1010` worker来源IP不允许或白名单配置无效。
---
@@ -104,7 +104,7 @@
### 接口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)算目标中心。测量失败时用发际线管线同编号点计算并标注来源。
- **目标高度**:`上庭=(中庭+下庭)×0.45`,用当前底图测量的未取整像素坐标(眉心9、鼻翼下缘94、下巴152)算目标中心。测量失败时用发际线管线同编号点计算并标注来源。
- **模板定位**:原有502点/贴图生成形状,`hairline/height_rule.py` 将每种 middle 曲线沿面部向上方向平移、校准中心 Y,保留曲线形状与倾斜。此规则仅接口52使用,接口2保持原逻辑。
- **生发**:移动后的预览曲线直接生成黑线和重绘遮罩,再送ComfyUI,避免预览与黑模板位置不一致。
- **裁切叠加预览**:测试页新增“裁切发际线叠加”。`static/hairline_reference_box.js`共享同一个162/389/9/152参考框几何,按原图像素裁切middle透明图层,再叠加到处理后人像;四栏顺序为原图、middle、裁切发际线叠加、生发图。
@@ -153,7 +153,7 @@
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`。
- `worker_config.json`(不入 git):`comfyui_password` 用于连接本机 ComfyUI。
- systemd 单元:`hair-worker.service`(8187) / `comfyui.service`(8188),仓库根有副本;手动启停用
`./run_worker.sh` / `./stop_worker.sh`。
+2 -2
View File
@@ -1,6 +1,6 @@
# GPU worker 接口 IP白名单
仅GPU worker使用项目根目录的`ip_access.json`,不存在时仅允许本机。网关不启用IP限制。配置每次请求重新读取,修改后立即生效。现有`X-Internal-Token`鉴权继续有效。
仅GPU worker使用项目根目录的`ip_access.json`,不存在时仅允许本机。网关不启用IP限制。配置每次请求重新读取,修改后立即生效。Worker 动态接口以直连来源 IP 为准。
```json
{
@@ -24,6 +24,6 @@ worker的所有动态路径统一保护,包括5号、52号、其他业务接
修改`allowed_ips`后保存为有效JSON即可生效,无需重启;建议写入同目录临时文件再原子替换,避免编辑过程中出现半份JSON。文件不存在时仅允许本机,内容损坏时动态接口一律拒绝。更新程序时保留本机配置文件。
IP检查先于Token检查。白名单内请求仍需原来的`X-Internal-Token`;`/health`和`/api/v1/debug/*`等原本免Token的路径仍需通过IP检查。静态页可打开不代表该来源可以调用API。
Worker 动态路由通过来源 IP 白名单授权,包括 `/health` 和 `/api/v1/debug/*`。静态文件公开读取,调用 API 时以直连 IP 的检查结果为准。
验证命令:`./venv/bin/pytest tests/test_ip_access.py -q`,覆盖本机、精确白名单、CIDR、IPv6、转发头伪造、热更新、全接口拦截与网关不受限制。
+24 -9
View File
@@ -17,6 +17,7 @@
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
| 22 生发密度与发际线高度 | POST | `/api/v1/hair/grow-22` |
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 72 B 端五个关键点 | POST | `/api/v1/face/b-end-points` |
| 4 用户特征 | POST | `/api/v1/face/features` |
@@ -91,7 +92,6 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
| 1008 | 图片格式不支持 | 非 JPG / PNG |
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
| 1010 | 来源IP不允许 | 仅GPU worker:直连IP不在本机/白名单内,或来源/白名单配置无效;HTTP403 |
---
@@ -363,6 +363,21 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
---
## 接口 22 生发密度与发际线高度
`POST /api/v1/hair/grow-22` 使用接口 2 的图片输入、`gender`、`hair_style` 和返回结构。
| 表单字段 | 默认值 | 取值和效果 |
| --- | --- | --- |
| `hair_density` | 必填 | `sparse` 稀疏、`normal` 正常、`dense` 浓密。至少提交一档,多选时重复提交同名字段。提示词为「填充遮罩区域的头发,发量{描述},人物增加一点磨皮」,三档描述依次为「稀疏」「正常」「适中偏多」。 |
| `hairline_height` | `0` | `[-1, 1]`,`-1` 最高,`0` 正常,`1` 最低。端点相对正常位置移动眉心到下巴垂直像素距离的 10%。非零时须启用 `use_mask`。 |
每个发型用一张正常高度贴图渲染预览,按请求高度移动预览线。女性 1–5 号在 WebUI 出图后移动贴回区域和重绘带。其他发型用移动后的预览曲线生成生发遮罩。
例如选择两档时,表单传 `hair_density=sparse` 和 `hair_density=dense`。每个「密度 × 发型」组合返回一项,结果的 `hair_density` 字段标明对应档位。
生发图与透明发际线 PNG 对齐到输入照片尺寸。测试页运行在本机 Worker 的 `/static/test_interface22.html`,扶正裁切预览依赖接口 52。
## 接口 3:B 端生发接口
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
@@ -476,7 +491,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
Worker 直连时图片字段使用 `_base64` 后缀。检测不到人脸返回 `1001`,非正面或标注所需点位无法取得时返回 `1003`,图片无法解码返回 `1008`。
对外 42、52、72 的客户端登录、令牌验证和刷新均由 `main` 分支的网关处理。网关将 52 和 72 转发至本分支的 worker,worker 仍校验来源 IP 和网关注入的 `X-Internal-Token`。对外测试请使用网关同源的测试页。
对外 42、52、72 的客户端登录、令牌验证和刷新均由 `main` 分支的网关处理。网关将 52 和 72 转发至本分支的 Worker,Worker 通过直连来源 IP 白名单授权。对外测试请使用网关同源的测试页。
---
@@ -659,7 +674,7 @@ Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只
## 接口 52:发际线 PNG 生成接口
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)×0.45**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
**请求**:`POST /api/v1/hairline/generate-52`(仅GPU worker,端口8187;本次不在网关新增路由)
@@ -769,12 +784,12 @@ Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只
"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
"new_center": {"x": 540.0, "y": 461.0},
"shift_x_px": 0.0, "shift_y_px": 11.0, "target_error_px": 0.0
}
}
],
"best_hairline_center_point": {"x": 540, "y": 430},
"best_hairline_center_point": {"x": 540, "y": 461},
"high_hairline_center_point": null,
"low_hairline_center_point": null,
"input_image_base64": "/9j/4AAQ...",
@@ -783,14 +798,14 @@ Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只
"coordinate_space": "original_image",
"framing": null,
"height_rule": {
"name": "court_average", "formula": "上庭 = (中庭 + 下庭) / 2",
"name": "court_average", "formula": "上庭 = (中庭 + 下庭) × 0.45",
"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}
"target_upper_height_px": 279.0, "target_upper_height_cm": null,
"target_center": {"x": 540.0, "y": 461.0}
},
"guide_landmarks": {
"9": {"x": 540.0, "y": 740.0}, "150": {"x": 470.0, "y": 1340.0},
+15 -3
View File
@@ -858,7 +858,7 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
color_match_strength, mb_feather_px, transition_band_px,
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
hair_mask=None, webui_steps=None):
hair_mask=None, webui_steps=None, paste_dy=0):
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 final+重绘带用):
@@ -903,6 +903,14 @@ def _grow_core(image_bgr, hairline_id, *, is_hr, seg_model, erode_cm, swap_mode,
mask_dilate_scale=mask_dilate_scale, webui_steps=webui_steps)
t_swap = time.time() - t0
# 接口22在生成之后才移动贴回位置,WebUI 始终使用原始坐标和遮罩。
if paste_dy:
transform = np.float32([[1, 0, 0], [0, 1, paste_dy]])
swap_result = cv2.warpAffine(swap_result, transform, (w, h))
mask_bool = cv2.warpAffine(mask_bool.astype(np.uint8), transform, (w, h),
flags=cv2.INTER_NEAREST).astype(bool)
mask_bool &= mask_viz["_upper_mask"]
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
hard_paste = image_bgr.copy()
hard_paste[mask_bool] = swap_result[mask_bool]
@@ -1038,7 +1046,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
comfyui_prompt=None, beauty_alpha=0.6,
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
hair_mask=None, webui_steps=None):
hair_mask=None, webui_steps=None, paste_dy=0):
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
@@ -1067,7 +1075,7 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
hair_mask=hair_mask, webui_steps=webui_steps)
hair_mask=hair_mask, webui_steps=webui_steps, paste_dy=paste_dy)
final = core["final"]
mask_viz = core["mask_viz"]
w, h = core["w"], core["h"]
@@ -1084,6 +1092,10 @@ def generate_hairline_redraw(image_bgr, hairline_id, is_hr=False, seg_model="seg
redraw_info = {"enabled": False}
band_mask = None
try:
if paste_dy:
offset = np.array([0, paste_dy])
if inner_pts is not None and outer_pts is not None:
inner_pts, outer_pts = inner_pts + offset, outer_pts + offset
band_mask = _redraw_band_mask(inner_pts, outer_pts, h, w, rid=rid, upper=upper_mask,
lo_mult=band_lo_mult, hi_mult=band_hi_mult)
if band_mask.sum() < 30:
+7
View File
@@ -205,6 +205,7 @@ async def index():
"test_pages": {
"if1_measure": "/static/test_interface1.html",
"if2_hair_grow": "/static/test_interface2.html",
"if22_hair_grow": "http://localhost:8187/static/test_interface22.html",
"if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html",
"if5_hairline": "/static/test_interface5.html",
@@ -521,6 +522,12 @@ async def hair_grow(request: Request):
return await _proxy(request, "/api/v1/hair/grow")
@app.post("/api/v1/hair/grow-22", tags=["生发"])
async def hair_grow_22(request: Request):
"""接口22:生发密度与发际线高度"""
return await _proxy(request, "/api/v1/hair/grow-22")
@app.post("/api/v1/hair/grow-b", tags=["生发"])
async def hair_grow_b(request: Request):
"""接口3:B端生发"""
+4 -4
View File
@@ -1,4 +1,4 @@
"""接口52设计高度:上庭 = (中庭 + 下庭) / 2;不改变接口2/5的几何。"""
"""接口52设计高度:目标上庭为眉心到下巴垂直距离的 45%。"""
from __future__ import annotations
import cv2
@@ -27,15 +27,15 @@ def height_target(landmarks, width, height, reference=None):
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
upper = (middle + lower) * 0.45
target_y = float(brow[1] - upper)
if not (0 <= brow[0] < width and 1 <= target_y < height - 1):
raise ValueError("按中下庭均值计算的目标发际线超出图片范围,请使用完整正面照")
raise ValueError("按中下庭高度计算的目标发际线超出图片范围,请使用完整正面照")
def pt(p):
return {"x": float(p[0]), "y": float(p[1])}
return {
"name": "court_average",
"formula": "上庭 = (中庭 + 下庭) / 2",
"formula": "上庭 = (中庭 + 下庭) × 0.45",
"measurement_source": source,
"brow_center": pt(brow), "nose_bottom": pt(nose), "chin_tip": pt(chin),
"middle_height_px": middle, "lower_height_px": lower,
+39 -8
View File
@@ -221,10 +221,26 @@ def generate_previews(image_bgr: np.ndarray, gender: str):
return results
def _hairline_shift_px(landmarks: np.ndarray, height: int, adjustment: float | None) -> int:
if not adjustment:
return 0
brow_to_chin = abs(float(landmarks[152, 1] - landmarks[9, 1])) * height
return round(adjustment * 0.10 * brow_to_chin)
def _shift_overlay(overlay: np.ndarray, dy: int) -> np.ndarray:
if dy == 0:
return overlay
h, w = overlay.shape[:2]
transform = np.float32([[1, 0, 0], [0, 1, dy]])
return cv2.warpAffine(overlay, transform, (w, h))
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
prompt: str = None, hair_styles: list[int] | None = None,
workflow_path: str | None = None,
unet_name: str | None = None):
unet_name: str | None = None,
hairline_height: float | None = None):
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..7。
@@ -269,15 +285,20 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = T
results = []
h, w = image_bgr.shape[:2]
dy = _hairline_shift_px(ctx["landmarks"], h, hairline_height)
for order, (key, white_path) in items:
white = load_texture_rgba(white_path)
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
overlay = _shift_overlay(build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white), dy)
if not use_mask:
grown_png = shared_grown
else:
grown_png = None
try:
if hairline_height is not None:
marked, mask = inpaint_from_curve(
image_bgr, ctx["landmarks"], ctx["parse_map"], clean_curve(overlay))
else:
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
@@ -302,7 +323,8 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
redraw_defaults: dict,
redraw_max_side: int | None = None,
unet_name: str | None = None,
prompt: str | None = None):
prompt: str | None = None,
hairline_height: float | None = None):
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 生发图。
生发图来源按发型分两路:
@@ -340,6 +362,7 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
results = []
h, w = image_bgr.shape[:2]
dy = _hairline_shift_px(ctx["landmarks"], h, hairline_height)
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
@@ -355,9 +378,10 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)",
w, h, _nw, _nh, eff_side)
paste_dy = round(dy * redraw_img.shape[0] / h)
for order, (key, white_path) in items:
white = load_texture_rgba(white_path)
overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
overlay = _shift_overlay(build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white), dy)
grown_png = None
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
@@ -365,12 +389,14 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
# 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
grown_png = _grow_native_one(image_bgr, ctx, white_path,
prompt=prompt, unet_name=unet_name)
prompt=prompt, unet_name=unet_name,
design_overlay=overlay if hairline_height is not None else None)
else:
try:
import time as _t
_ts0 = _t.perf_counter()
data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults)
data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw,
paste_dy=paste_dy, **redraw_defaults)
_ts1 = _t.perf_counter()
steps = data.get("steps") or {}
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
@@ -438,7 +464,8 @@ def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
prompt: str | None = None, unet_name: str | None = None):
prompt: str | None = None, unet_name: str | None = None,
design_overlay: np.ndarray | None = None):
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
接口2 女性新发型(bigflower/clasicalflower 等)无对应 change_hair LoRA,改走此路径:
@@ -447,6 +474,10 @@ def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
"""
try:
h, w = image_bgr.shape[:2]
if design_overlay is not None:
marked, mask = inpaint_from_curve(
image_bgr, ctx["landmarks"], ctx["parse_map"], clean_curve(design_overlay))
else:
black = load_texture_rgba(_black_texture_path(white_path))
marked, mask = build_inpaint_mask(
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
@@ -539,7 +570,7 @@ def generate_hairline_pngs_52(image_bgr: np.ndarray, gender: str,
height_reference: dict | None = None,
compare_rules: bool = False,
precomputed_landmarks: np.ndarray | None = None):
"""接口52:每种 middle 曲线中点对齐到眉心上方 (中庭+下庭)/2。
"""接口52:每种 middle 曲线中点对齐到眉心上方 (中庭+下庭) × 0.45。
只读取 middle 贴图,high/low 保留空值。生发直接复用移动后的预览曲线,
不再使用位置不同的黑模板。compare_rules 仅为调试页附带旧规则叠图。
-3
View File
@@ -6,9 +6,6 @@
# 可用环境变量覆盖:
# HOST=127.0.0.1 PORT=9000 ./run_worker.sh
# FOREGROUND=1 ./run_worker.sh # 前台运行(调试用,Ctrl+C 退出)
#
# 鉴权密码读 worker_config.json 的 accept_passwords,
# 也可用 WORKER_ACCEPT_PASSWORDS=逗号分隔 覆盖。
set -euo pipefail
cd "$(dirname "$0")"
+1 -1
View File
@@ -178,7 +178,7 @@ async function render() {
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>`;
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>${esc(rule.formula || "目标上庭")} · ${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"?"橙色旧线 / 青色新线":"接口 52 设计线"}</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);}});
+2 -1
View File
@@ -268,7 +268,7 @@ console.log(features.gene_style); // "自然型"</pre>
<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 class="desc">GPU Worker(8187)提供此接口。发际线中点位于眉心上方中下庭总高度的 45%,支持 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>
@@ -335,6 +335,7 @@ console.log(features.gene_style); // "自然型"</pre>
<tr><th>接口</th><th>测试页</th><th>功能</th></tr>
<tr><td>1. 四庭七眼</td><td><a href="/static/test_interface1.html" class="link">/static/test_interface1.html</a></td><td>上传照片 → 原图+标注叠加,底图/标注开关,指标卡片</td></tr>
<tr><td>2. C端生发</td><td><a href="/static/test_interface2.html" class="link">/static/test_interface2.html</a></td><td>上传+性别 → 方案一覧(原图/叠加/生发),双图对比</td></tr>
<tr><td>22. 生发密度与发际线高度</td><td><a href="http://localhost:8187/static/test_interface22.html" class="link">本机 Worker 测试页</a></td><td>选择密度和高度,对照发际线预览与生发图</td></tr>
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
+451
View File
@@ -0,0 +1,451 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>接口 22 · 生发测试</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #f3f5f8; color: #172334; font: 15px/1.5 system-ui, sans-serif; }
main { max-width: 1100px; margin: 32px auto; padding: 0 16px; }
h1 { margin: 0 0 4px; font-size: 28px; }
h2 { margin: 0 0 12px; font-size: 18px; }
.muted, figcaption { color: #526075; }
.card { background: white; border: 1px solid #dce3eb; border-radius: 12px; padding: 18px; margin: 16px 0; }
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
.field { display: grid; gap: 5px; align-content: start; }
input, select, button { font: inherit; }
.field input, select { min-width: 0; width: 100%; padding: 6px 8px; }
fieldset { margin: 16px 0; border: 1px solid #dce3eb; border-radius: 8px; }
#sourceFields { margin-top: 0; padding: 0; border: 0; min-width: 0; }
.choices { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.choices label { white-space: nowrap; }
button { background: #1757ab; color: white; border: 0; border-radius: 8px; padding: 8px 16px; cursor: pointer; }
button:disabled { opacity: .6; cursor: wait; }
.status { margin-bottom: 0; }
.status[data-error="true"] { color: #a02020; }
.images { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; }
.images figure { max-width: 380px; }
.result-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 122px), 1fr)); gap: 10px; }
.result-tile { min-width: 0; overflow: hidden; background: white; border: 1px solid #dce3eb; border-radius: 8px; }
.result-tile img { display: block; width: 100%; height: auto; }
.result-tile figcaption { display: grid; gap: 2px; margin: 0; padding: 6px 8px; font-size: 12px; }
.result-tile strong { color: #172334; }
.preview-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px; margin: 16px 0; }
.preview-editor:has(#previews > figure:only-child) { grid-template-columns: minmax(0, 380px) auto; justify-content: start; }
.height-controls { display: grid; gap: 6px; padding-top: 27px; }
.height-scale { display: flex; gap: 6px; height: clamp(170px, 42vw, 300px); }
#height { writing-mode: vertical-lr; direction: rtl; width: 24px; height: 100%; margin: 0; }
.height-presets { display: flex; flex-direction: column; justify-content: space-between; }
.height-presets label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; }
figure { margin: 0; min-width: 0; }
figcaption { margin-bottom: 6px; }
.stack { position: relative; overflow: hidden; border-radius: 6px; }
.stack img, .stack canvas { width: 100%; height: auto; display: block; border-radius: 6px; }
.preview-layer, .preview-clip, .stack .overlay, .crop-border { position: absolute; inset: 0; width: 100%; height: 100%; }
.preview-layer { transform: translateY(calc(-1 * var(--height-adjustment) * var(--max-shift))); }
.crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 560px) { .images { grid-template-columns: 1fr; } }
</style>
<script src="/static/img_downscale.js?v=2" defer></script>
<script src="/static/hairline_reference_box.js?v=2" defer></script>
</head>
<body>
<main>
<h1>接口 22 生发测试</h1>
<div class="muted"><code>POST /api/v1/hair/grow-22</code></div>
<form id="generateRequest" class="card">
<fieldset id="sourceFields" aria-label="照片与性别">
<div class="fields">
<label class="field">照片(JPG / PNG)
<input type="file" name="image_file" accept="image/jpeg,image/png,.jpg,.jpeg,.png" required>
</label>
<label class="field">性别
<select name="gender"><option value="female">女性</option><option value="male">男性</option></select>
</label>
</div>
</fieldset>
<fieldset id="styleFields">
<legend>发际线形状(可多选)</legend>
<div id="styles" class="choices"></div>
<p id="generationPreviewStatus" class="status" role="status"></p>
</fieldset>
<p id="firstRunHint" class="muted">首次按默认高度 0 生图,出图后可预览和调整发际线高度。</p>
<div id="heightEditor" hidden>
<p class="muted">勾选右侧档位后按所选高度生图。未勾选时,使用滑块当前高度。</p>
<div class="preview-editor">
<div id="previews" class="images" aria-label="52 号裁切发际线预览"></div>
<div class="height-controls">
<label for="height">高度 <output id="heightValue" for="height">0</output></label>
<div class="height-scale">
<input type="range" id="height" name="hairline_height" min="-1" max="1" step="0.1" value="0">
<div class="height-presets" role="group" aria-label="预设高度(可多选)">
<label><input type="checkbox" name="height_preset" value="1"> +1</label>
<label><input type="checkbox" name="height_preset" value="0.5"> +0.5</label>
<label><input type="checkbox" name="height_preset" value="0"> 0</label>
<label><input type="checkbox" name="height_preset" value="-0.5"> −0.5</label>
<label><input type="checkbox" name="height_preset" value="-1"> −1</label>
</div>
</div>
</div>
</div>
</div>
<fieldset>
<legend>生发密度(至少选择一档)</legend>
<div class="choices">
<label><input type="checkbox" name="hair_density" value="sparse"> 稀疏</label>
<label><input type="checkbox" name="hair_density" value="normal" checked> 正常</label>
<label><input type="checkbox" name="hair_density" value="dense"> 浓密</label>
</div>
</fieldset>
<button id="submit" type="submit">生成生发效果</button>
<p id="status" class="status" role="status"></p>
</form>
<section id="results" class="result-grid" aria-label="生发结果"></section>
<details class="card" id="response" hidden>
<summary>响应 JSON(省略图片 Base64)</summary>
<pre id="json"></pre>
</details>
</main>
<script>
const generateForm = document.getElementById('generateRequest');
const sourceFields = document.getElementById('sourceFields');
const firstRunHint = document.getElementById('firstRunHint');
const heightEditor = document.getElementById('heightEditor');
const previews = document.getElementById('previews');
const height = document.getElementById('height');
const heightPresets = generateForm.querySelectorAll('[name="height_preset"]');
const styles = document.getElementById('styles');
const styleFields = document.getElementById('styleFields');
const generationPreviewStatus = document.getElementById('generationPreviewStatus');
const statusBar = document.getElementById('status');
const results = document.getElementById('results');
const response = document.getElementById('response');
const submit = document.getElementById('submit');
const densityLabels = { sparse: '稀疏', normal: '正常', dense: '浓密' };
const styleNames = {
female: ['椭圆', '花瓣', '心形', '直线', '波浪', '大花瓣', '古典花瓣'],
male: ['椭圆', '倒弧', 'M 形', '直线', '心形', '柔和花瓣', '波浪'],
};
let prepared;
let previewRevision = 0;
function renderStyleChoices(container, gender, selected) {
container.replaceChildren(...styleNames[gender].map((name, index) => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.name = 'hair_style';
checkbox.value = index + 1;
checkbox.checked = selected.includes(checkbox.value);
label.append(checkbox, ' ' + (index + 1) + '. ' + name);
return label;
}));
}
function imageSource(url, base64, mime) {
if (url) return url;
if (!base64) return '';
return base64.startsWith('data:') ? base64 : 'data:' + mime + ';base64,' + base64;
}
function imagePanel(title, src, preview) {
const figure = document.createElement('figure');
const caption = document.createElement('figcaption');
caption.textContent = title;
figure.append(caption);
if (!src) {
figure.append(preview?.error || '未返回图片');
return figure;
}
const stack = document.createElement('div');
stack.className = 'stack';
const image = new Image();
image.src = src;
image.alt = title;
stack.append(image);
if (preview) {
const movingLayer = document.createElement('div');
movingLayer.className = 'preview-layer';
movingLayer.style.setProperty('--max-shift', preview.maxShift + '%');
const clip = document.createElement('div');
clip.className = 'preview-clip';
const polygonPoints = preview.corners.map(([x, y]) =>
(100 * x / preview.width) + '% ' + (100 * y / preview.height) + '%');
clip.style.clipPath = 'polygon(' + polygonPoints.join(', ') + ')';
const layer = new Image();
layer.src = preview.overlay;
layer.alt = '发际线';
layer.className = 'overlay';
clip.append(layer);
movingLayer.append(clip);
const ns = 'http://www.w3.org/2000/svg';
const border = document.createElementNS(ns, 'svg');
border.classList.add('crop-border');
border.setAttribute('viewBox', '0 0 ' + preview.width + ' ' + preview.height);
border.setAttribute('aria-label', '发际线裁切边界');
const polygon = document.createElementNS(ns, 'polygon');
polygon.setAttribute('points', preview.corners.map(point => point.join(',')).join(' '));
border.append(polygon);
movingLayer.append(border);
stack.append(movingLayer);
}
figure.append(stack);
return figure;
}
async function loadCroppedPreviews(data, selected) {
const formData = new FormData();
for (const [key, value] of data) formData.set(key, value);
formData.set('hair_style', selected.join(','));
formData.set('normalize_image', 'true');
formData.set('generate_grow_image', 'false');
const reply = await fetch('/api/v1/hairline/generate-52', { method: 'POST', body: formData });
const body = await reply.json();
if (!reply.ok || body.code !== 0) {
throw new Error(body.message || 'HTTP ' + reply.status);
}
const preview = body.data;
const base = imageSource(preview.input_image_url, preview.input_image_base64, 'image/jpeg');
const { width, height } = preview;
const { corners, landmarks } = window.hairlineReferenceBox(preview.guide_landmarks);
// Convert the interface 22 scale (10% of brow-to-chin distance) to a percentage of the image height.
const maxShift = 100 * 0.10 * Math.abs(landmarks[152][1] - landmarks[9][1]) / height;
const items = preview.hairline_images.map(item => {
const overlay = imageSource(item.image_middle_url, item.image_middle_base64, 'image/png');
return { order: item.order, ...((base && overlay)
? { base, corners, width, height, maxShift, overlay }
: { error: '接口 52 未返回完整预览图片' }) };
});
return { items, framing: preview.framing };
}
function makePreviewPanels(items, gender) {
const names = styleNames[gender];
return new Map(items.filter(item => item.base).map(item => [
String(item.order), imagePanel('#' + item.order + ' · ' + names[item.order - 1], item.base, item),
]));
}
async function replacePreviewBases(images, panels, framing, fallback = false) {
const byOrder = new Map(images.map(item => [String(item.order), item]));
const targets = [...panels].flatMap(([order, panel]) => {
const image = byOrder.get(order) || (fallback ? images[0] : undefined);
return image ? [[image, panel]] : [];
});
if (!targets.length) return 0;
const { source_to_processed: matrix, source_size: source, output_size: output } = framing || {};
if (!matrix || !source || !output) return targets.length;
const [[a, c, e], [b, d, f]] = matrix;
const updated = await Promise.all(targets.map(async ([item, panel]) => {
const base = panel.querySelector('.stack > img');
if (!base) return false;
try {
const generated = new Image();
generated.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
await generated.decode();
const canvas = document.createElement('canvas');
canvas.width = output.width;
canvas.height = output.height;
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', base.alt + ' · 默认高度生发底图');
const ctx = canvas.getContext('2d', { alpha: false });
ctx.setTransform(a, b, c, d, e, f);
ctx.drawImage(generated, 0, 0, source.width, source.height);
base.replaceWith(canvas);
return true;
} catch (error) {
return false;
}
}));
return updated.filter(ok => !ok).length;
}
function renderResult(item, submittedHeight, gender) {
const tile = document.createElement('figure');
tile.className = 'result-tile';
const image = new Image();
image.loading = 'lazy';
image.src = imageSource(item.grown_image_url, item.grown_image_base64, 'image/jpeg');
const name = styleNames[gender][item.order - 1] || item.hairline_type;
const style = '#' + item.order + ' ' + name + ' · ' + (gender === 'male' ? '男性' : '女性');
const details = (densityLabels[item.hair_density] || item.hair_density) + ' · 高度 ' + submittedHeight;
image.alt = style + ',' + details;
const caption = document.createElement('figcaption');
const title = document.createElement('strong');
title.textContent = style;
const parameters = document.createElement('span');
parameters.textContent = details;
caption.append(title, parameters);
tile.append(image, caption);
return tile;
}
async function requestGrowth(data) {
const reply = await fetch('/api/v1/hair/grow-22', { method: 'POST', body: data });
const body = await reply.json();
if (!reply.ok || body.code !== 0) {
throw new Error(body.message || 'HTTP ' + reply.status);
}
return body;
}
function updatePreviewHeight() {
document.getElementById('heightValue').value = height.value;
previews.style.setProperty('--height-adjustment', height.value);
}
height.addEventListener('input', updatePreviewHeight);
heightPresets.forEach(preset => preset.addEventListener('change', () => {
if (preset.checked) {
height.value = preset.value;
updatePreviewHeight();
}
}));
updatePreviewHeight();
function renderSourceStyles() {
renderStyleChoices(styles, generateForm.elements.gender.value, ['1']);
}
generateForm.elements.gender.addEventListener('change', renderSourceStyles);
renderSourceStyles();
sourceFields.addEventListener('change', () => {
prepared = undefined;
heightEditor.hidden = true;
firstRunHint.hidden = false;
previews.replaceChildren();
results.replaceChildren();
response.hidden = true;
statusBar.textContent = '';
generationPreviewStatus.textContent = '';
submit.disabled = false;
height.value = '0';
heightPresets.forEach(preset => { preset.checked = false; });
updatePreviewHeight();
});
async function refreshStylePreviews(selected) {
const current = prepared;
if (!current) return;
const revision = ++previewRevision;
const showSelected = () => previews.replaceChildren(...selected.flatMap(order =>
current.panels.has(order) ? [current.panels.get(order)] : []));
showSelected();
generationPreviewStatus.dataset.error = 'false';
generationPreviewStatus.textContent = selected.length ? '' : '请至少选择一种发际线形状。';
const missing = selected.filter(order => !current.panels.has(order));
submit.disabled = !selected.length || !!missing.length;
if (!missing.length) return;
generationPreviewStatus.textContent = '正在更新发际线预览…';
try {
const { items, framing } = await loadCroppedPreviews(current.data, missing);
const panels = makePreviewPanels(items, current.data.get('gender'));
const unmatched = await replacePreviewBases(current.defaultImages, panels, framing, true);
if (current !== prepared || revision !== previewRevision) return;
for (const [order, panel] of panels) current.panels.set(order, panel);
showSelected();
if (missing.some(order => !panels.has(order))) {
throw new Error('接口 52 未返回全部所选形状。');
}
generationPreviewStatus.textContent = unmatched ? '部分生成图无法对齐,已显示对应的裁切原照。' : '';
} catch (error) {
if (current !== prepared || revision !== previewRevision) return;
generationPreviewStatus.dataset.error = 'true';
generationPreviewStatus.textContent = '更新发际线预览失败:' + error.message;
} finally {
if (current === prepared && revision === previewRevision) submit.disabled = false;
}
}
styles.addEventListener('change', () => {
refreshStylePreviews(new FormData(generateForm).getAll('hair_style'));
});
generateForm.addEventListener('submit', async (event) => {
event.preventDefault();
if (submit.disabled) return;
const data = new FormData(generateForm);
const selectedStyles = data.getAll('hair_style');
if (!selectedStyles.length) {
statusBar.dataset.error = 'true';
statusBar.textContent = '请至少选择一种发际线形状。';
return;
}
const densities = data.getAll('hair_density');
if (!densities.length) {
statusBar.dataset.error = 'true';
statusBar.textContent = '请至少选择一档生发密度。';
return;
}
const selectedHeights = data.getAll('height_preset');
const firstRun = !prepared;
const submittedHeights = firstRun ? ['0'] : (selectedHeights.length ? selectedHeights : [data.get('hairline_height')]);
data.delete('height_preset');
data.set('hair_style', selectedStyles.join(','));
sourceFields.disabled = styleFields.disabled = submit.disabled = true;
statusBar.dataset.error = 'false';
results.replaceChildren();
response.hidden = true;
const reports = [];
const failedHeights = [];
const defaultImages = new Map();
const planned = submittedHeights.length * densities.length * selectedStyles.length;
let generated = 0;
const start = performance.now();
try {
statusBar.textContent = '正在准备照片…';
const file = firstRun
? await window.downscaleImageFile(data.get('image_file'))
: prepared.data.get('image_file');
data.set('image_file', file);
for (const [index, submittedHeight] of submittedHeights.entries()) {
statusBar.textContent = '正在生成 ' + (index + 1) + '/' + submittedHeights.length + ' 档 · 高度 ' + submittedHeight + '…';
// The slider is positive up, while the API uses positive image Y (down).
data.set('hairline_height', String(-Number(submittedHeight)));
try {
const body = await requestGrowth(data);
const images = body.data.results.filter(item => item.grown_image_url || item.grown_image_base64);
results.append(...images.map(item => renderResult(item, submittedHeight, data.get('gender'))));
generated += images.length;
if (firstRun) {
for (const item of images) {
if (!defaultImages.has(item.order) || item.hair_density === 'normal') {
const { order, grown_image_url, grown_image_base64 } = item;
defaultImages.set(order, { order, grown_image_url, grown_image_base64 });
}
}
}
reports.push(JSON.stringify({ height: Number(submittedHeight), response: body }, (key, value) =>
key.endsWith('_base64') && typeof value === 'string' ? '[图片已省略]' : value, 2));
} catch (error) {
failedHeights.push(submittedHeight);
reports.push(JSON.stringify({ height: Number(submittedHeight), error: error.message }, null, 2));
}
}
document.getElementById('json').textContent = '[\n' + reports.join(',\n') + '\n]';
response.hidden = false;
const seconds = ((performance.now() - start) / 1000).toFixed(1);
statusBar.dataset.error = String(generated !== planned);
statusBar.textContent = '生发成功 ' + generated + '/' + planned + ' 张 · ' + seconds + ' 秒'
+ (failedHeights.length ? ' · 请求失败的高度 ' + failedHeights.join('、') : '');
if (firstRun && generated) {
const source = new FormData();
source.set('image_file', file);
source.set('gender', data.get('gender'));
prepared = { data: source, panels: new Map(), defaultImages: [...defaultImages.values()] };
heightEditor.hidden = false;
firstRunHint.hidden = true;
await refreshStylePreviews(selectedStyles);
}
} catch (error) {
statusBar.dataset.error = 'true';
statusBar.textContent = '生发失败:' + error.message;
} finally {
sourceFields.disabled = styleFields.disabled = submit.disabled = false;
}
});
</script>
</body>
</html>
+2 -2
View File
@@ -100,8 +100,8 @@
<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>
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭高度定位的设计曲线和生发效果。</p>
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)× 0.45</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">
+2 -2
View File
@@ -12,11 +12,11 @@
<body>
<main>
<header class="page-header">
<div><div class="eyebrow">接口 52 / 规则验证</div><h1>发际线高度规则对比</h1><p>用同一张照片、同一个形状,查看旧规则与中下庭均值规则的位置差异。</p></div>
<div><div class="eyebrow">接口 52 / 规则验证</div><h1>发际线高度规则对比</h1><p>用同一张照片、同一个形状,查看旧规则与接口 52 高度规则的位置差异。</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><span class="pill">新规则</span><h2>目标上庭 =(中庭 + 下庭)× 0.45</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>
+20 -29
View File
@@ -8,11 +8,7 @@ from conftest import fixture
import app as app_module
# 测试自带固定密码,避免依赖 worker_config.json 的实际值(中间件运行期读模块全局)
app_module.ACCEPT_PASSWORDS = ["testpass"]
URL = "/api/v1/face/measure"
H = {"X-Internal-Token": "testpass"}
@pytest.fixture(scope="module")
@@ -22,18 +18,13 @@ def client():
yield c
def _post(client, fixture_name=None, headers=H, data=None, extra_files=None):
def _post(client, fixture_name=None, data=None, extra_files=None):
files = {}
if fixture_name:
files["image_file"] = (fixture_name, open(fixture(fixture_name), "rb"), "application/octet-stream")
if extra_files:
files.update(extra_files)
return client.post(URL, headers=headers, files=files or None, data=data)
def test_auth_missing_token_401(client):
r = _post(client, "frontal.jpg", headers={})
assert r.status_code == 401
return client.post(URL, files=files or None, data=data)
def _patch_health_ok(monkeypatch):
@@ -107,7 +98,7 @@ def test_tcp_probe_real_socket():
def test_param_none_provided_1007(client):
r = client.post(URL, headers=H)
r = client.post(URL)
assert r.json()["code"] == 1007
@@ -131,7 +122,7 @@ GROW = "/api/v1/hair/grow"
def test_grow_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
r = client.post(GROW, headers=H, files=files)
r = client.post(GROW, files=files)
assert r.json()["code"] == 1004
@@ -148,7 +139,7 @@ def test_grow_female_returns_5(client, monkeypatch):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
# hair_style 必填(cb1989c 起):指定全部 5 种发型以验证完整管线
r = client.post(GROW, headers=H, files=files, data={"gender": "female", "hair_style": "1,2,3,4,5"})
r = client.post(GROW, files=files, data={"gender": "female", "hair_style": "1,2,3,4,5"})
body = r.json()
assert body["code"] == 0, body
results = body["data"]["results"]
@@ -163,13 +154,13 @@ GROWB = "/api/v1/hair/grow-b"
def test_growb_missing_marked_1007(client):
r = client.post(GROWB, headers=H) # 一张图都没传
r = client.post(GROWB) # 一张图都没传
assert r.json()["code"] == 1007
def test_growb_no_line_1001(client):
files = {"marked_image_file": ("m.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
r = client.post(GROWB, headers=H, files=files) # 无划线 → 拒识
r = client.post(GROWB, files=files) # 无划线 → 拒识
assert r.json()["code"] == 1001
@@ -177,7 +168,7 @@ def test_growb_success(client, monkeypatch):
import hairline.comfyui as comfy
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
files = {"marked_image_file": ("m.jpg", open(fixture("marked_hairline.jpg"), "rb"), "application/octet-stream")}
body = client.post(GROWB, headers=H, files=files).json() # 只传划线图一张
body = client.post(GROWB, files=files).json() # 只传划线图一张
assert body["code"] == 0, body
d = body["data"]
assert d["hairline_type"] == "custom"
@@ -190,7 +181,7 @@ HLGEN = "/api/v1/hairline/generate"
def test_hairline5_restored_schema_has_only_historical_parameters(client):
schema = client.get("/openapi.json", headers=H).json()
schema = client.get("/openapi.json").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",
@@ -204,7 +195,7 @@ def test_hairline5_restored_schema_has_only_historical_parameters(client):
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")},
body = client.post(HLGEN, 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"]
@@ -225,13 +216,13 @@ def test_hairline5_restored_three_levels_and_original_coordinate_contract(client
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
assert client.post(HLGEN, files=files).json()["code"] == 1004
def test_hairline_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(HLGEN, headers=H, files=files, data={"gender": "female"}).json()["code"] == 1007
assert client.post(HLGEN, files=files, data={"gender": "female"}).json()["code"] == 1007
def test_hairline_gen_female(client, monkeypatch):
@@ -240,7 +231,7 @@ def test_hairline_gen_female(client, monkeypatch):
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(HLGEN, headers=H, files=files, data={"gender": "female", "hair_style": "1,3"}).json()
body = client.post(HLGEN, files=files, data={"gender": "female", "hair_style": "1,3"}).json()
assert body["code"] == 0, body
d = body["data"]
imgs = d["hairline_images"]
@@ -279,7 +270,7 @@ 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()
schema = client.get("/openapi.json").json()
body = schema["paths"][HLGEN52]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]
body = schema["components"]["schemas"][body["$ref"].rsplit("/", 1)[-1]]
fields = body["properties"]
@@ -310,7 +301,7 @@ def test_hairline52_legacy_request_keeps_fields_and_original_coordinates(client,
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}
kwargs = {"data": form}
if image_kind == "file":
kwargs["files"] = {"image_file": ("frontal.jpg", raw, "image/jpeg")}
elif image_kind == "base64":
@@ -385,13 +376,13 @@ def test_hairline52_height_compare_uses_same_new_rule(client, monkeypatch, norma
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
assert client.post(HLGEN52, 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
assert client.post(HLGEN52, files=files, data={"gender": "female"}).json()["code"] == 1007
def test_hairline52_gen_female(client, monkeypatch):
@@ -400,7 +391,7 @@ def test_hairline52_gen_female(client, monkeypatch):
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,
body = client.post(HLGEN52, files=files,
data={"gender": "female", "hair_style": "1,3", "normalize_image": "true"}).json()
assert body["code"] == 0, body
d = body["data"]
@@ -422,7 +413,7 @@ def test_hairline52_gen_female(client, monkeypatch):
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)
(rule["middle_height_px"] + rule["lower_height_px"]) * 0.45)
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"
@@ -512,6 +503,6 @@ def _assert_vertical_lines(data, variant):
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()
body = client.post("/api/v1/face/measure-v2", files=files).json()
assert body["code"] == 0, body
_assert_vertical_lines(body["data"], "v6")
+6 -2
View File
@@ -2,11 +2,15 @@
import asyncio
import importlib
import pytest
from starlette.requests import Request
def test_legacy_hairline_forwarding(monkeypatch):
name, path = "hairline_generate", "/api/v1/hairline/generate"
@pytest.mark.parametrize(("name", "path"), [
("hairline_generate", "/api/v1/hairline/generate"),
("hair_grow_22", "/api/v1/hair/grow-22"),
])
def test_hairline_forwarding(monkeypatch, name, path):
gateway = importlib.import_module("gateway.app")
request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
calls = []
+91
View File
@@ -0,0 +1,91 @@
import base64
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
import app as worker
@pytest.mark.parametrize(("densities", "labels"), [
(["dense"], ["适中偏多"]),
(["sparse", "normal", "dense"], ["稀疏", "正常", "适中偏多"]),
([], []),
])
def test_interface22_selects_prompts_and_aligns_output_size(monkeypatch, densities, labels):
from hairline import service
prompts = []
rounded_png = cv2.imencode(".png", np.zeros((15, 13, 3), np.uint8))[1].tobytes()
def generate(image, styles, defaults, *, prompt, hairline_height, **kwargs):
assert styles == [1, 3]
assert hairline_height == -1
prompts.append(prompt)
return [
{"order": order, "hairline_type": key, "grown_png": rounded_png,
"overlay": np.zeros((16, 19, 4), np.uint8)}
for order, key in [(1, "ellipse"), (3, "heart")]
]
monkeypatch.setattr(service, "generate_grow_results_swap", generate)
image = cv2.imencode(".png", np.zeros((16, 19, 3), np.uint8))[1].tobytes()
form = {"gender": "female", "hair_style": "1,3", "hairline_height": "-1"}
if densities:
form["hair_density"] = densities
client = TestClient(worker.app, client=("127.0.0.1", 50000))
response = client.post("/api/v1/hair/grow-22", data=form,
files={"image_file": ("input.png", image, "image/png")})
if not densities:
assert response.status_code == 422
assert prompts == []
return
assert response.status_code == 200
assert prompts == [f"填充遮罩区域的头发,发量{label},人物增加一点磨皮" for label in labels]
results = response.json()["data"]["results"]
assert [(item["hair_density"], item["order"]) for item in results] == [
(density, order) for density in densities for order in (1, 3)
]
for item in results:
jpg = np.frombuffer(base64.b64decode(item["grown_image_base64"]), np.uint8)
assert cv2.imdecode(jpg, cv2.IMREAD_COLOR).shape[:2] == (16, 19)
@pytest.mark.parametrize("dy", [-5, 5])
def test_height_moves_paste_and_redraw_band_after_swap(monkeypatch, dy):
from face_analysis import hairline_grow as grow
image = np.zeros((80, 80, 3), np.uint8)
source_mask = np.zeros((80, 80), dtype=bool)
source_mask[25:45, 20:60] = True
inner = np.array([[20, 42], [59, 42]])
outer = np.array([[20, 32], [59, 32]])
upper = np.ones_like(source_mask)
monkeypatch.setattr(grow.detector, "detect", lambda image: object())
monkeypatch.setattr(grow, "estimate_scale_factor", lambda *args: 50)
monkeypatch.setattr(grow, "compute_mask", lambda *args, **kwargs: (
source_mask, {"_inner_pts": inner, "_outer_pts": outer, "_upper_mask": upper,
"mask_pixels": int(source_mask.sum())}))
def swap(original, hairline_id, is_hr, ext_mask, denoising_strength, **kwargs):
assert original is image
assert ext_mask is source_mask
generated = np.zeros_like(image)
generated[30, :, :] = 180
return generated
def composite(original, generated, mask, *args, **kwargs):
assert np.flatnonzero(mask[:, 40]).tolist() == list(range(25 + dy, 45 + dy))
assert np.flatnonzero(generated[:, 40, 0]).tolist() == [30 + dy]
return np.where(mask[..., None], generated, original), mask.astype(np.float32)
def encode_red_mask(mask, h, w):
assert np.flatnonzero(mask[:, 40]).tolist() == list(range(27 + dy, 38 + dy))
return "verified"
monkeypatch.setattr(grow, "_call_swap", swap)
monkeypatch.setattr(grow, "_composite", composite)
monkeypatch.setattr(grow, "_red_mask_b64", encode_red_mask)
result = grow.generate_hairline_redraw(image, "chang_tuoyuan", paste_dy=dy)
assert result["steps"]["redraw_band_mask_base64"] == "verified"
+6 -6
View File
@@ -16,21 +16,21 @@ def _landmarks(width=320, height=400):
return lm
def test_target_uses_mean_courts_without_old_estimation_ratio():
def test_target_is_lowered_by_five_percent_of_brow_to_chin():
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)
assert rule["target_upper_height_px"] == pytest.approx(63, abs=1e-4)
assert rule["target_center"]["y"] == pytest.approx(117, 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["target_upper_height_px"] == pytest.approx(63.09)
assert rule["target_center"]["y"] == pytest.approx(118.21)
assert rule["target_upper_height_cm"] == pytest.approx(3.1545)
assert rule["measurement_source"] == "face_measure"
+10 -14
View File
@@ -66,22 +66,21 @@ def test_rewritten_peer_or_invalid_policy_fail_closed(policy):
assert not access.check_access(request("127.0.0.1"))[0]
def test_worker_guard_covers_all_interfaces_before_auth(policy, monkeypatch):
def test_worker_guard_covers_all_interfaces(policy):
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/hairline/generate-52/').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.post('/api/v1/hairline/generate').status_code == 403
assert client.post('/api/v1/face/measure').status_code == 403
assert client.post('/api/v1/redraw').status_code == 403
assert client.post('/api/v1/hair/grow-22').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
assert client.post('/api/v1/hairline/generate-52').json()['code'] == 1004
assert client.post('/api/v1/hair/grow-22').status_code == 422
def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
@@ -105,16 +104,13 @@ def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
("/api/v1/hairline/generate-52", 1004),
("/api/v1/face/b-end-points", 1007),
])
def test_52_and_72_use_worker_transport_auth_not_client_bearer(policy, monkeypatch, path, expected_code):
def test_52_and_72_accept_allowlisted_peer_without_token(policy, path, expected_code):
import app as worker
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
monkeypatch.setattr(worker, "ACCEPT_PASSWORDS", ["access-test"])
client = TestClient(worker.app, client=("49.232.51.171", 50000))
try:
denied = client.post(path, headers={"Authorization": "Bearer client-access-token"})
assert denied.status_code == 401 and denied.json()["code"] == 1009
accepted = client.post(path, headers={"X-Internal-Token": "access-test"})
accepted = client.post(path)
assert accepted.status_code == 200 and accepted.json()["code"] == expected_code
finally:
client.close()
+1 -2
View File
@@ -1,6 +1,5 @@
{
"_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"],
"_comment": "复制为 worker_config.json(不入 git)。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 已迁到网关配置。",
"comfyui_password": "your-comfyui-basic-auth-password",
"health": {
"min_free_gb": 5,