Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb9c142c4 | ||
|
|
b64313dc73 | ||
|
|
1c51f3eaab | ||
|
|
5b2b73f5d5 | ||
|
|
a3fddbbc34 | ||
|
|
8554850d55 | ||
|
|
6b098c0e15 |
@@ -9,6 +9,7 @@ gateway/config.json
|
||||
|
||||
# worker 配置(含鉴权密码,不入 git)
|
||||
worker_config.json
|
||||
ip_access.json
|
||||
|
||||
# ComfyUI Basic Auth 密码(不入 git)
|
||||
password.txt
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
|
||||
接口 1(四庭七眼测量)已为**真实算法实现**(见 face_analysis 包);接口 2~5 仍为 Mock。
|
||||
拆分架构:worker 跑算法、返回 `annotated_image_base64`(不落盘、不拼 URL,由网关完成)。
|
||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验。
|
||||
worker 对 `/api/*` 校验内网鉴权头 `X-Internal-Token`,`/health` 供网关探测不校验
|
||||
(深检:磁盘水位 + 下游依赖 TCP 探测,异常返回 503 degraded,见 _load_health_cfg)。
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from io import BytesIO
|
||||
from typing import Any, List, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -189,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:
|
||||
@@ -365,22 +374,22 @@ 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。
|
||||
不含标注图(annotated_image_*),供接口5 容错复用;接口1 调用后再自行生成标注图。
|
||||
任何分割/七眼计算异常均内部吞掉(七眼相关字段不出现),不影响主测量结果。
|
||||
|
||||
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)。
|
||||
variant="v6"(接口6):去顶庭、不画端线、无 eye1~eye7。
|
||||
variant="v1"(接口1/接口5):完整四庭七眼 + eye1~eye7(含头部端线段)+ vertical_lines。
|
||||
variant="v6"(接口6):去顶庭、不画端线、无 eye1/eye7,vertical_lines 仅 6 条内线。
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
from face_analysis.detector import detector
|
||||
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):
|
||||
@@ -419,30 +428,29 @@ def _run_face_measure_data(image, variant="v1"):
|
||||
# 占比、标注图仍按三庭处理,显示效果不变。
|
||||
|
||||
# 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。
|
||||
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。
|
||||
# eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 用竖线坐标里的人头端线,仅接口1 有。
|
||||
# vertical_lines 与标注图竖线同一套几何。
|
||||
try:
|
||||
from face_analysis.annotation import vertical_line_geometry
|
||||
epts = result.eyes["points"]
|
||||
lcx, rcx = epts["left_cheek"][0], epts["right_cheek"][0]
|
||||
pc = result.px_per_cm
|
||||
inner_xs = [lcx, epts["left_outer"][0], epts["left_inner"][0],
|
||||
epts["right_inner"][0], epts["right_outer"][0], rcx]
|
||||
geom = vertical_line_geometry(
|
||||
result, w, h, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
|
||||
data["vertical_lines"] = geom
|
||||
inner_xs = [epts["left_cheek"][0], epts["left_outer"][0], epts["left_inner"][0],
|
||||
epts["right_inner"][0], epts["right_outer"][0], epts["right_cheek"][0]]
|
||||
for i in range(5):
|
||||
a, b = inner_xs[i], inner_xs[i + 1]
|
||||
data["seven_eyes"][f"eye{i + 2}"] = (
|
||||
None if (a is None or b is None) else round((b - a) / pc, 2))
|
||||
if variant != "v6":
|
||||
# 接口1 额外算 eye1/eye7(左/右耳外段),需耳朵分割端线。
|
||||
# 竖向范围上界用头顶(hair_top 始终可用)。
|
||||
from face_analysis.annotation import _ear_edges_from_mask
|
||||
top_y = vd["hair_top"][1]
|
||||
head_l, head_r = _ear_edges_from_mask(
|
||||
ear_mask, hair_mask,
|
||||
top_y, vd["chin_tip"][1],
|
||||
lcx, rcx, (lcx + rcx) / 2)
|
||||
by_name = {line["name"]: line["x"] for line in geom["lines"]}
|
||||
head_l = by_name.get("left_head_edge")
|
||||
head_r = by_name.get("right_head_edge")
|
||||
data["seven_eyes"]["eye1"] = (
|
||||
None if (head_l is None) else round((lcx - head_l) / pc, 2))
|
||||
None if head_l is None else round((by_name["left_cheek"] - head_l) / pc, 2))
|
||||
data["seven_eyes"]["eye7"] = (
|
||||
None if (head_r is None) else round((head_r - rcx) / pc, 2))
|
||||
None if head_r is None else round((head_r - by_name["right_cheek"]) / pc, 2))
|
||||
except Exception as seg_e: # noqa: BLE001
|
||||
logger.warning("七眼段宽度计算失败:%s", seg_e)
|
||||
|
||||
@@ -892,6 +900,7 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
- 四庭(顶庭/上庭/中庭/下庭)各段**厘米数值及占比**
|
||||
- 七眼(眼宽/脸宽/两眼间距)**厘米数值及占比**
|
||||
- 五个关键分界点的**原图像素坐标**(头顶/发际线/眉心/鼻翼下缘/下巴尖)
|
||||
- 竖线坐标 `vertical_lines`(与标注图同一套:共用 y 范围 + 从左到右的 x)
|
||||
|
||||
{_image_fields_desc}
|
||||
|
||||
@@ -947,6 +956,19 @@ async def _face_measure_impl(image_file, image_url, image_base64, variant="v1"):
|
||||
"nose_bottom": {"x": 540, "y": 1050},
|
||||
"chin_tip": {"x": 540, "y": 1360},
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 98, "y_bottom": 1410,
|
||||
"lines": [
|
||||
{"name": "left_head_edge", "x": 120},
|
||||
{"name": "left_cheek", "x": 210},
|
||||
{"name": "left_eye_outer", "x": 310},
|
||||
{"name": "left_eye_inner", "x": 420},
|
||||
{"name": "right_eye_inner", "x": 560},
|
||||
{"name": "right_eye_outer", "x": 670},
|
||||
{"name": "right_cheek", "x": 780},
|
||||
{"name": "right_head_edge", "x": 890},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1023,6 +1045,7 @@ async def face_measure_debug(
|
||||
- 三庭(上庭/中庭/下庭)各段**厘米数值及占比**(**不含顶庭**)
|
||||
- 五眼段宽(eye2~eye6:左脸颊/左眼/两眼间距/右眼/右脸颊)**厘米数值**,另含眼宽/脸宽/两眼间距
|
||||
- 四个关键分界点的**原图像素坐标**(发际线/眉心/鼻翼下缘/下巴尖)
|
||||
- 竖线坐标 `vertical_lines`(发际线→下巴尖,仅 6 条内线,无人头两端)
|
||||
|
||||
基于接口1 的变体,与接口1 的差异:
|
||||
- **去顶庭**:不画头顶横线、不返回顶庭数据;`face_total_height_cm` 为三庭之和
|
||||
@@ -1080,6 +1103,17 @@ async def face_measure_debug(
|
||||
"nose_bottom": {"x": 540, "y": 1050},
|
||||
"chin_tip": {"x": 540, "y": 1360},
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 430, "y_bottom": 1360,
|
||||
"lines": [
|
||||
{"name": "left_cheek", "x": 210},
|
||||
{"name": "left_eye_outer", "x": 310},
|
||||
{"name": "left_eye_inner", "x": 420},
|
||||
{"name": "right_eye_inner", "x": 560},
|
||||
{"name": "right_eye_outer", "x": 670},
|
||||
{"name": "right_cheek", "x": 780},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1552,7 +1586,7 @@ async def face_features(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 接口 5:发际线 PNG 生成
|
||||
# 接口 5:发际线 PNG 生成(恢复 a3fddbb 历史版本)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.post(
|
||||
@@ -1722,6 +1756,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_base64`:新高度规则的透明发际线 PNG;`image_high_base64` / `image_low_base64` 固定 null。
|
||||
- `height_placement`:旧/新中心坐标、移动距离和目标误差(像素)。
|
||||
- `grown_image_base64`:该发型的**生发图**(生发失败时为 `null`)。
|
||||
- `hairline_type`:发际线类型 key。
|
||||
本接口仅部署在GPU worker,图片始终返回 `*_base64`;空图片保留字段为 null。
|
||||
- `best_hairline_center_point`:**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标,
|
||||
以**当前底图像素**为基准(左上角为原点,x 向右,y 向下)。
|
||||
- `high_hairline_center_point` / `low_hairline_center_point`:旧字段,固定 null。
|
||||
- `height_rule`:中庭、下庭、目标上庭高度、目标中心与测量来源;`face_measure` 为当前底图测量。
|
||||
- `input_image_base64`:处理后底图,须用它叠加发际线;`original_image_base64`:处理前图片。
|
||||
- `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:头发遮罩生成
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2328,12 +2641,127 @@ async def download_hairline_log(rid: Optional[str] = None, tail: int = 500):
|
||||
# 健康检查
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_health_cfg() -> dict:
|
||||
"""深健康检查配置:环境变量优先,回退 worker_config.json 的 "health" 段。
|
||||
|
||||
min_free_gb=0 关闭磁盘检查;check_deps=false 关闭依赖探测(均退化为旧行为,
|
||||
即只看模型就绪标志)。
|
||||
"""
|
||||
cfg_path = os.path.join(os.path.dirname(__file__), "worker_config.json")
|
||||
file_cfg: dict = {}
|
||||
if os.path.isfile(cfg_path):
|
||||
try:
|
||||
with open(cfg_path, encoding="utf-8") as f:
|
||||
file_cfg = json.load(f).get("health") or {}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("读取 worker_config.json health 段失败:%s", e)
|
||||
|
||||
def _get(env_key, file_key, default):
|
||||
raw = os.getenv(env_key)
|
||||
return raw if raw not in (None, "") else file_cfg.get(file_key, default)
|
||||
|
||||
def _as_float(value, default):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return {
|
||||
"min_free_gb": _as_float(_get("HEALTH_MIN_FREE_GB", "min_free_gb", 5.0), 5.0),
|
||||
"check_deps": str(_get("HEALTH_CHECK_DEPS", "check_deps", True)).strip().lower()
|
||||
not in ("0", "false", "no", "off"),
|
||||
"dep_comfyui": str(_get("HEALTH_DEP_COMFYUI", "dep_comfyui", "127.0.0.1:8188")),
|
||||
"deps_cache_seconds": max(0.0, _as_float(
|
||||
_get("HEALTH_DEPS_CACHE_SECONDS", "deps_cache_seconds", 5.0), 5.0)),
|
||||
}
|
||||
|
||||
|
||||
_HEALTH_CFG = _load_health_cfg()
|
||||
|
||||
|
||||
def _disk_free_gb() -> float:
|
||||
"""worker 所在文件系统的剩余空间(GB)。"""
|
||||
st = shutil.disk_usage(os.path.dirname(os.path.abspath(__file__)))
|
||||
return st.free / (1 << 30)
|
||||
|
||||
|
||||
def _parse_host_port(addr: str, default_port: int):
|
||||
"""解析 "host:port"(仅支持 IPv4/主机名形式)。"""
|
||||
host, _, port = str(addr).partition(":")
|
||||
try:
|
||||
return host or "127.0.0.1", int(port) if port else default_port
|
||||
except ValueError:
|
||||
return host or "127.0.0.1", default_port
|
||||
|
||||
|
||||
def _change_hair_addr():
|
||||
"""从 _CHANGE_HAIR_BASE 解析 (host, port),失败返回 None。"""
|
||||
try:
|
||||
u = urlsplit(_CHANGE_HAIR_BASE)
|
||||
if u.hostname:
|
||||
return u.hostname, u.port or 80
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def _tcp_probe(host: str, port: int, timeout: float = 0.8) -> bool:
|
||||
"""TCP 连通性探测:只判下游进程死活,不过 HTTP 认证、不受业务繁忙影响。"""
|
||||
try:
|
||||
_, writer = await asyncio.wait_for(asyncio.open_connection(host, port), timeout)
|
||||
writer.close()
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
_DEP_CACHE = {"ts": -1.0, "detail": None}
|
||||
|
||||
|
||||
async def _check_deps():
|
||||
"""并发探测下游依赖(ComfyUI、change_hair),结果缓存 deps_cache_seconds 秒。"""
|
||||
now = time.monotonic()
|
||||
detail = _DEP_CACHE["detail"]
|
||||
if detail is None or now - _DEP_CACHE["ts"] >= _HEALTH_CFG["deps_cache_seconds"]:
|
||||
probes = {"comfyui": _tcp_probe(*_parse_host_port(_HEALTH_CFG["dep_comfyui"], 8188))}
|
||||
ch = _change_hair_addr()
|
||||
if ch:
|
||||
probes["change_hair"] = _tcp_probe(*ch)
|
||||
names = list(probes)
|
||||
results = await asyncio.gather(*probes.values())
|
||||
detail = dict(zip(names, results))
|
||||
_DEP_CACHE["ts"], _DEP_CACHE["detail"] = now, detail
|
||||
return all(detail.values()), detail
|
||||
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
async def health():
|
||||
# 模型就绪后才返回 200,供网关探测;未就绪返回 503。
|
||||
"""网关探测入口。
|
||||
|
||||
模型未就绪 → 503 starting;磁盘剩余低于阈值 / 下游依赖 TCP 不通 → 503 degraded;
|
||||
正常 → 200(附 disk_free_gb 与 deps 明细,供人工排查)。深检自身异常时按就绪返回
|
||||
(健康检查不能自己成为故障点)。
|
||||
"""
|
||||
if not _STATE["ready"]:
|
||||
return JSONResponse(status_code=503, content={"status": "starting"})
|
||||
return {"status": "ok"}
|
||||
try:
|
||||
free_gb = _disk_free_gb()
|
||||
body = {"status": "ok", "disk_free_gb": round(free_gb, 1)}
|
||||
min_free = _HEALTH_CFG["min_free_gb"]
|
||||
if min_free > 0 and free_gb < min_free:
|
||||
body.update(status="degraded", reason=f"disk_free_below_{min_free}g")
|
||||
return JSONResponse(status_code=503, content=body)
|
||||
if _HEALTH_CFG["check_deps"]:
|
||||
deps_ok, detail = await _check_deps()
|
||||
body["deps"] = detail
|
||||
if not deps_ok:
|
||||
down = ",".join(k for k, v in detail.items() if not v)
|
||||
body.update(status="degraded", reason=f"dep_down:{down}")
|
||||
return JSONResponse(status_code=503, content=body)
|
||||
return body
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("深健康检查异常,按就绪返回")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
|
||||
+21
-7
@@ -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` | 仅GPU worker,本次不新增网关路由;直接返回`*_base64` |
|
||||
| 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,20 +45,21 @@
|
||||
| 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — |
|
||||
|
||||
> 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。
|
||||
> 兼容约定:网关保持原样,有图片时落盘为`*_url`,空图片保留`*_base64: null`。接口52仅在worker返回base64,与网关的图片改名无关。
|
||||
> **图片格式**:接口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不允许或白名单配置无效。
|
||||
|
||||
---
|
||||
|
||||
## 2. 各接口实现简述
|
||||
|
||||
### 接口1 四庭七眼测量 `/api/v1/face/measure`(worker)
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、一张透明底标注 PNG。
|
||||
- **做什么**:正面照 → 四庭(顶/上/中/下庭) + 七眼(眼宽/脸宽/间距) 的 cm 与占比、5 个关键点坐标、竖线坐标 `vertical_lines`、一张透明底标注 PNG。
|
||||
- **怎么实现**(`face_analysis/`):MediaPipe Face Mesh 468+虹膜点 → solvePnP 姿态校验(非正面 1003) →
|
||||
虹膜直径法定标(px→cm) → **眉心以下实测**;**眉心以上**用 BiSeNet 头发分割取真实发际线/头顶(方案B),
|
||||
失败回退比例推算(方案A,`hairline_source` 透出)。标注图 numpy 向量化渐变线 + 思源黑体。返回 `annotated_image_base64`。
|
||||
@@ -65,7 +68,7 @@
|
||||
### 接口6 四庭七眼测量 v2 `/api/v1/face/measure-v2`(worker)—— 接口1 的去顶庭变体
|
||||
- **做什么**:基于接口 1,**去顶庭**:不画头顶横线、不返回顶庭数据(`four_courts` 仅上/中/下庭,`landmarks` 无 `hair_top`,`face_total_height_cm` 为三庭之和)。
|
||||
- **与接口1 的标注差异**(`create_annotated_image(variant="v6")`):①竖线范围改为发际线→下巴尖;②不画人头最左/最右端线(仅七眼 6 点 5 段,接口1 为 8 线 7 段);③左侧只标上/中/下庭。箭头/虚线/字体等与接口1 一致。
|
||||
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。
|
||||
- **怎么实现**:与接口 1 共用 `_face_measure_impl(variant="v6")`;v6 时标注走变体分支、数据由 app.py 边界删顶庭字段并重算三庭比例。`vertical_lines` 与标注图共用 `vertical_line_geometry`:接口 6 的 y 范围是发际线到下巴尖,`lines` 只有 6 条内线。
|
||||
- **网关改动**:新增路由 `POST /api/v1/face/measure-v2`,转发到 worker 同路径;base64→URL 改写无需改动。
|
||||
|
||||
### 接口2 C端生发 `/api/v1/hair/grow`(worker)—— 预览 + 生发图
|
||||
@@ -74,6 +77,7 @@
|
||||
按 `face_ext.obj` 的 UV 把发际线贴图渲染到额头(预览)。原生生发:黑贴图渲染遮罩 → 调本机 **ComfyUI 8188** 的
|
||||
`add_hair.json`(Flux-2) 出图。**关键坑**:obj 是重排序,需 `INDEX_MAP_468` 把 MP 序→OBJ 序。
|
||||
返回 `results[].image_base64` + `grown_image_base64`。
|
||||
- 模板对齐:左右刘海边界高低不同时,按 MediaPipe 左右对应额头点协调沿人脸向上方向的延伸距离,避免把 M 型等设计模板拉歪;保留人脸姿态,之后仍做头部轮廓钳制。预览白线与生发黑线/遮罩共用修正后的 502 点几何,不改变真实发际线测量算法。
|
||||
- `hair_style` 映射: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。female 1..5 走「换发型(change_hair)」+Flux-2 重绘管线(`generate_grow_results_swap`,复用接口12 final 的 `_V2_FINAL_DEFAULTS`,长边默认压到 `REDRAW_MAX_SIDE`=1024 再放大回原尺寸);female 6/7 与 male 全部走原生发(ComfyUI add_hair)管线。可选参数 `flux_model`(切换 Flux unet,.gguf 自动配 UnetLoaderGGUF + 配套 qwen CLIP)、`redraw_max_side`(0=不缩图)。
|
||||
|
||||
### 接口7 C端生发 v2 `/api/v1/hair/grow-v2`(worker)—— 接口2同款,add_hair2 工作流
|
||||
@@ -98,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。接口52始终返回`*_base64`,空图片保留字段并返回null。本次不修改网关路由、落盘、字段改名或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` 两组各四张图。
|
||||
@@ -125,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 推理)。
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# 接口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号的worker处理路由和生成函数独立;52号仅部署在GPU worker,本次不新增网关转发。
|
||||
|
||||
## 已确定的要求
|
||||
|
||||
- 仅用于接口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仅属于GPU worker,返回`*_base64`及null;网关路由、落盘、字段改名、mock worker均恢复`a3fddbb`,没有“仅52号改名”的特殊逻辑。测试确认旧网关行为保持原样且未暴露52号路由。
|
||||
- 范围修正后的回归检查:`./venv/bin/pytest tests/test_ip_access.py tests/test_gateway_hairline_routes.py tests/test_gateway_nullable_images.py tests/test_api.py -q`,54项通过。
|
||||
- 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为当前成功结果,失败组及原因保留在报告中。
|
||||
@@ -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、转发头伪造、热更新、全接口拦截与网关不受限制。
|
||||
+206
-4
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -109,6 +113,7 @@
|
||||
| four_courts | object | 四庭数据,见下表 |
|
||||
| seven_eyes | object | 七眼数据,见下表 |
|
||||
| landmarks | object | 关键分界点坐标(头顶 / 发际线 / 眉心 / 鼻翼下缘 / 下巴尖),原图像素坐标 |
|
||||
| vertical_lines | object | 标注图竖线坐标,见下表。与标注 PNG 上的竖线同一套几何 |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
@@ -138,7 +143,28 @@
|
||||
| eye6 | number | 从左到右第 6 段宽度(cm):右眼外角 → 右脸颊 |
|
||||
| eye7 | number \| null | 从左到右第 7 段宽度(cm):右脸颊 → 人头最右(右耳外侧段)。该侧耳朵不可见时为 null |
|
||||
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。
|
||||
> `eye1`~`eye7` 为从左到右共 7 段宽度,与标注图竖线一一对应。最左/最右端线取自耳朵分割外缘;某侧耳朵被头发或侧脸遮挡(不可见)时该侧端线省略,对应 `eye1` 或 `eye7` 为 `null`(键始终保留),实际有效段为 5 或 6 段。`eye3`/`eye5` 为左右眼宽、`eye4` 为两眼间距,与 `eye_width_cm` / `inter_eye_distance_cm` 语义一致。`eye1`/`eye7` 的厘米数由 `vertical_lines` 里对应端点的 x 相减得到。
|
||||
|
||||
`vertical_lines`(竖线,原图像素,原点左上,x 向右,y 向下):
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| y_top | int | 竖线共用上端。接口 1 在头顶之上,伸出 `max(6, round(短边 × 0.03))` 像素,可能略小于 0 |
|
||||
| y_bottom | int | 竖线共用下端。接口 1 在下巴尖之下,伸出同样像素,可能略大于图高 |
|
||||
| lines | array | 从左到右实际画出的竖线。每项 `{ "name": string, "x": int }` |
|
||||
|
||||
`lines[].name` 从左到右:
|
||||
|
||||
| name | 竖线 |
|
||||
|------|------|
|
||||
| left_head_edge | 人头最左(耳朵外缘)。该侧耳朵不可见时不出现,此时 `eye1` 为 `null` |
|
||||
| left_cheek | 左脸颊 |
|
||||
| left_eye_outer | 左眼外角 |
|
||||
| left_eye_inner | 左眼内角 |
|
||||
| right_eye_inner | 右眼内角 |
|
||||
| right_eye_outer | 右眼外角 |
|
||||
| right_cheek | 右脸颊 |
|
||||
| right_head_edge | 人头最右(耳朵外缘)。该侧耳朵不可见时不出现,此时 `eye7` 为 `null` |
|
||||
|
||||
### 标注图片(UI)规范
|
||||
|
||||
@@ -182,6 +208,19 @@
|
||||
"brow_center": { "x": 540, "y": 740 },
|
||||
"nose_bottom": { "x": 540, "y": 1050 },
|
||||
"chin_tip": { "x": 540, "y": 1360 }
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 98, "y_bottom": 1410,
|
||||
"lines": [
|
||||
{ "name": "left_head_edge", "x": 120 },
|
||||
{ "name": "left_cheek", "x": 210 },
|
||||
{ "name": "left_eye_outer", "x": 310 },
|
||||
{ "name": "left_eye_inner", "x": 420 },
|
||||
{ "name": "right_eye_inner", "x": 560 },
|
||||
{ "name": "right_eye_outer", "x": 670 },
|
||||
{ "name": "right_cheek", "x": 780 },
|
||||
{ "name": "right_head_edge", "x": 890 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,6 +252,7 @@
|
||||
| four_courts | object | 三庭数据(上/中/下庭,各含 cm 与 ratio;**无顶庭**) |
|
||||
| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + **eye2~eye6** 共 5 段宽度) |
|
||||
| landmarks | object | 四个关键点像素坐标(发际线/眉心/鼻翼下缘/下巴尖) |
|
||||
| vertical_lines | object | 标注图竖线坐标。结构同接口 1,但 `y_top`/`y_bottom` 为发际线到下巴尖(不伸出),`lines` 只有中间 6 条,无 `left_head_edge` / `right_head_edge` |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:`{ "x": int, "y": int }` |
|
||||
|
||||
@@ -242,6 +282,17 @@
|
||||
"brow_center": { "x": 540, "y": 740 },
|
||||
"nose_bottom": { "x": 540, "y": 1050 },
|
||||
"chin_tip": { "x": 540, "y": 1360 }
|
||||
},
|
||||
"vertical_lines": {
|
||||
"y_top": 430, "y_bottom": 1360,
|
||||
"lines": [
|
||||
{ "name": "left_cheek", "x": 210 },
|
||||
{ "name": "left_eye_outer", "x": 310 },
|
||||
{ "name": "left_eye_inner", "x": 420 },
|
||||
{ "name": "right_eye_inner", "x": 560 },
|
||||
{ "name": "right_eye_outer", "x": 670 },
|
||||
{ "name": "right_cheek", "x": 780 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,6 +500,7 @@
|
||||
| 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 |
|
||||
| vertical_lines | object | 竖线坐标,结构同接口1 |
|
||||
| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) |
|
||||
| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) |
|
||||
| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),原图像素:`{ "x": int, "y": int }` |
|
||||
@@ -518,6 +570,155 @@
|
||||
|
||||
---
|
||||
|
||||
## 接口 52:发际线 PNG 生成接口
|
||||
|
||||
**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。
|
||||
|
||||
**请求**:`POST /api/v1/hairline/generate-52`(仅GPU worker,端口8187;本次不在网关新增路由)
|
||||
|
||||
**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_base64 | string | 当前底图的JPEG base64,发际线叠图、参考点和测量值均使用该图的坐标系 |
|
||||
| original_image_base64 | string | EXIF方向修正后的上传原图JPEG base64 |
|
||||
| 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_base64 | string | middle 档发际线曲线**透明 PNG** base64(仅曲线,透明底,**不含人物**,需叠加 `input_image_base64` 显示) |
|
||||
| image_high_base64 | null | 已停用,保留字段为 `null` |
|
||||
| image_low_base64 | null | 已停用,保留字段为 `null` |
|
||||
| grown_image_base64 | string \| null | **生发后图片** JPEG base64(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) |
|
||||
| order | int | 发型序号(= 传入的 hair_style 值) |
|
||||
| height_placement | object | 旧/新中心、XY移动距离、目标误差,均为未取整的像素值 |
|
||||
|
||||
> 上表为GPU worker的实际响应字段;所有图片使用base64,空图片保留原字段并返回null。网关的落盘与URL改写属于网关职责,本次不修改。
|
||||
>
|
||||
> 前端将base64转为data URI后显示。middle透明图叠加到`input_image_base64`,生发图直接显示。GPU worker本机测试页为`http://localhost:8187/static/test_interface52.html`。
|
||||
|
||||
`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 |
|
||||
| vertical_lines | object | 竖线坐标,结构同接口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说明。
|
||||
|
||||
### GPU worker响应结构示例(normalize_image=false)
|
||||
|
||||
此例未开启裁切,坐标基于原图;`face_measure=null`时仍可按发际线关键点计算目标。图片内容以缩略base64字符串示意,空图片字段返回null,字段名不变。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"request_id": "mock-request-id",
|
||||
"data": {
|
||||
"hairline_images": [
|
||||
{
|
||||
"hairline_type": "ellipse",
|
||||
"image_middle_base64": "iVBORw0KGgo...",
|
||||
"image_high_base64": null,
|
||||
"image_low_base64": null,
|
||||
"grown_image_base64": "/9j/4AAQ...",
|
||||
"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_base64": "/9j/4AAQ...",
|
||||
"original_image_base64": "/9j/4AAQ...",
|
||||
"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_base64`。原始图片仅用于展示和坐标回溯。
|
||||
|
||||
---
|
||||
|
||||
## 接口 9:头发遮罩生成接口
|
||||
|
||||
**说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。
|
||||
@@ -702,6 +903,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 +918,7 @@
|
||||
|
||||
- 图片传参:**文件 / URL / base64 三种都支持,严格互斥**(必须且只能传一个,base64 需带 `data:image/...;base64,` 前缀)。
|
||||
- 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。
|
||||
- 所有坐标基准:**原图像素**。
|
||||
- 坐标基准:默认**原图像素**;接口52开启预处理时使用响应中的**处理后底图像素**。
|
||||
- 接口 2:提供「美颜」开关 `beauty_enabled`。
|
||||
- 接口 3:仅传**划线图**(`marked_image_*` 三选一),需返回发际线类型(`custom`)。
|
||||
- 接口 4:`data` 返回**一个 JSON 字符串**,字段不固定,可随时调整。
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# 网关摘除隔离期实施方案(方案 B)
|
||||
|
||||
> 目标读者:网关机器的部署/维护者。本文档给出基于 **main 分支** `gateway/` 代码的
|
||||
> 完整改动说明,可直接照做。**不需要改 worker 侧任何代码。**
|
||||
|
||||
## 1. 背景与事故复盘
|
||||
|
||||
2026-09-19 晚,GPU worker 机器(ubuntu3090_debug_4090 所在主机)磁盘写满,业务接口
|
||||
全部失败,但网关**持续把请求打到该机器近 16 个小时**(21:30 故障发生 → 次日 13:36
|
||||
人工重启),没有切换到其他 worker。复盘结论是三层防线同时失效:
|
||||
|
||||
| 层 | 问题 | 后果 |
|
||||
|---|---|---|
|
||||
| 1. worker `/health` 太浅 | 只返回内存里的"模型就绪"标志,磁盘满/下游 ComfyUI 挂掉不影响它 | 探测一直 200 |
|
||||
| 2. 业务错误不触发摘除 | 下游出错经包装后以 HTTP 200/400 + 错误码返回,`forward.py` 只对 连接异常/超时/≥500/401 摘除 | 请求失败但 worker 被视为健康 |
|
||||
| 3. 摘除后 8 秒即复活(**本方案要修的**) | `mark_worker_unhealthy()` 下线后,下一轮探测(8s 后)`/health` 返回 200,`healthy_threshold=1` 使其**一次成功立即重新上线** | 即使被摘除也会反复横跳,流量持续打入 |
|
||||
|
||||
worker 侧的第 1 层已经修复(方案 A:`/health` 深检磁盘水位 + 下游依赖 TCP 探测,
|
||||
异常返回 503,代码在分支 `ubuntu3090_debug_4090`,提交 `14562d4`)。本方案修复
|
||||
第 3 层:**被动摘除后进入隔离期,隔离期内即使探测成功也不复活**。两层配合后:
|
||||
worker 磁盘满 → `/health` 503 → 网关 2 次探测失败摘除 → 隔离期内不再接流量。
|
||||
|
||||
> 第 2 层(错误码语义化,涉及调用方兼容)暂不处理,单独立项。
|
||||
|
||||
## 2. 现状代码行为(main 分支 gateway/pool.py)
|
||||
|
||||
- `mark_worker_unhealthy(w)`:被 `forward.py` 在转发失败(连接异常/超时/5xx/401)时
|
||||
调用,置 `w.online = False`。**没有任何冷却机制**。
|
||||
- `_health_check_loop(cfg)`:每 `interval_seconds`(默认 8s)探测所有 worker 的
|
||||
`/health`;返回 200 时 `consecutive_successes += 1`,达到 `healthy_threshold`
|
||||
(默认 **1**)且当前 offline → 立即 `online = True`。
|
||||
- 因此被动摘除最多 8 秒后就被一次探测成功拉回——如果 worker 是"浅健康"或故障
|
||||
间歇性表现,就会陷入 摘除→复活→失败→摘除 的横跳。
|
||||
- 另外 58-96 行有一段**从未被调用的死代码** `_check_worker_health()` /
|
||||
`_mark_failure()`(写了一半的旧实现),本次顺带删除,避免后人改错地方。
|
||||
|
||||
## 3. 代码改动(共 2 个文件 + 1 个配置)
|
||||
|
||||
### 3.1 `gateway/pool.py`
|
||||
|
||||
**(1) `WorkerState` 增加一个字段:**
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class WorkerState:
|
||||
"""单个 worker 的运行时状态。"""
|
||||
url: str
|
||||
online: bool = False # 初始 offline,等健康检查通过后上线
|
||||
busy: bool = False
|
||||
consecutive_failures: int = 0
|
||||
consecutive_successes: int = 0
|
||||
offline_until: float = 0.0 # 被动摘除后的隔离截止时刻(time.monotonic() 基准)
|
||||
```
|
||||
|
||||
**(2) 模块顶部(`logger = ...` 之后)加一个配置读取 helper:**
|
||||
|
||||
```python
|
||||
def _cooldown_seconds() -> float:
|
||||
"""被动摘除后的隔离期时长(秒),配置 health_check.cooldown_seconds,默认 60。"""
|
||||
try:
|
||||
from gateway.config import get_config
|
||||
return float(get_config()["health_check"].get("cooldown_seconds", 60))
|
||||
except Exception: # noqa: BLE001
|
||||
return 60.0
|
||||
```
|
||||
|
||||
(函数内 import 是为了不引入模块级循环依赖;`gateway/config.py` 不依赖 pool,顶层
|
||||
import 也可以。)
|
||||
|
||||
**(3) `mark_worker_unhealthy()` 摘除时同时设置隔离期:**
|
||||
|
||||
```python
|
||||
async def mark_worker_unhealthy(w: WorkerState) -> None:
|
||||
"""被动标记:转发失败时立即将该 worker 下线,并进入隔离期。
|
||||
|
||||
隔离期内即使 /health 探测成功也不复活(由 _health_check_loop 守卫),
|
||||
避免"摘除→8 秒复活→再失败"的横跳。
|
||||
"""
|
||||
async with _pool_condition:
|
||||
if w.online:
|
||||
w.online = False
|
||||
w.consecutive_failures += 1
|
||||
w.offline_until = time.monotonic() + _cooldown_seconds()
|
||||
logger.warning("⚠ Worker 被动下线: %s(转发失败,隔离 %.0fs)",
|
||||
w.url, _cooldown_seconds())
|
||||
```
|
||||
|
||||
**(4) `_health_check_loop()` 的探测成功分支加隔离期守卫。** 找到这段:
|
||||
|
||||
```python
|
||||
if resp.status_code == 200:
|
||||
w.consecutive_failures = 0
|
||||
w.consecutive_successes += 1
|
||||
if w.consecutive_successes >= healthy_threshold and not w.online:
|
||||
w.online = True
|
||||
logger.info("✅ Worker 上线: %s", w.url)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```python
|
||||
if resp.status_code == 200:
|
||||
w.consecutive_failures = 0
|
||||
w.consecutive_successes += 1
|
||||
if w.consecutive_successes >= healthy_threshold and not w.online:
|
||||
if time.monotonic() < w.offline_until:
|
||||
logger.debug("Worker %s 探测成功但处于隔离期,暂不复活", w.url)
|
||||
else:
|
||||
w.online = True
|
||||
logger.info("✅ Worker 上线: %s", w.url)
|
||||
```
|
||||
|
||||
注意:`except`(连接异常)分支只累计失败、不涉及复活,无需改动;
|
||||
`init_pool()` 启动时的首轮探测同理无需改动(进程刚启动时 `offline_until` 为 0,
|
||||
不构成隔离)。
|
||||
|
||||
**(5) 删除死代码**:`_check_worker_health()` 与
|
||||
`_mark_failure()` 两个函数整体删除(约 58-96 行,以函数名为准,别删到
|
||||
`_health_check_loop`)。删除前可确认无调用方:
|
||||
`grep -rn "_check_worker_health\|_mark_failure" .` 应只剩定义处。
|
||||
|
||||
### 3.2 `gateway/config.py`
|
||||
|
||||
`DEFAULTS["health_check"]` 增加一个 key:
|
||||
|
||||
```python
|
||||
"health_check": {
|
||||
"path": "/health",
|
||||
"interval_seconds": 8,
|
||||
"timeout_seconds": 3,
|
||||
"unhealthy_threshold": 2,
|
||||
"healthy_threshold": 1,
|
||||
"cooldown_seconds": 60, # ← 新增:被动摘除后的隔离期(秒)
|
||||
},
|
||||
```
|
||||
|
||||
`config.example.json` 同步加这个 key。网关机器的实际 `config.json` **不加也不会报错**
|
||||
(代码里有 60 的默认兜底),但建议显式加上,便于运维感知。
|
||||
|
||||
### 3.3 建议单测:`tests/test_gateway_pool.py`(新文件)
|
||||
|
||||
gateway 子包目前没有任何测试。隔离期是纯逻辑,建议至少覆盖以下三个用例
|
||||
(不依赖真实网络):
|
||||
|
||||
```python
|
||||
"""gateway/pool.py 隔离期逻辑单测。"""
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from gateway import pool
|
||||
from gateway.pool import WorkerState
|
||||
|
||||
|
||||
def _drain(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_passive_ejection_sets_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000", online=True)
|
||||
async def run():
|
||||
pool._pool_condition = asyncio.Condition()
|
||||
with patch.object(pool, "_cooldown_seconds", return_value=60.0):
|
||||
await pool.mark_worker_unhealthy(w)
|
||||
_drain(run())
|
||||
assert w.online is False
|
||||
assert w.offline_until > time.monotonic()
|
||||
|
||||
|
||||
def test_probe_success_revives_after_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000") # offline
|
||||
w.offline_until = time.monotonic() - 1 # 隔离期已过
|
||||
# 直接验证状态迁移逻辑(若按 3.1(4) 内联实现,可复制该分支逻辑做断言,
|
||||
# 或将判断抽成 _try_revive(w, healthy_threshold) 小函数后单测)
|
||||
...
|
||||
|
||||
|
||||
def test_probe_success_blocked_during_cooldown():
|
||||
w = WorkerState(url="http://127.0.0.1:9000")
|
||||
w.offline_until = time.monotonic() + 60
|
||||
... # 断言保持 offline
|
||||
```
|
||||
|
||||
> 提示:如果按 3.1(4) 原样内联修改,后两个用例不好直接驱动,可以把"是否允许复活"
|
||||
> 抽成一个 6 行的纯函数 `_try_revive(w, healthy_threshold) -> bool` 再测,主循环
|
||||
> 调用它即可——实现方式自定,覆盖语义即可。
|
||||
|
||||
## 4. 部署步骤(网关机器)
|
||||
|
||||
1. `git pull` main 分支(含本方案代码改动)。
|
||||
2. (可选但建议)`config.json` 的 `health_check` 段加 `"cooldown_seconds": 60`。
|
||||
3. 重启网关服务(`hair-gateway.service` 或等价的 uvicorn 进程)。重启期间网关
|
||||
本身短暂不可用,选低峰执行。
|
||||
4. 观察启动日志:`健康检查循环启动 | 间隔=8s | 下线阈值=2 | 上线阈值=1 | workers=N`
|
||||
正常出现。
|
||||
|
||||
## 5. 验证
|
||||
|
||||
**功能验证(低峰演练):**
|
||||
|
||||
1. 人工把主 worker 摘除一次最直接的方法:临时在 worker 机器
|
||||
`systemctl stop hair-worker`(或临时设 `HEALTH_MIN_FREE_GB=999` 重启)。
|
||||
2. 观察网关日志:
|
||||
- 连续 2 次探测失败 → `⚠ Worker 下线`(原有行为);
|
||||
- **转发失败触发的** `⚠ Worker 被动下线: ...(转发失败,隔离 60s)`(新日志);
|
||||
- 恢复 worker 后,若刚被被动摘除过,即使 `/health` 恢复 200,也应看到 60 秒内
|
||||
**不出现** `✅ Worker 上线`,隔离期满后下一次探测成功才上线。
|
||||
3. `GET /gateway-health` 观察 `workers_healthy` 的变化节奏。
|
||||
|
||||
**回归确认:** 正常场景 worker 上下线不受影响(隔离期只对"被动摘除"生效;
|
||||
探测失败导致的下线,恢复后仍按 `healthy_threshold` 立即上线)。
|
||||
|
||||
## 6. 回滚
|
||||
|
||||
- 代码回滚:`git revert` 对应提交即可,无数据/配置迁移。
|
||||
- 紧急关闭隔离期:网关机器 `config.json` 设 `"cooldown_seconds": 0`
|
||||
(等价于旧行为——摘除后下一次探测成功即复活),无需回代码。
|
||||
|
||||
## 7. 附:worker 侧方案 A 的行为(已上线,供联调参考)
|
||||
|
||||
worker(分支 `ubuntu3090_debug_4090`,提交 `14562d4`)的 `/health` 现在会在以下
|
||||
情况返回 **503**(网关 2 次探测失败即摘除):
|
||||
|
||||
- 模型加载中:`503 {"status":"starting"}`(原有行为不变)
|
||||
- 磁盘剩余低于阈值:`503 {"status":"degraded","reason":"disk_free_below_5.0g","disk_free_gb":...}`
|
||||
- 下游依赖(ComfyUI 8188 / change_hair 8801)TCP 不通:
|
||||
`503 {"status":"degraded","reason":"dep_down:comfyui,...","deps":{...}}`
|
||||
|
||||
正常时返回 `200 {"status":"ok","disk_free_gb":24.6,"deps":{"comfyui":true,"change_hair":true}}`。
|
||||
|
||||
worker 侧配置(`worker_config.json` 的 `health` 段,环境变量可覆盖):
|
||||
`min_free_gb`(默认 5,0=关闭)、`check_deps`(默认 true)、`dep_comfyui`(默认
|
||||
127.0.0.1:8188)、`deps_cache_seconds`(默认 5)。**其他 worker 机器如果也想启用
|
||||
深检,cherry-pick 该提交并补一份 worker_config.json 的 health 段即可。**
|
||||
+59
-16
@@ -180,6 +180,60 @@ def _ear_edges_from_mask(ear_mask, hair_mask, y0, y1, left_cheek_x, right_cheek_
|
||||
return head_l, head_r
|
||||
|
||||
|
||||
# 返回给接口的竖线名 → eyes["points"] 里的键。从左到右的解剖顺序。
|
||||
_VERTICAL_INNER_LINES = (
|
||||
("left_cheek", "left_cheek"),
|
||||
("left_eye_outer", "left_outer"),
|
||||
("left_eye_inner", "left_inner"),
|
||||
("right_eye_inner", "right_inner"),
|
||||
("right_eye_outer", "right_outer"),
|
||||
("right_cheek", "right_cheek"),
|
||||
)
|
||||
|
||||
|
||||
def vertical_line_geometry(measure_result, image_width, image_height,
|
||||
ear_mask=None, hair_mask=None, variant="v1"):
|
||||
"""标注图竖线的像素几何,供画线与接口返回共用。
|
||||
|
||||
返回 {"y_top", "y_bottom", "lines": [{"name", "x"}, ...]}。
|
||||
x / y 均为原图像素整数(四舍五入)。lines 从左到右,只含实际画出的线。
|
||||
variant="v1":竖线从头顶上方延伸到下巴下方;有耳朵外缘时含
|
||||
left_head_edge / right_head_edge。
|
||||
variant="v6":竖线从发际线到下巴尖,不含人头两端。
|
||||
"""
|
||||
v = measure_result.vertical
|
||||
s = min(image_width, image_height)
|
||||
if variant == "v6":
|
||||
order = ["hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||
else:
|
||||
order = ["hair_top", "hairline", "brow_center", "nose_bottom", "chin_tip"]
|
||||
ys = [v[name][1] for name in order]
|
||||
|
||||
pts = measure_result.eyes["points"]
|
||||
named = [(public, float(pts[internal][0])) for public, internal in _VERTICAL_INNER_LINES]
|
||||
if variant != "v6":
|
||||
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
|
||||
head_l, head_r = _ear_edges_from_mask(
|
||||
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
|
||||
if head_l is not None:
|
||||
named.append(("left_head_edge", float(head_l)))
|
||||
if head_r is not None:
|
||||
named.append(("right_head_edge", float(head_r)))
|
||||
named.sort(key=lambda item: item[1])
|
||||
|
||||
fy0, fy1 = ys[0], ys[-1]
|
||||
over = max(6, round(s * 0.030))
|
||||
if variant == "v6":
|
||||
y_top, y_bottom = fy0, fy1
|
||||
else:
|
||||
y_top, y_bottom = fy0 - over, fy1 + over
|
||||
return {
|
||||
"y_top": int(round(y_top)),
|
||||
"y_bottom": int(round(y_bottom)),
|
||||
"lines": [{"name": name, "x": int(round(x))} for name, x in named],
|
||||
}
|
||||
|
||||
|
||||
def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=None,
|
||||
variant="v1"):
|
||||
"""生成标注图层 PNG(透明底 RGBA,尺寸同原图)。返回 PIL.Image。
|
||||
@@ -221,19 +275,11 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
ys = [v[name][1] for name in order]
|
||||
|
||||
pts = measure_result.eyes["points"]
|
||||
seven_keys = ["left_cheek", "left_outer", "left_inner",
|
||||
"right_inner", "right_outer", "right_cheek"]
|
||||
base_xs = [pts[k][0] for k in seven_keys]
|
||||
if variant == "v6":
|
||||
xs = sorted(base_xs) # 接口6:仅七眼 6 点,不画人头最左/最右端线
|
||||
else:
|
||||
# 人头最左/最右:取自耳朵分割掩膜外缘(方案B,类7/8),脸纵向范围内统计。
|
||||
# 看不到耳朵(被头发/侧脸遮挡 → 掩膜空)或外缘未越过脸颊线则省略该侧端线。
|
||||
lcx, rcx = pts["left_cheek"][0], pts["right_cheek"][0]
|
||||
head_l, head_r = _ear_edges_from_mask(
|
||||
ear_mask, hair_mask, ys[0], ys[-1], lcx, rcx, (lcx + rcx) / 2)
|
||||
head_xs = [x for x in (head_l, head_r) if x is not None]
|
||||
xs = sorted(base_xs + head_xs) # 自左向右(6 或 7/8 点)
|
||||
# 竖线 x 与纵向范围与接口返回的 vertical_lines 同一份几何。
|
||||
geom = vertical_line_geometry(
|
||||
measure_result, w, h, ear_mask=ear_mask, hair_mask=hair_mask, variant=variant)
|
||||
xs = [line["x"] for line in geom["lines"]]
|
||||
v_top, v_bot = geom["y_top"], geom["y_bottom"]
|
||||
|
||||
# 人脸/人头包围盒
|
||||
fx0, fx1 = xs[0], xs[-1]
|
||||
@@ -241,9 +287,6 @@ def create_annotated_image(image_bgr, measure_result, ear_mask=None, hair_mask=N
|
||||
face_cx = (fx0 + fx1) / 2
|
||||
over = max(6, round(s * 0.030)) # 线超出包围盒的长度(参考图风格)
|
||||
face_half = (fx1 - fx0) / 2 + over # 横线超出最外侧竖线一点
|
||||
# 竖线纵向范围:v6 = 发际线→下巴尖(不超出);v1 = 头顶→下巴尖并两端超出一点
|
||||
v_top = fy0 if variant == "v6" else fy0 - over
|
||||
v_bot = fy1 if variant == "v6" else fy1 + over
|
||||
|
||||
# --- 1. 横向分界线(渐变,覆盖头宽并超出一点) ---
|
||||
for cy in ys:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ MP_TOP_ANCHORS: list[int] = [
|
||||
]
|
||||
N_ANCHORS = len(MP_TOP_ANCHORS) # 17
|
||||
|
||||
# Anatomical left/right pairs, in MediaPipe indices. The anchor list is
|
||||
# NOT mirror-ordered at its ends: 127 pairs with 356, 234 with 454.
|
||||
MP_TOP_ANCHOR_PAIRS = [
|
||||
(127, 356), (234, 454), (162, 389), (21, 251),
|
||||
(54, 284), (103, 332), (67, 297), (109, 338),
|
||||
]
|
||||
|
||||
# Vertex ID layout in the extended array of length 468 + 2*N_ANCHORS.
|
||||
# [0 .. 468) : MediaPipe canonical landmarks
|
||||
# [468 .. 468+N) : middle row (between MP boundary and hairline)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -124,6 +124,46 @@ def build_hairline_mask(parse_map: np.ndarray) -> np.ndarray:
|
||||
return build_hair_mask(parse_map)
|
||||
|
||||
|
||||
def align_hairline_for_template(
|
||||
landmarks_norm: np.ndarray,
|
||||
hairline_norm: np.ndarray,
|
||||
valid: np.ndarray,
|
||||
image_shape: tuple[int, int],
|
||||
) -> np.ndarray:
|
||||
"""Keep a designed template aligned with the face rather than the fringe.
|
||||
|
||||
Uneven bangs can make the detected hair boundary much higher on one
|
||||
side, shearing an otherwise balanced template. Average the upward
|
||||
extension of each anatomical anchor pair, measured in image pixels.
|
||||
Keep the face anchors and the perpendicular component unchanged so
|
||||
facial roll/perspective remain visible; do not level the curve in the
|
||||
image. Missing ray hits retain their fallback and never influence a
|
||||
valid partner. Call silhouette clamping afterwards to bound the result.
|
||||
|
||||
This is for template placement only, not measurement of the real hairline.
|
||||
"""
|
||||
h, w = image_shape
|
||||
scale = np.array([w, h], dtype=np.float64)
|
||||
up = face_up_vector(landmarks_norm) * scale
|
||||
length = np.linalg.norm(up)
|
||||
if length < 1e-9:
|
||||
return hairline_norm.copy()
|
||||
up /= length
|
||||
|
||||
anchors = landmarks_norm[C.MP_TOP_ANCHORS, :2] * scale
|
||||
points = hairline_norm * scale
|
||||
distances = (points - anchors) @ up
|
||||
indices = {mp_idx: i for i, mp_idx in enumerate(C.MP_TOP_ANCHORS)}
|
||||
for left, right in C.MP_TOP_ANCHOR_PAIRS:
|
||||
i, j = indices[left], indices[right]
|
||||
if not (valid[i] and valid[j]):
|
||||
continue
|
||||
mean_distance = (distances[i] + distances[j]) / 2.0
|
||||
for k in (i, j):
|
||||
points[k] += (mean_distance - distances[k]) * up
|
||||
return (points / scale).astype(np.float32)
|
||||
|
||||
|
||||
def smooth_hairline(
|
||||
hairline_norm: np.ndarray,
|
||||
valid: np.ndarray,
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
+86
-6
@@ -15,12 +15,14 @@ from . import comfyui
|
||||
from .face_landmarks import FaceLandmarker
|
||||
from .face_parsing import FaceParser
|
||||
from .hairline_2d import (
|
||||
smooth_hairline, sample_hairline_clamped,
|
||||
smooth_hairline, sample_hairline, align_hairline_for_template,
|
||||
clamp_hairline_to_silhouette,
|
||||
)
|
||||
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
|
||||
@@ -177,20 +179,25 @@ 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。
|
||||
|
||||
发际线几何检测固定用 `sample_hairline_clamped`(射线检测 + 头部轮廓钳制):
|
||||
发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制:
|
||||
左右刘海高低不同时,协调对应锚点的延伸距离,避免把设计模板拉歪;
|
||||
保留人脸本身的倾斜。预览白线与生发黑线/遮罩共用同一套 points。
|
||||
短发/剃光头照片(如 man_test.jpg)中间锚点检测失效时,纯射线检测的固定 fallback
|
||||
偏移会把点顶到头部轮廓外面的背景,产生"发际线贴到头部外面"的视觉 bug;钳制兜底后
|
||||
fallback 点不会再跑出头部轮廓,正常长发照片结果与旧行为一致。
|
||||
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)
|
||||
hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
|
||||
hairline_2d, valid = sample_hairline(landmarks, parse_map)
|
||||
hairline_2d = align_hairline_for_template(landmarks, hairline_2d, valid, parse_map.shape)
|
||||
hairline_2d = clamp_hairline_to_silhouette(hairline_2d, parse_map)
|
||||
hairline_2d = smooth_hairline(hairline_2d, valid)
|
||||
hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
|
||||
middle_3d = build_middle_row(landmarks, hairline_3d)
|
||||
@@ -533,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"))
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"allowed_ips": ["49.232.51.171"]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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);
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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>
|
||||
@@ -106,6 +107,7 @@
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>标注图竖线坐标,与标注 PNG 同一套几何。原图像素:<code>y_top</code> / <code>y_bottom</code> 为竖线共用上下端(头顶之上到下巴之下,两端各伸出约短边的 3%,可能略超出图片);<code>lines</code> 从左到右,每项 <code>{ name, x }</code>。name:<code>left_head_edge</code>(人头最左,耳朵不可见则不出现,此时 eye1 为 null)、<code>left_cheek</code>、<code>left_eye_outer</code>、<code>left_eye_inner</code>、<code>right_eye_inner</code>、<code>right_eye_outer</code>、<code>right_cheek</code>、<code>right_head_edge</code>(人头最右,不可见则不出现,此时 eye7 为 null)</td></tr>
|
||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割,可信度高)/ <code>"estimated"</code>(比例估算,可信度低)</td></tr>
|
||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度),接近 0 表示正面照</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
@@ -126,6 +128,7 @@ const { code, data } = await res.json();
|
||||
// data.annotated_image_url → 标注 PNG
|
||||
// data.face_total_height_cm → 全脸高度
|
||||
// data.four_courts.ratios → { top_court: 0.25, ... }
|
||||
// data.vertical_lines → { y_top, y_bottom, lines: [{ name, x }, ...] }
|
||||
// 前端叠加显示:原图 + annotated_image_url(absolute 叠加)</pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,6 +254,7 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>四庭:top/upper/middle/lower,各含 <code>_cm</code> 和 <code>ratios</code></td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <code>eye1</code>~<code>eye7</code>(从左到右 7 段宽度 cm,<code>eye1</code>/<code>eye7</code> 耳朵不可见时为 null)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>5 个关键点像素坐标:hair_top/hairline/brow_center/nose_bottom/chin_tip</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标,结构同接口1</td></tr>
|
||||
<tr><td><code>hairline_source</code></td><td>string</td><td>发际线来源:<code>"segmentation"</code>(真实分割)/ <code>"estimated"</code>(比例估算)</td></tr>
|
||||
<tr><td><code>head_pose</code></td><td>object</td><td>头部姿态角度:<code>{ yaw, pitch, roll }</code>(度)</td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
@@ -261,6 +265,16 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="if52">
|
||||
<h2>52. 发际线新规则与裁切 <span class="badge post">POST</span> <code>/api/v1/hairline/generate-52</code></h2>
|
||||
<div class="card-body">
|
||||
<p class="desc">仅部署在GPU worker(8187),本次不新增网关路由。中下庭均值定位 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>*_base64</code>,空图片保留字段并返回null。另有<code>height_rule</code>、<code>framing</code>、<code>guide_landmarks</code>、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。</p>
|
||||
<p style="margin-top:12px"><a href="http://localhost:8187/static/test_interface52.html" class="link">52号规则测试页</a> · <a href="http://localhost:8187/static/test_interface52_compare.html" class="link">52号30案例对比页</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======== 接口6 ======== -->
|
||||
<div class="card" id="if6">
|
||||
<h2>6. 四庭七眼测量 v2 <span class="badge post">POST</span> <code>/api/v1/face/measure-v2</code> <span class="badge warn">v2</span></h2>
|
||||
@@ -286,6 +300,7 @@ console.log(features.gene_style); // "自然型"</pre>
|
||||
<tr><td><code>four_courts</code></td><td>object</td><td>三庭:upper/middle/lower,各含 _cm 和 ratios(<strong>无 top_court</strong>)</td></tr>
|
||||
<tr><td><code>seven_eyes</code></td><td>object</td><td>七眼:<code>eye_width_cm</code>/<code>face_width_cm</code>/<code>inter_eye_distance_cm</code> + <code>ratios</code> + <strong><code>eye2</code>~<code>eye6</code></strong>(左脸颊/左眼/两眼间距/右眼/右脸颊,5 段宽度 cm;<strong>无 eye1/eye7</strong>)</td></tr>
|
||||
<tr><td><code>landmarks</code></td><td>object</td><td>4 个关键点:hairline/brow_center/nose_bottom/chin_tip(<strong>无 hair_top</strong>)</td></tr>
|
||||
<tr><td><code>vertical_lines</code></td><td>object</td><td>竖线坐标。结构同接口1,但 <code>y_top</code> / <code>y_bottom</code> 为发际线到下巴尖(不伸出),<code>lines</code> 只有中间 6 条,无 <code>left_head_edge</code> / <code>right_head_edge</code></td></tr>
|
||||
<tr><td><code>left_position</code></td><td>object</td><td>MediaPipe 21 号关键点坐标(左脸定位点),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
<tr><td><code>right_position</code></td><td>object</td><td>MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),原图像素:<code>{ x: number, y: number }</code></td></tr>
|
||||
</table>
|
||||
@@ -323,6 +338,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="http://localhost:8187/static/test_interface52.html" class="link">/static/test_interface52.html</a></td><td>中下庭定位、扶正裁切、矩形发际线叠加及生发</td></tr>
|
||||
<tr><td>52. 规则对比</td><td><a href="http://localhost:8187/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">
|
||||
|
||||
@@ -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 正面照 | Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 | Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 | 每个发型返回一张设计曲线,生发遮罩同步到新位置</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 ? ' <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) + '° pitch ' + _FMT(p.pitch) + '° 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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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"}
|
||||
]
|
||||
}
|
||||
+313
-3
@@ -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
|
||||
|
||||
|
||||
@@ -36,12 +36,76 @@ def test_auth_missing_token_401(client):
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_health_no_token_200(client):
|
||||
def _patch_health_ok(monkeypatch):
|
||||
"""让 /health 深检确定性通过:不依赖本机磁盘水位与 8188/8801 服务存活。"""
|
||||
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
|
||||
**app_module._HEALTH_CFG, "min_free_gb": 0.0, "check_deps": True})
|
||||
|
||||
async def _deps_ok():
|
||||
return True, {"comfyui": True, "change_hair": True}
|
||||
monkeypatch.setattr(app_module, "_check_deps", _deps_ok)
|
||||
|
||||
|
||||
def test_health_no_token_200(client, monkeypatch):
|
||||
_patch_health_ok(monkeypatch)
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_disk_low_503(client, monkeypatch):
|
||||
# 阈值调到不可能的 99999G → 必然触发磁盘水位 503
|
||||
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
|
||||
**app_module._HEALTH_CFG, "min_free_gb": 99999.0, "check_deps": False})
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 503
|
||||
body = r.json()
|
||||
assert body["status"] == "degraded"
|
||||
assert body["reason"].startswith("disk_free_below")
|
||||
|
||||
|
||||
def test_health_dep_down_503(client, monkeypatch):
|
||||
monkeypatch.setattr(app_module, "_HEALTH_CFG", {
|
||||
**app_module._HEALTH_CFG, "min_free_gb": 0.0, "check_deps": True})
|
||||
|
||||
async def _deps_down():
|
||||
return False, {"comfyui": False, "change_hair": True}
|
||||
monkeypatch.setattr(app_module, "_check_deps", _deps_down)
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 503
|
||||
body = r.json()
|
||||
assert body["status"] == "degraded"
|
||||
assert "comfyui" in body["reason"]
|
||||
|
||||
|
||||
def test_health_ok_carries_detail(client, monkeypatch):
|
||||
_patch_health_ok(monkeypatch)
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "disk_free_gb" in body
|
||||
assert body["deps"] == {"comfyui": True, "change_hair": True}
|
||||
|
||||
|
||||
def test_tcp_probe_real_socket():
|
||||
# 用进程内真实 socket 验证探测本身(曾因 open_connection 返回元组误判全部不可达)
|
||||
import asyncio
|
||||
|
||||
async def scenario():
|
||||
server = await asyncio.start_server(lambda r, w: None, host="127.0.0.1", port=0)
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
up = await app_module._tcp_probe("127.0.0.1", port)
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
# 端口 1 无监听,本机 connect 立即被拒
|
||||
down = await app_module._tcp_probe("127.0.0.1", 1)
|
||||
return up, down
|
||||
|
||||
up, down = asyncio.run(scenario())
|
||||
assert up is True
|
||||
assert down is False
|
||||
|
||||
|
||||
def test_param_none_provided_1007(client):
|
||||
r = client.post(URL, headers=H)
|
||||
assert r.json()["code"] == 1007
|
||||
@@ -125,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
|
||||
@@ -177,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 这边不再覆盖。
|
||||
|
||||
@@ -188,7 +461,7 @@ def test_success_structure(client):
|
||||
data = body["data"]
|
||||
# 业务字段对齐文档
|
||||
assert set(["face_total_height_cm", "four_courts", "seven_eyes",
|
||||
"landmarks", "hairline_source", "head_pose",
|
||||
"landmarks", "hairline_source", "head_pose", "vertical_lines",
|
||||
"annotated_image_base64"]).issubset(data.keys())
|
||||
assert set(["top_court_cm", "upper_court_cm", "middle_court_cm",
|
||||
"lower_court_cm", "ratios"]).issubset(data["four_courts"].keys())
|
||||
@@ -205,3 +478,40 @@ def test_success_structure(client):
|
||||
assert "annotated_image_url" not in data
|
||||
png = base64.b64decode(data["annotated_image_base64"])
|
||||
assert png[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
_assert_vertical_lines(data, "v1")
|
||||
|
||||
|
||||
_INNER_VLINES = [
|
||||
"left_cheek", "left_eye_outer", "left_eye_inner",
|
||||
"right_eye_inner", "right_eye_outer", "right_cheek",
|
||||
]
|
||||
|
||||
|
||||
def _assert_vertical_lines(data, variant):
|
||||
vl = data["vertical_lines"]
|
||||
assert isinstance(vl["y_top"], int) and isinstance(vl["y_bottom"], int)
|
||||
assert vl["y_top"] < vl["y_bottom"]
|
||||
names = [line["name"] for line in vl["lines"]]
|
||||
xs = [line["x"] for line in vl["lines"]]
|
||||
assert xs == sorted(xs)
|
||||
assert all(isinstance(x, int) for x in xs)
|
||||
if variant == "v6":
|
||||
assert names == _INNER_VLINES
|
||||
assert vl["y_top"] == data["landmarks"]["hairline"]["y"]
|
||||
assert vl["y_bottom"] == data["landmarks"]["chin_tip"]["y"]
|
||||
assert "eye1" not in data["seven_eyes"]
|
||||
assert "eye7" not in data["seven_eyes"]
|
||||
return
|
||||
assert set(_INNER_VLINES).issubset(names)
|
||||
assert names[0] in ("left_head_edge", "left_cheek")
|
||||
assert names[-1] in ("right_head_edge", "right_cheek")
|
||||
by_name = {line["name"]: line["x"] for line in vl["lines"]}
|
||||
assert ("left_head_edge" in by_name) == (data["seven_eyes"]["eye1"] is not None)
|
||||
assert ("right_head_edge" in by_name) == (data["seven_eyes"]["eye7"] is not None)
|
||||
|
||||
|
||||
def test_measure_v2_vertical_lines_have_no_head_edges(client):
|
||||
files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")}
|
||||
body = client.post("/api/v1/face/measure-v2", headers=H, files=files).json()
|
||||
assert body["code"] == 0, body
|
||||
_assert_vertical_lines(body["data"], "v6")
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Interface 52 belongs to the GPU worker; the gateway keeps its legacy routes."""
|
||||
import asyncio
|
||||
import importlib
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
def test_legacy_hairline_forwarding(monkeypatch):
|
||||
name, path = "hairline_generate", "/api/v1/hairline/generate"
|
||||
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]
|
||||
|
||||
|
||||
def test_interface52_is_not_exposed_by_gateway():
|
||||
gateway = importlib.import_module("gateway.app")
|
||||
assert not any(getattr(route, "path", None) == "/api/v1/hairline/generate-52"
|
||||
for route in gateway.app.routes)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Keep the gateway's historical image-field conversion unchanged."""
|
||||
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_original_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))
|
||||
assert actual == value
|
||||
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))
|
||||
assert actual["image_middle_url"].startswith("https://example.test/static/annotations/")
|
||||
assert actual["image_high_base64"] is None
|
||||
assert "image_high_url" not in actual
|
||||
assert len(list(tmp_path.iterdir())) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,expected_key", [
|
||||
("/api/v1/hairline/generate", "grown_image_base64"),
|
||||
("/api/v1/hair/grow", "grown_image_base64"),
|
||||
])
|
||||
def test_proxy_keeps_original_null_image_fields(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}]
|
||||
@@ -27,5 +27,9 @@ def test_mp_to_obj_anchor_mapping():
|
||||
|
||||
def test_texture_map():
|
||||
m = get_texture_map()
|
||||
assert [k for k, _ in m["female"]] == ["ellipse", "flower", "heart", "straight", "wave"]
|
||||
assert [k for k, _ in m["male"]] == ["ellipse", "inverse_arc", "m", "straight"]
|
||||
assert [k for k, _ in m["female"]] == [
|
||||
"ellipse", "flower", "heart", "straight", "wave", "bigflower", "clasicalflower",
|
||||
]
|
||||
assert [k for k, _ in m["male"]] == [
|
||||
"ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal", "wave",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Template placement must follow face pose, not unequal left/right bangs."""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from hairline import constants as C
|
||||
from hairline.hairline_2d import (
|
||||
align_hairline_for_template,
|
||||
clamp_hairline_to_silhouette,
|
||||
)
|
||||
|
||||
|
||||
def _scene(angle=0.0, shape=(600, 900)):
|
||||
h, w = shape
|
||||
scale = np.array([w, h])
|
||||
lm = np.zeros((468, 3), np.float32)
|
||||
xy = np.full((468, 2), [450.0, 300.0])
|
||||
for i, (left, right) in enumerate(C.MP_TOP_ANCHOR_PAIRS):
|
||||
xy[left] = [300.0 + i * 12, 210.0 - i * 8]
|
||||
xy[right] = [600.0 - i * 12, 210.0 - i * 8]
|
||||
xy[10] = [450, 150]
|
||||
xy[152] = [450, 450]
|
||||
theta = np.deg2rad(angle)
|
||||
rotation = np.array([[np.cos(theta), -np.sin(theta)],
|
||||
[np.sin(theta), np.cos(theta)]])
|
||||
xy = (xy - [450, 300]) @ rotation.T + [450, 300]
|
||||
lm[:, :2] = xy / scale
|
||||
up = rotation @ [0.0, -1.0]
|
||||
anchors = lm[C.MP_TOP_ANCHORS, :2] * scale
|
||||
distances = np.full(C.N_ANCHORS, 50.0)
|
||||
indices = {k: i for i, k in enumerate(C.MP_TOP_ANCHORS)}
|
||||
distances[indices[67]] = 80.0
|
||||
distances[indices[297]] = 20.0
|
||||
hairline = ((anchors + distances[:, None] * up) / scale).astype(np.float32)
|
||||
return lm, hairline, np.ones(C.N_ANCHORS, bool), anchors, up
|
||||
|
||||
|
||||
@pytest.mark.parametrize("angle,shape", [(0, (600, 900)), (23, (600, 900)),
|
||||
(-17, (1200, 900))])
|
||||
def test_uneven_bangs_do_not_shear_template(angle, shape):
|
||||
lm, raw, valid, anchors, up = _scene(angle, shape)
|
||||
raw_copy, lm_copy = raw.copy(), lm.copy()
|
||||
aligned = align_hairline_for_template(lm, raw, valid, shape)
|
||||
displacement = aligned * shape[::-1] - anchors
|
||||
# Every anchor extends 50 px along the rotated face axis, even when
|
||||
# one side's hair boundary is 60 px higher than its counterpart.
|
||||
np.testing.assert_allclose(displacement, np.tile(50 * up, (C.N_ANCHORS, 1)), atol=1e-4)
|
||||
np.testing.assert_array_equal(raw, raw_copy)
|
||||
np.testing.assert_array_equal(lm, lm_copy)
|
||||
|
||||
|
||||
def test_anatomical_pairs_are_not_reversed_anchor_order():
|
||||
shape = (600, 900)
|
||||
lm, raw, valid, anchors, up = _scene(shape=shape)
|
||||
# Endpoints of MP_TOP_ANCHORS are not mirror-ordered.
|
||||
distances = {127: 10, 356: 30, 234: 60, 454: 100}
|
||||
for mp_idx, distance in distances.items():
|
||||
i = C.MP_TOP_ANCHORS.index(mp_idx)
|
||||
raw[i] = (anchors[i] + distance * up) / shape[::-1]
|
||||
aligned = align_hairline_for_template(lm, raw, valid, shape)
|
||||
actual = (aligned * shape[::-1] - anchors) @ up
|
||||
for mp_idx, expected in [(127, 20), (356, 20), (234, 80), (454, 80)]:
|
||||
assert actual[C.MP_TOP_ANCHORS.index(mp_idx)] == pytest.approx(expected, abs=1e-4)
|
||||
|
||||
|
||||
def test_missing_ray_hit_does_not_pull_valid_partner():
|
||||
shape = (600, 900)
|
||||
lm, raw, valid, _anchors, _up = _scene(shape=shape)
|
||||
left, right = C.MP_TOP_ANCHORS.index(67), C.MP_TOP_ANCHORS.index(297)
|
||||
valid[right] = False
|
||||
raw[right, 1] = -0.5 # An extrapolated fallback outside the head.
|
||||
aligned = align_hairline_for_template(lm, raw, valid, shape)
|
||||
np.testing.assert_allclose(aligned[[left, right]], raw[[left, right]], atol=1e-7)
|
||||
|
||||
|
||||
def test_template_alignment_still_allows_silhouette_clamping():
|
||||
shape = (600, 900)
|
||||
lm, raw, valid, _anchors, _up = _scene(shape=shape)
|
||||
aligned = align_hairline_for_template(lm, raw, valid, shape)
|
||||
parse_map = np.zeros(shape, np.int32)
|
||||
parse_map[140:450, 250:650] = C.PARSE_SKIN
|
||||
clamped = clamp_hairline_to_silhouette(aligned, parse_map)
|
||||
assert np.min(clamped[:, 1] * shape[0]) >= 142 - 1e-4
|
||||
@@ -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())
|
||||
@@ -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').status_code == 404
|
||||
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', '/api/v1/hair/grow']
|
||||
@@ -0,0 +1,83 @@
|
||||
"""竖线坐标:与标注图同一套几何,接口1/6 的返回字段由此而来。"""
|
||||
import numpy as np
|
||||
|
||||
from face_analysis.measure import MeasureResult
|
||||
from face_analysis.annotation import vertical_line_geometry
|
||||
|
||||
|
||||
def _result():
|
||||
"""已知像素坐标的测量结果。顶庭 2cm,不会触发发际线修正。"""
|
||||
vertical = {
|
||||
"hair_top": (500.0, 100.0),
|
||||
"hairline": (500.0, 200.2),
|
||||
"brow_center": (500.0, 350.0),
|
||||
"nose_bottom": (500.0, 500.0),
|
||||
"chin_tip": (500.0, 700.4),
|
||||
"top_court_px": 100.0,
|
||||
"upper_court_px": 150.0,
|
||||
"middle_court_px": 150.0,
|
||||
"lower_court_px": 200.0,
|
||||
}
|
||||
eyes = {
|
||||
"eye_width_px": 80.0,
|
||||
"face_width_px": 400.0,
|
||||
"inter_eye_distance_px": 90.0,
|
||||
"points": {
|
||||
"left_cheek": (300.4, 400.0),
|
||||
"left_outer": (360.6, 400.0),
|
||||
"left_inner": (440.2, 400.0),
|
||||
"right_inner": (530.8, 400.0),
|
||||
"right_outer": (610.4, 400.0),
|
||||
"right_cheek": (700.7, 400.0),
|
||||
},
|
||||
}
|
||||
return MeasureResult(vertical, eyes, 50.0, "estimated", None)
|
||||
|
||||
|
||||
_INNER = [
|
||||
"left_cheek", "left_eye_outer", "left_eye_inner",
|
||||
"right_eye_inner", "right_eye_outer", "right_cheek",
|
||||
]
|
||||
|
||||
|
||||
def test_v1_inner_lines_and_extended_span():
|
||||
geom = vertical_line_geometry(_result(), 1000, 800, variant="v1")
|
||||
# 短边 800,伸出 max(6, round(800*0.03)) = 24
|
||||
assert geom["y_top"] == 76
|
||||
assert geom["y_bottom"] == 724
|
||||
assert [line["name"] for line in geom["lines"]] == _INNER
|
||||
assert [line["x"] for line in geom["lines"]] == [300, 361, 440, 531, 610, 701]
|
||||
|
||||
|
||||
def test_v6_span_is_hairline_to_chin_without_head_edges():
|
||||
ear = np.zeros((800, 1000), dtype=np.uint8)
|
||||
ear[120:680, 250] = 255
|
||||
ear[120:680, 760] = 255
|
||||
geom = vertical_line_geometry(
|
||||
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v6")
|
||||
assert geom["y_top"] == 200
|
||||
assert geom["y_bottom"] == 700
|
||||
assert [line["name"] for line in geom["lines"]] == _INNER
|
||||
|
||||
|
||||
def test_v1_includes_ear_edges_left_to_right():
|
||||
ear = np.zeros((800, 1000), dtype=np.uint8)
|
||||
ear[120:680, 250] = 255
|
||||
ear[120:680, 760] = 255
|
||||
geom = vertical_line_geometry(
|
||||
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v1")
|
||||
names = [line["name"] for line in geom["lines"]]
|
||||
xs = [line["x"] for line in geom["lines"]]
|
||||
assert names == ["left_head_edge", *_INNER, "right_head_edge"]
|
||||
assert xs[0] == 250 and xs[-1] == 760
|
||||
assert xs == sorted(xs)
|
||||
|
||||
|
||||
def test_missing_ear_omits_that_edge():
|
||||
ear = np.zeros((800, 1000), dtype=np.uint8)
|
||||
ear[120:680, 250] = 255
|
||||
geom = vertical_line_geometry(
|
||||
_result(), 1000, 800, ear_mask=ear, hair_mask=None, variant="v1")
|
||||
names = [line["name"] for line in geom["lines"]]
|
||||
assert names[0] == "left_head_edge"
|
||||
assert "right_head_edge" not in names
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"_comment": "复制为 worker_config.json(不入 git)。accept_passwords:worker 内网鉴权密码列表,网关带 X-Internal-Token: <其中之一>(也可用环境变量 WORKER_ACCEPT_PASSWORDS 覆盖)。comfyui_password:本机 ComfyUI(8188) Basic Auth 密码(user 默认 admin;也可放 password.txt 或环境变量 COMFYUI_PASSWORD)。注:接口4 火山方舟 ark_api_key 已迁到网关配置。",
|
||||
"_comment": "复制为 worker_config.json(不入 git)。accept_passwords:worker 内网鉴权密码列表,网关带 X-Internal-Token: <其中之一>(也可用环境变量 WORKER_ACCEPT_PASSWORDS 覆盖)。comfyui_password:本机 ComfyUI(8188) Basic Auth 密码(user 默认 admin;也可放 password.txt 或环境变量 COMFYUI_PASSWORD)。health:深健康检查(/health 磁盘水位+下游依赖 TCP 探测,均可用环境变量 HEALTH_MIN_FREE_GB / HEALTH_CHECK_DEPS / HEALTH_DEP_COMFYUI / HEALTH_DEPS_CACHE_SECONDS 覆盖)。注:接口4 火山方舟 ark_api_key 已迁到网关配置。",
|
||||
"accept_passwords": ["change-me-to-a-strong-secret"],
|
||||
"comfyui_password": "your-comfyui-basic-auth-password"
|
||||
"comfyui_password": "your-comfyui-basic-auth-password",
|
||||
"health": {
|
||||
"min_free_gb": 5,
|
||||
"check_deps": true,
|
||||
"dep_comfyui": "127.0.0.1:8188",
|
||||
"deps_cache_seconds": 5
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user