feat: 新增接口22生发控制与测试页
This commit is contained in:
@@ -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 重绘也复用它。
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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端生发"""
|
||||
|
||||
@@ -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
@@ -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 仅为调试页附带旧规则叠图。
|
||||
|
||||
@@ -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")"
|
||||
|
||||
|
||||
@@ -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);}});
|
||||
|
||||
@@ -268,7 +268,7 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<div class="card" id="if52">
|
||||
<h2>52. 发际线新规则与裁切 <span class="badge post">POST</span> <code>/api/v1/hairline/generate-52</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">仅部署在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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user