diff --git a/CLAUDE.md b/CLAUDE.md index 13a1db5..7876c94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 重绘也复用它。 diff --git a/app.py b/app.py index 1a99e23..3e88752 100644 --- a/app.py +++ b/app.py @@ -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:仍走原生发管线。 - 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) - 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) - 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), - "hairline_type": p["hairline_type"], - "order": p["order"], - }) + 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=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, density_prompt, hair_styles, + unet_name=flux_model, hairline_height=hairline_height) + if items is None: + return err(1001, "无法识别人像") + + for p in items: + 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": { diff --git a/docs/实现说明.md b/docs/实现说明.md index ed09170..762400d 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -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`。 diff --git a/docs/接口IP白名单.md b/docs/接口IP白名单.md index 4f09fd8..4064540 100644 --- a/docs/接口IP白名单.md +++ b/docs/接口IP白名单.md @@ -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、转发头伪造、热更新、全接口拦截与网关不受限制。 diff --git a/docs/接口文档.md b/docs/接口文档.md index 71694d2..1b01506 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -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}, diff --git a/face_analysis/hairline_grow.py b/face_analysis/hairline_grow.py index 29c4349..6932c5b 100644 --- a/face_analysis/hairline_grow.py +++ b/face_analysis/hairline_grow.py @@ -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: diff --git a/gateway/app.py b/gateway/app.py index 5e85e16..8b13889 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -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端生发""" diff --git a/hairline/height_rule.py b/hairline/height_rule.py index 63184ee..1291a38 100644 --- a/hairline/height_rule.py +++ b/hairline/height_rule.py @@ -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, diff --git a/hairline/service.py b/hairline/service.py index 74434a8..a7d243e 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -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,18 +285,23 @@ 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: - black = load_texture_rgba(_black_texture_path(white_path)) - marked, mask = build_inpaint_mask( - image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black) + 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) m_s, msk_s, gsc = _prep_comfy_input(marked, mask) buf = io.BytesIO() compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1) @@ -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,9 +474,13 @@ def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str, """ try: h, w = image_bgr.shape[:2] - black = load_texture_rgba(_black_texture_path(white_path)) - marked, mask = build_inpaint_mask( - image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black) + 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) m_s, msk_s, gsc = _prep_comfy_input(marked, mask) buf = io.BytesIO() compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1) @@ -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 仅为调试页附带旧规则叠图。 diff --git a/run_worker.sh b/run_worker.sh index e91f538..b7bd04c 100755 --- a/run_worker.sh +++ b/run_worker.sh @@ -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")" diff --git a/static/hairline_height_compare.js b/static/hairline_height_compare.js index ebcae89..f9f52ad 100644 --- a/static/hairline_height_compare.js +++ b/static/hairline_height_compare.js @@ -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=`
${group.gender==="female"?"女性":"男性"}发际线

${esc(label)} ${esc(result.hairline_type)}

目标上庭 (${fixed(rule.middle_height_px)} + ${fixed(rule.lower_height_px)}) ÷ 2 = ${fixed(rule.target_upper_height_px)} px

新 y ${fixed(p.new_center.y)}${p.shift_y_px<0?"上移":"下移"} ${fixed(Math.abs(p.shift_y_px))} px
${mode==="overlay"?"橙色旧线 / 青色新线":"新规则 · 中下庭均值"}
中心误差 ${fixed(p.target_error_px)} px原图 ↗
`; + card.innerHTML=`
${group.gender==="female"?"女性":"男性"}发际线

${esc(label)} ${esc(result.hairline_type)}

${esc(rule.formula || "目标上庭")} · ${fixed(rule.target_upper_height_px)} px

新 y ${fixed(p.new_center.y)}${p.shift_y_px<0?"上移":"下移"} ${fixed(Math.abs(p.shift_y_px))} px
${mode==="overlay"?"橙色旧线 / 青色新线":"接口 52 设计线"}
中心误差 ${fixed(p.target_error_px)} px原图 ↗
`; 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);}}); diff --git a/static/integration.html b/static/integration.html index 6d1d867..26aad52 100644 --- a/static/integration.html +++ b/static/integration.html @@ -268,7 +268,7 @@ console.log(features.gene_style); // "自然型"

52. 发际线新规则与裁切  POST  /api/v1/hairline/generate-52

-

仅部署在GPU worker(8187),本次不新增网关路由。中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。

+

GPU Worker(8187)提供此接口。发际线中点位于眉心上方中下庭总高度的 45%,支持 9→152 扶正、人像裁切和矩形发际线预览。接口 5 提供原有三档预览。

基础图片参数、gender、hair_style、use_mask、prompt、generate_grow_image沿用接口5。新增可选normalize_image,默认false,传true开启扶正与裁切;测试页显式开启。

图片返回*_base64,空图片保留字段并返回null。另有height_rule、framing、guide_landmarks、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。

52号规则测试页 · 52号30案例对比页

@@ -335,6 +335,7 @@ console.log(features.gene_style); // "自然型" 接口测试页功能 1. 四庭七眼/static/test_interface1.html上传照片 → 原图+标注叠加,底图/标注开关,指标卡片 2. C端生发/static/test_interface2.html上传+性别 → 方案一覧(原图/叠加/生发),双图对比 + 22. 生发密度与发际线高度本机 Worker 测试页选择密度和高度,对照发际线预览与生发图 3. B端生发/static/test_interface3.html划线图上传 → 生发效果图 4. 用户特征/static/test_interface4.html上传照片 → 6项面部特征 + 原始JSON 5. 发际线PNG/static/test_interface5.html上传+性别 → 发际线方案+中心点坐标 diff --git a/static/test_interface22.html b/static/test_interface22.html new file mode 100644 index 0000000..1512a1b --- /dev/null +++ b/static/test_interface22.html @@ -0,0 +1,451 @@ + + + + + +接口 22 · 生发测试 + + + + + +
+

接口 22 生发测试

+
POST /api/v1/hair/grow-22
+
+
+
+ + +
+
+
+ 发际线形状(可多选) +
+

+
+

首次按默认高度 0 生图,出图后可预览和调整发际线高度。

+ +
+ 生发密度(至少选择一档) +
+ + + +
+
+ +

+
+
+ +
+ + + diff --git a/static/test_interface52.html b/static/test_interface52.html index e56ff29..8e07e1e 100644 --- a/static/test_interface52.html +++ b/static/test_interface52.html @@ -100,8 +100,8 @@

💈 接口52 — 发际线 PNG 生成 测试

-

上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。

-
目标上庭高度 =(中庭高度 + 下庭高度)÷ 2查看旧 / 新规则对比 →
上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。
+

上传正面照,选择性别与发型,查看按中下庭高度定位的设计曲线和生发效果。

+
目标上庭高度 =(中庭高度 + 下庭高度)× 0.45查看旧 / 新规则对比 →
上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。
diff --git a/static/test_interface52_compare.html b/static/test_interface52_compare.html index 8290b42..b5ec876 100644 --- a/static/test_interface52_compare.html +++ b/static/test_interface52_compare.html @@ -12,11 +12,11 @@
-
新规则

目标上庭 =(中庭 + 下庭)÷ 2

从眉心向上定位,所有形状的中点对齐同一高度。各发型保留自身轮廓,叠加在扶正后的底图上。

+
新规则

目标上庭 =(中庭 + 下庭)× 0.45

从眉心向上定位,所有形状的中点对齐同一高度。各发型保留自身轮廓,叠加在扶正后的底图上。

旧规则:根据头发边界和模板定位
新规则:根据中庭、下庭定位
虚线:新规则的目标高度
查看已扶正的标准图与裁切参数
正在加载标准图参数…
diff --git a/tests/test_api.py b/tests/test_api.py index 4d12818..656ffef 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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") diff --git a/tests/test_gateway_hairline_routes.py b/tests/test_gateway_hairline_routes.py index bf592dc..0f14958 100644 --- a/tests/test_gateway_hairline_routes.py +++ b/tests/test_gateway_hairline_routes.py @@ -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 = [] diff --git a/tests/test_hair_grow_22.py b/tests/test_hair_grow_22.py new file mode 100644 index 0000000..d439fae --- /dev/null +++ b/tests/test_hair_grow_22.py @@ -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" diff --git a/tests/test_hairline_height.py b/tests/test_hairline_height.py index bdd91e2..5b9fde2 100644 --- a/tests/test_hairline_height.py +++ b/tests/test_hairline_height.py @@ -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" diff --git a/tests/test_ip_access.py b/tests/test_ip_access.py index 16d4a9c..3681aa9 100644 --- a/tests/test_ip_access.py +++ b/tests/test_ip_access.py @@ -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() diff --git a/worker_config.example.json b/worker_config.example.json index 9a13911..b93bcd7 100644 --- a/worker_config.example.json +++ b/worker_config.example.json @@ -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,