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
+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())