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
+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:头发遮罩生成
# ---------------------------------------------------------------------------