接口 22 生发测试
+POST /api/v1/hair/grow-22diff --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=`
目标上庭 (${fixed(rule.middle_height_px)} + ${fixed(rule.lower_height_px)}) ÷ 2 = ${fixed(rule.target_upper_height_px)} px
${esc(rule.formula || "目标上庭")} · ${fixed(rule.target_upper_height_px)} px
/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。
POST /api/v1/hair/grow-22上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。
- +上传正面照,选择性别与发型,查看按中下庭高度定位的设计曲线和生发效果。
+用同一张照片、同一个形状,查看旧规则与中下庭均值规则的位置差异。
用同一张照片、同一个形状,查看旧规则与接口 52 高度规则的位置差异。
从眉心向上定位,所有形状的中点对齐同一高度。各发型保留自身轮廓,叠加在扶正后的底图上。
从眉心向上定位,所有形状的中点对齐同一高度。各发型保留自身轮廓,叠加在扶正后的底图上。