feat: 新增52号发际线接口并限制GPU worker来源IP
保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。 worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。 验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
This commit is contained in:
@@ -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:头发遮罩生成
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user