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 格式不支持。
|
- **统一信封**:`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_*`。
|
- **图片三选一**:每个接口 `image_file`(multipart) / `image_url` / `image_base64`(带 `data:` 前缀)严格互斥,由 `resolve_image_bytes()` 统一处理(0 个或多个 → 1007)。接口3 是 `marked_image_*`。
|
||||||
- **阻塞算法进线程池**:所有推理 handler 用 `run_in_threadpool(...)`,避免卡事件循环。
|
- **阻塞算法进线程池**:所有推理 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`。
|
- **发型序号映射**:`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 重绘也复用它。
|
- **接口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。
|
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
|
||||||
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
||||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验
|
Worker 通过 IP 白名单控制动态路由访问。
|
||||||
(深检:磁盘水位 + 下游依赖 TCP 探测,异常返回 503 degraded,见 _load_health_cfg)。
|
`/health` 检查磁盘水位和下游依赖,异常返回 503 degraded。
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
@@ -14,7 +14,7 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Literal, Optional
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
@@ -40,25 +40,6 @@ SAMPLE_IMAGE_URL = f"{BASE_URL}/static/sample.jpg"
|
|||||||
_STATE = {"ready": False}
|
_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
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
"""启动时加载模型单例(detector 必需,segmenter 尽力而为),就绪后才放行 /health。"""
|
"""启动时加载模型单例(detector 必需,segmenter 尽力而为),就绪后才放行 /health。"""
|
||||||
@@ -142,12 +123,6 @@ app = FastAPI(
|
|||||||
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
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 问题)
|
# change_hair 代理路由(解决 CORS 问题)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -187,28 +162,12 @@ async def proxy_hair_color(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def internal_token_auth(request: Request, call_next):
|
async def enforce_ip_allowlist(request: Request, call_next):
|
||||||
"""worker 内网鉴权:/api/* 必须带正确的 X-Internal-Token,否则 401。
|
|
||||||
|
|
||||||
密码列表来自 worker 配置(accept_passwords)。未配置任何密码时放行(仅便于
|
|
||||||
本地起步),生产务必通过 worker_config.json / 环境变量配置密码。
|
|
||||||
"""
|
|
||||||
path = request.url.path
|
|
||||||
from ip_access import rejection
|
from ip_access import rejection
|
||||||
|
|
||||||
denied = rejection(request)
|
denied = rejection(request)
|
||||||
if denied is not None:
|
if denied is not None:
|
||||||
return denied
|
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)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -293,11 +252,13 @@ def _rgba_png_b64(rgba) -> str:
|
|||||||
return base64.b64encode(buf.getvalue()).decode()
|
return base64.b64encode(buf.getvalue()).decode()
|
||||||
|
|
||||||
|
|
||||||
def _png_to_jpg_b64(png_bytes) -> str:
|
def _png_to_jpg_b64(png_bytes, output_size: tuple[int, int] | None = None) -> str:
|
||||||
"""ComfyUI 返回的 PNG 字节 → 重编码为 JPG base64;无法解码则原样透传。"""
|
"""把 ComfyUI 结果编码为 JPG,可选对齐到 (宽, 高)。"""
|
||||||
img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
|
img = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
|
||||||
if img is None:
|
if img is None:
|
||||||
return base64.b64encode(png_bytes).decode()
|
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)
|
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"),
|
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"),
|
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)
|
# 1. gender 必填校验(非法/缺失 → 1004)
|
||||||
if gender not in ("male", "female"):
|
if gender not in ("male", "female"):
|
||||||
return err(1004, "gender 必填且只能为 male / female")
|
return err(1004, "gender 必填且只能为 male / female")
|
||||||
@@ -1246,30 +1236,40 @@ async def hair_grow(
|
|||||||
try:
|
try:
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
|
||||||
# 预览 + 生发/换发型 都是阻塞且较慢,放线程池避免卡住事件循环。
|
results = []
|
||||||
# female:换发型 + Flux-2 整帧重绘(= 接口12 final 管线);male:仍走原生发管线。
|
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":
|
if gender == "female":
|
||||||
from hairline.service import generate_grow_results_swap
|
from hairline.service import generate_grow_results_swap
|
||||||
items = await run_in_threadpool(
|
items = await run_in_threadpool(
|
||||||
generate_grow_results_swap, image, hair_styles, _V2_FINAL_DEFAULTS,
|
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:
|
else:
|
||||||
from hairline.service import generate_grow_results
|
from hairline.service import generate_grow_results
|
||||||
items = await run_in_threadpool(
|
items = await run_in_threadpool(
|
||||||
generate_grow_results, image, gender, use_mask, prompt, hair_styles,
|
generate_grow_results, image, gender, use_mask, density_prompt, hair_styles,
|
||||||
unet_name=flux_model)
|
unet_name=flux_model, hairline_height=hairline_height)
|
||||||
if items is None:
|
if items is None:
|
||||||
return err(1001, "无法识别人像")
|
return err(1001, "无法识别人像")
|
||||||
|
|
||||||
results = []
|
|
||||||
for p in items:
|
for p in items:
|
||||||
results.append({
|
result = {
|
||||||
"image_base64": _rgba_png_b64(p["overlay"]), # 发际线曲线透明 PNG
|
"image_base64": _rgba_png_b64(p["overlay"]),
|
||||||
"grown_image_base64": (_png_to_jpg_b64(p["grown_png"]) # 生发图 JPG
|
"grown_image_base64": _png_to_jpg_b64(p["grown_png"], output_size) if p["grown_png"] else None,
|
||||||
if p["grown_png"] else None),
|
|
||||||
"hairline_type": p["hairline_type"],
|
"hairline_type": p["hairline_type"],
|
||||||
"order": p["order"],
|
"order": p["order"],
|
||||||
})
|
}
|
||||||
|
if density is not None:
|
||||||
|
result["hair_density"] = density
|
||||||
|
results.append(result)
|
||||||
return ok({"results": results})
|
return ok({"results": results})
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
logger.exception("接口2 处理异常")
|
logger.exception("接口2 处理异常")
|
||||||
@@ -1846,7 +1846,7 @@ async def _generate_hairline52_data(image, gender, hair_styles, use_mask=True,
|
|||||||
summary="接口52 发际线PNG生成",
|
summary="接口52 发际线PNG生成",
|
||||||
tags=["人脸分析"],
|
tags=["人脸分析"],
|
||||||
description=f"""
|
description=f"""
|
||||||
输入用户照片 + 性别 + 多选发型,各发型 middle 中点统一对齐到眉心上方(中庭+下庭)/2。
|
输入用户照片 + 性别 + 多选发型,各发型 middle 中点统一对齐到眉心上方(中庭+下庭)× 0.45。
|
||||||
返回设计曲线、生发图、目标高度与定位误差;high/low 旧字段保留为 null。
|
返回设计曲线、生发图、目标高度与定位误差;high/low 旧字段保留为 null。
|
||||||
|
|
||||||
{_image_fields_desc}
|
{_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": "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},
|
{"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,
|
"high_hairline_center_point": None,
|
||||||
"low_hairline_center_point": None,
|
"low_hairline_center_point": None,
|
||||||
"face_measure": {
|
"face_measure": {
|
||||||
|
|||||||
+7
-7
@@ -17,16 +17,16 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机
|
- **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` 手动起)。
|
`/health` 模型就绪才返回 200。监听 **8187**(systemd `hair-worker.service`,也可 `./run_worker.sh` 手动起)。
|
||||||
另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、
|
另有 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限制。
|
所有动态路径先校验直连来源IP:允许本机与网关`49.232.51.171`,其余返回HTTP403/code1010;`/static/`除外。配置见[`接口IP白名单.md`](接口IP白名单.md),网关侧不增加IP限制。
|
||||||
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
|
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
|
||||||
网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测,
|
网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测,
|
||||||
直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。
|
直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。
|
||||||
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
|
- **图片三选一**:所有接口图片入参 `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 映射(网关落盘改写,递归进数组)
|
### base64 → URL 映射(网关落盘改写,递归进数组)
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
|------|--------------------|----------|
|
|------|--------------------|----------|
|
||||||
| 1 | `annotated_image_base64` | `annotated_image_url` |
|
| 1 | `annotated_image_base64` | `annotated_image_url` |
|
||||||
| 6 | `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` |
|
| 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` |
|
| 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 | `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)|
|
`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)
|
### 接口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`开启;规则页显式开启,对比调试页默认开启。返回当前底图、原图及双向坐标矩阵。
|
- **预处理**:`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保持原逻辑。
|
- **模板定位**:原有502点/贴图生成形状,`hairline/height_rule.py` 将每种 middle 曲线沿面部向上方向平移、校准中心 Y,保留曲线形状与倾斜。此规则仅接口52使用,接口2保持原逻辑。
|
||||||
- **生发**:移动后的预览曲线直接生成黑线和重绘遮罩,再送ComfyUI,避免预览与黑模板位置不一致。
|
- **生发**:移动后的预览曲线直接生成黑线和重绘遮罩,再送ComfyUI,避免预览与黑模板位置不一致。
|
||||||
- **裁切叠加预览**:测试页新增“裁切发际线叠加”。`static/hairline_reference_box.js`共享同一个162/389/9/152参考框几何,按原图像素裁切middle透明图层,再叠加到处理后人像;四栏顺序为原图、middle、裁切发际线叠加、生发图。
|
- **裁切叠加预览**:测试页新增“裁切发际线叠加”。`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`,
|
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、
|
也有 systemd `change_hair-{webui,photo,hair}.service`)。不跑时接口2女1..5、接口11/12、
|
||||||
swapHair/hairColor 代理不可用。
|
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),仓库根有副本;手动启停用
|
- systemd 单元:`hair-worker.service`(8187) / `comfyui.service`(8188),仓库根有副本;手动启停用
|
||||||
`./run_worker.sh` / `./stop_worker.sh`。
|
`./run_worker.sh` / `./stop_worker.sh`。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# GPU worker 接口 IP白名单
|
# GPU worker 接口 IP白名单
|
||||||
|
|
||||||
仅GPU worker使用项目根目录的`ip_access.json`,不存在时仅允许本机。网关不启用IP限制。配置每次请求重新读取,修改后立即生效。现有`X-Internal-Token`鉴权继续有效。
|
仅GPU worker使用项目根目录的`ip_access.json`,不存在时仅允许本机。网关不启用IP限制。配置每次请求重新读取,修改后立即生效。Worker 动态接口以直连来源 IP 为准。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,6 @@ worker的所有动态路径统一保护,包括5号、52号、其他业务接
|
|||||||
|
|
||||||
修改`allowed_ips`后保存为有效JSON即可生效,无需重启;建议写入同目录临时文件再原子替换,避免编辑过程中出现半份JSON。文件不存在时仅允许本机,内容损坏时动态接口一律拒绝。更新程序时保留本机配置文件。
|
修改`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、转发头伪造、热更新、全接口拦截与网关不受限制。
|
验证命令:`./venv/bin/pytest tests/test_ip_access.py -q`,覆盖本机、精确白名单、CIDR、IPv6、转发头伪造、热更新、全接口拦截与网关不受限制。
|
||||||
|
|||||||
+24
-9
@@ -17,6 +17,7 @@
|
|||||||
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
|
| 1 四庭七眼测量 | POST | `/api/v1/face/measure` |
|
||||||
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
|
| 6 四庭七眼测量 v2 | POST | `/api/v1/face/measure-v2` |
|
||||||
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
|
| 2 C 端生发 | POST | `/api/v1/hair/grow` |
|
||||||
|
| 22 生发密度与发际线高度 | POST | `/api/v1/hair/grow-22` |
|
||||||
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
|
||||||
| 72 B 端五个关键点 | POST | `/api/v1/face/b-end-points` |
|
| 72 B 端五个关键点 | POST | `/api/v1/face/b-end-points` |
|
||||||
| 4 用户特征 | POST | `/api/v1/face/features` |
|
| 4 用户特征 | POST | `/api/v1/face/features` |
|
||||||
@@ -91,7 +92,6 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
|||||||
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
|
||||||
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
|
||||||
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
| 1008 | 图片格式不支持 | 非 JPG / PNG |
|
||||||
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
|
|
||||||
| 1010 | 来源IP不允许 | 仅GPU worker:直连IP不在本机/白名单内,或来源/白名单配置无效;HTTP403 |
|
| 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 端生发接口
|
## 接口 3:B 端生发接口
|
||||||
|
|
||||||
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
|
**说明**:医生/操作端在用户照片上用马克笔标注目标发际线后,**只需上传这一张划线图**。系统检测划线 →
|
||||||
@@ -476,7 +491,7 @@ GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.jso
|
|||||||
|
|
||||||
Worker 直连时图片字段使用 `_base64` 后缀。检测不到人脸返回 `1001`,非正面或标注所需点位无法取得时返回 `1003`,图片无法解码返回 `1008`。
|
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 生成接口
|
## 接口 52:发际线 PNG 生成接口
|
||||||
|
|
||||||
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
|
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)×0.45**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
|
||||||
|
|
||||||
**请求**:`POST /api/v1/hairline/generate-52`(仅GPU worker,端口8187;本次不在网关新增路由)
|
**请求**:`POST /api/v1/hairline/generate-52`(仅GPU worker,端口8187;本次不在网关新增路由)
|
||||||
|
|
||||||
@@ -769,12 +784,12 @@ Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只
|
|||||||
"order": 1,
|
"order": 1,
|
||||||
"height_placement": {
|
"height_placement": {
|
||||||
"old_center": {"x": 540.0, "y": 450.0},
|
"old_center": {"x": 540.0, "y": 450.0},
|
||||||
"new_center": {"x": 540.0, "y": 430.0},
|
"new_center": {"x": 540.0, "y": 461.0},
|
||||||
"shift_x_px": 0.0, "shift_y_px": -20.0, "target_error_px": 0.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,
|
"high_hairline_center_point": null,
|
||||||
"low_hairline_center_point": null,
|
"low_hairline_center_point": null,
|
||||||
"input_image_base64": "/9j/4AAQ...",
|
"input_image_base64": "/9j/4AAQ...",
|
||||||
@@ -783,14 +798,14 @@ Ft 两端保持横坐标并取平均高度,Zy 为 234 到 454 的距离。只
|
|||||||
"coordinate_space": "original_image",
|
"coordinate_space": "original_image",
|
||||||
"framing": null,
|
"framing": null,
|
||||||
"height_rule": {
|
"height_rule": {
|
||||||
"name": "court_average", "formula": "上庭 = (中庭 + 下庭) / 2",
|
"name": "court_average", "formula": "上庭 = (中庭 + 下庭) × 0.45",
|
||||||
"measurement_source": "hairline_landmarks",
|
"measurement_source": "hairline_landmarks",
|
||||||
"brow_center": {"x": 540.0, "y": 740.0},
|
"brow_center": {"x": 540.0, "y": 740.0},
|
||||||
"nose_bottom": {"x": 540.0, "y": 1050.0},
|
"nose_bottom": {"x": 540.0, "y": 1050.0},
|
||||||
"chin_tip": {"x": 540.0, "y": 1360.0},
|
"chin_tip": {"x": 540.0, "y": 1360.0},
|
||||||
"middle_height_px": 310.0, "lower_height_px": 310.0,
|
"middle_height_px": 310.0, "lower_height_px": 310.0,
|
||||||
"target_upper_height_px": 310.0, "target_upper_height_cm": null,
|
"target_upper_height_px": 279.0, "target_upper_height_cm": null,
|
||||||
"target_center": {"x": 540.0, "y": 430.0}
|
"target_center": {"x": 540.0, "y": 461.0}
|
||||||
},
|
},
|
||||||
"guide_landmarks": {
|
"guide_landmarks": {
|
||||||
"9": {"x": 540.0, "y": 740.0}, "150": {"x": 470.0, "y": 1340.0},
|
"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,
|
mb_levels, hairline_push_cm, hairline_edge, blend_method, color_match,
|
||||||
color_match_strength, mb_feather_px, transition_band_px,
|
color_match_strength, mb_feather_px, transition_band_px,
|
||||||
inpainting_fill, mask_blur, mask_dilate_scale, rid, render_viz=True,
|
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。
|
"""接口11 共享核心:遮罩(pushed)→生成→硬贴回→接缝融合,产出 ④ final。
|
||||||
|
|
||||||
不做任何重绘。返回中间产物 dict(供接口11 构造响应、接口12 取 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)
|
mask_dilate_scale=mask_dilate_scale, webui_steps=webui_steps)
|
||||||
t_swap = time.time() - t0
|
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:严格按遮罩硬贴回(无融合,用于对比)
|
# 步骤3:严格按遮罩硬贴回(无融合,用于对比)
|
||||||
hard_paste = image_bgr.copy()
|
hard_paste = image_bgr.copy()
|
||||||
hard_paste[mask_bool] = swap_result[mask_bool]
|
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,
|
inpainting_fill=1, mask_blur=11, mask_dilate_scale=1.0,
|
||||||
comfyui_prompt=None, beauty_alpha=0.6,
|
comfyui_prompt=None, beauty_alpha=0.6,
|
||||||
band_lo_mult=0.5, band_hi_mult=1.5, rid=None,
|
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,再取 ⑤-① 发际线重绘带
|
"""接口12 发际线带重绘。内部先跑接口11 核心拿到 ④ final,再取 ⑤-① 发际线重绘带
|
||||||
(外推↔内推之间、经 baseline 截断只留上部)作遮罩。
|
(外推↔内推之间、经 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,
|
mb_feather_px=mb_feather_px, transition_band_px=transition_band_px,
|
||||||
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
inpainting_fill=inpainting_fill, mask_blur=mask_blur,
|
||||||
mask_dilate_scale=mask_dilate_scale, rid=rid, render_viz=False,
|
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"]
|
final = core["final"]
|
||||||
mask_viz = core["mask_viz"]
|
mask_viz = core["mask_viz"]
|
||||||
w, h = core["w"], core["h"]
|
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}
|
redraw_info = {"enabled": False}
|
||||||
band_mask = None
|
band_mask = None
|
||||||
try:
|
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,
|
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)
|
lo_mult=band_lo_mult, hi_mult=band_hi_mult)
|
||||||
if band_mask.sum() < 30:
|
if band_mask.sum() < 30:
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ async def index():
|
|||||||
"test_pages": {
|
"test_pages": {
|
||||||
"if1_measure": "/static/test_interface1.html",
|
"if1_measure": "/static/test_interface1.html",
|
||||||
"if2_hair_grow": "/static/test_interface2.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",
|
"if3_hair_grow_b": "/static/test_interface3.html",
|
||||||
"if4_features": "/static/test_interface4.html",
|
"if4_features": "/static/test_interface4.html",
|
||||||
"if5_hairline": "/static/test_interface5.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")
|
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=["生发"])
|
@app.post("/api/v1/hair/grow-b", tags=["生发"])
|
||||||
async def hair_grow_b(request: Request):
|
async def hair_grow_b(request: Request):
|
||||||
"""接口3:B端生发"""
|
"""接口3:B端生发"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""接口52设计高度:上庭 = (中庭 + 下庭) / 2;不改变接口2/5的几何。"""
|
"""接口52设计高度:目标上庭为眉心到下巴垂直距离的 45%。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import cv2
|
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]:
|
if not np.isfinite([brow, nose, chin]).all() or not brow[1] < nose[1] < chin[1]:
|
||||||
raise ValueError("眉心、鼻翼下缘与下巴坐标异常,无法计算目标上庭高度")
|
raise ValueError("眉心、鼻翼下缘与下巴坐标异常,无法计算目标上庭高度")
|
||||||
middle, lower = float(nose[1] - brow[1]), float(chin[1] - nose[1])
|
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)
|
target_y = float(brow[1] - upper)
|
||||||
if not (0 <= brow[0] < width and 1 <= target_y < height - 1):
|
if not (0 <= brow[0] < width and 1 <= target_y < height - 1):
|
||||||
raise ValueError("按中下庭均值计算的目标发际线超出图片范围,请使用完整正面照")
|
raise ValueError("按中下庭高度计算的目标发际线超出图片范围,请使用完整正面照")
|
||||||
def pt(p):
|
def pt(p):
|
||||||
return {"x": float(p[0]), "y": float(p[1])}
|
return {"x": float(p[0]), "y": float(p[1])}
|
||||||
return {
|
return {
|
||||||
"name": "court_average",
|
"name": "court_average",
|
||||||
"formula": "上庭 = (中庭 + 下庭) / 2",
|
"formula": "上庭 = (中庭 + 下庭) × 0.45",
|
||||||
"measurement_source": source,
|
"measurement_source": source,
|
||||||
"brow_center": pt(brow), "nose_bottom": pt(nose), "chin_tip": pt(chin),
|
"brow_center": pt(brow), "nose_bottom": pt(nose), "chin_tip": pt(chin),
|
||||||
"middle_height_px": middle, "lower_height_px": lower,
|
"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
|
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,
|
def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
|
||||||
prompt: str = None, hair_styles: list[int] | None = None,
|
prompt: str = None, hair_styles: list[int] | None = None,
|
||||||
workflow_path: str | None = None,
|
workflow_path: str | None = None,
|
||||||
unet_name: str | None = None):
|
unet_name: str | None = None,
|
||||||
|
hairline_height: float | None = None):
|
||||||
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
"""指定发际线类型:发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。
|
||||||
|
|
||||||
hair_styles(1-indexed 列表):指定生成哪几张发际线(按贴图排序)。female: 1..7,male: 1..7。
|
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 = []
|
results = []
|
||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
|
dy = _hairline_shift_px(ctx["landmarks"], h, hairline_height)
|
||||||
for order, (key, white_path) in items:
|
for order, (key, white_path) in items:
|
||||||
white = load_texture_rgba(white_path)
|
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:
|
if not use_mask:
|
||||||
grown_png = shared_grown
|
grown_png = shared_grown
|
||||||
else:
|
else:
|
||||||
grown_png = None
|
grown_png = None
|
||||||
try:
|
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))
|
black = load_texture_rgba(_black_texture_path(white_path))
|
||||||
marked, mask = build_inpaint_mask(
|
marked, mask = build_inpaint_mask(
|
||||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
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_defaults: dict,
|
||||||
redraw_max_side: int | None = None,
|
redraw_max_side: int | None = None,
|
||||||
unet_name: str | None = None,
|
unet_name: str | None = None,
|
||||||
prompt: str | None = None):
|
prompt: str | None = None,
|
||||||
|
hairline_height: float | None = None):
|
||||||
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 生发图。
|
"""接口2 女性专用:发际线透明叠图(同 generate_grow_results)+ 生发图。
|
||||||
|
|
||||||
生发图来源按发型分两路:
|
生发图来源按发型分两路:
|
||||||
@@ -340,6 +362,7 @@ def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | N
|
|||||||
|
|
||||||
results = []
|
results = []
|
||||||
h, w = image_bgr.shape[:2]
|
h, w = image_bgr.shape[:2]
|
||||||
|
dy = _hairline_shift_px(ctx["landmarks"], h, hairline_height)
|
||||||
|
|
||||||
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
|
# 重绘管线(swapHair + ComfyUI)统一降分辨率:真实照片 swap(SD WebUI)~5s、blend、ComfyUI
|
||||||
# 均随分辨率线性下降。overlay 预览仍用全分辨率;grown_png 最后放大回原尺寸。
|
# 均随分辨率线性下降。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)",
|
logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)",
|
||||||
w, h, _nw, _nh, eff_side)
|
w, h, _nw, _nh, eff_side)
|
||||||
|
|
||||||
|
paste_dy = round(dy * redraw_img.shape[0] / h)
|
||||||
for order, (key, white_path) in items:
|
for order, (key, white_path) in items:
|
||||||
white = load_texture_rgba(white_path)
|
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
|
grown_png = None
|
||||||
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
|
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)→ 走与男性一致的原生生发
|
# 无对应 change_hair LoRA(如 bigflower/clasicalflower)→ 走与男性一致的原生生发
|
||||||
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
|
logger.info("接口2 女 key=%s 无 chang_id,走原生生发(ComfyUI add_hair)", key)
|
||||||
grown_png = _grow_native_one(image_bgr, ctx, white_path,
|
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:
|
else:
|
||||||
try:
|
try:
|
||||||
import time as _t
|
import time as _t
|
||||||
_ts0 = _t.perf_counter()
|
_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()
|
_ts1 = _t.perf_counter()
|
||||||
steps = data.get("steps") or {}
|
steps = data.get("steps") or {}
|
||||||
# ④ final(接缝融合基底)+ ⑤-② 纯红遮罩 PNG
|
# ④ 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,
|
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 一致。
|
"""对单个发际线做原生生发(ComfyUI add_hair),与男性 generate_grow_results 一致。
|
||||||
|
|
||||||
接口2 女性新发型(bigflower/clasicalflower 等)无对应 change_hair LoRA,改走此路径:
|
接口2 女性新发型(bigflower/clasicalflower 等)无对应 change_hair LoRA,改走此路径:
|
||||||
@@ -447,6 +474,10 @@ def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
h, w = image_bgr.shape[:2]
|
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))
|
black = load_texture_rgba(_black_texture_path(white_path))
|
||||||
marked, mask = build_inpaint_mask(
|
marked, mask = build_inpaint_mask(
|
||||||
image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
|
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,
|
height_reference: dict | None = None,
|
||||||
compare_rules: bool = False,
|
compare_rules: bool = False,
|
||||||
precomputed_landmarks: np.ndarray | None = None):
|
precomputed_landmarks: np.ndarray | None = None):
|
||||||
"""接口52:每种 middle 曲线中点对齐到眉心上方 (中庭+下庭)/2。
|
"""接口52:每种 middle 曲线中点对齐到眉心上方 (中庭+下庭) × 0.45。
|
||||||
|
|
||||||
只读取 middle 贴图,high/low 保留空值。生发直接复用移动后的预览曲线,
|
只读取 middle 贴图,high/low 保留空值。生发直接复用移动后的预览曲线,
|
||||||
不再使用位置不同的黑模板。compare_rules 仅为调试页附带旧规则叠图。
|
不再使用位置不同的黑模板。compare_rules 仅为调试页附带旧规则叠图。
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
# 可用环境变量覆盖:
|
# 可用环境变量覆盖:
|
||||||
# HOST=127.0.0.1 PORT=9000 ./run_worker.sh
|
# HOST=127.0.0.1 PORT=9000 ./run_worker.sh
|
||||||
# FOREGROUND=1 ./run_worker.sh # 前台运行(调试用,Ctrl+C 退出)
|
# FOREGROUND=1 ./run_worker.sh # 前台运行(调试用,Ctrl+C 退出)
|
||||||
#
|
|
||||||
# 鉴权密码读 worker_config.json 的 accept_passwords,
|
|
||||||
# 也可用 WORKER_ACCEPT_PASSWORDS=逗号分隔 覆盖。
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
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 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;
|
const card=document.createElement("article");card.className="case-card";card.tabIndex=0;
|
||||||
card.dataset.styleKey=`${group.gender}:${result.order}`;
|
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);
|
strip.append(card);
|
||||||
card.addEventListener("click",event=>{if(!event.target.closest("a"))selectOld(group,result);});
|
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);}});
|
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">
|
<div class="card" id="if52">
|
||||||
<h2>52. 发际线新规则与裁切 <span class="badge post">POST</span> <code>/api/v1/hairline/generate-52</code></h2>
|
<h2>52. 发际线新规则与裁切 <span class="badge post">POST</span> <code>/api/v1/hairline/generate-52</code></h2>
|
||||||
<div class="card-body">
|
<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>基础图片参数、<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">图片返回<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>
|
<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><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>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>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>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>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
|
||||||
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
|
<tr><td>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>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>💈 接口52 — 发际线 PNG 生成 测试</h1>
|
<h1>💈 接口52 — 发际线 PNG 生成 测试</h1>
|
||||||
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p>
|
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭高度定位的设计曲线和生发效果。</p>
|
||||||
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)÷ 2</strong><a href="/static/test_interface52_compare.html">查看旧 / 新规则对比 →</a><br>上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。</div>
|
<div class="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">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<header class="page-header">
|
<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>
|
<a class="button secondary" href="/static/test_interface52.html">打开规则测试页 ↗</a>
|
||||||
</header>
|
</header>
|
||||||
<section class="rule-card" aria-label="规则说明">
|
<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>
|
<div class="rule-note"><span class="dot old"></span>旧规则:根据头发边界和模板定位<br><span class="dot new"></span>新规则:根据中庭、下庭定位<br><span class="dash"></span>虚线:新规则的目标高度</div>
|
||||||
</section>
|
</section>
|
||||||
<details class="framing-standard"><summary>查看已扶正的标准图与裁切参数</summary><div id="framingStandard">正在加载标准图参数…</div></details>
|
<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
|
import app as app_module
|
||||||
|
|
||||||
# 测试自带固定密码,避免依赖 worker_config.json 的实际值(中间件运行期读模块全局)
|
|
||||||
app_module.ACCEPT_PASSWORDS = ["testpass"]
|
|
||||||
|
|
||||||
URL = "/api/v1/face/measure"
|
URL = "/api/v1/face/measure"
|
||||||
H = {"X-Internal-Token": "testpass"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
@@ -22,18 +18,13 @@ def client():
|
|||||||
yield c
|
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 = {}
|
files = {}
|
||||||
if fixture_name:
|
if fixture_name:
|
||||||
files["image_file"] = (fixture_name, open(fixture(fixture_name), "rb"), "application/octet-stream")
|
files["image_file"] = (fixture_name, open(fixture(fixture_name), "rb"), "application/octet-stream")
|
||||||
if extra_files:
|
if extra_files:
|
||||||
files.update(extra_files)
|
files.update(extra_files)
|
||||||
return client.post(URL, headers=headers, files=files or None, data=data)
|
return client.post(URL, files=files or None, data=data)
|
||||||
|
|
||||||
|
|
||||||
def test_auth_missing_token_401(client):
|
|
||||||
r = _post(client, "frontal.jpg", headers={})
|
|
||||||
assert r.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def _patch_health_ok(monkeypatch):
|
def _patch_health_ok(monkeypatch):
|
||||||
@@ -107,7 +98,7 @@ def test_tcp_probe_real_socket():
|
|||||||
|
|
||||||
|
|
||||||
def test_param_none_provided_1007(client):
|
def test_param_none_provided_1007(client):
|
||||||
r = client.post(URL, headers=H)
|
r = client.post(URL)
|
||||||
assert r.json()["code"] == 1007
|
assert r.json()["code"] == 1007
|
||||||
|
|
||||||
|
|
||||||
@@ -131,7 +122,7 @@ GROW = "/api/v1/hair/grow"
|
|||||||
|
|
||||||
def test_grow_missing_gender_1004(client):
|
def test_grow_missing_gender_1004(client):
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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
|
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")}
|
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||||
# hair_style 必填(cb1989c 起):指定全部 5 种发型以验证完整管线
|
# 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()
|
body = r.json()
|
||||||
assert body["code"] == 0, body
|
assert body["code"] == 0, body
|
||||||
results = body["data"]["results"]
|
results = body["data"]["results"]
|
||||||
@@ -163,13 +154,13 @@ GROWB = "/api/v1/hair/grow-b"
|
|||||||
|
|
||||||
|
|
||||||
def test_growb_missing_marked_1007(client):
|
def test_growb_missing_marked_1007(client):
|
||||||
r = client.post(GROWB, headers=H) # 一张图都没传
|
r = client.post(GROWB) # 一张图都没传
|
||||||
assert r.json()["code"] == 1007
|
assert r.json()["code"] == 1007
|
||||||
|
|
||||||
|
|
||||||
def test_growb_no_line_1001(client):
|
def test_growb_no_line_1001(client):
|
||||||
files = {"marked_image_file": ("m.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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
|
assert r.json()["code"] == 1001
|
||||||
|
|
||||||
|
|
||||||
@@ -177,7 +168,7 @@ def test_growb_success(client, monkeypatch):
|
|||||||
import hairline.comfyui as comfy
|
import hairline.comfyui as comfy
|
||||||
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||||||
files = {"marked_image_file": ("m.jpg", open(fixture("marked_hairline.jpg"), "rb"), "application/octet-stream")}
|
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
|
assert body["code"] == 0, body
|
||||||
d = body["data"]
|
d = body["data"]
|
||||||
assert d["hairline_type"] == "custom"
|
assert d["hairline_type"] == "custom"
|
||||||
@@ -190,7 +181,7 @@ HLGEN = "/api/v1/hairline/generate"
|
|||||||
|
|
||||||
|
|
||||||
def test_hairline5_restored_schema_has_only_historical_parameters(client):
|
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"]
|
ref = schema["paths"][HLGEN]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"]
|
||||||
fields = schema["components"]["schemas"][ref.rsplit("/", 1)[-1]]["properties"]
|
fields = schema["components"]["schemas"][ref.rsplit("/", 1)[-1]]["properties"]
|
||||||
assert set(fields) == {"image_file", "image_url", "image_base64", "gender", "hair_style",
|
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):
|
def test_hairline5_restored_three_levels_and_original_coordinate_contract(client, gender):
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
photo = Path(fixture("frontal.jpg")).read_bytes()
|
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()
|
data={"gender": gender, "hair_style": "1,3", "generate_grow_image": "false"}).json()
|
||||||
assert body["code"] == 0, body
|
assert body["code"] == 0, body
|
||||||
data = body["data"]
|
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):
|
def test_hairline_gen_missing_gender_1004(client):
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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):
|
def test_hairline_gen_missing_hairstyle_1007(client):
|
||||||
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
|
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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):
|
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)
|
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||||||
|
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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
|
assert body["code"] == 0, body
|
||||||
d = body["data"]
|
d = body["data"]
|
||||||
imgs = d["hairline_images"]
|
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):
|
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["paths"][HLGEN52]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]
|
||||||
body = schema["components"]["schemas"][body["$ref"].rsplit("/", 1)[-1]]
|
body = schema["components"]["schemas"][body["$ref"].rsplit("/", 1)[-1]]
|
||||||
fields = body["properties"]
|
fields = body["properties"]
|
||||||
@@ -310,7 +301,7 @@ def test_hairline52_legacy_request_keeps_fields_and_original_coordinates(client,
|
|||||||
return _PNG_1x1
|
return _PNG_1x1
|
||||||
monkeypatch.setattr(comfy, "run", generate)
|
monkeypatch.setattr(comfy, "run", generate)
|
||||||
form = {"gender": "male", "hair_style": "1", "use_mask": "false", "prompt": "legacy prompt"}
|
form = {"gender": "male", "hair_style": "1", "use_mask": "false", "prompt": "legacy prompt"}
|
||||||
kwargs = {"headers": H, "data": form}
|
kwargs = {"data": form}
|
||||||
if image_kind == "file":
|
if image_kind == "file":
|
||||||
kwargs["files"] = {"image_file": ("frontal.jpg", raw, "image/jpeg")}
|
kwargs["files"] = {"image_file": ("frontal.jpg", raw, "image/jpeg")}
|
||||||
elif image_kind == "base64":
|
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):
|
def test_hairline52_gen_missing_gender_1004(client):
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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):
|
def test_hairline52_gen_missing_hairstyle_1007(client):
|
||||||
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
|
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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):
|
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)
|
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
|
||||||
|
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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()
|
data={"gender": "female", "hair_style": "1,3", "normalize_image": "true"}).json()
|
||||||
assert body["code"] == 0, body
|
assert body["code"] == 0, body
|
||||||
d = body["data"]
|
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
|
assert d["high_hairline_center_point"] is None and d["low_hairline_center_point"] is None
|
||||||
rule = d["height_rule"]
|
rule = d["height_rule"]
|
||||||
assert rule["target_upper_height_px"] == pytest.approx(
|
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:
|
for x in imgs:
|
||||||
assert x["height_placement"]["new_center"]["y"] == pytest.approx(rule["target_center"]["y"], abs=1)
|
assert x["height_placement"]["new_center"]["y"] == pytest.approx(rule["target_center"]["y"], abs=1)
|
||||||
assert base64.b64decode(imgs[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff"
|
assert 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):
|
def test_measure_v2_vertical_lines_have_no_head_edges(client):
|
||||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
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 body["code"] == 0, body
|
||||||
_assert_vertical_lines(body["data"], "v6")
|
_assert_vertical_lines(body["data"], "v6")
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_hairline_forwarding(monkeypatch):
|
@pytest.mark.parametrize(("name", "path"), [
|
||||||
name, path = "hairline_generate", "/api/v1/hairline/generate"
|
("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")
|
gateway = importlib.import_module("gateway.app")
|
||||||
request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
|
request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
|
||||||
calls = []
|
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
|
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)
|
rule = height_target(_landmarks(), 320, 400)
|
||||||
assert rule["middle_height_px"] == pytest.approx(80, abs=1e-4)
|
assert rule["middle_height_px"] == pytest.approx(80, abs=1e-4)
|
||||||
assert rule["lower_height_px"] == pytest.approx(60, 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_upper_height_px"] == pytest.approx(63, abs=1e-4)
|
||||||
assert rule["target_center"]["y"] == pytest.approx(110, abs=1e-4)
|
assert rule["target_center"]["y"] == pytest.approx(117, abs=1e-4)
|
||||||
|
|
||||||
|
|
||||||
def test_reference_uses_unrounded_measurement_and_scale():
|
def test_reference_uses_unrounded_measurement_and_scale():
|
||||||
ref = {"brow_center": (161.4, 181.3), "nose_bottom": (160.0, 261.9),
|
ref = {"brow_center": (161.4, 181.3), "nose_bottom": (160.0, 261.9),
|
||||||
"chin_tip": (160.0, 321.5), "px_per_cm": 20}
|
"chin_tip": (160.0, 321.5), "px_per_cm": 20}
|
||||||
rule = height_target(_landmarks(), 320, 400, ref)
|
rule = height_target(_landmarks(), 320, 400, ref)
|
||||||
assert rule["target_upper_height_px"] == pytest.approx(70.1)
|
assert rule["target_upper_height_px"] == pytest.approx(63.09)
|
||||||
assert rule["target_center"]["y"] == pytest.approx(111.2)
|
assert rule["target_center"]["y"] == pytest.approx(118.21)
|
||||||
assert rule["target_upper_height_cm"] == pytest.approx(3.505)
|
assert rule["target_upper_height_cm"] == pytest.approx(3.1545)
|
||||||
assert rule["measurement_source"] == "face_measure"
|
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]
|
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
|
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:
|
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').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/debug/hairline-height-compare').status_code == 403
|
||||||
assert client.post('/api/v1/hairline/generate', headers=headers).status_code == 403
|
assert client.post('/api/v1/hairline/generate').status_code == 403
|
||||||
assert client.post('/api/v1/face/measure', headers=headers).status_code == 403
|
assert client.post('/api/v1/face/measure').status_code == 403
|
||||||
assert client.post('/api/v1/redraw', headers=headers).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('/health').status_code == 403
|
||||||
assert client.get('/openapi.json').status_code == 403
|
assert client.get('/openapi.json').status_code == 403
|
||||||
policy.write_text('{"allowed_ips": ["203.0.113.9"]}')
|
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').json()['code'] == 1004
|
||||||
assert client.post('/api/v1/hairline/generate-52').status_code == 401
|
assert client.post('/api/v1/hair/grow-22').status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
|
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/hairline/generate-52", 1004),
|
||||||
("/api/v1/face/b-end-points", 1007),
|
("/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
|
import app as worker
|
||||||
|
|
||||||
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
|
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))
|
client = TestClient(worker.app, client=("49.232.51.171", 50000))
|
||||||
try:
|
try:
|
||||||
denied = client.post(path, headers={"Authorization": "Bearer client-access-token"})
|
accepted = client.post(path)
|
||||||
assert denied.status_code == 401 and denied.json()["code"] == 1009
|
|
||||||
accepted = client.post(path, headers={"X-Internal-Token": "access-test"})
|
|
||||||
assert accepted.status_code == 200 and accepted.json()["code"] == expected_code
|
assert accepted.status_code == 200 and accepted.json()["code"] == expected_code
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
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 已迁到网关配置。",
|
"_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 已迁到网关配置。",
|
||||||
"accept_passwords": ["change-me-to-a-strong-secret"],
|
|
||||||
"comfyui_password": "your-comfyui-basic-auth-password",
|
"comfyui_password": "your-comfyui-basic-auth-password",
|
||||||
"health": {
|
"health": {
|
||||||
"min_free_gb": 5,
|
"min_free_gb": 5,
|
||||||
|
|||||||
Reference in New Issue
Block a user