feat: 新增52号发际线接口并限制GPU worker来源IP

保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。

worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。

验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
This commit is contained in:
xsl
2026-11-04 10:20:29 +08:00
parent a3fddbbc34
commit 5b2b73f5d5
35 changed files with 2826 additions and 26 deletions
+1
View File
@@ -9,6 +9,7 @@ gateway/config.json
# worker 配置(含鉴权密码,不入 git)
worker_config.json
ip_access.json
# ComfyUI Basic Auth 密码(不入 git)
password.txt
+286 -3
View File
@@ -194,6 +194,10 @@ async def internal_token_auth(request: Request, call_next):
本地起步),生产务必通过 worker_config.json / 环境变量配置密码。
"""
path = request.url.path
from ip_access import rejection
denied = rejection(request)
if denied is not None:
return denied
if path == "/" or path.startswith(_AUTH_EXEMPT):
return await call_next(request)
if path.startswith("/api/") and ACCEPT_PASSWORDS:
@@ -370,7 +374,7 @@ class ImageJsonBody(BaseModel):
# ---------------------------------------------------------------------------
def _run_face_measure_data(image, variant="v1"):
def _run_face_measure_data(image, variant="v1", landmarks_override=None):
"""接口1测量数值核心:detect → 姿态校验 → 头发/耳朵分割 → measure_face → eye1~eye7。
返回 (data_dict, result, hair_mask, ear_mask),或检测/姿态失败时返回 None。
@@ -385,7 +389,7 @@ def _run_face_measure_data(image, variant="v1"):
from face_analysis.pose import estimate_head_pose, check_frontal_face
from face_analysis.measure import measure_face
landmarks = detector.detect(image)
landmarks = landmarks_override if landmarks_override is not None else detector.detect(image)
if landmarks is None:
return None
if not check_frontal_face(landmarks, w, h):
@@ -1557,7 +1561,7 @@ async def face_features(
# ---------------------------------------------------------------------------
# 接口 5:发际线 PNG 生成
# 接口 5:发际线 PNG 生成(恢复 a3fddbb 历史版本)
# ---------------------------------------------------------------------------
@app.post(
@@ -1727,6 +1731,285 @@ async def hairline_generate(
return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 接口 52:中下庭规则、扶正裁切与矩形叠加预览
# ---------------------------------------------------------------------------
async def _generate_hairline52_data(image, gender, hair_styles, use_mask=True,
prompt=None, generate_grow_image=False,
compare_rules=False, normalize_image=True):
"""Share measurement coordinates between interface 52 and its comparison page."""
from fastapi.concurrency import run_in_threadpool
from hairline.service import generate_hairline_pngs_52, get_landmarker
original = image
framing, normalized_landmarks, landmarks_override = None, None, None
if normalize_image:
from hairline.framing import normalize_portrait
from mediapipe.framework.formats import landmark_pb2
landmarks = await run_in_threadpool(
get_landmarker().detect, cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True)
if landmarks is None:
return None, None
image, normalized_landmarks, framing = await run_in_threadpool(normalize_portrait, image, landmarks)
landmarks_override = landmark_pb2.NormalizedLandmarkList()
for point in normalized_landmarks:
landmarks_override.landmark.add(x=float(point[0]), y=float(point[1]), z=float(point[2]))
fm, reference = None, None
try:
measured = await run_in_threadpool(_run_face_measure_data, image, landmarks_override=landmarks_override)
if measured is not None:
fm, result = measured[:2]
reference = {key: result.vertical[key]
for key in ("brow_center", "nose_bottom", "chin_tip")}
reference["px_per_cm"] = result.px_per_cm
except Exception as ex:
logger.warning("接口52 当前底图测量失败,使用发际线关键点计算中下庭:%s", ex)
res = await run_in_threadpool(
generate_hairline_pngs_52, image, gender, hair_styles, use_mask, prompt,
generate_grow_image=generate_grow_image, height_reference=reference,
compare_rules=compare_rules,
precomputed_landmarks=normalized_landmarks[:468] if normalized_landmarks is not None else None)
if res is not None:
res.update(processed_bgr=image, original_bgr=original, framing=framing)
return res, fm
@app.post(
"/api/v1/hairline/generate-52",
summary="接口52 发际线PNG生成",
tags=["人脸分析"],
description=f"""
输入用户照片 + 性别 + 多选发型,各发型 middle 中点统一对齐到眉心上方(中庭+下庭)/2。
返回设计曲线、生发图、目标高度与定位误差;high/low 旧字段保留为 null。
{_image_fields_desc}
图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。
---
**入参**(同接口2:先选性别,再多选发型):
- 必填 `gender`(`male`/`female`),决定发型集合(female 7 / male 7)。
- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。
`female`:1=ellipse,2=flower,3=heart,4=straight,5=wave,6=bigflower,7=clasicalflower;
`male`:1=ellipse,2=inverse_arc,3=m,4=straight,5=heart,6=Softpetal,7=wave。
- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。
生发遮罩直接使用新位置的 middle 曲线,与预览保持一致。
- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。
`false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回设计叠图与中心点。
- 新增可选 `normalize_image`(默认 `false`):显式传 `true` 时扶正并裁切,长图采用3:4。
不传时保留旧调用的原图尺寸与坐标。测试页显式开启此参数,保留实际像素、不放大图片。
**返回说明**:
- `hairline_images`:**选中发型**的列表,数量 = 所选发型数,`order` = 发型序号。每项含:
- `image_middle_url`:新高度规则的透明发际线 PNG;`image_high_url` / `image_low_url` 固定 null。
- `height_placement`:旧/新中心坐标、移动距离和目标误差(像素)。
- `grown_image_url`:该发型的**生发图**(生发失败时为 `null`)。
- `hairline_type`:发际线类型 key。
worker 返回 `*_base64`,网关落盘后改写为 `*_url`。
- `best_hairline_center_point`:**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标,
以**当前底图像素**为基准(左上角为原点,x 向右,y 向下)。
- `high_hairline_center_point` / `low_hairline_center_point`:旧字段,固定 null。
- `height_rule`:中庭、下庭、目标上庭高度、目标中心与测量来源;`face_measure` 为当前底图测量。
- `input_image_*`:处理后底图,须用它叠加发际线;`original_image_*`:处理前图片。
- `coordinate_space`:默认 `original_image`,开启预处理后为 `processed_image`。
- `framing`:是否标准裁切、旋转角、前后尺寸及双向坐标变换;关闭预处理时为 null。
""",
responses={
200: {
"description": "成功",
"content": {
"application/json": {
"example": {
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"hairline_images": [
{"hairline_type": "ellipse", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": None, "image_low_base64": None, "grown_image_base64": "iVBORw0KGgo...", "order": 1},
{"hairline_type": "flower", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": None, "image_low_base64": None, "grown_image_base64": None, "order": 2},
],
"best_hairline_center_point": {"x": 540, "y": 430},
"high_hairline_center_point": None,
"low_hairline_center_point": None,
"face_measure": {
"face_total_height_cm": 13.76,
"four_courts": {
"top_court_cm": 3.44, "upper_court_cm": 3.44,
"middle_court_cm": 3.44, "lower_court_cm": 3.44,
"ratios": {"top_court": 0.25, "upper_court": 0.25,
"middle_court": 0.25, "lower_court": 0.25},
},
"seven_eyes": {
"eye_width_cm": 3.44, "face_width_cm": 24.08,
"inter_eye_distance_cm": 3.44,
"ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143},
"eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44,
"eye5": 3.44, "eye6": 3.44, "eye7": 3.44,
},
"landmarks": {
"hair_top": {"x": 540, "y": 120},
"hairline": {"x": 540, "y": 430},
"brow_center": {"x": 540, "y": 740},
"nose_bottom": {"x": 540, "y": 1050},
"chin_tip": {"x": 540, "y": 1360},
},
"hairline_source": "segmentation",
"head_pose": {"yaw": -1.39, "pitch": 2.49, "roll": -0.06},
},
},
}
}
},
},
400: {
"description": "参数错误 / 图片识别失败",
"content": {
"application/json": {
"example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None}
}
},
},
},
)
async def hairline_generate_52(
image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"),
image_url: Optional[str] = Form(default=None, description="图片 URL"),
image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"),
gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"),
hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-7"),
use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"),
prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"),
generate_grow_image: bool = Form(default=True, description="是否生成生发效果图。false 时仅返回新规则发际线叠图与中心点"),
normalize_image: bool = Form(default=False, description="新增可选开关,默认false保留原图尺寸和坐标;true先扶正再裁切,长图采用3:4。测试页显式开启"),
):
if gender not in ("male", "female"):
return err(1004, "gender 必填且只能为 male / female")
# hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007
max_styles = {"female": 7, "male": 7}[gender]
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}")
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
from hairline.framing import decode_portrait
image = decode_portrait(raw)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
res, fm = await _generate_hairline52_data(
image, gender, hair_styles, use_mask, prompt,
generate_grow_image=generate_grow_image, normalize_image=normalize_image)
if res is None:
return err(1001, "无法识别人像")
hairline_images = []
for it in res["images"]:
ov = it["overlays"]
hairline_images.append({
"hairline_type": it["hairline_type"],
"image_middle_base64": _rgba_png_b64(ov["middle"]),
"image_high_base64": None,
"image_low_base64": None,
"grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null)
if it.get("grown_png") else None),
"order": it["order"],
"height_placement": it["height_placement"],
})
c = res.get("best_centers") or {}
def _pt(p):
return ({"x": p[0], "y": p[1]} if p else None)
data = {
"hairline_images": hairline_images,
"best_hairline_center_point": _pt(c.get("middle")),
"high_hairline_center_point": _pt(c.get("high")),
"low_hairline_center_point": _pt(c.get("low")),
"height_rule": res["height_rule"],
"guide_landmarks": res["guide_landmarks"],
"face_measure": fm,
"framing": res["framing"],
"input_image_base64": _jpg_b64(res["processed_bgr"]),
"original_image_base64": _jpg_b64(res["original_bgr"]),
"coordinate_space": "processed_image" if normalize_image else "original_image",
"width": res["processed_bgr"].shape[1], "height": res["processed_bgr"].shape[0],
}
return ok(data)
except Exception as ex: # noqa: BLE001
logger.exception("接口52 处理异常")
return err(1007, f"处理失败:{ex}")
@app.get("/api/v1/debug/hairline-height-cases", include_in_schema=False)
async def hairline_height_cases():
path = os.path.join(os.path.dirname(__file__), "tests", "fixtures", "hairline_height_cases.json")
with open(path, encoding="utf-8") as f:
return ok(json.load(f))
@app.get("/api/v1/debug/hairline-framing-reference", include_in_schema=False)
async def hairline_framing_reference():
from hairline.framing import load_profile
return ok(load_profile())
@app.post("/api/v1/debug/hairline-height-compare", include_in_schema=False)
async def hairline_height_compare(
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
gender: str = Form(default="female"),
hair_style: str = Form(default="1,2,3,4,5,6,7"),
generate_grow_image: bool = Form(default=False),
normalize_image: bool = Form(default=True),
):
if gender not in ("female", "male"):
return err(1004, "gender 必须为 male / female")
styles = _parse_hair_styles(hair_style, 7)
if styles is None:
return err(1007, "hair_style 必须为 1..7 的整数,逗号分隔")
raw, error = await resolve_image_bytes(image_file, image_url, image_base64)
if error is not None:
return error
from hairline.framing import decode_portrait
photo = decode_portrait(raw)
if photo is None:
return err(1008, "图片格式不支持")
try:
res, fm = await _generate_hairline52_data(
photo, gender, styles, prompt="填充遮罩区域的头发,人物增加一点磨皮",
generate_grow_image=generate_grow_image, compare_rules=True,
normalize_image=normalize_image)
if res is None:
return err(1001, "无法识别人像")
h, w = res["processed_bgr"].shape[:2]
return ok({
"gender": gender, "width": w, "height": h,
"input_base64": _jpg_b64(res["processed_bgr"]),
"original_base64": _jpg_b64(res["original_bgr"]),
"framing": res["framing"],
"coordinate_space": "processed_image" if normalize_image else "original_image",
"height_rule": res["height_rule"], "face_measure": fm,
"guide_landmarks": res["guide_landmarks"],
"results": [{
"hairline_type": item["hairline_type"], "order": item["order"],
"old_overlay_base64": _rgba_png_b64(item["legacy_overlay"]),
"new_overlay_base64": _rgba_png_b64(item["overlays"]["middle"]),
"height_placement": item["height_placement"],
"grown_image_base64": _png_to_jpg_b64(item["grown_png"]) if item["grown_png"] else None,
} for item in res["images"]],
})
except Exception as ex:
logger.exception("接口52 高度规则对比失败")
return err(1007, str(ex))
# ---------------------------------------------------------------------------
# 接口 9:头发遮罩生成
# ---------------------------------------------------------------------------
+18 -5
View File
@@ -13,21 +13,22 @@
```
客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py)
│ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI/change_hair)
└ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/6/7/9/10/11/12 + 工具路由
└ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/52/6/7/9/10/11/12 + 工具路由
```
- **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机
回环外无外网依赖。对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`);
`/health` 模型就绪才返回 200。监听 **8187**(systemd `hair-worker.service`,也可 `./run_worker.sh` 手动起)。
另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、
`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免鉴权调试)。
`/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免Token调试,仍受IP限制)。
所有动态路径先校验直连来源IP:允许本机与网关`49.232.51.171`,其余返回HTTP403/code1010;`/static/`除外。配置见[`接口IP白名单.md`](接口IP白名单.md),网关侧不增加IP限制。
- **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。
网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测,
直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。
- **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。
- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 一律 200)。
- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 200);IP拒绝为HTTP403/code1010,Token错误为HTTP401/code1009,依赖健康异常为HTTP503。
### base64 → URL 映射(网关落盘改写,递归进数组、可空保留 null)
### base64 → URL 映射(网关落盘改写,递归进数组)
| 接口 | worker 字段(内部) | 对外字段 |
|------|--------------------|----------|
@@ -36,6 +37,7 @@
| 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` |
| 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` |
| 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` |
| 52 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`、`input_image_base64` / `original_image_base64` | 同名 `*_url`,空图片也改为`*_url: null` |
| 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` |
| 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` |
| 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` |
@@ -43,13 +45,14 @@
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
> 兼容约定:接口52显式开启空图片字段改名;接口2/3/5等旧接口仍保留空图片的`*_base64: null`字段名。有图片时均落盘为`*_url`。
> **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。
> 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。
### 错误码
`1001` 无法识别人像 | `1002` 分辨率过低 | `1003` 非正面 | `1004` gender 必填/非法(接口2/5/7)|
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持。
`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持 | `1009` Token鉴权失败 | `1010` worker来源IP不允许或白名单配置无效。
---
@@ -99,6 +102,15 @@
- **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。
- **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。
### 接口52 发际线PNG生成 `/api/v1/hairline/generate-52`(worker)
- **预处理**:`hairline/framing.py`先扶正9→152,再按高(3÷0.9)L、宽2.5L(3:4)做原像素裁切,人物相对原3L规则缩小约10%。L为9→152距离;眉心位置沿用已扶正参考图比例。长图的标准窗口不足时,求原图内最大的3:4窗口,通过可行区域约束保留全部人脸关键点,并尽量贴近目标眉心位置;无法保留人脸时回退仅扶正。其余图片空间不足时仍仅扶正、去除旋转空角。参考图与参数由`scripts/calibrate_hairline_framing.py`生成。公开接口为兼容旧调用默认关闭预处理,`normalize_image=true`开启;规则页显式开启,对比调试页默认开启。返回当前底图、原图及双向坐标矩阵。
- **目标高度**:`上庭=(中庭+下庭)/2`,用当前底图测量的未取整像素坐标(眉心9、鼻翼下缘94、下巴152)算目标中心。测量失败时用发际线管线同编号点计算并标注来源。
- **模板定位**:原有502点/贴图生成形状,`hairline/height_rule.py` 将每种 middle 曲线沿面部向上方向平移、校准中心 Y,保留曲线形状与倾斜。此规则仅接口52使用,接口2保持原逻辑。
- **生发**:移动后的预览曲线直接生成黑线和重绘遮罩,再送ComfyUI,避免预览与黑模板位置不一致。
- **裁切叠加预览**:测试页新增“裁切发际线叠加”。`static/hairline_reference_box.js`共享同一个162/389/9/152参考框几何,按原图像素裁切middle透明图层,再叠加到处理后人像;四栏顺序为原图、middle、裁切发际线叠加、生发图。
- **兼容**:high/low不再生成,图片与中心点字段保留为null。网关`gateway/forward.py`仅为接口52开启空图片`*_base64`→`*_url: null`,旧接口空字段名保持原样。部署52路由时同步该改写;网关不增加IP限制。`face_measure`为当前底图测量;新增`height_rule`与各项`height_placement`说明设计目标与实际误差。
- **测试**:规则页`static/test_interface52.html`,对比页`static/test_interface52_compare.html`;运行`scripts/compare_hairline_height.py`默认保存全部30张×男女各7型,共420组对比。每人一行,顺序为未裁切原图、单张旧规则图、全部新规则样式,支持位置对照、额头特写、同图叠线和上传新图。新图可叠加162/389两侧、底边经过9、高度为9→152距离70%的参考框。原图与叠图按需加载。
### 接口9 头发遮罩 `/api/v1/head/mask`(worker)
- **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。
- **怎么实现**(`face_analysis/head_mask.py`):MediaPipe 底部分割线(21/68/104/69/108/151/337/299/333/298/251 连线,21/251 水平延伸)→ 线以上为上半区 → BiSeNet 与 SegFormer 双路头发分割、逐列向下填充到分割线得闭合区域(头发+额头)→ 外缘朝 151 内缩 `erode_cm`(默认1.2cm,虹膜标定) → 最终遮罩。返回 `steps_common` + `bisenet`/`segformer` 两组各四张图。
@@ -126,6 +138,7 @@
## 3. 部署 / 环境要点
**worker(GPU 机,本机 = gpu_worker 角色)**
- 启动使用`./run_worker.sh`或`hair-worker.service`,均显式传`--no-proxy-headers`以保留直连来源IP。部署时复制`ip_access.example.json`为`ip_access.json`并核对网关IP;运行配置不入Git,改动即时生效。旧启动方式也应补上此参数,详见[`接口IP白名单.md`](接口IP白名单.md)。
- Python **3.10.12**(本机 venv 在 `./venv`);`numpy<2`(1.26.4),`scikit-image==0.24.0`
(**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。
- 本机 **RTX 4090**:venv 实装 `torch 2.7.1+cu128`,CUDA 可用(BiSeNet/SegFormer GPU 推理)。
+119
View File
@@ -0,0 +1,119 @@
# 接口52上传图片裁切与扶正规划
状态:2026-11-04 在原3L规则上将人物显示缩小约10%,裁切高度改为(3÷0.9)L≈3.3333L、宽度2.5L,已接入接口52与对比页。参考图先扶正后重新标定,以下旧预检数值仅为历史记录,当前标准以 `hairline/reference/framing_profile.json` 为准。
## 接口拆分
- 接口5已按11月2日前最新提交`a3fddbb`恢复:`POST /api/v1/hairline/generate`,返回middle/high/low三档,测试页`/static/test_interface5.html`。
- 当前新规则完整迁至接口52:`POST /api/v1/hairline/generate-52`,测试页`/static/test_interface52.html`,30案例对比`/static/test_interface52_compare.html`。接口52保留迁移前所有参数和返回字段,页面显式开启`normalize_image=true`。
- 原`test_interface5_compare.html`书签跳转至52号对比页;公开5号与52号的处理路由、生成函数和网关转发均独立。
## 已确定的要求
- 仅用于接口52与当前旧/新规则对比页。
- 先扶正:变换后的9号点与152号点保持同一X坐标,9在上、152在下。
- 原图空间够时按目标人脸比例和位置裁切;标准窗口不足时,偏长图片在保留人脸的前提下裁为3:4,其余仍仅扶正。3:4无法保留完整人脸时不强裁。
- 输出保留实际像素尺寸,不为了凑参考图尺寸而放大小图。
- 当前展示:全部30个案例,每个案例一行,未裁切原图放最前,旧规则图仅一张,新规则男女各7型横排,人物图片完整显示。选择新样式时旧图同步对应形状;失败案例仍保留原图并显示原因。
## 已扶正标准(当前生效)
- 标准图:`static/framing_reference.png`,786×1048,3:4。
- 两点X均为385.18783691;9号Y为360.65437127,152号Y为725.82510480。
- 相对位置:9号点 x=49.00608612%,y=34.41358504%。
- 当前裁切高/L=3÷0.9≈3.3333、宽/L=2.5(3:4),L为9→152长度。参考图自身测得的宽/L=2.15241783589、高/L=2.86989044785作为原始标定记录保留;眉心在画面中的位置比例继续使用参考图数值。
- 校准旋转约0.25962484°,变换后横向误差约5.7e-14像素。参数取自扶正并去空角后的图,不再使用未扶正原图的比例。
- 重标定脚本:`./venv/bin/python scripts/calibrate_hairline_framing.py <参考图路径>`。
- 使用同一套原始检测点作刚体变换与后续测量,避免二次检测抖动破坏轴线竖直。
- 公开接口新增`normalize_image`默认false,旧请求保持原图尺寸和坐标;显式true开启预处理。规则页主动传true,对比调试页默认开启。响应明确返回`coordinate_space`、当前底图和坐标变换。
## 原始参考构图(旧测量)
[用户指定参考图](https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg)
测量采用当前发际线管线的MediaPipe FaceLandmarker:
| 指标 | 测量值 |
|---|---|
| 画面 | 792×1056,3:4 |
| 9号点 | (388.93, 364.62) |
| 152号点 | (387.27, 729.79) |
| 9→152长度L | 365.17像素 |
| 面部轴线距竖直方向 | 约0.26° |
| 9号点画面相对位置 | x约49.1%,y约34.5% |
| 152号点画面相对高度 | y约69.1% |
| 画面宽度/L | 约2.16885 |
| 画面高度/L | 约2.89180 |
参考图接近竖直。以9号点为定位锚点,用9→152长度作为缩放无关的构图标尺。这里的L仅为眉心到下巴的距离,不是头顶到下巴的全头高度。
## 实施流程
1. 读取上传原图、处理EXIF方向,再检测人脸关键点。
2. 用9→152方向计算二维旋转,使转换后两点竖直。不改变脸部比例。
3. 按本人的L算目标裁切尺寸:高(3÷0.9)L、宽2.5L;令9号点位于裁切画面约(49.0061%,34.4136%)处。高度取最接近的4的倍数,宽=高×3/4,不改变原图像素尺度。
4. 将裁切窗口四角反算回原图,四角均在有效图片区域内才执行标准构图。采用一次仿射变换合并旋转与裁切,降低重复插值。
5. 标准窗口空间不足时,偏长图片采用原图内最大的3:4有效窗口,约束所有人脸关键点在框内,并尽量靠近目标眉心位置。若无法保留完整人脸则仅扶正并给出原因。其余图片仍仅扶正,最小去除旋转空角。
6. 后续测量、分割、旧/新发际线和70%参考框使用同一个处理后底图与坐标系。保留原图及原图/处理后坐标转换关系,防止叠图错位。
7. 页面展示原图、处理后图、旋转角、是否标准裁切、跳过原因与前后尺寸。旧/新规则对比使用同一处理后底图,避免构图变化混入高度规则差异。
## 当前:长图与矩形发际线预览
- 长图即使放不下标准人脸占比窗口,也尝试在原图有效区域裁出最大的3:4窗口;通过可行区域约束保留468个人脸关键点,并尽量贴近参考眉心位置。无法完整保留人脸时回退仅扶正,响应说明原因。
- 案例7从1080×1920裁为1062×1416,保持9→152竖直。长图7、20、21、22、50、51的男女各7型已更新,84组均成功;对比页30张、378组结果统一使用当前规则。
- 规则页展示原图、middle、裁切发际线叠加、生发图。新增预览使用共享矩形几何裁切透明发际线,矩形外清空透明,底图仍是完整处理后人像。
## 人物缩小10%的验证(长图规则加入前)
- 裁切高=(3÷0.9)L、宽=2.5L,使同样展示尺寸下的人物线性尺寸约为原3L规则的90%。
- 案例1由828×1104改为918×1224,人脸相对大小约90.20%,差异来自原像素窗口取整;底图、发际线和实际生发图均为918×1224。
- 对比页保留全部30张。重新计算了1、3、42、43、44、45;其余48组结果验证新窗口仍超出有效范围,且只扶正后的矩阵和尺寸完全一致,复用原定位图并更新裁切参数。
- 当前生成378组对比,43、44、45的男女组仍显示原图及定位失败原因。布局、眉心比例和9→152扶正方向保持一致。
## 历史:3L规则的五个案例验证
2026-11-04 按3L规则重跑男女各7型,共70组,失败0组。保留原始像素,不缩放人脸;实际尺寸如下。
| 案例 | 原尺寸 | 处理后尺寸 | 实际处理 |
|---|---|---|---|
| 1 | 1088×1406 | 828×1104 | 按3L规则裁切 |
| 2 | 1088×1406 | 1042×1346 | 空间不足,仅扶正并去除旋转空角 |
| 3 | 1088×1406 | 810×1080 | 按3L规则裁切 |
| 4 | 1088×1406 | 1042×1346 | 空间不足,仅扶正并去除旋转空角 |
| 5 | 1088×1367 | 1071×1346 | 空间不足,仅扶正并去除旋转空角 |
## 历史几何预检(未扶正参考图)
以下为修改前的预检记录,当前生效尺寸见上表。
| 案例 | 原尺寸 | 所需裁切尺寸(约) | 预计处理 |
|---|---|---|---|
| 1 | 1088×1406 | 797×1063 | 可按参考构图裁切并扶正 |
| 2 | 1088×1406 | 1442×1923 | 空间不足,仅扶正 |
| 3 | 1088×1406 | 782×1042 | 可按参考构图裁切并扶正 |
| 4 | 1088×1406 | 1465×1953 | 空间不足,仅扶正 |
| 5 | 1088×1367 | 1463×1950 | 空间不足,仅扶正 |
## 历史:3L规则的全部30张验证
- 按3L规则运行全部30张、男女各7型,共60次请求;55组请求成功,生成385组旧/新规则对比。27张男女均成功,案例43额外生成男性7型。
- 案例43女性:模板在面部中轴处没有有效曲线;案例44、45男女:扶正后无法保留9/152参考点。三个案例均在对应行显示完整原图和未生成组合的原因,不隐藏失败案例。
- 页面顺序为未裁切原图、旧规则、全部新规则;案例筛选包括失败图片,重跑按钮运行完整30张。
- 页面筛选、旧图切换、末尾图片加载及移动端横向浏览均通过;3L调参后再次检查全部输出尺寸与混合成功/失败案例的筛选。
## 验收重点
- 处理后的9、152号点X差不超过1像素,保证9在152上方。
- 标准裁切成功时,画面比例及两点位置接近参考构图,保留实际像素,不放大小图。
- 空间不足的案例明确提示,仍完成扶正。
- 旧/新规则叠图、参考框与底图坐标一致,原图坐标可通过逆变换还原。
- 全部30案例均可查看,成功案例显示男女各7型,失败案例显示原图与原因;沿用每人一行、单张旧规则图的展示。
## 2026-11-04 提交前 Review 与验证
- 接口5的请求处理函数、三档PNG生成函数与`a3fddbb`逐项AST比较一致,测试页逐字节一致;新高度规则、扶正裁切和矩形叠加均在接口52。
- 修复网关空图片字段改名影响旧接口的兼容性问题:仅接口52开启`*_base64: null`→`*_url: null`,接口2/3/5等旧接口保留原字段名;已增加递归改写和真实转发函数的回归测试。
- GPU worker所有动态接口启用直连IP白名单,本机和网关`49.232.51.171`可访问;网关侧不启用IP限制。配置与部署见[接口IP白名单](接口IP白名单.md)。
- `./venv/bin/pytest -q`:113项通过、1项已有失败。`tests/test_pipeline.py::test_numeric_regression`期望全脸总高24.01cm,当前实测22.47cm;从未修改的`a3fddbb`导出代码、夹具后独立运行,同样失败。此次保留历史断言,未通过修改基线掩盖差异。
- 浏览器实测:接口5返回middle/high/low且保持868×1166;接口52长图案例7返回1062×1416、`status=aspect_crop`,四栏图和裁切边框正常;对比页显示30个案例、378组结果,旧书签可跳转52,未出现页面脚本错误。定位验证均关闭ComfyUI生发,本轮未重复运行耗时的真实生发。
- 批量结果与上传图位于被Git忽略的`static/annotations/`及`tests/output/`,不随提交分发;新环境执行`scripts/compare_hairline_height.py`重建。420是计划组合数,378为当前成功结果,失败组及原因保留在报告中。
+29
View File
@@ -0,0 +1,29 @@
# GPU worker 接口 IP白名单
仅GPU worker使用项目根目录的`ip_access.json`,不存在时仅允许本机。网关不启用IP限制。配置每次请求重新读取,修改后立即生效。现有`X-Internal-Token`鉴权继续有效。
```json
{
"allowed_ips": ["49.232.51.171"]
}
```
当前已将网关IP `49.232.51.171`加入白名单;本机回环地址和网卡自身地址自动允许,其余来源返回HTTP403、`code=1010`。支持IPv4、IPv6及CIDR,不自动允许整个内网。
worker的所有动态路径统一保护,包括5号、52号、其他业务接口、调试接口、代理接口、健康检查和文档接口。`/static/`下的测试页和图片可正常访问,但页面发出的API请求仍需通过IP限制。
启动GPU worker时使用`uvicorn ... --no-proxy-headers`,保留真实连接来源。`run_worker.sh`与`hair-worker.service`已配置该选项。来源被服务器提前改写、地址格式错误或白名单配置无效时拒绝访问。
判断依据是直接连接worker的IP,忽略`X-Forwarded-For`、`X-Real-IP`和`Forwarded`,这些请求头不能用于绕过限制。由网关转发的请求按网关IP判断,网关后面的最终用户无需加入worker白名单。网关保持原有鉴权和转发流程,无需部署此配置。
`ip_access.json`不入Git;配置格式参考`ip_access.example.json`。本机网卡地址在请求时自动获取。
## 部署与维护
首次部署复制`ip_access.example.json`为`ip_access.json`,示例已包含网关`49.232.51.171`;已有配置不覆盖。该操作仅在GPU worker执行,网关服务和启动参数保持原样。
修改`allowed_ips`后保存为有效JSON即可生效,无需重启;建议写入同目录临时文件再原子替换,避免编辑过程中出现半份JSON。文件不存在时仅允许本机,内容损坏时动态接口一律拒绝。更新程序时保留本机配置文件。
IP检查先于Token检查。白名单内请求仍需原来的`X-Internal-Token`;`/health`和`/api/v1/debug/*`等原本免Token的路径仍需通过IP检查。静态页可打开不代表该来源可以调用API。
验证命令:`./venv/bin/pytest tests/test_ip_access.py -q`,覆盖本机、精确白名单、CIDR、IPv6、转发头伪造、热更新、全接口拦截与网关不受限制。
+156 -3
View File
@@ -20,11 +20,14 @@
| 3 B 端生发 | POST | `/api/v1/hair/grow-b` |
| 4 用户特征 | POST | `/api/v1/face/features` |
| 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` |
| 52 发际线新规则与裁切 | POST | `/api/v1/hairline/generate-52` |
---
## 通用约定
GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.json`白名单(当前为网关IP `49.232.51.171`),其他来源返回HTTP403、code=1010;静态页面与图片除外。按直接连接worker的IP判断,网关不启用此限制,已有鉴权继续有效。
以下约定适用于全部 5 个接口(最终以联调为准)。
### 请求
@@ -35,7 +38,7 @@
| 请求方式 | `POST` |
| 图片传参方式 | 三选一,三种都支持:① `multipart/form-data` 上传文件、② 图片 URL、③ base64 字符串 |
| 字符编码 | UTF-8 |
| 坐标基准 | 所有返回的坐标均以**原图像素**为基准(原点为图片左上角,x 向右、y 向下) |
| 坐标基准 | 默认以**原图像素**为基准;接口52开启预处理时使用响应中的处理后底图坐标(原点为左上角,x向右、y向下) |
图片传参字段(三者**严格互斥**,必须且只能传其一;传了 0 个或 ≥2 个均报错):
@@ -81,12 +84,13 @@
| 1001 | 无法识别人像 | 图片中未检测到人脸 |
| 1002 | 人像分辨率过低 | 低于最低分辨率要求 |
| 1003 | 角度问题,非正面照 | 非正面 / 角度过大 |
| 1004 | gender 必填/非法 | 接口2/5 的 `gender` 缺失或非 `male`/`female` |
| 1004 | gender 必填/非法 | 接口2/5/52/7 的 `gender` 缺失或非 `male`/`female` |
| 1005 | 检测到多张人脸 | 默认仅支持单人,检测到 2 人或以上时返回 |
| 1006 | 文件超出大小限制 | 单文件超过 1 MB |
| 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) |
| 1008 | 图片格式不支持 | 非 JPG / PNG |
| 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) |
| 1010 | 来源IP不允许 | 仅GPU worker:直连IP不在本机/白名单内,或来源/白名单配置无效;HTTP403 |
---
@@ -518,6 +522,154 @@
---
## 接口 52:发际线 PNG 生成接口
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
**请求**:`POST /api/v1/hairline/generate-52`
**IP限制**:GPU worker的所有动态接口统一仅允许本机回环地址、本机网卡地址及白名单;目前白名单含网关IP `49.232.51.171`,其他直连来源需加入`ip_access.json`的`allowed_ips`。该文件支持单个IP与CIDR,修改后立即生效;拒绝时返回HTTP403、code=1010。网关不启用此限制,已有鉴权仍保留。详见[接口IP白名单](接口IP白名单.md)。
### 图片扶正与标准构图
显式传`normalize_image=true`时,先扶正9→152,再按**高(3÷0.9)×L≈3.3333×L、宽2.5×L(3:4)**裁切,L为9→152的直线像素距离。相对原3L规则,裁切框宽高均扩大约11.11%,同样显示尺寸下人物缩小约10%。高度取最接近的4的倍数,宽度取高度的3/4。眉心定位沿用已扶正参考图:9号点相对位置约(49.0061%,34.4136%)。参考图仍为786×1048,两点X均为385.18784。
目标窗口完全在原图有效区域内才进行标准裁切。标准窗口不足时,偏长图片(宽/高<3/4)采用原图有效范围内最大的3:4窗口,尽量贴近眉心目标位置并保留完整人脸,`status=aspect_crop`;人脸放不进3:4窗口时回退仅扶正并返回原因。其余图片空间不足时仍仅扶正、去除旋转空角。不放大小图。**所有叠图与坐标使用响应中的处理后底图 `input_image_*`**,调用方应使用该底图叠加;`normalize_image=false` 可关闭此预处理。
`framing`包含处理前后尺寸、旋转角、是否标准裁切、跳过原因、`source_to_processed`/`processed_to_source`仿射矩阵。`status`区分`reference_crop`(标准窗口)、`aspect_crop`(长图3:4裁切)和`rotation_only`(仅扶正);`standard_crop=false`时仍可能有`aspect_crop=true`。`aspect_crop_skip_reason`说明长图无法保留人脸时的回退原因。`original_image_*`保留EXIF方向修正后的上传图,原图坐标可通过逆矩阵还原。
### 输入
图片参数见「通用约定 → 图片传参字段」。专属参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 7)。缺失/非法返回 `1004` |
| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。缺失/越界/非法返回 `1007` |
| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 |
| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本 |
| normalize_image | bool | 否 | 新增可选开关,默认false:保留原图尺寸与坐标。传true开启扶正和裁切;规则测试页显式传true,对比调试页默认开启 |
| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回设计叠图、中心点与高度规则数据,可大幅降低耗时 |
> 生发划线与遮罩直接取新位置的 middle 曲线;旧规则的头发边界与模板用于保留形状和倾斜,不决定新中心高度。计算使用未取整的像素高度,与四庭测量采用同一 Y 方向;测量主流程失败时用发际线管线的同编号关键点计算,`measurement_source` 标明来源。目标越出图片或无法对齐时返回 1007,不静默回退旧规则。
### 高度规则与对比
- **参数与字段**:原有8个请求参数、响应外层结构和所有旧返回字段均保留。`use_mask=true`、默认prompt、`generate_grow_image=true`保持原值。新功能只新增可选参数和返回字段;旧请求未传`normalize_image`时保留原图坐标,high/low按已约定保留null,worker仍返回`*_base64`,网关仍返回对应`*_url`。
- `height_rule`:`middle_height_px`、`lower_height_px`、`target_upper_height_px`、`target_upper_height_cm`(可空)、`target_center`、`brow_center`、`nose_bottom`、`chin_tip`、`measurement_source`。
- 每项 `height_placement`:`old_center`、`new_center`、`shift_x_px`、`shift_y_px`(正=下移)、`target_error_px`。浮点坐标保留精度,旧中心点字段继续返回取整像素。
- `face_measure` 为处理后底图的实际测量值,未被设计目标覆盖。
- `guide_landmarks`:9、150、152、162、251、389 号 MediaPipe 点的图片像素坐标,供对比页绘制参考框。参考框左/右边分别经过162/389,底边经过9,高度为9→152距离的70%;两侧均固定平行9→152。
- 规则页:`/static/test_interface52.html`;旧/新规则对比:`/static/test_interface52_compare.html`。
- 规则页每个发型依次展示未裁切原图、middle、裁切发际线叠加、生发图。“裁切发际线叠加”由页面按`guide_landmarks`生成:在相同处理后底图上叠加矩形内的middle PNG,矩形外保持透明;左/右边过162/389,底边过9,高度0.7×9→152,左右边平行9→152。
- 对比调试接口:`POST /api/v1/debug/hairline-height-compare`,图片三选一、gender、hair_style;默认只出旧/新曲线,可选 `generate_grow_image=true` 额外验证新规则生发。
- 测试清单:`tests/fixtures/hairline_height_cases.json`(用户指定30张);批量运行:`./venv/bin/python scripts/compare_hairline_height.py`,默认全部30张、男女各7型,共420组。每人一行,未裁切原图放在最前,后接一张可切换样式的旧规则图和全部新规则样式。可用 `--cases 1,2,3,4,5` 指定部分案例。
### 输出(data)
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 |
| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,当前底图像素:`{ "x": number, "y": number }` |
| high_hairline_center_point | object \| null | 已停用,固定 `null` |
| low_hairline_center_point | object \| null | 已停用,固定 `null` |
| input_image_url | string | 当前底图,发际线叠图、参考点和测量值均使用该图的坐标系 |
| original_image_url | string | EXIF方向修正后的上传原图 |
| width / height | int | 当前底图的实际像素宽 / 高 |
| coordinate_space | string | 开启预处理时为 `processed_image`,关闭时为 `original_image` |
| framing | object \| null | 裁切状态、旋转角、前后尺寸、轴线误差及双向仿射矩阵;关闭预处理时为 `null` |
| height_rule | object | 中庭/下庭、目标上庭高度、目标中心及测量来源,详见“高度规则与对比” |
| guide_landmarks | object | 9、150、152、162、251、389 号点的当前底图像素坐标;参考框实际使用9、152、162、389 |
| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 |
`hairline_images` 元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) |
| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加 `input_image_url` 显示) |
| image_high_url | null | 已停用,保留字段为 `null` |
| image_low_url | null | 已停用,保留字段为 `null` |
| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
| order | int | 发型序号(= 传入的 hair_style 值) |
| height_placement | object | 旧/新中心、XY移动距离、目标误差,均为未取整的像素值 |
> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。
>
> 💡 `image_middle_url` 为透明底 PNG,前端需用绝对定位叠加到 `input_image_url` 上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface52.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。worker返回上述图片的 `*_base64`,网关改写为 `*_url`。
`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段):
| 字段 | 类型 | 说明 |
|------|------|------|
| face_total_height_cm | number | 全脸总高度(cm)= 四庭之和 |
| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 |
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 |
| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 |
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),当前底图像素:`{ "x": int, "y": int }` |
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),当前底图像素:`{ "x": int, "y": int }` |
> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。
### 网关响应结构示例(normalize_image=false)
此例未开启裁切,坐标基于原图;`face_measure=null`时仍可按发际线关键点计算目标。网关对接口52的所有空图片字段均返回`*_url: null`。worker直连时图片字段名对应为`*_base64`,图片内容为base64。
```json
{
"code": 0,
"message": "success",
"request_id": "mock-request-id",
"data": {
"hairline_images": [
{
"hairline_type": "ellipse",
"image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid1.png",
"image_high_url": null,
"image_low_url": null,
"grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.jpg",
"order": 1,
"height_placement": {
"old_center": {"x": 540.0, "y": 450.0},
"new_center": {"x": 540.0, "y": 430.0},
"shift_x_px": 0.0, "shift_y_px": -20.0, "target_error_px": 0.0
}
}
],
"best_hairline_center_point": {"x": 540, "y": 430},
"high_hairline_center_point": null,
"low_hairline_center_point": null,
"input_image_url": "https://hair.xiangsilian.com/static/annotations/input.jpg",
"original_image_url": "https://hair.xiangsilian.com/static/annotations/original.jpg",
"width": 1080, "height": 1440,
"coordinate_space": "original_image",
"framing": null,
"height_rule": {
"name": "court_average", "formula": "上庭 = (中庭 + 下庭) / 2",
"measurement_source": "hairline_landmarks",
"brow_center": {"x": 540.0, "y": 740.0},
"nose_bottom": {"x": 540.0, "y": 1050.0},
"chin_tip": {"x": 540.0, "y": 1360.0},
"middle_height_px": 310.0, "lower_height_px": 310.0,
"target_upper_height_px": 310.0, "target_upper_height_cm": null,
"target_center": {"x": 540.0, "y": 430.0}
},
"guide_landmarks": {
"9": {"x": 540.0, "y": 740.0}, "150": {"x": 470.0, "y": 1340.0},
"152": {"x": 540.0, "y": 1360.0}, "162": {"x": 300.0, "y": 680.0},
"251": {"x": 770.0, "y": 630.0}, "389": {"x": 780.0, "y": 680.0}
},
"face_measure": null
}
}
```
开启`normalize_image=true`后,`coordinate_space=processed_image`,`framing`包含前述裁切状态和坐标变换,所有发际线图片须叠加到`input_image_url`。原始图片仅用于展示和坐标回溯。
---
## 接口 9:头发遮罩生成接口
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
@@ -702,6 +854,7 @@
| 3 B 端生发 | `/api/v1/hair/grow-b` | 划线图片 | 检测到的发际线 + 生发后图片 |
| 4 用户特征 | `/api/v1/face/features` | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格),网关本机实现 |
| 5 发际线 PNG | `/api/v1/hairline/generate` | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 |
| 52 发际线新规则 | `/api/v1/hairline/generate-52` | 用户照片 + gender + hair_style(多选) | 每个选中发型的新规则 middle 设计叠图 + 生发图 + 目标中心坐标;high/low 旧字段为 null |
| 9 头发遮罩 | `/api/v1/head/mask` | 用户照片 | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) |
| 10 外缘膨胀带 | `/api/v1/head/band` | 用户照片 | 头部外缘带状遮罩 + 分步可视化 |
| 11 发际线生发 | `/api/v1/hairline/grow` | 用户照片 + hairline_id | 压低发际线图 + 全步骤可视化(不含重绘) |
@@ -716,7 +869,7 @@
- 图片传参:**文件 / URL / base64 三种都支持,严格互斥**(必须且只能传一个,base64 需带 `data:image/...;base64,` 前缀)。
- 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。
- 所有坐标基准:**原图像素**。
- 坐标基准:默认**原图像素**;接口52开启预处理时使用响应中的**处理后底图像素**。
- 接口 2:提供「美颜」开关 `beauty_enabled`。
- 接口 3:仅传**划线图**(`marked_image_*` 三选一),需返回发际线类型(`custom`)。
- 接口 4:`data` 返回**一个 JSON 字符串**,字段不固定,可随时调整。
+8
View File
@@ -208,6 +208,8 @@ async def index():
"if3_hair_grow_b": "/static/test_interface3.html",
"if4_features": "/static/test_interface4.html",
"if5_hairline": "/static/test_interface5.html",
"if52_hairline": "/static/test_interface52.html",
"if52_compare": "/static/test_interface52_compare.html",
"if6_measure_v2": "/static/test_interface6.html",
"if7_hair_grow_v2": "/static/test_interface7.html",
"if9_head_mask": "/static/test_interface9.html",
@@ -591,6 +593,12 @@ async def hairline_generate(request: Request):
return await _proxy(request, "/api/v1/hairline/generate")
@app.post("/api/v1/hairline/generate-52", tags=["人脸分析"])
async def hairline_generate_52(request: Request):
"""接口52:新高度规则、扶正裁切与发际线生成"""
return await _proxy(request, "/api/v1/hairline/generate-52")
@app.post("/api/v1/hair/grow-v2", tags=["生发"])
async def hair_grow_v2(request: Request):
"""接口7:C端生发 v2(add_hair2 工作流)"""
+15 -3
View File
@@ -88,6 +88,8 @@ def rewrite_base64_to_url(
obj: Any,
public_base_url: str,
static_dir: str,
*,
rewrite_null_images: bool = False,
) -> Any:
"""递归遍历响应 JSON,将所有 *_base64 字段改写为 *_url。
@@ -95,10 +97,16 @@ def rewrite_base64_to_url(
- 支持 data: URI 和原始 base64 两种格式
- 解码 base64 → 保存到 static_dir/{uuid}.png
- 删除 *_base64 字段,新增 *_url 字段指向公网 URL
- 接口52显式开启 rewrite_null_images,空图片也返回 *_url: null;旧接口保持原空字段
"""
if isinstance(obj, dict):
new_dict: Dict[str, Any] = {}
for key, value in obj.items():
if rewrite_null_images and key.endswith("_base64") and value is None:
# Deprecated/optional image fields must keep their external
# *_url names even when no image is generated.
new_dict[key[:-7] + "_url"] = None
continue
if key.endswith("_base64") and isinstance(value, str):
img_bytes = _decode_base64_value(value)
if img_bytes is not None:
@@ -118,10 +126,12 @@ def rewrite_base64_to_url(
logger.warning("字段 %s 的值无法解码为图片,保留", key)
new_dict[key] = value
else:
new_dict[key] = rewrite_base64_to_url(value, public_base_url, static_dir)
new_dict[key] = rewrite_base64_to_url(
value, public_base_url, static_dir, rewrite_null_images=rewrite_null_images)
return new_dict
elif isinstance(obj, list):
return [rewrite_base64_to_url(item, public_base_url, static_dir) for item in obj]
return [rewrite_base64_to_url(item, public_base_url, static_dir,
rewrite_null_images=rewrite_null_images) for item in obj]
else:
return obj
@@ -248,7 +258,9 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
)
# 递归改写 base64 图片字段
rewritten = rewrite_base64_to_url(worker_json, public_base_url, static_dir)
rewritten = rewrite_base64_to_url(
worker_json, public_base_url, static_dir,
rewrite_null_images=path == "/api/v1/hairline/generate-52")
return JSONResponse(
status_code=resp.status_code,
+10
View File
@@ -7,6 +7,7 @@
- POST /api/v1/hair/grow-b → mock 响应
- POST /api/v1/face/features → mock 响应
- POST /api/v1/hairline/generate → mock 响应
- POST /api/v1/hairline/generate-52 → 新规则 mock 响应
鉴权:校验 X-Internal-Token 头,不匹配返回 401。
并发模拟:可通过 ?delay=N 让接口 sleep N 秒(默认 1)。
@@ -213,6 +214,7 @@ def create_app(port: int, password: str) -> FastAPI:
# --- 接口5 ---
@app.post("/api/v1/hairline/generate")
@app.post("/api/v1/hairline/generate-52")
async def hairline_generate(
request: Request,
x_internal_token: Optional[str] = Header(default=None),
@@ -245,6 +247,14 @@ def create_app(port: int, password: str) -> FastAPI:
],
"best_hairline_center_point": {"x": 540, "y": 430},
}
if request.url.path.endswith("generate-52"):
for item in data["hairline_images"]:
item["image_high_base64"] = None
item["image_low_base64"] = None
data.update(high_hairline_center_point=None, low_hairline_center_point=None,
face_measure=None, framing=None, height_rule=None,
guide_landmarks={}, input_image_base64=_png, original_image_base64=_png,
coordinate_space="original_image", width=1, height=1)
return mock_ok(data, delay=delay)
return app
+1 -1
View File
@@ -7,7 +7,7 @@ Wants=comfyui.service change_hair-hair.service
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/hair
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187
ExecStart=/home/ubuntu/hair/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8187 --no-proxy-headers
Restart=on-failure
RestartSec=5
+6 -6
View File
@@ -44,18 +44,18 @@ class FaceLandmarker:
self._detector = vision.FaceLandmarker.create_from_options(options)
self._mp = mp
def detect(self, image_rgb: np.ndarray) -> np.ndarray | None:
"""Returns (468, 3) float32 of normalized x, y and relative z, or None.
def detect(self, image_rgb: np.ndarray, include_iris: bool = False) -> np.ndarray | None:
"""Returns (468, 3), or (478, 3) with iris, normalized float32 points.
The task model produces 478 landmarks (468 face + 10 iris); we return
only the first 468 to match the canonical FaceMesh topology used by
the SDK's OBJ file.
The task model produces 478 landmarks (468 face + 10 iris). By default
only the first 468 match the SDK's canonical FaceMesh topology;
include_iris=True retains all 478 for face-measurement calibration.
"""
mp_image = self._mp.Image(image_format=self._mp.ImageFormat.SRGB, data=image_rgb)
result = self._detector.detect(mp_image)
if not result.face_landmarks:
return None
lm = result.face_landmarks[0][:468]
lm = result.face_landmarks[0] if include_iris else result.face_landmarks[0][:468]
arr = np.array([[p.x, p.y, p.z] for p in lm], dtype=np.float32)
return arr
+218
View File
@@ -0,0 +1,218 @@
"""Interface 52: upright face axis, then native-resolution reference framing."""
from __future__ import annotations
from functools import lru_cache
from io import BytesIO
import json
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageOps
PROFILE_PATH = Path(__file__).parent / "reference/framing_profile.json"
def decode_portrait(raw):
"""Respect phone EXIF orientation before detecting or transforming points."""
try:
with Image.open(BytesIO(raw)) as image:
return cv2.cvtColor(np.array(ImageOps.exif_transpose(image).convert("RGB")), cv2.COLOR_RGB2BGR)
except Exception:
return None
@lru_cache(maxsize=1)
def load_profile():
return json.loads(PROFILE_PATH.read_text(encoding="utf-8"))
def transform_points(points, matrix):
return np.asarray(points, np.float64) @ matrix[:, :2].T + matrix[:, 2]
def _rotation(landmarks, width, height):
xy = landmarks[:, :2].astype(np.float64) * [width, height]
delta = xy[152] - xy[9]
length = float(np.linalg.norm(delta))
if not np.isfinite(xy).all() or length < 2 or delta[1] <= 0:
raise ValueError("9号点与152号点无效,无法扶正人脸")
down = delta / length
rotation = np.array([[down[1], -down[0]], [down[0], down[1]]], np.float64)
return xy, rotation, length
def _matrix(rotation, source_anchor, target_anchor):
return np.column_stack((rotation, np.asarray(target_anchor) - rotation @ source_anchor))
def crop_fits(matrix, source_size, output_size):
w, h = output_size
corners = transform_points([[0, 0], [w-1, 0], [w-1, h-1], [0, h-1]], cv2.invertAffineTransform(matrix))
sw, sh = source_size
return bool((corners[:, 0] >= -1e-7).all() and (corners[:, 0] <= sw-1+1e-7).all()
and (corners[:, 1] >= -1e-7).all() and (corners[:, 1] <= sh-1+1e-7).all())
def _inscribed_frame(rotation, source_size, aspect=None):
"""Largest centred valid rectangle: no generated background or upscaling."""
sw, sh = source_size
ratio = sw / sh if aspect is None else aspect
source_center = np.array([(sw-1)/2, (sh-1)/2])
low, high = 2.0, min(float(sh), sw/ratio)
for _ in range(48):
h = (low+high)/2
w = h*ratio
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
if crop_fits(matrix, source_size, (w, h)):
low = h
else:
high = h
if aspect == .75:
h = max(4, int(low+1e-7)//4*4)
w = h//4*3
else:
h, w = max(2, int(low+1e-7)), max(2, int(low*ratio+1e-7))
matrix = _matrix(rotation, source_center, [(w-1)/2, (h-1)/2])
if not crop_fits(matrix, source_size, (w, h)):
raise ValueError("图片边缘空间不足,无法得到有效扶正画面")
return matrix, (w, h)
def _tall_portrait_frame(xy, rotation, source_size, profile):
"""Largest native 3:4 frame, positioned to retain the entire face."""
_, size = _inscribed_frame(rotation, source_size, aspect=.75)
half = (np.array(size, np.float64)-1)/2
extent = np.abs(rotation.T) @ half
low, high = extent, np.array(source_size)-1-extent
polygon = [np.array(p) for p in ((low[0], low[1]), (high[0], low[1]),
(high[0], high[1]), (low[0], high[1]))]
face = xy[:468] @ rotation.T
# The crop centre must satisfy both the source boundaries and the face
# bounds. Clip the feasible polygon in double precision to avoid padding.
face_low, face_high = face.max(axis=0)-half, face.min(axis=0)+half
for axis in range(2):
for normal, bound in ((rotation[axis], face_high[axis]),
(-rotation[axis], -face_low[axis])):
clipped = []
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
a, b = float(normal @ start-bound-1e-8), float(normal @ end-bound-1e-8)
if (a <= 0) != (b <= 0):
clipped.append(start+(end-start)*(a/(a-b)))
if b <= 0:
clipped.append(end)
polygon = clipped
if not polygon:
raise ValueError("3:4裁切窗口无法保留完整人脸")
target = np.array([profile["brow_x_ratio"]*size[0], profile["brow_y_ratio"]*size[1]])
preferred = xy[9]-rotation.T @ (target-half)
# Euclidean projection keeps the brow as close as possible to its target.
candidates = []
if (preferred >= low-1e-8).all() and (preferred <= high+1e-8).all():
upright = rotation @ preferred
if (upright >= face_low-1e-8).all() and (upright <= face_high+1e-8).all():
candidates.append(preferred)
for start, end in zip(polygon, polygon[1:]+polygon[:1]):
edge = end-start
length2 = float(edge @ edge)
t = np.clip(float((preferred-start) @ edge)/length2, 0, 1) if length2 > 1e-16 else 0
candidates.append(start+t*edge)
centre = min(candidates, key=lambda p: float(np.sum((p-preferred)**2)))
matrix = _matrix(rotation, centre, half)
if not crop_fits(matrix, source_size, size):
raise ValueError("3:4裁切窗口超出原图有效范围")
return matrix, size
def _apply(photo, landmarks, matrix, size):
h, w = photo.shape[:2]
ow, oh = size
moved = np.asarray(landmarks, np.float64).copy()
moved[:, :2] = transform_points(landmarks[:, :2]*[w, h], matrix) / [ow, oh]
# MediaPipe Z is measured in normalized image-width units.
moved[:, 2] *= w/ow
result = cv2.warpAffine(photo, matrix, size, flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0))
return result, moved
def calibrate_reference(photo, landmarks):
"""First straighten the actual reference; derive ratios from its final frame."""
h, w = photo.shape[:2]
_xy, rotation, length = _rotation(landmarks, w, h)
matrix, size = _inscribed_frame(rotation, (w, h), aspect=.75)
image, moved = _apply(photo, landmarks, matrix, size)
ow, oh = size
b, c = moved[[9, 152], :2]*[ow, oh]
profile = {
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
"reference_image_url": "/static/framing_reference.png",
"reference_size": {"width": ow, "height": oh},
"source_size": {"width": w, "height": h},
"brow_9": {"x": float(b[0]), "y": float(b[1])},
"chin_152": {"x": float(c[0]), "y": float(c[1])},
"face_axis_length_px": length,
"brow_x_ratio": float(b[0]/ow), "brow_y_ratio": float(b[1]/oh),
"reference_width_per_face_length": ow/length,
"reference_height_per_face_length": oh/length,
"width_per_face_length": 2.5, "height_per_face_length": 3.0/.9,
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
"axis_x_error_px": float(abs(b[0]-c[0])),
"source_to_reference": matrix.tolist(),
"landmark_source": "source_landmarks_transformed_without_redetection",
}
return image, moved, profile
def normalize_portrait(photo, landmarks, profile=None):
profile = load_profile() if profile is None else profile
h, w = photo.shape[:2]
xy, rotation, length = _rotation(landmarks, w, h)
# Round to an exact 3:4 frame without changing pixel scale.
target_h = max(4, round(profile["height_per_face_length"]*length/4)*4)
target_w = target_h//4*3
matrix = _matrix(rotation, xy[9], [profile["brow_x_ratio"]*target_w,
profile["brow_y_ratio"]*target_h])
cropped = crop_fits(matrix, (w, h), (target_w, target_h))
aspect_cropped = False
aspect_skip_reason = None
if cropped:
size = (target_w, target_h)
elif h*3 > w*4:
try:
matrix, size = _tall_portrait_frame(xy, rotation, (w, h), profile)
aspect_cropped = True
except ValueError as ex:
aspect_skip_reason = str(ex)
matrix, size = _inscribed_frame(rotation, (w, h))
else:
matrix, size = _inscribed_frame(rotation, (w, h))
image, moved = _apply(photo, landmarks, matrix, size)
ow, oh = size
axis = moved[[9, 152], :2]*[ow, oh]
if not ((axis[:, 0] >= 0).all() and (axis[:, 0] < ow).all()
and (axis[:, 1] >= 0).all() and (axis[:, 1] < oh).all()):
raise ValueError("人脸过于贴近边缘,扶正后无法保留9号点与152号点")
metadata = {
"enabled": True, "standard_crop": cropped, "aspect_crop": aspect_cropped,
"aspect_crop_skip_reason": aspect_skip_reason,
"status": "reference_crop" if cropped else ("aspect_crop" if aspect_cropped else "rotation_only"),
"skip_reason": None if cropped else "insufficient_space",
"message": ("已扶正并按参考图裁切" if cropped else
"原图偏长,已扶正并裁成3:4,保留完整人脸" if aspect_cropped else
"3:4窗口无法完整保留人脸,已仅扶正" if aspect_skip_reason else
"空间不足,仅扶正,未按参考比例裁切"),
"rotation_degrees": float(np.degrees(np.arctan2(rotation[0, 1], rotation[0, 0]))),
"source_size": {"width": w, "height": h}, "output_size": {"width": ow, "height": oh},
"requested_crop_size": {"width": target_w, "height": target_h},
"face_axis_length_px": length,
"height_per_face_length": profile["height_per_face_length"],
"width_per_face_length": profile["height_per_face_length"]*.75,
"source_to_processed": matrix.tolist(),
"processed_to_source": cv2.invertAffineTransform(matrix).tolist(),
"axis_x_error_px": float(abs(axis[0, 0]-axis[1, 0])),
"profile_version": profile["version"],
"reference_image_url": profile["reference_image_url"],
}
return image, moved, metadata
+117
View File
@@ -0,0 +1,117 @@
"""接口52设计高度:上庭 = (中庭 + 下庭) / 2;不改变接口2/5的几何。"""
from __future__ import annotations
import cv2
import numpy as np
from .mask import mask_from_curve
def height_target(landmarks, width, height, reference=None):
"""Use unrounded measurement coordinates; fall back to the same MP indices.
Distances use image Y, matching face_analysis.measure's court heights.
No rounded centimetre values or existing-hairline estimates enter the rule.
"""
scale = np.array([width, height], dtype=np.float64)
if reference is None:
brow, nose, chin = landmarks[[9, 94, 152], :2] * scale
source = "hairline_landmarks"
px_per_cm = None
else:
brow = np.asarray(reference["brow_center"], dtype=np.float64)
nose = np.asarray(reference["nose_bottom"], dtype=np.float64)
chin = np.asarray(reference["chin_tip"], dtype=np.float64)
source = "face_measure"
px_per_cm = reference.get("px_per_cm")
if not np.isfinite([brow, nose, chin]).all() or not brow[1] < nose[1] < chin[1]:
raise ValueError("眉心、鼻翼下缘与下巴坐标异常,无法计算目标上庭高度")
middle, lower = float(nose[1] - brow[1]), float(chin[1] - nose[1])
upper = (middle + lower) / 2.0
target_y = float(brow[1] - upper)
if not (0 <= brow[0] < width and 1 <= target_y < height - 1):
raise ValueError("按中下庭均值计算的目标发际线超出图片范围,请使用完整正面照")
def pt(p):
return {"x": float(p[0]), "y": float(p[1])}
return {
"name": "court_average",
"formula": "上庭 = (中庭 + 下庭) / 2",
"measurement_source": source,
"brow_center": pt(brow), "nose_bottom": pt(nose), "chin_tip": pt(chin),
"middle_height_px": middle, "lower_height_px": lower,
"target_upper_height_px": upper,
"target_upper_height_cm": upper / px_per_cm if px_per_cm else None,
"target_center": {"x": float(brow[0]), "y": target_y},
}
def clean_curve(overlay):
"""Keep the white design line; discard opaque black texture-border pixels."""
out = np.zeros_like(overlay, dtype=np.float32)
out[:, :, :3] = 255
out[:, :, 3] = overlay[:, :, 3] * (overlay[:, :, :3].mean(axis=2) > 128)
return out
def curve_center(overlay, center_x):
"""Alpha-weighted centre of the line at the facial midline, in image pixels."""
h, w = overlay.shape[:2]
column = int(round(center_x))
if not 0 <= column < w:
raise ValueError("面部中轴超出图片范围")
alpha = overlay[:, column, 3].astype(np.float64)
alpha[alpha <= 1] = 0
if not alpha.any():
# Rasterized endpoints may leave a 1px gap, but distant sideburns
# must never be silently substituted for a missing central curve.
columns = np.flatnonzero((overlay[:, :, 3] > 1).any(axis=0))
if not columns.size or np.min(np.abs(columns - column)) > 2:
raise ValueError("发际线模板在面部中轴处没有有效曲线")
column = int(columns[np.argmin(np.abs(columns - column))])
alpha = overlay[:, column, 3].astype(np.float64)
alpha[alpha <= 1] = 0
return {"x": float(center_x), "y": float(np.dot(np.arange(h), alpha) / alpha.sum())}
def place_curve(overlay, target, landmarks):
"""Rigidly shift a rendered template along face-up to meet the target Y.
Keeping the curve rigid preserves roll and the template's shape. Pixel Y
remains the height convention, even for a rolled face. A short correction
compensates for the curve's slope as the local-axis shift also changes X.
"""
curve = clean_curve(overlay)
h, w = curve.shape[:2]
center_x, target_y = target["x"], target["y"]
before = curve_center(curve, center_x)
up = (landmarks[10, :2] - landmarks[152, :2]) * [w, h]
if abs(up[1]) < 1e-6:
raise ValueError("无法确定人脸向上方向")
x_per_y = float(np.clip(up[0] / up[1], -1.0, 1.0))
dy = target_y - before["y"]
moved = curve
for _ in range(8):
transform = np.array([[1, 0, dy * x_per_y], [0, 1, dy]], np.float32)
moved = cv2.warpAffine(curve, transform, (w, h), flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
center = curve_center(moved, center_x)
residual = target_y - center["y"]
if abs(residual) <= 0.15:
break
dy += residual
if abs(center["y"] - target_y) > 1.0:
raise ValueError("发际线中心无法对齐目标高度,请检查照片姿态与裁剪范围")
return moved, {
"old_center": before, "new_center": center,
"shift_x_px": float(dy * x_per_y), "shift_y_px": float(dy),
"target_error_px": float(abs(center["y"] - target_y)),
}
def inpaint_from_curve(photo, landmarks, parse_map, overlay):
"""The preview line is also the exact boundary used for the black line/mask."""
alpha = np.clip(overlay[:, :, 3:4] / 255.0, 0, 1)
marked = np.clip(photo * (1.0 - alpha), 0, 255).astype(np.uint8)
curve = (overlay[:, :, 3] > 40).astype(np.uint8) * 255
mask = mask_from_curve(curve, landmarks, parse_map)
return marked, mask
+43
View File
@@ -0,0 +1,43 @@
{
"version": "2026-11-04-upright-9-152-subject-90pct-tall-3x4",
"source_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg",
"reference_image_url": "/static/framing_reference.png",
"reference_size": {
"width": 786,
"height": 1048
},
"source_size": {
"width": 792,
"height": 1056
},
"brow_9": {
"x": 385.1878369059212,
"y": 360.65437126595765
},
"chin_152": {
"x": 385.18783690592113,
"y": 725.8251047982238
},
"face_axis_length_px": 365.1707335322661,
"brow_x_ratio": 0.4900608612034621,
"brow_y_ratio": 0.3441358504446161,
"reference_width_per_face_length": 2.152417835890317,
"reference_height_per_face_length": 2.8698904478537566,
"width_per_face_length": 2.5,
"height_per_face_length": 3.333333333333333,
"rotation_degrees": 0.2596248408989324,
"axis_x_error_px": 5.684341886080802e-14,
"source_to_reference": [
[
0.9999897336401403,
0.004531292786961665,
-5.386196599797756
],
[
-0.004531292786961665,
0.9999897336401403,
-2.202458197930582
]
],
"landmark_source": "source_landmarks_transformed_without_redetection"
}
+77 -2
View File
@@ -22,6 +22,7 @@ from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
from .marker_detect import detect_marker_hairline, path_to_curve_mask
from .height_rule import height_target, clean_curve, place_curve, inpaint_from_curve
import base64
import io
@@ -178,7 +179,7 @@ def extract_502(image_bgr: np.ndarray):
return ctx["points"], ctx["valid"]
def extract_context(image_bgr: np.ndarray):
def extract_context(image_bgr: np.ndarray, landmarks: np.ndarray | None = None):
"""照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。
发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制:
@@ -189,7 +190,8 @@ def extract_context(image_bgr: np.ndarray):
fallback 点不会再跑出头部轮廓。
"""
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
landmarks = get_landmarker().detect(rgb)
if landmarks is None:
return None
parse_map = get_parser().parse(rgb)
@@ -538,6 +540,79 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
return {"images": images, "best_centers": best_centers}
def generate_hairline_pngs_52(image_bgr: np.ndarray, gender: str,
hair_styles: list[int], use_mask: bool = True,
prompt: str | None = None,
generate_grow_image: bool = True,
height_reference: dict | None = None,
compare_rules: bool = False,
precomputed_landmarks: np.ndarray | None = None):
"""接口52:每种 middle 曲线中点对齐到眉心上方 (中庭+下庭)/2。
只读取 middle 贴图,high/low 保留空值。生发直接复用移动后的预览曲线,
不再使用位置不同的黑模板。compare_rules 仅为调试页附带旧规则叠图。
接口2仍使用原有管线。
"""
if gender not in ("male", "female"):
raise ValueError(f"gender 必须是 male/female,收到 {gender!r}")
if not hair_styles:
raise ValueError("hair_styles 必填且不能为空")
ctx = extract_context(image_bgr, landmarks=precomputed_landmarks)
if ctx is None:
return None
h, w = image_bgr.shape[:2]
uv, ext_faces = load_ext_mesh()
rule = height_target(ctx["landmarks"], w, h, height_reference)
textures = get_texture_map()[gender]
# use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用
# generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None
shared_grown = None
if generate_grow_image and not use_mask:
shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)
images, best_centers = [], None
for s in hair_styles: # s = 1-indexed 发型序号
key, mid_path = textures[s - 1]
old = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, load_texture_rgba(mid_path))
overlay, placement = place_curve(old, rule["target_center"], ctx["landmarks"])
overlays = {"middle": overlay, "high": None, "low": None}
if not generate_grow_image:
grown_png = None
elif not use_mask:
grown_png = shared_grown
else:
grown_png = None
try:
marked, mask = inpaint_from_curve(
image_bgr, ctx["landmarks"], ctx["parse_map"], overlay)
buf = io.BytesIO()
compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
except Exception as ex: # 单张生发失败不丢弃设计曲线
logger.warning("接口52 生发失败 type=%s:%s", key, ex)
# Flux may round dimensions to a multiple of its latent stride.
# Restore the input size so the preview and generated photo align.
if grown_png:
grown_png = _upscale_png_to(grown_png, w, h)
item = {"hairline_type": key, "order": s, "overlays": overlays,
"grown_png": grown_png, "height_placement": placement}
if compare_rules:
item["legacy_overlay"] = clean_curve(old)
images.append(item)
if best_centers is None:
center = placement["new_center"]
best_centers = {"middle": (round(center["x"]), round(center["y"])),
"high": None, "low": None}
guide_landmarks = {
str(index): {"x": float(ctx["landmarks"][index, 0] * w),
"y": float(ctx["landmarks"][index, 1] * h)}
for index in (9, 150, 152, 162, 251, 389)
}
return {"images": images, "best_centers": best_centers, "height_rule": rule,
"guide_landmarks": guide_landmarks}
# 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。
# 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))
+3
View File
@@ -0,0 +1,3 @@
{
"allowed_ips": ["49.232.51.171"]
}
+77
View File
@@ -0,0 +1,77 @@
"""Direct-peer IP access policy for the GPU worker's dynamic endpoints."""
import ipaddress
import json
from pathlib import Path
import socket
CONFIG_PATH = Path(__file__).resolve().parent / "ip_access.json"
def _address(value):
address = ipaddress.ip_address(value)
return address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped else address
def local_addresses():
addresses = {_address("127.0.0.1"), _address("::1")}
try:
addresses.update(_address(item[4][0].split("%", 1)[0])
for item in socket.getaddrinfo(socket.gethostname(), None))
except (OSError, ValueError):
pass
# Include all assigned Linux interface addresses, not entire private ranges.
try:
import fcntl
import struct
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
for _, name in socket.if_nameindex():
try:
data = fcntl.ioctl(sock.fileno(), 0x8915, struct.pack("256s", name.encode()[:15]))
addresses.add(_address(socket.inet_ntoa(data[20:24])))
except OSError:
continue
path = Path("/proc/net/if_inet6")
if path.exists():
addresses.update(ipaddress.IPv6Address(line.split()[0]) for line in path.read_text().splitlines())
except (ImportError, OSError, ValueError):
pass
return addresses
def _networks(values):
if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
raise ValueError("IP配置必须是字符串列表")
return [ipaddress.ip_network(value, strict=False) for value in values]
def _matches(address, networks):
return any(address.version == network.version and address in network for network in networks)
def check_access(request):
"""Return (allowed, reason), re-reading the whitelist for each request."""
try:
config = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
allowed = _networks(config.get("allowed_ips", []))
if request.client is None or request.client.port == 0:
# Uvicorn proxy middleware has discarded the socket peer at port 0.
# Reject rather than treating an unverified forwarded IP as local.
return False, "无法确认直连来源,请使用 --no-proxy-headers 启动服务"
peer = _address(request.client.host)
# Authorize the gateway's socket IP, never its end-user forwarding headers.
if peer.is_unspecified:
return False, "来源地址无效"
return (True, "") if peer.is_loopback or peer in local_addresses() or _matches(peer, allowed) else (False, "来源IP不在接口白名单")
except (OSError, ValueError, TypeError, AttributeError):
return False, "IP白名单配置或来源地址无效"
def rejection(request):
from fastapi.responses import JSONResponse
if request.url.path == "/static" or request.url.path.startswith("/static/"):
return None
allowed, reason = check_access(request)
if allowed:
return None
return JSONResponse(status_code=403, content={"code": 1010, "message": reason,
"request_id": "ip-access", "data": None})
+2 -2
View File
@@ -31,11 +31,11 @@ fi
if [ "${FOREGROUND:-0}" = "1" ]; then
echo ">> 前台运行 worker: ${HOST}:${PORT}"
exec "$VENV_UVICORN" app:app --host "$HOST" --port "$PORT"
exec "$VENV_UVICORN" app:app --host "$HOST" --port "$PORT" --no-proxy-headers
fi
echo ">> 后台启动 worker: ${HOST}:${PORT}"
nohup "$VENV_UVICORN" app:app --host "$HOST" --port "$PORT" >> "$LOG_FILE" 2>&1 &
nohup "$VENV_UVICORN" app:app --host "$HOST" --port "$PORT" --no-proxy-headers >> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
sleep 1
if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Straighten the specified reference first, then recalibrate framing ratios."""
import json
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
import cv2
from hairline.framing import calibrate_reference, decode_portrait, PROFILE_PATH
from hairline.service import get_landmarker
if __name__ == "__main__":
source = Path(sys.argv[1]) if len(sys.argv)>1 else ROOT/"tests/output/upload_framing/reference.jpg"
photo = decode_portrait(source.read_bytes())
if photo is None:
raise ValueError("Reference image cannot be decoded")
landmarks = get_landmarker().detect(cv2.cvtColor(photo, cv2.COLOR_BGR2RGB), include_iris=True)
if landmarks is None:
raise ValueError("Reference face not detected")
image, _, profile = calibrate_reference(photo, landmarks)
cv2.imwrite(str(ROOT/"static/framing_reference.png"), image)
PROFILE_PATH.write_text(json.dumps(profile, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
print(json.dumps(profile, ensure_ascii=False, indent=2))
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""保存接口52旧/新高度规则对比,供 static/test_interface52_compare.html 展示。
默认验证用户清单全部30张,男女各7型,共420组;只跑定位,不调用ComfyUI。
用法:./venv/bin/python scripts/compare_hairline_height.py
可用 --cases 1,2,3,4,5 只验证指定案例。
"""
from __future__ import annotations
import argparse
import base64
from datetime import datetime
import json
import os
from pathlib import Path
import time
from zoneinfo import ZoneInfo
import cv2
import httpx
import numpy as np
ROOT = Path(__file__).resolve().parent.parent
def run(args):
manifest = json.loads((ROOT / "tests/fixtures/hairline_height_cases.json").read_text())
available = {c["image_number"]: c for c in manifest["cases"]}
requested = list(available) if args.cases == "all" else [int(n) for n in args.cases.split(",")]
cases = [available[n] for n in requested]
inputs = ROOT / "tests/output/hairline_height_compare/inputs"
inputs.mkdir(parents=True, exist_ok=True)
output = ROOT / "static/annotations/hairline_height_compare"
output.mkdir(parents=True, exist_ok=True)
report = {
"created_at": datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(),
"case_numbers": requested, "groups": [], "errors": [],
}
started = time.monotonic()
with httpx.Client(timeout=180, follow_redirects=True) as client:
for case in cases:
number = case["image_number"]
source = inputs / f"case_{number}.png"
if not source.exists():
response = client.get(case["image_url"])
response.raise_for_status()
source.write_bytes(response.content)
photo = cv2.imdecode(np.frombuffer(source.read_bytes(), np.uint8), cv2.IMREAD_COLOR)
if photo is None:
raise ValueError(f"案例 {number} 无法解码")
# Keep the source photo available even when positioning fails.
original_path = output / f"case_{number}_original.jpg"
cv2.imwrite(str(original_path), photo, [cv2.IMWRITE_JPEG_QUALITY, 95])
original_url = "/static/annotations/hairline_height_compare/" + original_path.name
# Interface 52 frames native-resolution input before any size changes.
content = cv2.imencode(".png", photo)[1].tobytes()
for gender in args.genders.split(","):
step = time.monotonic()
try:
response = client.post(args.base_url.rstrip("/") + "/api/v1/debug/hairline-height-compare",
files={"image_file": (f"case_{number}.png", content, "image/png")},
data={"gender": gender, "hair_style": "1,2,3,4,5,6,7"})
response.raise_for_status()
body = response.json()
if body["code"] != 0:
raise ValueError(body["message"])
group = body["data"]
group.update(case_number=number, source_url=case["image_url"],
elapsed_seconds=round(time.monotonic() - step, 2))
def save_image(obj, field, suffix):
value = obj.pop(field + "_base64", None)
if not value:
return
# Both gender sets use the same source and processed
# photos; share their URLs and browser image cache.
prefix = f"case_{number}" if obj is group else f"case_{number}_{gender}"
path = output / f"{prefix}_{suffix}"
path.write_bytes(base64.b64decode(value.split(",", 1)[-1]))
obj[field + "_url"] = "/static/annotations/hairline_height_compare/" + path.name
save_image(group, "input", "input.jpg")
save_image(group, "original", "original.jpg")
for result in group["results"]:
save_image(result, "old_overlay", f"{result['order']}_old.png")
save_image(result, "new_overlay", f"{result['order']}_new.png")
save_image(result, "grown_image", f"{result['order']}_grown.jpg")
if result["height_placement"]["target_error_px"] > 1:
raise ValueError(f"{result['hairline_type']} 中心误差超过1像素")
if group["framing"]["axis_x_error_px"] > 1:
raise ValueError("扶正后的9/152横向误差超过1像素")
report["groups"].append(group)
print(f"case {number} / {gender}: {len(group['results'])} shapes OK ({group['elapsed_seconds']}s)", flush=True)
except Exception as ex:
report["errors"].append({"case_number": number, "gender": gender, "message": str(ex),
"source_url": case["image_url"], "original_url": original_url,
"width": photo.shape[1], "height": photo.shape[0]})
print(f"case {number} / {gender}: FAILED {ex}", flush=True)
# Atomically expose partial results to the page while running.
temporary = output / "latest.json.tmp"
temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2))
os.replace(temporary, output / "latest.json")
results = [r for g in report["groups"] for r in g["results"]]
summary = {
"cases": len(cases), "successful_groups": len(report["groups"]),
"comparisons": len(results), "failures": len(report["errors"]),
"max_center_error_px": max((r["height_placement"]["target_error_px"] for r in results), default=None),
"elapsed_seconds": round(time.monotonic() - started, 2),
}
report["summary"] = summary
temporary = output / "latest.json.tmp"
temporary.write_text(json.dumps(report, ensure_ascii=False, indent=2))
os.replace(temporary, output / "latest.json")
print(json.dumps(summary, ensure_ascii=False), flush=True)
if report["errors"]:
raise SystemExit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--cases", default="all", help="all 或逗号分隔的图片编号")
parser.add_argument("--genders", default="female,male")
parser.add_argument("--base-url", default="http://127.0.0.1:8187")
run(parser.parse_args())
Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

File diff suppressed because one or more lines are too long
+254
View File
@@ -0,0 +1,254 @@
"use strict";
const $ = id => document.getElementById(id);
const styleNames = {
female: ["椭圆形", "花瓣形", "心形", "直线形", "波浪形", "大花瓣形", "古典花瓣形"],
male: ["椭圆形", "倒弧形", "M 型", "直线形", "心形", "柔和花瓣形", "波浪形"]
};
let dataset = {groups: [], errors: []}, mode = "side", drawVersion = 0, busy = false, cardObserver = null;
const inputImages = new Map();
const rowSelections = new Map();
const esc = value => String(value).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const fixed = (value, n = 2) => Number(value).toFixed(n);
const imageSource = (object, field, mime = "image/png") => object[field + "_url"] || window.resolveImgSrc(null, object[field + "_base64"], mime);
function status(text, error = false) { $("status").textContent = text; $("status").classList.toggle("error", error); }
function loadImage(src) {
return new Promise((resolve, reject) => {
const im = new Image();
im.onload = () => resolve(im); im.onerror = () => reject(new Error("无法读取对比图片,请重新运行案例")); im.src = src;
});
}
function inputImage(group) {
const src = imageSource(group, "input", "image/jpeg");
if (!inputImages.has(src)) inputImages.set(src, loadImage(src));
return inputImages.get(src);
}
function originalCard(group,caseName){
const src=imageSource(group,"original","image/jpeg")||group.source_url||imageSource(group,"input","image/jpeg");
const size=group.framing?.source_size||{width:group.width,height:group.height};
const dimensions=size.width&&size.height?`width="${size.width}" height="${size.height}"`:"";
const sizeLabel=size.width&&size.height?`原尺寸 ${size.width}×${size.height}`:"完整上传图片";
const panel=document.createElement("article");panel.className="original-reference-card";
panel.innerHTML=`<div class="case-header"><div><span class="case-number original-reference-label">未裁切原图</span><h3>${esc(caseName)} · 上传原图</h3><p>处理前完整图片</p></div></div><div class="metric-line">${sizeLabel}</div><div class="image-pair overlay"><figure><figcaption>未扶正、未裁切</figcaption><a href="${esc(src)}" target="_blank" rel="noopener"><img src="${esc(src)}" ${dimensions} alt="${esc(caseName)}未裁切原图" loading="lazy" decoding="async"></a></figure></div><div class="case-bottom"><span>保留完整画面</span><a href="${esc(src)}" target="_blank" rel="noopener">查看原图 ↗</a></div>`;
return panel;
}
function failureCard(errors){
const panel=document.createElement("article");panel.className="failed-reference-card";
panel.innerHTML='<h3>定位失败,未生成对比图</h3><p>左侧保留完整上传原图。</p>';
for(const error of errors){
const message=document.createElement("p");
message.textContent=`${error.gender==="female"?"女性":"男性"}7型:${error.message}`;
panel.append(message);
}
return panel;
}
function refreshStyleOptions() {
const old = $("styleFilter").value || "all", gender = $("genderFilter").value;
$("styleFilter").replaceChildren();
for (let i = 0; i < 7; i++) {
const label = gender === "all" ? `${i+1}. 女:${styleNames.female[i]} / 男:${styleNames.male[i]}` : `${i+1}. ${styleNames[gender][i]}`;
$("styleFilter").add(new Option(label, String(i+1)));
}
$("styleFilter").add(new Option("全部 7 种形状", "all"));
$("styleFilter").value = old;
}
function refreshCases() {
const selected = $("caseFilter").value;
const cases = [...new Set([...dataset.groups,...dataset.errors].map(g => String(g.case_number)))].sort((a,b)=>a.localeCompare(b,"en",{numeric:true}));
$("caseFilter").replaceChildren(new Option(`全部 ${cases.length} 个案例`, "all"));
for (const key of cases) $("caseFilter").add(new Option(key.startsWith("custom") ? "自定义图片" : `案例 ${key}`, key));
if (cases.includes(selected)) $("caseFilter").value = selected;
}
function refreshStats() {
const results = dataset.groups.flatMap(g => g.results);
const errors = results.map(r => r.height_placement.target_error_px);
$("statCases").textContent = new Set([...dataset.groups,...dataset.errors].map(g => g.case_number)).size;
$("statComparisons").textContent = results.length;
$("statError").textContent = errors.length ? `${fixed(Math.max(...errors))} px` : "—";
$("statStatus").textContent = busy ? "运行中" : (dataset.errors.length ? "有异常" : (results.length ? "已完成" : "待运行"));
$("runTime").textContent = dataset.created_at ? `更新于 ${new Date(dataset.created_at).toLocaleString("zh-CN", {timeZone:"Asia/Shanghai",hour12:false})}` : "尚无保存结果";
}
function cropBox(group) {
if (!$("zoomFace").checked) return [0, 0, group.width, group.height];
if (group.crop_box) return group.crop_box;
const rule = group.height_rule, upper = rule.target_upper_height_px, center = rule.target_center;
const oldY = group.results.map(r => r.height_placement.old_center.y);
const left = Math.max(0, center.x - 1.7 * upper), top = Math.max(0, Math.min(center.y, ...oldY) - .55 * upper);
const right = Math.min(group.width, center.x + 1.7 * upper), bottom = Math.min(group.height, rule.brow_center.y + .75 * upper);
return [left, top, right, bottom];
}
function paint(canvas, photo, oldLine, newLine, group, kind) {
const [x0,y0,x1,y1] = cropBox(group), cw = x1-x0, ch = y1-y0;
canvas.width = kind === "overlay" ? 1000 : 650;
canvas.height = Math.round(canvas.width * ch / cw);
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height;
ctx.drawImage(photo, x0,y0,cw,ch,0,0,W,H);
const tint = (line, color) => {
const layer = document.createElement("canvas"); layer.width=W; layer.height=H;
const ink=layer.getContext("2d"); ink.drawImage(line,x0,y0,cw,ch,0,0,W,H);
ink.globalCompositeOperation="source-in";ink.fillStyle=color;ink.fillRect(0,0,W,H);ctx.drawImage(layer,0,0);
};
if (kind !== "new") tint(oldLine, "#f3a338");
if (kind !== "old") tint(newLine, "#10c6b6");
if ($("showTarget").checked) {
const target = group.height_rule.target_center, tx=(target.x-x0)/cw*W, ty=(target.y-y0)/ch*H;
ctx.save();ctx.strokeStyle="rgba(255,255,255,.85)";ctx.lineWidth=1.7;ctx.setLineDash([7,6]);
ctx.beginPath();ctx.moveTo(0,ty);ctx.lineTo(W,ty);ctx.stroke();ctx.setLineDash([]);
ctx.fillStyle="#fff";ctx.beginPath();ctx.arc(tx,ty,4,0,2*Math.PI);ctx.fill();ctx.restore();
}
if(kind!=="old"&&$("showBox").checked&&group.guide_landmarks){
window.drawHairlineReferenceBox(ctx,group.guide_landmarks,[x0,y0,x1,y1],W,H);
}
}
async function render() {
if(cardObserver)cardObserver.disconnect();
const version=++drawVersion,gender=$("genderFilter").value,number=$("caseFilter").value,style=$("styleFilter").value;
const cases=new Map();
for(const group of dataset.groups){
if(gender!=="all"&&group.gender!==gender)continue;
if(number!=="all"&&String(group.case_number)!==number)continue;
for(const result of group.results){
if(style!=="all"&&result.order!==Number(style))continue;
if(!cases.has(group.case_number))cases.set(group.case_number,[]);
cases.get(group.case_number).push({group,result});
}
}
const visibleErrors=dataset.errors.filter(error=>(gender==="all"||error.gender===gender)&&(number==="all"||String(error.case_number)===number));
for(const error of visibleErrors)if(!cases.has(error.case_number))cases.set(error.case_number,[]);
$("results").replaceChildren();
let drawErrors=0,total=0;
const rowUpdates=[],drawJobs=new WeakMap();
cardObserver=new IntersectionObserver(entries=>{
for(const entry of entries)if(entry.isIntersecting){
cardObserver.unobserve(entry.target);drawJobs.get(entry.target)?.();
}
},{rootMargin:"600px 0px"});
for(const [caseNumber,cards] of [...cases].sort((a,b)=>String(a[0]).localeCompare(String(b[0]),"en",{numeric:true}))){
total+=cards.length;
const row=document.createElement("section");row.className="person-row";
const caseName=String(caseNumber).startsWith("custom")?"自定义图片":`案例 ${caseNumber}`;
const caseErrors=visibleErrors.filter(error=>error.case_number===caseNumber);
if(!cards.length){
row.classList.add("failed-case");
row.innerHTML=`<div class="person-row-header"><h2 class="case-group-heading">${esc(caseName)} · 定位失败</h2></div><div class="person-body"><div class="person-styles" role="region" tabindex="0" aria-label="${esc(caseName)}的原图与定位结果"></div></div>`;
row.querySelector(".person-styles").append(originalCard(caseErrors[0],caseName),failureCard(caseErrors));
$("results").append(row);continue;
}
row.innerHTML=`<div class="person-row-header"><h2 class="case-group-heading">${esc(caseName)} · ${cards.length} 种发际线样式</h2><div class="row-controls"><span>点击样式,左侧旧图同步切换</span><button type="button" aria-label="查看前面的样式">←</button><button type="button" aria-label="查看后面的样式">→</button></div></div><div class="person-body"><article class="old-reference-card"><div class="case-header"><div><span class="case-number old-reference-label"><span class="dot old"></span>旧规则 · 头发边界定位</span><h3 class="old-style-name">正在加载…</h3><p>与右侧选中的形状对应</p></div></div><div class="metric-line old-style-metrics"></div><div class="image-pair overlay"><figure><figcaption>当前选中样式的原位置</figcaption><canvas aria-label="旧规则参考图"></canvas></figure></div><div class="case-bottom">本行仅显示一次 · 点击右侧样式切换</div></article><div class="person-styles" role="region" tabindex="0" aria-label="${esc(caseName)}的全部新规则样式"></div></div>`;
$("results").append(row);
const groupForRow=cards[0].group,framing=groupForRow.framing;
if(framing){
const info=document.createElement("div");info.className="framing-info";
info.innerHTML=`${esc(framing.message)} · 旋转 ${fixed(framing.rotation_degrees,3)}° · ${framing.source_size.width}×${framing.source_size.height} → ${framing.output_size.width}×${framing.output_size.height} · 9/152 横向差 ${fixed(framing.axis_x_error_px,3)} px <a target="_blank" rel="noopener" href="${esc(imageSource(groupForRow,"original","image/jpeg")||groupForRow.source_url)}">上传原图 ↗</a> <a target="_blank" rel="noopener" href="${esc(imageSource(groupForRow,"input","image/jpeg"))}">处理后底图 ↗</a>`;
row.insertBefore(info,row.querySelector(".person-body"));
}
const strip=row.querySelector(".person-styles"),oldPanel=row.querySelector(".old-reference-card"),oldCanvas=oldPanel.querySelector("canvas");
// One horizontal row keeps the unprocessed photo first on narrow screens
// too, without squeezing the new styles into a zero-width column.
strip.append(originalCard(groupForRow,caseName),oldPanel);
if(caseErrors.length)strip.append(failureCard(caseErrors));
const [previous,next]=row.querySelectorAll(".row-controls button");
previous.addEventListener("click",()=>strip.scrollBy({left:-strip.clientWidth*.85,behavior:"smooth"}));
next.addEventListener("click",()=>strip.scrollBy({left:strip.clientWidth*.85,behavior:"smooth"}));
const updateArrows=()=>{previous.disabled=strip.scrollLeft<=2;next.disabled=strip.scrollWidth-strip.clientWidth-strip.scrollLeft<=2;};
strip.addEventListener("scroll",updateArrows,{passive:true});rowUpdates.push(updateArrows);
let selectionVersion=0;
const selectOld=async(group,result,drawNow=true)=>{
const selected=++selectionVersion,key=`${group.gender}:${result.order}`;
oldPanel.dataset.drawn="false";
rowSelections.set(caseNumber,key);row.dataset.selectedStyle=key;
for(const card of strip.querySelectorAll(".case-card")){
const active=card.dataset.styleKey===key;card.classList.toggle("selected",active);
card.setAttribute("aria-current",String(active));card.querySelector(".select-style").textContent=active?"已对照":"对照此样式";
}
const label=styleNames[group.gender][result.order-1];
oldPanel.querySelector(".old-style-name").textContent=`${group.gender==="female"?"女性":"男性"} · ${label}`;
oldPanel.querySelector(".old-style-metrics").textContent=`原中心 y ${fixed(result.height_placement.old_center.y)} · 目标 y ${fixed(group.height_rule.target_center.y)}`;
const box=cropBox(group);oldCanvas.width=650;oldCanvas.height=Math.round(650*(box[3]-box[1])/(box[2]-box[0]));
const draw=async()=>{
try{
const [photo,line]=await Promise.all([inputImage(group),loadImage(imageSource(result,"old_overlay"))]);
if(version!==drawVersion||selected!==selectionVersion)return;
paint(oldCanvas,photo,line,null,group,"old");oldPanel.dataset.drawn="true";
}catch(error){if(version===drawVersion)status(error.message,true);}
};
if(drawNow)await draw();
else{drawJobs.set(oldPanel,draw);cardObserver.observe(oldPanel);}
};
for(const {group,result} of cards){
const rule=group.height_rule,p=result.height_placement,label=styleNames[group.gender][result.order-1];
const card=document.createElement("article");card.className="case-card";card.tabIndex=0;
card.dataset.styleKey=`${group.gender}:${result.order}`;
card.innerHTML=`<div class="case-header"><div><span class="case-number">${group.gender==="female"?"女性":"男性"}发际线</span><h3>${esc(label)} <span style="font-size:11px;color:#7c8c88;font-weight:400">${esc(result.hairline_type)}</span></h3><p>目标上庭 (${fixed(rule.middle_height_px)} + ${fixed(rule.lower_height_px)}) ÷ 2 = ${fixed(rule.target_upper_height_px)} px</p></div></div><div class="metric-line"><span>新 y <b>${fixed(p.new_center.y)}</b></span><span><b>${p.shift_y_px<0?"上移":"下移"} ${fixed(Math.abs(p.shift_y_px))} px</b></span></div><div class="image-pair overlay"><figure><figcaption class="new"><span class="dot new"></span>${mode==="overlay"?"橙色旧线 / 青色新线":"新规则 · 中下庭均值"}</figcaption><canvas aria-label="新规则发际线"></canvas></figure></div><div class="case-bottom"><span>中心误差 ${fixed(p.target_error_px)} px</span><button class="select-style" type="button">对照此样式</button><a href="${esc(group.source_url||imageSource(group,"input","image/jpeg"))}" target="_blank" rel="noopener">原图 ↗</a></div>`;
strip.append(card);
card.addEventListener("click",event=>{if(!event.target.closest("a"))selectOld(group,result);});
card.addEventListener("keydown",event=>{if(event.target===card&&(event.key==="Enter"||event.key===" ")){event.preventDefault();selectOld(group,result);}});
const canvas=card.querySelector("canvas"),box=cropBox(group);canvas.width=650;canvas.height=Math.round(650*(box[3]-box[1])/(box[2]-box[0]));
drawJobs.set(card,async()=>{
try{
const [photo,line,oldLine]=await Promise.all([inputImage(group),loadImage(imageSource(result,"new_overlay")),mode==="overlay"?loadImage(imageSource(result,"old_overlay")):Promise.resolve(null)]);
if(version!==drawVersion)return;
paint(canvas,photo,oldLine,line,group,mode==="overlay"?"overlay":"new");card.dataset.drawn="true";
}catch(error){drawErrors++;const message=document.createElement("p");message.className="error-card";message.textContent=error.message;card.append(message);status(`有 ${drawErrors} 组图片加载失败,可重跑案例。`,true);}
});
cardObserver.observe(card);
}
const active=cards.find(c=>`${c.group.gender}:${c.result.order}`===rowSelections.get(caseNumber))||cards[0];
selectOld(active.group,active.result,false);
}
if(!cases.size&&!dataset.errors.length)$("results").innerHTML='<div class="empty">暂无对比结果。可上传图片或点击“重跑全部30个案例”。</div>';
requestAnimationFrame(()=>rowUpdates.forEach(update=>update()));
if(version===drawVersion&&!busy){
const failures=new Set(visibleErrors.map(error=>error.case_number)).size;
status(`显示 ${cases.size} 个案例、${total} 种新规则样式${failures?`,${failures} 个案例存在定位失败`:""}。每人一行,最前面为未裁切原图,旧图仅一次;左右滑动查看全部样式。`,failures>0);
}
}
function setBusy(value) {busy=value;$("rerunBatch").disabled=value;$("customRun").disabled=value;refreshStats();}
async function compare(form) {
const response=await fetch("/api/v1/debug/hairline-height-compare",{method:"POST",body:form});
if(!response.ok)throw new Error(`请求失败:HTTP ${response.status}`);
const json=await response.json();if(json.code!==0)throw new Error(json.message);return json.data;
}
async function rerun() {
if(busy)return;setBusy(true);let failure=null;
try {
const response=await fetch("/api/v1/debug/hairline-height-cases");const json=await response.json();
if(json.code!==0)throw new Error(json.message);
dataset={groups:[],errors:[],created_at:new Date().toISOString()};inputImages.clear();
const cases=json.data.cases,total=cases.length*2;let done=0;
for(const test of cases)for(const gender of ["female","male"]){
status(`正在运行案例 ${test.image_number} / ${gender==="female"?"女性":"男性"} 7 型(${done}/${total})…`);
const form=new FormData();form.append("image_url",test.image_url);form.append("gender",gender);form.append("hair_style","1,2,3,4,5,6,7");
try{const data=await compare(form);dataset.groups.push({...data,case_number:test.image_number,source_url:test.image_url});}
catch(error){dataset.errors.push({case_number:test.image_number,gender,message:error.message,source_url:test.image_url,original_url:test.image_url});}
done++;refreshCases();refreshStats();await render();
}
}catch(error){failure=error.message;}finally{setBusy(false);await render();if(failure)status(failure,true);}
}
async function custom(event) {
event.preventDefault();if(busy)return;const file=$("customFile").files[0];if(!file)return;setBusy(true);status("正在计算自定义图片的旧 / 新位置…");let failure=null;
try {
const photo=file,form=new FormData(),gender=$("customGender").value;
form.append("image_file",photo);form.append("gender",gender);form.append("hair_style","1,2,3,4,5,6,7");
const data=await compare(form),number=`custom-${Date.now()}`;
dataset.groups.push({...data,case_number:number});dataset.created_at=new Date().toISOString();
$("genderFilter").value=gender;refreshStyleOptions();refreshCases();$("caseFilter").value=number;
}catch(error){failure=error.message;}finally{setBusy(false);await render();if(failure)status(failure,true);}
}
async function init() {
fetch("/api/v1/debug/hairline-framing-reference").then(r=>r.json()).then(json=>{
if(json.code!==0)throw new Error(json.message);
const p=json.data;
$("framingStandard").innerHTML=`<img src="${esc(p.reference_image_url)}" alt="9号与152号点已竖直对齐的参考图" loading="lazy"><div><strong>已扶正参考图:${p.reference_size.width}×${p.reference_size.height}(3:4)</strong><p>参考图9号点 (${fixed(p.brow_9.x)}, ${fixed(p.brow_9.y)})<br>参考图152号点 (${fixed(p.chin_152.x)}, ${fixed(p.chin_152.y)})<br>两点X相同;旋转 ${fixed(p.rotation_degrees,3)}°</p><p><strong>当前裁切:高约 ${Number(fixed(p.height_per_face_length,4))}×L,宽 ${Number(fixed(p.width_per_face_length,4))}×L</strong>L 为9号点到152号点的像素距离。<br>眉心位置:横向 ${fixed(p.brow_x_ratio*100,2)}%,纵向 ${fixed(p.brow_y_ratio*100,2)}%</p><p>用户图先扶正,有空间时按上述尺寸裁切;长图保留人脸裁为3:4,其余空间不足时仅扶正。保留实际像素,不放大图片。</p></div>`;
}).catch(error=>{$("framingStandard").textContent=error.message;});
refreshStyleOptions();
$("genderFilter").addEventListener("change",()=>{refreshStyleOptions();render();});
for(const id of ["styleFilter","caseFilter","zoomFace","showTarget","showBox"])$(id).addEventListener("change",render);
for(const [id,value] of [["sideMode","side"],["overlayMode","overlay"]])$(id).addEventListener("click",()=>{mode=value;$("sideMode").classList.toggle("active",value==="side");$("overlayMode").classList.toggle("active",value==="overlay");render();});
$("customForm").addEventListener("submit",custom);$("rerunBatch").addEventListener("click",rerun);
try {
const response=await fetch("/static/annotations/hairline_height_compare/latest.json",{cache:"no-store"});
if(!response.ok)throw new Error("尚无保存的批量结果,可上传图片或重跑全部30个案例。");
dataset=await response.json();dataset.errors ||= [];refreshCases();refreshStats();await render();
}catch(error){refreshStats();status(error.message,true);}
}
document.addEventListener("DOMContentLoaded",init);
+62
View File
@@ -0,0 +1,62 @@
"use strict";
// Reference geometry is in input-image pixels; no screen-vertical assumptions.
window.hairlineReferenceBox = function(points) {
const p = id => {
const value = points[String(id)];
if (!value || !Number.isFinite(value.x) || !Number.isFinite(value.y)) throw new Error(`缺少 ${id} 号关键点`);
return [value.x, value.y];
};
const add = (a,b) => [a[0]+b[0],a[1]+b[1]];
const sub = (a,b) => [a[0]-b[0],a[1]-b[1]];
const mul = (a,s) => [a[0]*s,a[1]*s];
const dot = (a,b) => a[0]*b[0]+a[1]*b[1];
const brow=p(9),chin=p(152),left=p(162),right=p(389);
const delta=sub(chin,brow),length=Math.hypot(...delta);
if(length<1)throw new Error("9→152 方向无法确定");
const up=mul(delta,-1/length),horizontal=[-up[1],up[0]],height=.7*length;
const sidePoint=(anchor,direction,level)=>{
const denominator=dot(direction,up);
if(Math.abs(denominator)<1e-6)throw new Error("参考框侧边与底边平行");
return add(anchor,mul(direction,(level-dot(sub(anchor,brow),up))/denominator));
};
const corners=[sidePoint(left,up,0),sidePoint(right,up,0),
sidePoint(right,up,height),sidePoint(left,up,height)];
if(dot(sub(corners[1],corners[0]),horizontal)<=0)throw new Error("参考框左右位置异常");
return {corners,height,faceLength:length,
landmarks:{9:brow,152:chin,162:left,389:right}};
};
window.clipHairlineToReferenceBox = function(image, points) {
const box=window.hairlineReferenceBox(points);
const width=image.naturalWidth||image.width,height=image.naturalHeight||image.height;
if(!width||!height)throw new Error("发际线图片尚未加载");
const canvas=document.createElement("canvas");canvas.width=width;canvas.height=height;
const ctx=canvas.getContext("2d");
ctx.beginPath();box.corners.forEach((p,i)=>i?ctx.lineTo(...p):ctx.moveTo(...p));ctx.closePath();
ctx.clip();ctx.drawImage(image,0,0,width,height);
return canvas;
};
window.drawHairlineReferenceBox = function(ctx, points, crop, width, height) {
const box=window.hairlineReferenceBox(points);
const [x0,y0,x1,y1]=crop;
const screen=p=>[(p[0]-x0)/(x1-x0)*width,(p[1]-y0)/(y1-y0)*height];
const color="#e33399";
ctx.save();ctx.strokeStyle=color;ctx.lineWidth=Math.max(2.5,width/280);ctx.lineJoin="round";
ctx.beginPath();box.corners.forEach((p,i)=>{const q=screen(p);i?ctx.lineTo(...q):ctx.moveTo(...q);});ctx.closePath();ctx.stroke();
ctx.lineWidth=1.4;ctx.setLineDash([7,6]);ctx.beginPath();ctx.moveTo(...screen(box.landmarks[9]));ctx.lineTo(...screen(box.landmarks[152]));ctx.stroke();ctx.setLineDash([]);
const fontSize=Math.max(13,width/43);ctx.font=`600 ${fontSize}px sans-serif`;
for(const id of [9,152,162,389]){
const [x,y]=screen(box.landmarks[id]);
if(x<0||x>width||y<0||y>height)continue;
ctx.fillStyle=color;ctx.beginPath();ctx.arc(x,y,Math.max(3,width/160),0,Math.PI*2);ctx.fill();
const label=String(id),tw=ctx.measureText(label).width;
const tx=Math.max(3,Math.min(width-tw-8,x+7)),ty=Math.max(fontSize+4,Math.min(height-5,y-7));
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(label,tx,ty);
}
const top=screen([(box.corners[2][0]+box.corners[3][0])/2,(box.corners[2][1]+box.corners[3][1])/2]);
const text="70% · 9→152",tw=ctx.measureText(text).width;
const tx=Math.max(4,Math.min(width-tw-6,top[0]-tw/2)),ty=Math.max(fontSize+4,top[1]-8);
ctx.fillStyle="rgba(255,255,255,.9)";ctx.fillRect(tx-3,ty-fontSize,tw+6,fontSize+4);ctx.fillStyle="#a51868";ctx.fillText(text,tx,ty);
ctx.restore();
};
+13
View File
@@ -56,6 +56,7 @@
<a href="#if3">接口3</a>
<a href="#if4">接口4</a>
<a href="#if5">接口5</a>
<a href="#if52">接口52</a>
<a href="#if6">接口6</a>
<a href="#errors">错误码</a>
<a href="#test">在线测试</a>
@@ -261,6 +262,16 @@ console.log(features.gene_style); // "自然型"</pre>
</div>
</div>
<div class="card" id="if52">
<h2>52. 发际线新规则与裁切 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/hairline/generate-52</code></h2>
<div class="card-body">
<p class="desc">中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。</p>
<p>基础图片参数、<code>gender</code>、<code>hair_style</code>、<code>use_mask</code>、<code>prompt</code>、<code>generate_grow_image</code>沿用接口5。新增可选<code>normalize_image</code>,默认false,传true开启扶正与裁切;测试页显式开启。</p>
<p style="margin-top:12px">返回<code>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="/static/test_interface52.html" class="link">52号规则测试页</a> · <a href="/static/test_interface52_compare.html" class="link">52号30案例对比页</a></p>
</div>
</div>
<!-- ======== 接口6 ======== -->
<div class="card" id="if6">
<h2>6. 四庭七眼测量 v2 &nbsp;<span class="badge post">POST</span> &nbsp;<code>/api/v1/face/measure-v2</code> &nbsp;<span class="badge warn">v2</span></h2>
@@ -323,6 +334,8 @@ console.log(features.gene_style); // "自然型"</pre>
<tr><td>3. B端生发</td><td><a href="/static/test_interface3.html" class="link">/static/test_interface3.html</a></td><td>划线图上传 → 生发效果图</td></tr>
<tr><td>4. 用户特征</td><td><a href="/static/test_interface4.html" class="link">/static/test_interface4.html</a></td><td>上传照片 → 6项面部特征 + 原始JSON</td></tr>
<tr><td>5. 发际线PNG</td><td><a href="/static/test_interface5.html" class="link">/static/test_interface5.html</a></td><td>上传+性别 → 发际线方案+中心点坐标</td></tr>
<tr><td>52. 发际线新规则</td><td><a href="/static/test_interface52.html" class="link">/static/test_interface52.html</a></td><td>中下庭定位、扶正裁切、矩形发际线叠加及生发</td></tr>
<tr><td>52. 规则对比</td><td><a href="/static/test_interface52_compare.html" class="link">/static/test_interface52_compare.html</a></td><td>30个案例,原图与旧/新规则样式对照</td></tr>
<tr><td>6. 四庭七眼 v2</td><td><a href="/static/test_interface6.html" class="link">/static/test_interface6.html</a></td><td>同接口1,去顶庭 · 竖线发际线→下巴 · 无头部端线</td></tr>
</table>
<p style="font-size:12px;color:#94a3b8;margin-top:12px">
+418
View File
@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口52 — 发际线PNG生成 测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 6px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 18px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.form-group { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.form-group label { font-size: 13px; font-weight: 600; color: #374151; }
.form-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
.hair-checkboxes { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; }
.hair-checkboxes label { font-size: 13px; font-weight: 500; color: #374151; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
.hair-checkboxes label input[type=checkbox] { cursor: pointer; }
.checkbox-actions { display: flex; gap: 6px; margin-left: 4px; }
.checkbox-actions button { font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid #d1d5db; background: #f9fafb; cursor: pointer; }
.checkbox-actions button:hover { background: #e5e7eb; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.results-layout { display: flex; gap: 24px; }
.col-main { flex: 1.5; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
/* 每个发型区块 */
.hair-block { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
.hair-block:first-child { padding-top: 4px; }
.hair-block:last-child { border-bottom: none; padding-bottom: 4px; }
.hair-block-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 10px; }
/* 未裁切原图 + middle + 矩形裁切发际线叠加 + 生发图 */
.level-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; align-items: start; }
.level-cell { background: #222; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
.level-cell .level-label { font-size: 12px; font-weight: 600; color: #fff; background: rgba(0,0,0,.55); padding: 4px 8px; min-height: 38px; display: flex; align-items: center; }
.level-cell img { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .level-none { color: #9ca3af; font-size: 13px; padding: 28px 0; text-align: center; }
/* 处理后底图 + 同尺寸透明设计曲线叠加 */
.level-cell .img-stack { position: relative; line-height: 0; }
.level-cell .img-stack .layer-base { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
.hairline-crop-border { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
.hairline-crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
@media (max-width: 700px) { .level-row { grid-template-columns: repeat(2, 1fr); } }
/* 坐标高亮 */
.coord-box { background: #f9fafb; border-radius: 8px; padding: 12px 16px; margin-top: 10px; font-size: 14px; }
.coord-box .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
.coord-box .value { font-weight: 700; font-size: 18px; color: #111827; }
/* 四庭七眼测量(face_measure) */
.fm-section { margin-bottom: 18px; }
.fm-section:last-child { margin-bottom: 0; }
.fm-title { font-size: 13px; font-weight: 700; color: #374151; margin-bottom: 8px; }
.fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.fm-cell { background: #f9fafb; border-radius: 6px; padding: 8px 10px; border: 1px solid #f0f0f0; }
.fm-cell .k { font-size: 11px; color: #9ca3af; margin-bottom: 2px; }
.fm-cell .v { font-size: 15px; font-weight: 700; color: #111827; }
.fm-cell .v .sub { font-size: 11px; font-weight: 500; color: #6b7280; margin-left: 4px; }
.fm-cell.muted .v { color: #9ca3af; font-weight: 600; }
.fm-tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; font-weight: 600; }
.fm-tag.ok { background: #d1fae5; color: #065f46; }
.fm-tag.warn { background: #fef3c7; color: #92400e; }
.json-panel { max-height: 550px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
.rule-banner { background:#ecfdf5; border:1px solid #a7f3d0; border-radius:12px; padding:18px; margin-bottom:20px; line-height:1.8; }
.rule-banner strong { color:#065f46; font-size:19px; }
.rule-banner a { color:#065f46; margin-left:18px; font-weight:600; }
.placement { font-size:12px; color:#64748b; font-weight:400; margin:6px 0 10px; }
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js?v=2"></script>
<script src="/static/hairline_reference_box.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>💈 接口52 — 发际线 PNG 生成 测试</h1>
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p>
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)÷ 2</strong><a href="/static/test_interface52_compare.html">查看旧 / 新规则对比 →</a><br>上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。</div>
<div class="card">
<div class="card-body">
<div class="upload-row">
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png"></div>
<div class="form-group">
<label>性别</label>
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(7种)</option></select>
</div>
<div class="form-group" style="align-items:flex-start">
<label style="padding-top:3px">发型 *</label>
<div>
<div class="hair-checkboxes" id="hairStyleGroup"></div>
<div class="checkbox-actions" style="margin-top:4px">
<button type="button" onclick="selectAllHair(true)">全选</button>
<button type="button" onclick="selectAllHair(false)">全不选</button>
</div>
</div>
</div>
<div class="form-group">
<label>生发效果图</label>
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
<input type="checkbox" id="genGrowImg" checked> 同时生成生发效果(关闭后只查看设计曲线,速度更快)
</label>
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除</button>
</div>
<div class="hint">JPG/PNG 正面照 &nbsp;|&nbsp; Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 &nbsp;|&nbsp; Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 &nbsp;|&nbsp; 每个发型返回一张设计曲线,生发遮罩同步到新位置</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<!-- 四庭七眼测量(face_measure,复用接口1数值) -->
<div class="card">
<div class="card-header"><span>📐 处理后图像的四庭七眼测量</span><span style="font-weight:400;font-size:12px;color:#9ca3af">与当前显示底图使用同一坐标系</span></div>
<div class="card-body"><div id="faceMeasureArea"><span style="color:#9ca3af">—</span></div></div>
</div>
<!-- 所有发型平铺 -->
<div class="card">
<div class="card-header"><span>🎯 未裁切原图 + middle + 裁切发际线叠加 + 生发图</span><span style="font-weight:400;font-size:12px;color:#9ca3af" id="resultCount">共 0 个发型</span></div>
<div class="card-body">
<div class="coord-box" style="margin-top:0;margin-bottom:16px">
<div class="label">📍 设计中心点 — 图片像素坐标</div>
<div class="value">
<span id="centerPoint">—</span>
</div>
</div>
<div id="ruleDetails" style="font-size:13px;color:#475569;margin-bottom:16px;line-height:1.8"></div>
<div id="resultsGrid"></div>
</div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-header"><span>📋 JSON</span><button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button></div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
let _images = [], _uploadUrl = '', _originalSrc = '', _processedSrc = '', _center = null, _guideLandmarks = null, _renderVersion = 0;
function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
// 发型列表(同接口2)
const HAIR_STYLES = {
female: [
{ value:'1', label:'1. ellipse 椭圆' },
{ value:'2', label:'2. flower 花瓣' },
{ value:'3', label:'3. heart 心形' },
{ value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. wave 波浪' },
{ value:'6', label:'6. bigflower 大花瓣型' },
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
],
male: [
{ value:'1', label:'1. ellipse 椭圆' },
{ value:'2', label:'2. inverse_arc 倒弧' },
{ value:'3', label:'3. m M形' },
{ value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. heart 桃心形' },
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
{ value:'7', label:'7. wave 波浪形' },
]
};
function onGenderChange() {
const g = $('gender').value;
const div = $('hairStyleGroup');
div.innerHTML = '';
HAIR_STYLES[g].forEach(s => {
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.value = s.value; cb.id = 'hs_' + s.value;
const lb = document.createElement('label');
lb.htmlFor = cb.id; lb.appendChild(cb);
lb.appendChild(document.createTextNode(' ' + s.label));
div.appendChild(lb);
});
}
function selectAllHair(select) {
document.querySelectorAll('#hairStyleGroup input[type=checkbox]').forEach(cb => { cb.checked = select; });
}
async function submitTest() {
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
if (!checked.length) { setStatus('请至少选择一个发型', 'error'); return; }
// Preserve native pixels: the worker frames and straightens the image first.
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
_uploadUrl = URL.createObjectURL(f);
_originalSrc = _uploadUrl; _processedSrc = ''; _images = []; _guideLandmarks = null; _renderVersion++;
$('resultsGrid').innerHTML = '';
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false'); fd.append('normalize_image', 'true');
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
_images = json.data.hairline_images || [];
_originalSrc = resolveImgSrc(json.data.original_image_url, json.data.original_image_base64, 'image/jpeg') || _uploadUrl;
_processedSrc = resolveImgSrc(json.data.input_image_url, json.data.input_image_base64, 'image/jpeg');
_guideLandmarks = json.data.guide_landmarks;
if (_originalSrc !== _uploadUrl) { URL.revokeObjectURL(_uploadUrl); _uploadUrl = ''; }
_center = json.data.best_hairline_center_point;
const _fmtPt = p => p ? '(' + p.x + ', ' + p.y + ')' : '—';
$('centerPoint').textContent = _fmtPt(json.data.best_hairline_center_point);
const rule = json.data.height_rule;
$('ruleDetails').textContent = rule
? '中庭 ' + rule.middle_height_px.toFixed(2) + ' px + 下庭 ' + rule.lower_height_px.toFixed(2) + ' px → 目标上庭 ' + rule.target_upper_height_px.toFixed(2) + ' px' + (rule.target_upper_height_cm == null ? '' : '(约 ' + rule.target_upper_height_cm.toFixed(2) + ' cm)') + ';目标 y = ' + rule.target_center.y.toFixed(2) + '。测量来源:' + (rule.measurement_source === 'face_measure' ? '当前底图测量' : '发际线关键点')
: '—';
if (json.data.framing) { const f = json.data.framing; $('ruleDetails').textContent += ';' + f.message + ',旋转 ' + f.rotation_degrees.toFixed(3) + '°,底图 ' + f.output_size.width + '×' + f.output_size.height + '。'; }
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
setStatus('✅ ' + _images.length + ' 个发型已按新规则定位 (' + _elapsed + 's)', 'success');
await renderGrid();
renderFaceMeasure(json.data.face_measure);
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}
async function buildClippedOverlay(src, points) {
const image = await new Promise((resolve, reject) => {
const img = new Image(); img.crossOrigin = 'anonymous';
img.onload = () => resolve(img); img.onerror = () => reject(new Error('无法读取发际线图片'));
img.src = src;
});
const canvas = window.clipHairlineToReferenceBox(image, points);
const corners = window.hairlineReferenceBox(points).corners.map(p => p.join(',')).join(' ');
const result = {
src: canvas.toDataURL('image/png'),
border: '<svg class="hairline-crop-border" viewBox="0 0 '+canvas.width+' '+canvas.height+'" role="img" aria-label="粉色虚线:发际线裁切边界"><polygon points="'+corners+'"></polygon></svg>'
};
canvas.width = 0; canvas.height = 0;
return result;
}
async function renderGrid() {
const version = ++_renderVersion;
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
const images = _images, originalSrc = _originalSrc, processedSrc = _processedSrc, guideLandmarks = _guideLandmarks;
const escapeText = text => String(text).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// The overlay uses the server's processed image, with matching coordinates.
const overlayCell = (label, src, className, emptyText = '暂无处理后图片', border = '') => (src && processedSrc)
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="img-stack">'+
'<img class="layer-base" src="'+processedSrc+'" alt="处理后底图">'+
'<img class="layer-anno" src="'+src+'" alt="'+label+'">'+border+'</div></div>'
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">'+escapeText(emptyText)+'</div></div>';
const photoCell = (label, src, className) => src
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><img src="'+src+'" alt="'+label+'"></div>'
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
const blocks = await Promise.all(images.map(async function(it) {
// 兼容网关 *_url 与 worker *_base64
const mid = resolveImgSrc(it.image_middle_url, it.image_middle_base64, 'image/png');
const grown = resolveImgSrc(it.grown_image_url, it.grown_image_base64, 'image/jpeg');
let clipped = '', clipBorder = '', clipError = '缺少发际线图片或参考点';
if (mid && processedSrc && guideLandmarks) {
try { const preview = await buildClippedOverlay(mid, guideLandmarks); clipped = preview.src; clipBorder = preview.border; }
catch (error) { clipError = error.message; }
}
return '<div class="hair-block">' +
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
(grown ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
(it.height_placement ? '<div class="placement">中心 y:' + it.height_placement.new_center.y.toFixed(2) + ' · 相对旧规则 ' + (it.height_placement.shift_y_px >= 0 ? '下移 ' : '上移 ') + Math.abs(it.height_placement.shift_y_px).toFixed(2) + ' px · 目标误差 ' + it.height_placement.target_error_px.toFixed(2) + ' px</div>' : '') +
'<div class="level-row">' +
photoCell('未裁切原图', originalSrc, 'original-cell') +
overlayCell('middle · 发际线叠加', mid, 'middle-cell') +
overlayCell('裁切发际线叠加', clipped, 'clipped-cell', clipError, clipBorder) +
photoCell('生发图', grown, 'grown-cell') +
'</div>' +
'</div>';
}));
if (version === _renderVersion) $('resultsGrid').innerHTML = blocks.join('');
}
function clearResults() {
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
_images=[]; _uploadUrl=''; _originalSrc=''; _processedSrc=''; _center=null; _guideLandmarks=null; _renderVersion++;
$('ruleDetails').textContent = '';
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
$('centerPoint').textContent='—';
$('resultCount').textContent = '共 0 个发型';
$('faceMeasureArea').innerHTML = '<span style="color:#9ca3af">—</span>';
}
// 四庭七眼测量渲染(face_measure,接口1数值)
const _PCT = r => (r == null ? '' : '(' + (r * 100).toFixed(1) + '%)');
const _FMT = v => (v == null ? '—' : Number(v).toFixed(2));
function renderFaceMeasure(fm) {
const box = $('faceMeasureArea');
if (!fm) {
box.innerHTML = '<div style="padding:24px;text-align:center;color:#9ca3af;font-size:14px">⚠️ face_measure = null(测量失败或未启用)</div>';
return;
}
// 发际线来源 + 头部姿态
const src = fm.hairline_source;
const srcTag = src === 'segmentation'
? '<span class="fm-tag ok">segmentation 真实分割</span>'
: (src === 'estimated' ? '<span class="fm-tag warn">estimated 比例估算</span>' : '');
let poseHtml = '—';
if (fm.head_pose) {
const p = fm.head_pose;
poseHtml = 'yaw ' + _FMT(p.yaw) + '° &nbsp; pitch ' + _FMT(p.pitch) + '° &nbsp; roll ' + _FMT(p.roll) + '°';
}
let h = '<div class="coord-box" style="margin-top:0;margin-bottom:16px;display:flex;gap:32px;flex-wrap:wrap;align-items:center">'
+ '<div><div class="label">脸总高</div><div class="value">' + _FMT(fm.face_total_height_cm) + ' cm</div></div>'
+ '<div><div class="label">发际线来源</div><div class="value" style="font-size:15px">' + (src || '—') + srcTag + '</div></div>'
+ '<div><div class="label">头部姿态</div><div class="value" style="font-size:14px">' + poseHtml + '</div></div>'
+ '</div>';
// 四庭
const fc = fm.four_courts || {};
const fr = (fc.ratios || {});
const courts = [
['顶庭', fc.top_court_cm, fr.top_court],
['上庭', fc.upper_court_cm, fr.upper_court],
['中庭', fc.middle_court_cm, fr.middle_court],
['下庭', fc.lower_court_cm, fr.lower_court],
];
h += '<div class="fm-section"><div class="fm-title">📏 四庭(纵向,自上而下)</div><div class="fm-grid">';
courts.forEach(c => {
h += '<div class="fm-cell"><div class="k">' + c[0] + '</div><div class="v">' + _FMT(c[1]) + ' cm<span class="sub">' + _PCT(c[2]).replace(/[()]/g, '') + '</span></div></div>';
});
h += '</div></div>';
// 七眼
const se = fm.seven_eyes || {};
const ser = (se.ratios || {});
h += '<div class="fm-section"><div class="fm-title">👁 七眼(横向汇总)</div><div class="fm-grid">';
[['单眼宽', se.eye_width_cm, ser.eye_width], ['脸宽', se.face_width_cm, null], ['两眼间距', se.inter_eye_distance_cm, ser.inter_eye_distance]].forEach(e => {
h += '<div class="fm-cell"><div class="k">' + e[0] + '</div><div class="v">' + _FMT(e[1]) + ' cm' + (e[2] != null ? '<span class="sub">' + (e[2] * 100).toFixed(1) + '%</span>' : '') + '</div></div>';
});
h += '</div></div>';
// 七眼 7 段(从左到右)
const segLabels = ['eye1 左耳外段', 'eye2 左脸颊段', 'eye3 左眼', 'eye4 两眼间距', 'eye5 右眼', 'eye6 右脸颊段', 'eye7 右耳外段'];
h += '<div class="fm-section"><div class="fm-title">↔️ 七眼 7 段宽度(从左到右,cm)</div><div class="fm-grid" style="grid-template-columns:repeat(7,1fr)">';
segLabels.forEach((lab, i) => {
const v = se['eye' + (i + 1)];
const muted = v == null ? ' muted' : '';
h += '<div class="fm-cell' + muted + '"><div class="k">' + lab + '</div><div class="v">' + (v == null ? '—' : _FMT(v)) + '</div></div>';
});
h += '</div></div>';
// 关键点坐标
const lm = fm.landmarks || {};
const lmLabels = { hair_top: '头顶', hairline: '发际线', brow_center: '眉心', nose_bottom: '鼻翼下缘', chin_tip: '下巴尖' };
h += '<div class="fm-section"><div class="fm-title">📍 关键分界点(当前底图像素坐标)</div><div class="fm-grid">';
Object.keys(lmLabels).forEach(k => {
const p = lm[k];
h += '<div class="fm-cell"><div class="k">' + lmLabels[k] + '</div><div class="v" style="font-size:14px">' + (p ? '(' + p.x + ', ' + p.y + ')' : '—') + '</div></div>';
});
h += '</div></div>';
box.innerHTML = h;
}
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
document.addEventListener('DOMContentLoaded', () => {
onGenderChange(); // 初始填充发型列表
const dz = $('imageFile');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
});
</script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>接口 52 · 发际线高度规则对比</title>
<link rel="stylesheet" href="/static/hairline_height_compare.css">
<script src="/static/img_downscale.js?v=2" defer></script>
<script src="/static/hairline_reference_box.js" defer></script>
<script src="/static/hairline_height_compare.js" defer></script>
</head>
<body>
<main>
<header class="page-header">
<div><div class="eyebrow">接口 52 / 规则验证</div><h1>发际线高度规则对比</h1><p>用同一张照片、同一个形状,查看旧规则与中下庭均值规则的位置差异。</p></div>
<a class="button secondary" href="/static/test_interface52.html">打开规则测试页 ↗</a>
</header>
<section class="rule-card" aria-label="规则说明">
<div><span class="pill">新规则</span><h2>目标上庭 =(中庭 + 下庭)÷ 2</h2><p>从眉心向上定位,所有形状的中点对齐同一高度。各发型保留自身轮廓,叠加在扶正后的底图上。</p></div>
<div class="rule-note"><span class="dot old"></span>旧规则:根据头发边界和模板定位<br><span class="dot new"></span>新规则:根据中庭、下庭定位<br><span class="dash"></span>虚线:新规则的目标高度</div>
</section>
<details class="framing-standard"><summary>查看已扶正的标准图与裁切参数</summary><div id="framingStandard">正在加载标准图参数…</div></details>
<section class="stats" aria-label="验证概况">
<div><span>已验证图片</span><strong id="statCases">—</strong><small>独立测试图片</small></div>
<div><span>发际线对比</span><strong id="statComparisons">—</strong><small>男性 / 女性各 7 型</small></div>
<div><span>最大中心误差</span><strong id="statError">—</strong><small>新中心与目标的像素差</small></div>
<div><span>验证状态</span><strong id="statStatus">加载中</strong><small id="runTime">正在读取已保存结果</small></div>
</section>
<section class="toolbar" aria-label="显示设置">
<label>发型集合<select id="genderFilter"><option value="all" selected>全部集合(男女各 7 型)</option><option value="female">女性发际线</option><option value="male">男性发际线</option></select></label>
<label>形状<select id="styleFilter"></select></label>
<label>图片<select id="caseFilter"><option value="all">全部 30 个案例</option></select></label>
<div class="segmented" aria-label="对比方式"><button id="sideMode" class="active" type="button">左右对比</button><button id="overlayMode" type="button">同图叠线</button></div>
<label class="check"><input type="checkbox" id="zoomFace">额头特写</label>
<label class="check"><input type="checkbox" id="showTarget" checked>目标参考线</label>
<label class="check"><input type="checkbox" id="showBox" checked>新图参考框</label>
</section>
<p id="status" class="status" role="status" aria-live="polite">正在加载30个案例的对比结果…</p>
<section id="results" class="results" aria-label="案例对比"></section>
<section class="tools-card">
<div class="tools-heading"><div><h2>继续验证</h2><p>上传新图片,或重新运行清单全部30张。每人先显示未裁切原图,再显示旧、新规则;对比只生成定位曲线,无需等待生发。</p></div><button id="rerunBatch" class="button secondary" type="button">重跑全部30个案例</button></div>
<form id="customForm" class="custom-form">
<label>选择图片<input id="customFile" type="file" accept="image/png,image/jpeg" required></label>
<label>发型集合<select id="customGender"><option value="female">女性 · 7 型</option><option value="male">男性 · 7 型</option></select></label>
<button class="button primary" id="customRun" type="submit">运行自定义对比</button>
</form>
<p class="footnote">high、low 已停用;接口旧字段保留为 null。当前页的“旧规则”是本次高度修改前的定位逻辑。旧、新规则使用同一张处理后底图。</p>
</section>
<footer>旧线 <span class="dot old"></span> 橙色 · 新线 <span class="dot new"></span> 青色 · 粉色参考框:两侧均平行 9→152,左边经过 162、右边经过 389、底边经过 9;高度为 9→152 距离的 70%<br>向上移动为负,向下移动为正 · 所有距离基于接口实际输入图片</footer>
</main>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>接口52 · 规则对比</title><meta http-equiv="refresh" content="0;url=/static/test_interface52_compare.html"></head>
<body><p>新规则对比已迁移至接口52:<a href="/static/test_interface52_compare.html">打开接口52对比页</a></p><script>location.replace('/static/test_interface52_compare.html'+location.search+location.hash);</script></body></html>
+38
View File
@@ -0,0 +1,38 @@
{
"description": "用户指定的接口 52 发际线高度规则回归验证图片清单",
"recorded_on": "2026-11-03",
"endpoint": "/api/v1/hairline/generate-52",
"total": 30,
"cases": [
{"image_number": 1, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%201.png"},
{"image_number": 2, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%202.png"},
{"image_number": 3, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%203.png"},
{"image_number": 4, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%204.png"},
{"image_number": 5, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%205.png"},
{"image_number": 6, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%206.png"},
{"image_number": 7, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%207.png"},
{"image_number": 20, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2020.png"},
{"image_number": 21, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2021.png"},
{"image_number": 22, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2022.png"},
{"image_number": 23, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2023.png"},
{"image_number": 27, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2027.png"},
{"image_number": 32, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2032.png"},
{"image_number": 33, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2033.png"},
{"image_number": 34, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2034.png"},
{"image_number": 35, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2035.png"},
{"image_number": 36, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2036.png"},
{"image_number": 37, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2037.png"},
{"image_number": 40, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2040.png"},
{"image_number": 41, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2041.png"},
{"image_number": 42, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2042.png"},
{"image_number": 43, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2043.png"},
{"image_number": 44, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2044.png"},
{"image_number": 45, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2045.png"},
{"image_number": 46, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2046.png"},
{"image_number": 47, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2047.png"},
{"image_number": 50, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2050.png"},
{"image_number": 51, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2051.png"},
{"image_number": 52, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2052.png"},
{"image_number": 53, "image_url": "https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/%E5%9B%BE%E7%89%87%2053.png"}
]
}
+210 -1
View File
@@ -18,7 +18,7 @@ H = {"X-Internal-Token": "testpass"}
@pytest.fixture(scope="module")
def client():
# with 触发 lifespan:加载模型单例(detector + 尽力加载 segmenter)
with TestClient(app_module.app) as c:
with TestClient(app_module.app, client=("127.0.0.1", 50000)) as c:
yield c
@@ -189,6 +189,40 @@ def test_growb_success(client, monkeypatch):
HLGEN = "/api/v1/hairline/generate"
def test_hairline5_restored_schema_has_only_historical_parameters(client):
schema = client.get("/openapi.json", headers=H).json()
ref = schema["paths"][HLGEN]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"]
fields = schema["components"]["schemas"][ref.rsplit("/", 1)[-1]]["properties"]
assert set(fields) == {"image_file", "image_url", "image_base64", "gender", "hair_style",
"use_mask", "prompt", "generate_grow_image"}
assert fields["use_mask"]["default"] is True
assert fields["generate_grow_image"]["default"] is True
assert fields["prompt"]["default"] == "填充遮罩区域的头发,人物增加一点磨皮"
@pytest.mark.parametrize("gender", ["female", "male"])
def test_hairline5_restored_three_levels_and_original_coordinate_contract(client, gender):
from pathlib import Path
photo = Path(fixture("frontal.jpg")).read_bytes()
body = client.post(HLGEN, headers=H, files={"image_file": ("frontal.jpg", photo, "image/jpeg")},
data={"gender": gender, "hair_style": "1,3", "generate_grow_image": "false"}).json()
assert body["code"] == 0, body
data = body["data"]
assert set(data) == {"hairline_images", "best_hairline_center_point", "high_hairline_center_point",
"low_hairline_center_point", "face_measure"}
expected = _cv2.imdecode(_np.frombuffer(photo, _np.uint8), _cv2.IMREAD_COLOR).shape[:2]
for key in ("best_hairline_center_point", "high_hairline_center_point", "low_hairline_center_point"):
assert set(data[key]) == {"x", "y"}
assert isinstance(data[key]["x"], int) and isinstance(data[key]["y"], int)
for item in data["hairline_images"]:
assert set(item) == {"hairline_type", "image_middle_base64", "image_high_base64", "image_low_base64",
"grown_image_base64", "order"}
assert item["grown_image_base64"] is None
for level in ("middle", "high", "low"):
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(item[f"image_{level}_base64"]), _np.uint8), _cv2.IMREAD_UNCHANGED)
assert image.shape == (*expected, 4)
def test_hairline_gen_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004
@@ -241,6 +275,181 @@ def test_hairline_gen_female(client, monkeypatch):
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
HLGEN52 = "/api/v1/hairline/generate-52"
def test_hairline52_legacy_form_parameters_and_defaults_are_preserved(client):
schema = client.get("/openapi.json", headers=H).json()
body = schema["paths"][HLGEN52]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]
body = schema["components"]["schemas"][body["$ref"].rsplit("/", 1)[-1]]
fields = body["properties"]
legacy = {"image_file", "image_url", "image_base64", "gender", "hair_style",
"use_mask", "prompt", "generate_grow_image"}
assert legacy.issubset(fields)
assert not set(body.get("required", [])) - legacy
for key in ("image_file", "image_url", "image_base64", "gender", "hair_style"):
assert any(choice.get("type") == "string" for choice in fields[key].get("anyOf", [fields[key]]))
assert fields["use_mask"]["type"] == "boolean" and fields["use_mask"]["default"] is True
assert fields["generate_grow_image"]["type"] == "boolean" and fields["generate_grow_image"]["default"] is True
assert fields["prompt"]["default"] == "填充遮罩区域的头发,人物增加一点磨皮"
assert fields["normalize_image"]["default"] is False
@pytest.mark.parametrize("image_kind", ["file", "base64", "url"])
def test_hairline52_legacy_request_keeps_fields_and_original_coordinates(client, monkeypatch, image_kind):
import contextlib
from pathlib import Path
from types import SimpleNamespace
import httpx
import hairline.comfyui as comfy
raw = Path(fixture("frontal.jpg")).read_bytes()
calls = []
def generate(*args, **kwargs):
calls.append(kwargs)
return _PNG_1x1
monkeypatch.setattr(comfy, "run", generate)
form = {"gender": "male", "hair_style": "1", "use_mask": "false", "prompt": "legacy prompt"}
kwargs = {"headers": H, "data": form}
if image_kind == "file":
kwargs["files"] = {"image_file": ("frontal.jpg", raw, "image/jpeg")}
elif image_kind == "base64":
form["image_base64"] = "data:image/jpeg;base64," + base64.b64encode(raw).decode()
else:
url = "https://legacy.example.test/frontal.jpg"
form["image_url"] = url
response = httpx.Response(200, content=raw, request=httpx.Request("GET", url))
def get_image(actual_url):
assert actual_url == url
return response
monkeypatch.setattr(httpx, "Client", lambda **kw: contextlib.nullcontext(SimpleNamespace(get=get_image)))
# No new parameters: old clients keep their original-size overlays.
body = client.post(HLGEN52, **kwargs).json()
assert body["code"] == 0, body
assert {"code", "message", "request_id", "data"}.issubset(body)
data = body["data"]
assert {"hairline_images", "best_hairline_center_point", "high_hairline_center_point",
"low_hairline_center_point", "face_measure"}.issubset(data)
assert data["coordinate_space"] == "original_image" and data["framing"] is None
assert data["input_image_base64"] == data["original_image_base64"]
original = _cv2.imdecode(_np.frombuffer(raw, _np.uint8), _cv2.IMREAD_COLOR)
expected = original.shape[:2]
assert (data["height"], data["width"]) == expected
item = data["hairline_images"][0]
assert {"hairline_type", "image_middle_base64", "image_high_base64", "image_low_base64",
"grown_image_base64", "order"}.issubset(item)
assert item["image_high_base64"] is None and item["image_low_base64"] is None
assert data["high_hairline_center_point"] is None and data["low_hairline_center_point"] is None
for key in ("image_middle_base64", "grown_image_base64"):
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(item[key]), _np.uint8), _cv2.IMREAD_UNCHANGED)
assert image.shape[:2] == expected
assert len(calls) == 1 and calls[0]["prompt"] == "legacy prompt"
@pytest.mark.parametrize("normalize_image", [True, False])
def test_hairline52_height_compare_uses_same_new_rule(client, monkeypatch, normalize_image):
import hairline.comfyui as comfy
def unexpected_generation(*args, **kwargs):
pytest.fail("Position comparison must not invoke ComfyUI by default")
monkeypatch.setattr(comfy, "run", unexpected_generation)
with open(fixture("frontal.jpg"), "rb") as photo:
response = client.post("/api/v1/debug/hairline-height-compare",
files={"image_file": ("frontal.jpg", photo, "image/jpeg")},
data={"gender": "male", "hair_style": "1,3", "normalize_image": str(normalize_image).lower()})
data = response.json()
assert data["code"] == 0, data
data = data["data"]
assert [r["order"] for r in data["results"]] == [1, 3]
assert set(data["guide_landmarks"]) == {"9", "150", "152", "162", "251", "389"}
assert data["guide_landmarks"]["9"]["y"] < data["guide_landmarks"]["152"]["y"]
expected_size = (data["height"], data["width"])
base = _cv2.imdecode(_np.frombuffer(base64.b64decode(data["input_base64"]), _np.uint8), _cv2.IMREAD_COLOR)
assert base.shape[:2] == expected_size
assert data["coordinate_space"] == ("processed_image" if normalize_image else "original_image")
if normalize_image:
assert data["framing"]["enabled"] is True
assert data["framing"]["axis_x_error_px"] < 1e-8
assert data["guide_landmarks"]["9"]["x"] == pytest.approx(data["guide_landmarks"]["152"]["x"], abs=1e-8)
else:
assert data["framing"] is None
original = _cv2.imread(fixture("frontal.jpg"))
assert base.shape == original.shape
for result in data["results"]:
assert result["height_placement"]["target_error_px"] <= 1
assert result["grown_image_base64"] is None
for key in ("old_overlay_base64", "new_overlay_base64"):
assert base64.b64decode(result[key])[:8] == b"\x89PNG\r\n\x1a\n"
overlay = _cv2.imdecode(_np.frombuffer(base64.b64decode(result[key]), _np.uint8), _cv2.IMREAD_UNCHANGED)
assert overlay.shape[:2] == expected_size
def test_hairline52_gen_missing_gender_1004(client):
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
assert client.post(HLGEN52, headers=H, files=files).json()["code"] == 1004
def test_hairline52_gen_missing_hairstyle_1007(client):
# hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
assert client.post(HLGEN52, headers=H, files=files, data={"gender": "female"}).json()["code"] == 1007
def test_hairline52_gen_female(client, monkeypatch):
# mock ComfyUI:验证处理后底图、发际线和生发图使用相同尺寸与坐标。
import hairline.comfyui as comfy
monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1)
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
body = client.post(HLGEN52, headers=H, files=files,
data={"gender": "female", "hair_style": "1,3", "normalize_image": "true"}).json()
assert body["code"] == 0, body
d = body["data"]
assert d["coordinate_space"] == "processed_image"
assert d["framing"]["enabled"] is True
assert d["framing"]["axis_x_error_px"] < 1e-8
expected_size = (d["height"], d["width"])
for value in (d["input_image_base64"], d["hairline_images"][0]["image_middle_base64"],
d["hairline_images"][0]["grown_image_base64"]):
image = _cv2.imdecode(_np.frombuffer(base64.b64decode(value), _np.uint8), _cv2.IMREAD_UNCHANGED)
assert image.shape[:2] == expected_size
imgs = d["hairline_images"]
# 只返回选中发型,order = 发型序号
assert [x["order"] for x in imgs] == [1, 3]
assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"]
# Only the designed middle curve is generated; deprecated fields stay null.
assert base64.b64decode(imgs[0]["image_middle_base64"])[:8] == b"\x89PNG\r\n\x1a\n"
assert all(x["image_high_base64"] is None and x["image_low_base64"] is None for x in imgs)
assert d["high_hairline_center_point"] is None and d["low_hairline_center_point"] is None
rule = d["height_rule"]
assert rule["target_upper_height_px"] == pytest.approx(
(rule["middle_height_px"] + rule["lower_height_px"]) / 2)
for x in imgs:
assert x["height_placement"]["new_center"]["y"] == pytest.approx(rule["target_center"]["y"], abs=1)
assert base64.b64decode(imgs[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff"
assert "image_middle_url" not in imgs[0]
c = d["best_hairline_center_point"]
assert 0 <= c["x"] < d["width"] and 0 <= c["y"] < d["height"]
# face_measure:复用接口1测量数值(容错,失败为 null;本用例正常 → 必须有完整结构)
fm = d["face_measure"]
assert fm is not None, "face_measure 不应为 null(正常正面照)"
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
"landmarks", "hairline_source", "head_pose"]).issubset(fm.keys())
# 不应含标注图字段(接口52 只要数值,不要画线图)
assert "annotated_image_base64" not in fm
assert "annotated_image_url" not in fm
# 子结构
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
"lower_court_cm", "ratios"]).issubset(fm["four_courts"].keys())
assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm",
"ratios"]).issubset(fm["seven_eyes"].keys())
# 七眼 eye1~eye7 键必须存在(eye1/eye7 在耳朵不可见时可为 null)
assert set([f"eye{i}" for i in range(1, 8)]).issubset(fm["seven_eyes"].keys())
assert set(["hair_top", "hairline", "brow_center",
"nose_bottom", "chin_tip"]).issubset(fm["landmarks"].keys())
assert fm["hairline_source"] in ("segmentation", "estimated", "corrected")
assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys())
# 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker;
# 其测试随实现一起在网关侧做,worker 这边不再覆盖。
+122
View File
@@ -0,0 +1,122 @@
"""Framing must align real pixel coordinates without scaling or invented pixels."""
import io
import cv2
import numpy as np
from PIL import Image
import pytest
from hairline.framing import calibrate_reference, crop_fits, decode_portrait, normalize_portrait, transform_points
PROFILE = {"version": "test", "reference_image_url": "/reference.png",
"width_per_face_length": 2.1, "height_per_face_length": 2.8,
"brow_x_ratio": .49, "brow_y_ratio": .34}
def landmarks(w, h, brow, length=100, angle=0):
lm = np.zeros((478, 3), np.float64)
down = np.array([np.sin(np.deg2rad(angle)), np.cos(np.deg2rad(angle))])
lm[:, :2] = np.array(brow)/[w, h]
lm[152, :2] = (np.array(brow)+down*length)/[w, h]
lm[:, 2] = .01
return lm
@pytest.mark.parametrize("angle", [-22, 0, 17])
def test_native_scale_upright_and_inverse_mapping(angle):
photo = np.full((700, 600, 3), 127, np.uint8)
lm = landmarks(600, 700, (300, 280), angle=angle)
result, moved, metadata = normalize_portrait(photo, lm, PROFILE)
h, w = result.shape[:2]
assert metadata["standard_crop"] is True
assert w/h == .75
xy = moved[[9, 152], :2]*[w, h]
assert abs(xy[0, 0]-xy[1, 0]) < 1e-9
assert xy[1, 1]-xy[0, 1] == pytest.approx(100)
assert xy[0, 0]/w == pytest.approx(PROFILE["brow_x_ratio"])
assert xy[0, 1]/h == pytest.approx(PROFILE["brow_y_ratio"])
matrix = np.array(metadata["source_to_processed"])
np.testing.assert_allclose(np.linalg.svd(matrix[:, :2])[1], [1, 1], atol=1e-10)
restored = transform_points(moved[:, :2]*[w, h], np.array(metadata["processed_to_source"]))
np.testing.assert_allclose(restored, lm[:, :2]*[600, 700], atol=1e-9)
assert result.min() == 127 # no black triangles / padded background
def test_insufficient_space_keeps_upright_original_size_when_no_rotation_needed():
photo = np.full((300, 230, 3), 127, np.uint8)
result, moved, metadata = normalize_portrait(photo, landmarks(230, 300, (115, 80), 150), PROFILE)
assert metadata["standard_crop"] is False
assert metadata["skip_reason"] == "insufficient_space"
assert result.shape == photo.shape
np.testing.assert_array_equal(result, photo)
assert abs(moved[9, 0]-moved[152, 0]) < 1e-10
def test_rotated_fallback_has_no_empty_corners():
photo = np.full((400, 300, 3), 127, np.uint8)
result, moved, metadata = normalize_portrait(photo, landmarks(300, 400, (150, 130), 160, 12), PROFILE)
assert metadata["standard_crop"] is False
assert result.min() == 127
assert metadata["axis_x_error_px"] < 1e-10
assert crop_fits(np.array(metadata["source_to_processed"]), (300, 400), result.shape[1::-1])
@pytest.mark.parametrize("brow,angle", [((150, 330), 0), ((150, 330), 8), ((150, 70), -8)])
def test_tall_fallback_is_three_by_four_and_retains_face(brow, angle):
photo = np.full((700, 300, 3), 127, np.uint8)
lm = landmarks(300, 700, brow, length=200, angle=angle)
image, moved, metadata = normalize_portrait(photo, lm, PROFILE)
h, w = image.shape[:2]
assert metadata["standard_crop"] is False
assert metadata["aspect_crop"] is True
assert metadata["status"] == "aspect_crop"
assert w*4 == h*3
xy = moved[:, :2]*[w, h]
assert (xy >= -1e-7).all() and (xy <= [w-1+1e-7, h-1+1e-7]).all()
assert abs(xy[9, 0]-xy[152, 0]) < 1e-9
assert np.linalg.norm(xy[152]-xy[9]) == pytest.approx(200)
assert image.min() == 127
assert crop_fits(np.array(metadata["source_to_processed"]), (300, 700), (w, h))
def test_tall_fallback_does_not_cut_face_that_cannot_fit_three_by_four():
photo = np.full((700, 300, 3), 127, np.uint8)
image, moved, metadata = normalize_portrait(photo, landmarks(300, 700, (150, 100), length=450), PROFILE)
assert metadata["aspect_crop"] is False
assert metadata["status"] == "rotation_only"
assert image.shape == photo.shape
def test_reference_parameters_are_measured_after_straightening():
photo = np.full((400, 300, 3), 127, np.uint8)
image, moved, profile = calibrate_reference(photo, landmarks(300, 400, (145, 140), angle=8))
h, w = image.shape[:2]
assert w/h == .75 and w < 300 and h < 400
assert profile["brow_9"]["x"] == pytest.approx(profile["chin_152"]["x"], abs=1e-9)
assert profile["brow_x_ratio"] == pytest.approx(moved[9, 0])
assert profile["brow_y_ratio"] == pytest.approx(moved[9, 1])
assert profile["reference_width_per_face_length"] == pytest.approx(w/100)
assert profile["reference_height_per_face_length"] == pytest.approx(h/100)
assert profile["width_per_face_length"] == 2.5
assert profile["height_per_face_length"] == pytest.approx(3/.9)
assert image.min() == 127
def test_default_crop_makes_face_ten_percent_smaller_than_three_l():
photo = np.full((700, 600, 3), 127, np.uint8)
result, moved, metadata = normalize_portrait(photo, landmarks(600, 700, (300, 280), length=120))
assert metadata["standard_crop"] is True
assert result.shape[:2] == (400, 300)
assert metadata["face_axis_length_px"] == pytest.approx(120)
assert metadata["height_per_face_length"] == pytest.approx(3/.9)
assert metadata["width_per_face_length"] == 2.5
old_height = 3*120
assert old_height/result.shape[0] == pytest.approx(.9)
def test_phone_orientation_is_applied_before_landmarks():
image = Image.new("RGB", (120, 80), "white")
exif = Image.Exif(); exif[274] = 6
buf = io.BytesIO(); image.save(buf, format="JPEG", exif=exif)
assert decode_portrait(buf.getvalue()).shape[:2] == (120, 80)
+27
View File
@@ -0,0 +1,27 @@
"""The legacy interface 5 and new interface 52 forward to separate worker paths."""
import asyncio
import importlib
import pytest
from starlette.requests import Request
@pytest.mark.parametrize("name,path", [
("hairline_generate", "/api/v1/hairline/generate"),
("hairline_generate_52", "/api/v1/hairline/generate-52"),
])
def test_distinct_hairline_forwarding(monkeypatch, name, path):
gateway = importlib.import_module("gateway.app")
request = Request({"type": "http", "method": "POST", "path": path, "headers": []})
calls = []
async def proxy(actual_request, actual_path):
assert actual_request is request
calls.append(actual_path)
return {"forwarded_to": actual_path}
monkeypatch.setattr(gateway, "_proxy", proxy)
endpoint = getattr(gateway, name)
assert next(route for route in gateway.app.routes if getattr(route, "path", None) == path).endpoint is endpoint
assert asyncio.run(endpoint(request)) == {"forwarded_to": path}
assert calls == [path]
+69
View File
@@ -0,0 +1,69 @@
"""Nullable/deprecated worker images retain their documented gateway keys."""
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
from starlette.requests import Request
from gateway.forward import rewrite_base64_to_url
def test_nullable_image_fields_keep_external_names(tmp_path):
value = {"hairline_images": [{"image_high_base64": None, "image_low_base64": None,
"grown_image_base64": None}],
"high_hairline_center_point": None, "face_measure": None}
actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path), rewrite_null_images=True)
assert actual == {"hairline_images": [{"image_high_url": None, "image_low_url": None,
"grown_image_url": None}],
"high_hairline_center_point": None, "face_measure": None}
assert not list(tmp_path.iterdir())
def test_nonempty_images_still_rewrite_alongside_null_fields(tmp_path):
import base64
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGBA", (2, 2), (255, 255, 255, 0)).save(buf, format="PNG")
value = {"image_middle_base64": base64.b64encode(buf.getvalue()).decode(),
"image_high_base64": None}
actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path), rewrite_null_images=True)
assert actual["image_middle_url"].startswith("https://example.test/static/annotations/")
assert actual["image_high_url"] is None
assert len(list(tmp_path.iterdir())) == 1
def test_legacy_nullable_image_field_names_remain_unchanged(tmp_path):
value = {"hairline_images": [{"grown_image_base64": None}], "face_measure": None}
assert rewrite_base64_to_url(value, "https://example.test", str(tmp_path)) == value
assert not list(tmp_path.iterdir())
@pytest.mark.parametrize("path,expected_key", [
("/api/v1/hairline/generate", "grown_image_base64"),
("/api/v1/hairline/generate-52", "grown_image_url"),
])
def test_proxy_rewrites_null_images_only_for_interface52(tmp_path, monkeypatch, path, expected_key):
import gateway.config as config
import gateway.forward as forward
monkeypatch.setattr(config, "get_config", lambda: {
"dispatch": {"request_timeout_seconds": 10}, "shared_password": "test-token",
"public_base_url": "https://example.test", "static_dir": str(tmp_path),
})
monkeypatch.setattr(forward, "acquire_worker", AsyncMock(return_value=SimpleNamespace(url="http://worker")))
monkeypatch.setattr(forward, "release_worker", AsyncMock())
worker_response = httpx.Response(200, json={"code": 0, "data": {"hairline_images": [
{"grown_image_base64": None}]}})
monkeypatch.setattr(forward, "get_client", lambda: SimpleNamespace(request=AsyncMock(return_value=worker_response)))
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
request = Request({"type": "http", "method": "POST", "path": path, "headers": []}, receive)
response = asyncio.run(forward.proxy_request(request, path))
assert response.status_code == 200
assert json.loads(response.body)["data"]["hairline_images"] == [{expected_key: None}]
+91
View File
@@ -0,0 +1,91 @@
"""Court-based design placement, including preview/inpaint consistency."""
import cv2
import numpy as np
import pytest
from hairline.height_rule import (
height_target, curve_center, place_curve, inpaint_from_curve,
)
def _landmarks(width=320, height=400):
lm = np.zeros((468, 3), np.float32)
for index, xy in {9: (160, 180), 94: (160, 260), 152: (160, 320),
10: (160, 140), 21: (70, 170), 251: (250, 170)}.items():
lm[index, :2] = np.array(xy) / [width, height]
return lm
def test_target_uses_mean_courts_without_old_estimation_ratio():
rule = height_target(_landmarks(), 320, 400)
assert rule["middle_height_px"] == pytest.approx(80, abs=1e-4)
assert rule["lower_height_px"] == pytest.approx(60, abs=1e-4)
assert rule["target_upper_height_px"] == pytest.approx(70, abs=1e-4)
assert rule["target_center"]["y"] == pytest.approx(110, abs=1e-4)
def test_reference_uses_unrounded_measurement_and_scale():
ref = {"brow_center": (161.4, 181.3), "nose_bottom": (160.0, 261.9),
"chin_tip": (160.0, 321.5), "px_per_cm": 20}
rule = height_target(_landmarks(), 320, 400, ref)
assert rule["target_upper_height_px"] == pytest.approx(70.1)
assert rule["target_center"]["y"] == pytest.approx(111.2)
assert rule["target_upper_height_cm"] == pytest.approx(3.505)
assert rule["measurement_source"] == "face_measure"
@pytest.mark.parametrize("scale", [1, 2])
@pytest.mark.parametrize("center_y", [90, 125, 150])
def test_all_shapes_align_to_same_height_at_multiple_scales(scale, center_y):
h, w = 400 * scale, 320 * scale
lm = _landmarks()
overlay = np.zeros((h, w, 4), np.float32)
# A non-horizontal design, preserving a relative 20px arc after movement.
pts = np.array([[70, center_y + 20], [160, center_y], [250, center_y + 20]]) * scale
cv2.polylines(overlay, [pts.astype(np.int32)], False, (255, 255, 255, 255), 3 * scale)
original = overlay.copy()
target = height_target(lm, w, h)["target_center"]
moved, placement = place_curve(overlay, target, lm)
assert abs(curve_center(moved, target["x"])["y"] - target["y"]) < .2
assert placement["target_error_px"] < .2
# Shape's relative heights stay constant after translation.
before_delta = curve_center(original, 100 * scale)["y"] - curve_center(original, 160 * scale)["y"]
after_delta = curve_center(moved, 100 * scale)["y"] - curve_center(moved, 160 * scale)["y"]
assert after_delta == pytest.approx(before_delta, abs=.1)
np.testing.assert_array_equal(overlay, original)
def test_rolled_face_preserves_curve_angle():
lm = _landmarks()
lm[10, 0] -= .08
overlay = np.zeros((400, 320, 4), np.float32)
cv2.line(overlay, (60, 95), (260, 135), (255, 255, 255, 255), 3)
moved, _ = place_curve(overlay, {"x": 160, "y": 150}, lm)
original_slope = curve_center(overlay, 200)["y"] - curve_center(overlay, 120)["y"]
moved_slope = curve_center(moved, 200)["y"] - curve_center(moved, 120)["y"]
assert moved_slope == pytest.approx(original_slope, abs=.2)
def test_inpaint_mask_follows_new_line_not_old_black_template():
lm = _landmarks()
photo = np.full((400, 320, 3), 200, np.uint8)
parsing = np.ones((400, 320), np.int32)
overlay = np.zeros((400, 320, 4), np.float32)
cv2.line(overlay, (20, 140), (300, 140), (255, 255, 255, 255), 3)
moved, _ = place_curve(overlay, {"x": 160, "y": 100}, lm)
marked, mask = inpaint_from_curve(photo, lm, parsing, moved)
assert marked[100, 160, 0] == 0
assert marked[140, 160, 0] == 200
assert mask[90, 160] > 240
assert mask[130, 160] < 10
def test_out_of_frame_target_is_reported_instead_of_silent_clamping():
ref = {"brow_center": (160, 50), "nose_bottom": (160, 200), "chin_tip": (160, 350)}
with pytest.raises(ValueError, match="超出图片"):
height_target(_landmarks(), 320, 400, ref)
def test_missing_midline_curve_is_reported():
with pytest.raises(ValueError, match="有效曲线"):
place_curve(np.zeros((400, 320, 4), np.float32), {"x": 160, "y": 100}, _landmarks())
+101
View File
@@ -0,0 +1,101 @@
import json
from ipaddress import ip_address
import pytest
from fastapi.testclient import TestClient
from starlette.requests import Request
import ip_access as access
@pytest.fixture
def policy(tmp_path, monkeypatch):
path = tmp_path / "access.json"
path.write_text(json.dumps({"allowed_ips": []}))
monkeypatch.setattr(access, "CONFIG_PATH", path)
monkeypatch.setattr(access, "local_addresses", lambda: {ip_address("192.168.10.2"), ip_address("::1")})
return path
def request(host, headers=None, port=50000):
return Request({"type": "http", "path": "/api/v1/hairline/generate-52", "scheme": "http",
"client": (host, port), "headers": [(k.encode(), v.encode()) for k, v in (headers or {}).items()]})
@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "::ffff:127.0.0.1", "192.168.10.2"])
def test_only_actual_local_addresses_allowed_by_default(policy, host):
assert access.check_access(request(host))[0]
@pytest.mark.parametrize("host", ["203.0.113.9", "192.168.10.3", "0.0.0.0", "invalid"])
def test_other_addresses_denied(policy, host):
assert not access.check_access(request(host))[0]
def test_whitelist_ipv4_ipv6_cidr_and_hot_reload(policy):
policy.write_text(json.dumps({"allowed_ips": ["203.0.113.9", "198.51.100.0/24", "2001:db8::/64"]}))
for host in ["203.0.113.9", "::ffff:203.0.113.9", "198.51.100.12", "2001:db8::12"]:
assert access.check_access(request(host))[0]
policy.write_text('{"allowed_ips": []}')
assert not access.check_access(request("203.0.113.9"))[0]
def test_configured_public_whitelist_is_exact(policy):
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
assert access.check_access(request("49.232.51.171"))[0]
assert not access.check_access(request("49.232.51.172"))[0]
@pytest.mark.parametrize("header", ["x-forwarded-for", "x-real-ip", "forwarded"])
def test_forwarding_headers_cannot_override_socket_peer(policy, header):
assert not access.check_access(request("203.0.113.9", {header: "127.0.0.1"}))[0]
assert access.check_access(request("127.0.0.1", {header: "203.0.113.9"}))[0]
def test_gateway_is_allowed_independently_of_end_user_ip(policy):
policy.write_text('{"allowed_ips": ["49.232.51.171"]}')
assert access.check_access(request("49.232.51.171", {"x-forwarded-for": "203.0.113.9"}))[0]
assert not access.check_access(request("203.0.113.9", {"x-forwarded-for": "49.232.51.171"}))[0]
def test_rewritten_peer_or_invalid_policy_fail_closed(policy):
assert not access.check_access(request("127.0.0.1", port=0))[0]
policy.write_text('{"allowed_ips": ["not-an-ip"]}')
assert not access.check_access(request("127.0.0.1"))[0]
policy.write_text('invalid JSON')
assert not access.check_access(request("127.0.0.1"))[0]
def test_worker_guard_covers_all_interfaces_before_auth(policy, monkeypatch):
import app as worker
monkeypatch.setattr(worker, "ACCEPT_PASSWORDS", ["access-test"])
headers = {"X-Internal-Token": "access-test"}
with TestClient(worker.app, client=("203.0.113.9", 50000)) as client:
assert client.post('/api/v1/hairline/generate-52').status_code == 403
assert client.post('/api/v1/hairline/generate-52/', headers=headers).status_code == 403
assert client.post('/api/v1/debug/hairline-height-compare').status_code == 403
assert client.post('/api/v1/hairline/generate', headers=headers).status_code == 403
assert client.post('/api/v1/face/measure', headers=headers).status_code == 403
assert client.post('/api/v1/redraw', headers=headers).status_code == 403
assert client.get('/health').status_code == 403
assert client.get('/openapi.json').status_code == 403
policy.write_text('{"allowed_ips": ["203.0.113.9"]}')
assert client.post('/api/v1/hairline/generate-52', headers=headers).json()['code'] == 1004
assert client.post('/api/v1/hairline/generate-52').status_code == 401
def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch):
import importlib
gateway = importlib.import_module('gateway.app')
calls = []
async def proxy(request, path):
calls.append(path)
return {'code': 0}
monkeypatch.setattr(gateway, '_proxy', proxy)
# Deliberately skip lifespan: no workers or configuration are needed.
client = TestClient(gateway.app, client=('203.0.113.9', 50000))
assert client.post('/api/v1/hairline/generate-52').json()['code'] == 0
assert client.post('/api/v1/hairline/generate').json()['code'] == 0
assert client.post('/api/v1/hair/grow').json()['code'] == 0
assert client.get('/static/test_interface52.html').status_code == 200
assert calls == ['/api/v1/hairline/generate-52', '/api/v1/hairline/generate', '/api/v1/hair/grow']