正在加载…
与右侧选中的形状对应
diff --git a/.gitignore b/.gitignore index a131c55..b86d202 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ gateway/config.json # worker 配置(含鉴权密码,不入 git) worker_config.json +ip_access.json # ComfyUI Basic Auth 密码(不入 git) password.txt diff --git a/app.py b/app.py index dcf5025..96b4f28 100644 --- a/app.py +++ b/app.py @@ -194,6 +194,10 @@ async def internal_token_auth(request: Request, call_next): 本地起步),生产务必通过 worker_config.json / 环境变量配置密码。 """ path = request.url.path + from ip_access import rejection + denied = rejection(request) + if denied is not None: + return denied if path == "/" or path.startswith(_AUTH_EXEMPT): return await call_next(request) if path.startswith("/api/") and ACCEPT_PASSWORDS: @@ -370,7 +374,7 @@ class ImageJsonBody(BaseModel): # --------------------------------------------------------------------------- -def _run_face_measure_data(image, variant="v1"): +def _run_face_measure_data(image, variant="v1", landmarks_override=None): """接口1测量数值核心:detect → 姿态校验 → 头发/耳朵分割 → measure_face → eye1~eye7。 返回 (data_dict, result, hair_mask, ear_mask),或检测/姿态失败时返回 None。 @@ -385,7 +389,7 @@ def _run_face_measure_data(image, variant="v1"): from face_analysis.pose import estimate_head_pose, check_frontal_face from face_analysis.measure import measure_face - landmarks = detector.detect(image) + landmarks = landmarks_override if landmarks_override is not None else detector.detect(image) if landmarks is None: return None if not check_frontal_face(landmarks, w, h): @@ -1557,7 +1561,7 @@ async def face_features( # --------------------------------------------------------------------------- -# 接口 5:发际线 PNG 生成 +# 接口 5:发际线 PNG 生成(恢复 a3fddbb 历史版本) # --------------------------------------------------------------------------- @app.post( @@ -1727,6 +1731,285 @@ async def hairline_generate( return err(1007, f"处理失败:{ex}") +# --------------------------------------------------------------------------- +# 接口 52:中下庭规则、扶正裁切与矩形叠加预览 +# --------------------------------------------------------------------------- + +async def _generate_hairline52_data(image, gender, hair_styles, use_mask=True, + prompt=None, generate_grow_image=False, + compare_rules=False, normalize_image=True): + """Share measurement coordinates between interface 52 and its comparison page.""" + from fastapi.concurrency import run_in_threadpool + from hairline.service import generate_hairline_pngs_52, get_landmarker + + original = image + framing, normalized_landmarks, landmarks_override = None, None, None + if normalize_image: + from hairline.framing import normalize_portrait + from mediapipe.framework.formats import landmark_pb2 + landmarks = await run_in_threadpool( + get_landmarker().detect, cv2.cvtColor(image, cv2.COLOR_BGR2RGB), include_iris=True) + if landmarks is None: + return None, None + image, normalized_landmarks, framing = await run_in_threadpool(normalize_portrait, image, landmarks) + landmarks_override = landmark_pb2.NormalizedLandmarkList() + for point in normalized_landmarks: + landmarks_override.landmark.add(x=float(point[0]), y=float(point[1]), z=float(point[2])) + fm, reference = None, None + try: + measured = await run_in_threadpool(_run_face_measure_data, image, landmarks_override=landmarks_override) + if measured is not None: + fm, result = measured[:2] + reference = {key: result.vertical[key] + for key in ("brow_center", "nose_bottom", "chin_tip")} + reference["px_per_cm"] = result.px_per_cm + except Exception as ex: + logger.warning("接口52 当前底图测量失败,使用发际线关键点计算中下庭:%s", ex) + res = await run_in_threadpool( + generate_hairline_pngs_52, image, gender, hair_styles, use_mask, prompt, + generate_grow_image=generate_grow_image, height_reference=reference, + compare_rules=compare_rules, + precomputed_landmarks=normalized_landmarks[:468] if normalized_landmarks is not None else None) + if res is not None: + res.update(processed_bgr=image, original_bgr=original, framing=framing) + return res, fm + + +@app.post( + "/api/v1/hairline/generate-52", + summary="接口52 发际线PNG生成", + tags=["人脸分析"], + description=f""" +输入用户照片 + 性别 + 多选发型,各发型 middle 中点统一对齐到眉心上方(中庭+下庭)/2。 +返回设计曲线、生发图、目标高度与定位误差;high/low 旧字段保留为 null。 + +{_image_fields_desc} + +图片同时支持 `multipart/form-data` 文件上传(字段名 `image_file`)。 + +--- + +**入参**(同接口2:先选性别,再多选发型): +- 必填 `gender`(`male`/`female`),决定发型集合(female 7 / male 7)。 +- 必填 `hair_style`(发型序号,逗号分隔如 `1,2,3`),决定返回哪些发际线类型。缺失/越界/非法返回 `1007`。 + `female`:1=ellipse,2=flower,3=heart,4=straight,5=wave,6=bigflower,7=clasicalflower; + `male`:1=ellipse,2=inverse_arc,3=m,4=straight,5=heart,6=Softpetal,7=wave。 +- 可选 `use_mask` / `prompt`:同接口2 的生发控制参数。 + 生发遮罩直接使用新位置的 middle 曲线,与预览保持一致。 +- 可选 `generate_grow_image`(默认 `true`):是否生成生发效果图(ComfyUI 生发,全流程最耗时)。 + `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回设计叠图与中心点。 +- 新增可选 `normalize_image`(默认 `false`):显式传 `true` 时扶正并裁切,长图采用3:4。 + 不传时保留旧调用的原图尺寸与坐标。测试页显式开启此参数,保留实际像素、不放大图片。 + +**返回说明**: + +- `hairline_images`:**选中发型**的列表,数量 = 所选发型数,`order` = 发型序号。每项含: + - `image_middle_url`:新高度规则的透明发际线 PNG;`image_high_url` / `image_low_url` 固定 null。 + - `height_placement`:旧/新中心坐标、移动距离和目标误差(像素)。 + - `grown_image_url`:该发型的**生发图**(生发失败时为 `null`)。 + - `hairline_type`:发际线类型 key。 + worker 返回 `*_base64`,网关落盘后改写为 `*_url`。 +- `best_hairline_center_point`:**首个选中发型**的 **middle 档**发际线曲线**面部中间点**坐标, + 以**当前底图像素**为基准(左上角为原点,x 向右,y 向下)。 +- `high_hairline_center_point` / `low_hairline_center_point`:旧字段,固定 null。 +- `height_rule`:中庭、下庭、目标上庭高度、目标中心与测量来源;`face_measure` 为当前底图测量。 +- `input_image_*`:处理后底图,须用它叠加发际线;`original_image_*`:处理前图片。 +- `coordinate_space`:默认 `original_image`,开启预处理后为 `processed_image`。 +- `framing`:是否标准裁切、旋转角、前后尺寸及双向坐标变换;关闭预处理时为 null。 +""", + responses={ + 200: { + "description": "成功", + "content": { + "application/json": { + "example": { + "code": 0, + "message": "success", + "request_id": "mock-request-id", + "data": { + "hairline_images": [ + {"hairline_type": "ellipse", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": None, "image_low_base64": None, "grown_image_base64": "iVBORw0KGgo...", "order": 1}, + {"hairline_type": "flower", "image_middle_base64": "iVBORw0KGgo...", "image_high_base64": None, "image_low_base64": None, "grown_image_base64": None, "order": 2}, + ], + "best_hairline_center_point": {"x": 540, "y": 430}, + "high_hairline_center_point": None, + "low_hairline_center_point": None, + "face_measure": { + "face_total_height_cm": 13.76, + "four_courts": { + "top_court_cm": 3.44, "upper_court_cm": 3.44, + "middle_court_cm": 3.44, "lower_court_cm": 3.44, + "ratios": {"top_court": 0.25, "upper_court": 0.25, + "middle_court": 0.25, "lower_court": 0.25}, + }, + "seven_eyes": { + "eye_width_cm": 3.44, "face_width_cm": 24.08, + "inter_eye_distance_cm": 3.44, + "ratios": {"eye_width": 0.143, "inter_eye_distance": 0.143}, + "eye1": 3.44, "eye2": 3.44, "eye3": 3.44, "eye4": 3.44, + "eye5": 3.44, "eye6": 3.44, "eye7": 3.44, + }, + "landmarks": { + "hair_top": {"x": 540, "y": 120}, + "hairline": {"x": 540, "y": 430}, + "brow_center": {"x": 540, "y": 740}, + "nose_bottom": {"x": 540, "y": 1050}, + "chin_tip": {"x": 540, "y": 1360}, + }, + "hairline_source": "segmentation", + "head_pose": {"yaw": -1.39, "pitch": 2.49, "roll": -0.06}, + }, + }, + } + } + }, + }, + 400: { + "description": "参数错误 / 图片识别失败", + "content": { + "application/json": { + "example": {"code": 1001, "message": "无法识别人像", "request_id": "x", "data": None} + } + }, + }, + }, +) +async def hairline_generate_52( + image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG)"), + image_url: Optional[str] = Form(default=None, description="图片 URL"), + image_base64: Optional[str] = Form(default=None, description="图片 base64(需带 data:image/...;base64, 前缀)"), + gender: Optional[str] = Form(default=None, description="性别 male/female(必填)"), + hair_style: Optional[str] = Form(default=None, description="发型序号逗号分隔(必填,如 1,2,3)。female:1-7 male:1-7"), + use_mask: bool = Form(default=True, description="生发是否启用 inpaint 遮罩(同接口2,测试对比用)"), + prompt: str = Form(default="填充遮罩区域的头发,人物增加一点磨皮", description="ComfyUI 提示词(同接口2),会替换工作流节点60的文本"), + generate_grow_image: bool = Form(default=True, description="是否生成生发效果图。false 时仅返回新规则发际线叠图与中心点"), + normalize_image: bool = Form(default=False, description="新增可选开关,默认false保留原图尺寸和坐标;true先扶正再裁切,长图采用3:4。测试页显式开启"), +): + if gender not in ("male", "female"): + return err(1004, "gender 必填且只能为 male / female") + + # hair_style 必填(同接口2):解析逗号分隔,缺失/越界/非法 → 1007 + max_styles = {"female": 7, "male": 7}[gender] + hair_styles = _parse_hair_styles(hair_style, max_styles) + if hair_styles is None: + return err(1007, f"hair_style 必填且为 1..{max_styles} 的整数(逗号分隔),收到 {hair_style!r}") + + raw, e = await resolve_image_bytes(image_file, image_url, image_base64) + if e is not None: + return e + + from hairline.framing import decode_portrait + image = decode_portrait(raw) + if image is None: + return err(1008, "图片格式不支持(仅 JPG / PNG)") + + try: + res, fm = await _generate_hairline52_data( + image, gender, hair_styles, use_mask, prompt, + generate_grow_image=generate_grow_image, normalize_image=normalize_image) + if res is None: + return err(1001, "无法识别人像") + + hairline_images = [] + for it in res["images"]: + ov = it["overlays"] + hairline_images.append({ + "hairline_type": it["hairline_type"], + "image_middle_base64": _rgba_png_b64(ov["middle"]), + "image_high_base64": None, + "image_low_base64": None, + "grown_image_base64": (_png_to_jpg_b64(it["grown_png"]) # 生发图 JPG(失败为 null) + if it.get("grown_png") else None), + "order": it["order"], + "height_placement": it["height_placement"], + }) + c = res.get("best_centers") or {} + def _pt(p): + return ({"x": p[0], "y": p[1]} if p else None) + data = { + "hairline_images": hairline_images, + "best_hairline_center_point": _pt(c.get("middle")), + "high_hairline_center_point": _pt(c.get("high")), + "low_hairline_center_point": _pt(c.get("low")), + "height_rule": res["height_rule"], + "guide_landmarks": res["guide_landmarks"], + "face_measure": fm, + "framing": res["framing"], + "input_image_base64": _jpg_b64(res["processed_bgr"]), + "original_image_base64": _jpg_b64(res["original_bgr"]), + "coordinate_space": "processed_image" if normalize_image else "original_image", + "width": res["processed_bgr"].shape[1], "height": res["processed_bgr"].shape[0], + } + return ok(data) + except Exception as ex: # noqa: BLE001 + logger.exception("接口52 处理异常") + return err(1007, f"处理失败:{ex}") + + +@app.get("/api/v1/debug/hairline-height-cases", include_in_schema=False) +async def hairline_height_cases(): + path = os.path.join(os.path.dirname(__file__), "tests", "fixtures", "hairline_height_cases.json") + with open(path, encoding="utf-8") as f: + return ok(json.load(f)) + + +@app.get("/api/v1/debug/hairline-framing-reference", include_in_schema=False) +async def hairline_framing_reference(): + from hairline.framing import load_profile + return ok(load_profile()) + + +@app.post("/api/v1/debug/hairline-height-compare", include_in_schema=False) +async def hairline_height_compare( + image_file: Optional[UploadFile] = File(default=None), + image_url: Optional[str] = Form(default=None), + image_base64: Optional[str] = Form(default=None), + gender: str = Form(default="female"), + hair_style: str = Form(default="1,2,3,4,5,6,7"), + generate_grow_image: bool = Form(default=False), + normalize_image: bool = Form(default=True), +): + if gender not in ("female", "male"): + return err(1004, "gender 必须为 male / female") + styles = _parse_hair_styles(hair_style, 7) + if styles is None: + return err(1007, "hair_style 必须为 1..7 的整数,逗号分隔") + raw, error = await resolve_image_bytes(image_file, image_url, image_base64) + if error is not None: + return error + from hairline.framing import decode_portrait + photo = decode_portrait(raw) + if photo is None: + return err(1008, "图片格式不支持") + try: + res, fm = await _generate_hairline52_data( + photo, gender, styles, prompt="填充遮罩区域的头发,人物增加一点磨皮", + generate_grow_image=generate_grow_image, compare_rules=True, + normalize_image=normalize_image) + if res is None: + return err(1001, "无法识别人像") + h, w = res["processed_bgr"].shape[:2] + return ok({ + "gender": gender, "width": w, "height": h, + "input_base64": _jpg_b64(res["processed_bgr"]), + "original_base64": _jpg_b64(res["original_bgr"]), + "framing": res["framing"], + "coordinate_space": "processed_image" if normalize_image else "original_image", + "height_rule": res["height_rule"], "face_measure": fm, + "guide_landmarks": res["guide_landmarks"], + "results": [{ + "hairline_type": item["hairline_type"], "order": item["order"], + "old_overlay_base64": _rgba_png_b64(item["legacy_overlay"]), + "new_overlay_base64": _rgba_png_b64(item["overlays"]["middle"]), + "height_placement": item["height_placement"], + "grown_image_base64": _png_to_jpg_b64(item["grown_png"]) if item["grown_png"] else None, + } for item in res["images"]], + }) + except Exception as ex: + logger.exception("接口52 高度规则对比失败") + return err(1007, str(ex)) + + # --------------------------------------------------------------------------- # 接口 9:头发遮罩生成 # --------------------------------------------------------------------------- diff --git a/docs/实现说明.md b/docs/实现说明.md index 8803648..f68061d 100644 --- a/docs/实现说明.md +++ b/docs/实现说明.md @@ -13,21 +13,22 @@ ``` 客户端 ──HTTPS──> 外网网关(gateway/) ──HTTP(X-Internal-Token)──> worker(GPU 机, app.py) │ 薄代理 + 落盘改URL │ 跑算法(本地模型/ComfyUI/change_hair) - └ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/6/7/9/10/11/12 + 工具路由 + └ 接口4(豆包)/99(特殊点) 本机实现(不转发) └ 接口1/2/3/5/52/6/7/9/10/11/12 + 工具路由 ``` - **worker**(`app.py` + `face_analysis/` + `hairline/`):跑真正的算法,除 ComfyUI/change_hair 走本机 回环外无外网依赖。对 `/api/*` 校验 `X-Internal-Token`(密码在 `worker_config.json` 的 `accept_passwords`); `/health` 模型就绪才返回 200。监听 **8187**(systemd `hair-worker.service`,也可 `./run_worker.sh` 手动起)。 另有 worker 本机的工具路由:`/api/swapHair/v1`、`/hairColor/v2`(CORS 代理转发 change_hair:8801)、 - `/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免鉴权调试)。 + `/api/v1/redraw`(直接 ComfyUI 重绘)、`/api/v1/debug/*`(免Token调试,仍受IP限制)。 + 所有动态路径先校验直连来源IP:允许本机与网关`49.232.51.171`,其余返回HTTP403/code1010;`/static/`除外。配置见[`接口IP白名单.md`](接口IP白名单.md),网关侧不增加IP限制。 - **网关**(`gateway/`):薄反向代理,健康检查/派发/鉴权/把 worker 的 `*_base64` 落盘改成 `*_url`。 网关本机实现的接口(不转发 worker):**接口4**(调外网豆包)与**接口99**(纯 CPU 特殊点检测, 直接 import 同仓库 `face_analysis/special_points`,`_if99_lock` 单路排队)。 - **图片三选一**:所有接口图片入参 `image_file`/`image_url`/`image_base64` 严格三选一(接口3 是 `marked_image_*`)。 -- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 一律 200)。 +- **响应信封**:`{code, message, request_id, data}`;业务错误用 `code`(HTTP 200);IP拒绝为HTTP403/code1010,Token错误为HTTP401/code1009,依赖健康异常为HTTP503。 -### base64 → URL 映射(网关落盘改写,递归进数组、可空保留 null) +### base64 → URL 映射(网关落盘改写,递归进数组) | 接口 | worker 字段(内部) | 对外字段 | |------|--------------------|----------| @@ -36,6 +37,7 @@ | 2 | `results[].image_base64` / `results[].grown_image_base64`(可空) | `results[].image_url` / `results[].grown_image_url` | | 3 | `hair_growth_image_base64`(可空) | `hair_growth_image_url` | | 5 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`(可空) | `hairline_images[].image_{middle,high,low}_url` / `grown_image_url` | +| 52 | `hairline_images[].image_{middle,high,low}_base64` / `grown_image_base64`、`input_image_base64` / `original_image_base64` | 同名 `*_url`,空图片也改为`*_url: null` | | 9 | `steps_common.*_base64`、`bisenet|segformer.{hair,closed_region,final_overlay,mask}_mask_base64` 等 | 同名 `*_url` | | 10 | `steps_common.*_base64`、`bisenet|segformer.{base_mask,contour,band_overlay,mask}_base64` 等 | 同名 `*_url` | | 11 | `steps.{input,baseline,hair_mask,mask_overlay,mask,swap_raw,alpha,final}_base64` | `steps.*_url` | @@ -43,13 +45,14 @@ | 4 | (网关本机产出,无图片字段,`features` 为 JSON 字符串) | — | > 实现建议:递归遍历 data,凡 key 以 `_base64` 结尾就落盘改 `_url`,自动覆盖嵌套/新增字段。 +> 兼容约定:接口52显式开启空图片字段改名;接口2/3/5等旧接口仍保留空图片的`*_base64: null`字段名。有图片时均落盘为`*_url`。 > **图片格式**:接口1 标注图含透明用 **PNG**;接口2/3/5 是不透明照片用 **JPG**(小很多,~9×)。 > 网关落盘按内容嗅探扩展名(PNG 头→`.png`,否则 `.jpg`)。 ### 错误码 `1001` 无法识别人像 | `1002` 分辨率过低 | `1003` 非正面 | `1004` gender 必填/非法(接口2/5/7)| -`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持。 +`1006` >1MB | `1007` 图片参数错误(0或多个)/未预期异常 | `1008` 格式不支持 | `1009` Token鉴权失败 | `1010` worker来源IP不允许或白名单配置无效。 --- @@ -99,6 +102,15 @@ - **做什么**:入参同接口2(`gender` + 多选 `hair_style` 必填)。对每个选中发型 → `middle`/`high`/`low` 三档发际线叠图 + 生发图 + 首个选中发型的面部中间点坐标。 - **怎么实现**:复用接口2 的 502 点渲染管线,三档分别用 `hairline_texture[/_high|/_low]` 同名贴图渲染叠图;生发同接口2(ComfyUI inpaint),**黑模板固定取 `hairline_texture_black/`(middle)**,每发型 1 张生发图。`best_hairline_center_point`=眉心 x × 首个选中发型 middle 档发际线 y。 +### 接口52 发际线PNG生成 `/api/v1/hairline/generate-52`(worker) +- **预处理**:`hairline/framing.py`先扶正9→152,再按高(3÷0.9)L、宽2.5L(3:4)做原像素裁切,人物相对原3L规则缩小约10%。L为9→152距离;眉心位置沿用已扶正参考图比例。长图的标准窗口不足时,求原图内最大的3:4窗口,通过可行区域约束保留全部人脸关键点,并尽量贴近目标眉心位置;无法保留人脸时回退仅扶正。其余图片空间不足时仍仅扶正、去除旋转空角。参考图与参数由`scripts/calibrate_hairline_framing.py`生成。公开接口为兼容旧调用默认关闭预处理,`normalize_image=true`开启;规则页显式开启,对比调试页默认开启。返回当前底图、原图及双向坐标矩阵。 +- **目标高度**:`上庭=(中庭+下庭)/2`,用当前底图测量的未取整像素坐标(眉心9、鼻翼下缘94、下巴152)算目标中心。测量失败时用发际线管线同编号点计算并标注来源。 +- **模板定位**:原有502点/贴图生成形状,`hairline/height_rule.py` 将每种 middle 曲线沿面部向上方向平移、校准中心 Y,保留曲线形状与倾斜。此规则仅接口52使用,接口2保持原逻辑。 +- **生发**:移动后的预览曲线直接生成黑线和重绘遮罩,再送ComfyUI,避免预览与黑模板位置不一致。 +- **裁切叠加预览**:测试页新增“裁切发际线叠加”。`static/hairline_reference_box.js`共享同一个162/389/9/152参考框几何,按原图像素裁切middle透明图层,再叠加到处理后人像;四栏顺序为原图、middle、裁切发际线叠加、生发图。 +- **兼容**:high/low不再生成,图片与中心点字段保留为null。网关`gateway/forward.py`仅为接口52开启空图片`*_base64`→`*_url: null`,旧接口空字段名保持原样。部署52路由时同步该改写;网关不增加IP限制。`face_measure`为当前底图测量;新增`height_rule`与各项`height_placement`说明设计目标与实际误差。 +- **测试**:规则页`static/test_interface52.html`,对比页`static/test_interface52_compare.html`;运行`scripts/compare_hairline_height.py`默认保存全部30张×男女各7型,共420组对比。每人一行,顺序为未裁切原图、单张旧规则图、全部新规则样式,支持位置对照、额头特写、同图叠线和上传新图。新图可叠加162/389两侧、底边经过9、高度为9→152距离70%的参考框。原图与叠图按需加载。 + ### 接口9 头发遮罩 `/api/v1/head/mask`(worker) - **做什么**:人像照 → 头发遮罩 + 全流程分步可视化。 - **怎么实现**(`face_analysis/head_mask.py`):MediaPipe 底部分割线(21/68/104/69/108/151/337/299/333/298/251 连线,21/251 水平延伸)→ 线以上为上半区 → BiSeNet 与 SegFormer 双路头发分割、逐列向下填充到分割线得闭合区域(头发+额头)→ 外缘朝 151 内缩 `erode_cm`(默认1.2cm,虹膜标定) → 最终遮罩。返回 `steps_common` + `bisenet`/`segformer` 两组各四张图。 @@ -126,6 +138,7 @@ ## 3. 部署 / 环境要点 **worker(GPU 机,本机 = gpu_worker 角色)** +- 启动使用`./run_worker.sh`或`hair-worker.service`,均显式传`--no-proxy-headers`以保留直连来源IP。部署时复制`ip_access.example.json`为`ip_access.json`并核对网关IP;运行配置不入Git,改动即时生效。旧启动方式也应补上此参数,详见[`接口IP白名单.md`](接口IP白名单.md)。 - Python **3.10.12**(本机 venv 在 `./venv`);`numpy<2`(1.26.4),`scikit-image==0.24.0` (**别升 0.25+,会顶 numpy≥2 顶崩 mediapipe**)。 - 本机 **RTX 4090**:venv 实装 `torch 2.7.1+cu128`,CUDA 可用(BiSeNet/SegFormer GPU 推理)。 diff --git a/docs/接口52图片裁切扶正规划.md b/docs/接口52图片裁切扶正规划.md new file mode 100644 index 0000000..0bd0917 --- /dev/null +++ b/docs/接口52图片裁切扶正规划.md @@ -0,0 +1,119 @@ +# 接口52上传图片裁切与扶正规划 + +状态:2026-11-04 在原3L规则上将人物显示缩小约10%,裁切高度改为(3÷0.9)L≈3.3333L、宽度2.5L,已接入接口52与对比页。参考图先扶正后重新标定,以下旧预检数值仅为历史记录,当前标准以 `hairline/reference/framing_profile.json` 为准。 + +## 接口拆分 + +- 接口5已按11月2日前最新提交`a3fddbb`恢复:`POST /api/v1/hairline/generate`,返回middle/high/low三档,测试页`/static/test_interface5.html`。 +- 当前新规则完整迁至接口52:`POST /api/v1/hairline/generate-52`,测试页`/static/test_interface52.html`,30案例对比`/static/test_interface52_compare.html`。接口52保留迁移前所有参数和返回字段,页面显式开启`normalize_image=true`。 +- 原`test_interface5_compare.html`书签跳转至52号对比页;公开5号与52号的处理路由、生成函数和网关转发均独立。 + +## 已确定的要求 + +- 仅用于接口52与当前旧/新规则对比页。 +- 先扶正:变换后的9号点与152号点保持同一X坐标,9在上、152在下。 +- 原图空间够时按目标人脸比例和位置裁切;标准窗口不足时,偏长图片在保留人脸的前提下裁为3:4,其余仍仅扶正。3:4无法保留完整人脸时不强裁。 +- 输出保留实际像素尺寸,不为了凑参考图尺寸而放大小图。 +- 当前展示:全部30个案例,每个案例一行,未裁切原图放最前,旧规则图仅一张,新规则男女各7型横排,人物图片完整显示。选择新样式时旧图同步对应形状;失败案例仍保留原图并显示原因。 + +## 已扶正标准(当前生效) + +- 标准图:`static/framing_reference.png`,786×1048,3:4。 +- 两点X均为385.18783691;9号Y为360.65437127,152号Y为725.82510480。 +- 相对位置:9号点 x=49.00608612%,y=34.41358504%。 +- 当前裁切高/L=3÷0.9≈3.3333、宽/L=2.5(3:4),L为9→152长度。参考图自身测得的宽/L=2.15241783589、高/L=2.86989044785作为原始标定记录保留;眉心在画面中的位置比例继续使用参考图数值。 +- 校准旋转约0.25962484°,变换后横向误差约5.7e-14像素。参数取自扶正并去空角后的图,不再使用未扶正原图的比例。 +- 重标定脚本:`./venv/bin/python scripts/calibrate_hairline_framing.py <参考图路径>`。 +- 使用同一套原始检测点作刚体变换与后续测量,避免二次检测抖动破坏轴线竖直。 +- 公开接口新增`normalize_image`默认false,旧请求保持原图尺寸和坐标;显式true开启预处理。规则页主动传true,对比调试页默认开启。响应明确返回`coordinate_space`、当前底图和坐标变换。 + +## 原始参考构图(旧测量) + +[用户指定参考图](https://raw.githubusercontent.com/feastfuyan/yonghe-badcase-0917/main/misc/20261103_user.jpg) + +测量采用当前发际线管线的MediaPipe FaceLandmarker: + +| 指标 | 测量值 | +|---|---| +| 画面 | 792×1056,3:4 | +| 9号点 | (388.93, 364.62) | +| 152号点 | (387.27, 729.79) | +| 9→152长度L | 365.17像素 | +| 面部轴线距竖直方向 | 约0.26° | +| 9号点画面相对位置 | x约49.1%,y约34.5% | +| 152号点画面相对高度 | y约69.1% | +| 画面宽度/L | 约2.16885 | +| 画面高度/L | 约2.89180 | + +参考图接近竖直。以9号点为定位锚点,用9→152长度作为缩放无关的构图标尺。这里的L仅为眉心到下巴的距离,不是头顶到下巴的全头高度。 + +## 实施流程 + +1. 读取上传原图、处理EXIF方向,再检测人脸关键点。 +2. 用9→152方向计算二维旋转,使转换后两点竖直。不改变脸部比例。 +3. 按本人的L算目标裁切尺寸:高(3÷0.9)L、宽2.5L;令9号点位于裁切画面约(49.0061%,34.4136%)处。高度取最接近的4的倍数,宽=高×3/4,不改变原图像素尺度。 +4. 将裁切窗口四角反算回原图,四角均在有效图片区域内才执行标准构图。采用一次仿射变换合并旋转与裁切,降低重复插值。 +5. 标准窗口空间不足时,偏长图片采用原图内最大的3:4有效窗口,约束所有人脸关键点在框内,并尽量靠近目标眉心位置。若无法保留完整人脸则仅扶正并给出原因。其余图片仍仅扶正,最小去除旋转空角。 +6. 后续测量、分割、旧/新发际线和70%参考框使用同一个处理后底图与坐标系。保留原图及原图/处理后坐标转换关系,防止叠图错位。 +7. 页面展示原图、处理后图、旋转角、是否标准裁切、跳过原因与前后尺寸。旧/新规则对比使用同一处理后底图,避免构图变化混入高度规则差异。 + +## 当前:长图与矩形发际线预览 + +- 长图即使放不下标准人脸占比窗口,也尝试在原图有效区域裁出最大的3:4窗口;通过可行区域约束保留468个人脸关键点,并尽量贴近参考眉心位置。无法完整保留人脸时回退仅扶正,响应说明原因。 +- 案例7从1080×1920裁为1062×1416,保持9→152竖直。长图7、20、21、22、50、51的男女各7型已更新,84组均成功;对比页30张、378组结果统一使用当前规则。 +- 规则页展示原图、middle、裁切发际线叠加、生发图。新增预览使用共享矩形几何裁切透明发际线,矩形外清空透明,底图仍是完整处理后人像。 + +## 人物缩小10%的验证(长图规则加入前) + +- 裁切高=(3÷0.9)L、宽=2.5L,使同样展示尺寸下的人物线性尺寸约为原3L规则的90%。 +- 案例1由828×1104改为918×1224,人脸相对大小约90.20%,差异来自原像素窗口取整;底图、发际线和实际生发图均为918×1224。 +- 对比页保留全部30张。重新计算了1、3、42、43、44、45;其余48组结果验证新窗口仍超出有效范围,且只扶正后的矩阵和尺寸完全一致,复用原定位图并更新裁切参数。 +- 当前生成378组对比,43、44、45的男女组仍显示原图及定位失败原因。布局、眉心比例和9→152扶正方向保持一致。 + +## 历史:3L规则的五个案例验证 + +2026-11-04 按3L规则重跑男女各7型,共70组,失败0组。保留原始像素,不缩放人脸;实际尺寸如下。 + +| 案例 | 原尺寸 | 处理后尺寸 | 实际处理 | +|---|---|---|---| +| 1 | 1088×1406 | 828×1104 | 按3L规则裁切 | +| 2 | 1088×1406 | 1042×1346 | 空间不足,仅扶正并去除旋转空角 | +| 3 | 1088×1406 | 810×1080 | 按3L规则裁切 | +| 4 | 1088×1406 | 1042×1346 | 空间不足,仅扶正并去除旋转空角 | +| 5 | 1088×1367 | 1071×1346 | 空间不足,仅扶正并去除旋转空角 | + +## 历史几何预检(未扶正参考图) + +以下为修改前的预检记录,当前生效尺寸见上表。 + +| 案例 | 原尺寸 | 所需裁切尺寸(约) | 预计处理 | +|---|---|---|---| +| 1 | 1088×1406 | 797×1063 | 可按参考构图裁切并扶正 | +| 2 | 1088×1406 | 1442×1923 | 空间不足,仅扶正 | +| 3 | 1088×1406 | 782×1042 | 可按参考构图裁切并扶正 | +| 4 | 1088×1406 | 1465×1953 | 空间不足,仅扶正 | +| 5 | 1088×1367 | 1463×1950 | 空间不足,仅扶正 | + +## 历史:3L规则的全部30张验证 + +- 按3L规则运行全部30张、男女各7型,共60次请求;55组请求成功,生成385组旧/新规则对比。27张男女均成功,案例43额外生成男性7型。 +- 案例43女性:模板在面部中轴处没有有效曲线;案例44、45男女:扶正后无法保留9/152参考点。三个案例均在对应行显示完整原图和未生成组合的原因,不隐藏失败案例。 +- 页面顺序为未裁切原图、旧规则、全部新规则;案例筛选包括失败图片,重跑按钮运行完整30张。 +- 页面筛选、旧图切换、末尾图片加载及移动端横向浏览均通过;3L调参后再次检查全部输出尺寸与混合成功/失败案例的筛选。 + +## 验收重点 + +- 处理后的9、152号点X差不超过1像素,保证9在152上方。 +- 标准裁切成功时,画面比例及两点位置接近参考构图,保留实际像素,不放大小图。 +- 空间不足的案例明确提示,仍完成扶正。 +- 旧/新规则叠图、参考框与底图坐标一致,原图坐标可通过逆变换还原。 +- 全部30案例均可查看,成功案例显示男女各7型,失败案例显示原图与原因;沿用每人一行、单张旧规则图的展示。 + +## 2026-11-04 提交前 Review 与验证 + +- 接口5的请求处理函数、三档PNG生成函数与`a3fddbb`逐项AST比较一致,测试页逐字节一致;新高度规则、扶正裁切和矩形叠加均在接口52。 +- 修复网关空图片字段改名影响旧接口的兼容性问题:仅接口52开启`*_base64: null`→`*_url: null`,接口2/3/5等旧接口保留原字段名;已增加递归改写和真实转发函数的回归测试。 +- GPU worker所有动态接口启用直连IP白名单,本机和网关`49.232.51.171`可访问;网关侧不启用IP限制。配置与部署见[接口IP白名单](接口IP白名单.md)。 +- `./venv/bin/pytest -q`:113项通过、1项已有失败。`tests/test_pipeline.py::test_numeric_regression`期望全脸总高24.01cm,当前实测22.47cm;从未修改的`a3fddbb`导出代码、夹具后独立运行,同样失败。此次保留历史断言,未通过修改基线掩盖差异。 +- 浏览器实测:接口5返回middle/high/low且保持868×1166;接口52长图案例7返回1062×1416、`status=aspect_crop`,四栏图和裁切边框正常;对比页显示30个案例、378组结果,旧书签可跳转52,未出现页面脚本错误。定位验证均关闭ComfyUI生发,本轮未重复运行耗时的真实生发。 +- 批量结果与上传图位于被Git忽略的`static/annotations/`及`tests/output/`,不随提交分发;新环境执行`scripts/compare_hairline_height.py`重建。420是计划组合数,378为当前成功结果,失败组及原因保留在报告中。 diff --git a/docs/接口IP白名单.md b/docs/接口IP白名单.md new file mode 100644 index 0000000..4f09fd8 --- /dev/null +++ b/docs/接口IP白名单.md @@ -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、转发头伪造、热更新、全接口拦截与网关不受限制。 diff --git a/docs/接口文档.md b/docs/接口文档.md index 25cf3d0..a1e549b 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -20,11 +20,14 @@ | 3 B 端生发 | POST | `/api/v1/hair/grow-b` | | 4 用户特征 | POST | `/api/v1/face/features` | | 5 发际线 PNG 生成 | POST | `/api/v1/hairline/generate` | +| 52 发际线新规则与裁切 | POST | `/api/v1/hairline/generate-52` | --- ## 通用约定 +GPU worker的所有动态接口均启用IP限制:允许本机及`ip_access.json`白名单(当前为网关IP `49.232.51.171`),其他来源返回HTTP403、code=1010;静态页面与图片除外。按直接连接worker的IP判断,网关不启用此限制,已有鉴权继续有效。 + 以下约定适用于全部 5 个接口(最终以联调为准)。 ### 请求 @@ -35,7 +38,7 @@ | 请求方式 | `POST` | | 图片传参方式 | 三选一,三种都支持:① `multipart/form-data` 上传文件、② 图片 URL、③ base64 字符串 | | 字符编码 | UTF-8 | -| 坐标基准 | 所有返回的坐标均以**原图像素**为基准(原点为图片左上角,x 向右、y 向下) | +| 坐标基准 | 默认以**原图像素**为基准;接口52开启预处理时使用响应中的处理后底图坐标(原点为左上角,x向右、y向下) | 图片传参字段(三者**严格互斥**,必须且只能传其一;传了 0 个或 ≥2 个均报错): @@ -81,12 +84,13 @@ | 1001 | 无法识别人像 | 图片中未检测到人脸 | | 1002 | 人像分辨率过低 | 低于最低分辨率要求 | | 1003 | 角度问题,非正面照 | 非正面 / 角度过大 | -| 1004 | gender 必填/非法 | 接口2/5 的 `gender` 缺失或非 `male`/`female` | +| 1004 | gender 必填/非法 | 接口2/5/52/7 的 `gender` 缺失或非 `male`/`female` | | 1005 | 检测到多张人脸 | 默认仅支持单人,检测到 2 人或以上时返回 | | 1006 | 文件超出大小限制 | 单文件超过 1 MB | | 1007 | 图片参数错误 | file / url / base64 未传,或同时传了多个(三者严格互斥) | | 1008 | 图片格式不支持 | 非 JPG / PNG | | 1009 | 未授权 | 缺少或错误的 `X-Internal-Token`(`/api/*` 路径鉴权) | +| 1010 | 来源IP不允许 | 仅GPU worker:直连IP不在本机/白名单内,或来源/白名单配置无效;HTTP403 | --- @@ -518,6 +522,154 @@ --- +## 接口 52:发际线 PNG 生成接口 + +**说明**:入参同接口2(先选性别、再多选发型)。对每个选中发型返回一张 `middle` 设计叠图与生发图。**目标上庭 =(中庭 + 下庭)÷2**,从眉心向上定位;所有形状的中点对齐同一目标。`high` / `low` 已停用,旧字段保留为 `null`。 + +**请求**:`POST /api/v1/hairline/generate-52` + +**IP限制**:GPU worker的所有动态接口统一仅允许本机回环地址、本机网卡地址及白名单;目前白名单含网关IP `49.232.51.171`,其他直连来源需加入`ip_access.json`的`allowed_ips`。该文件支持单个IP与CIDR,修改后立即生效;拒绝时返回HTTP403、code=1010。网关不启用此限制,已有鉴权仍保留。详见[接口IP白名单](接口IP白名单.md)。 + +### 图片扶正与标准构图 + +显式传`normalize_image=true`时,先扶正9→152,再按**高(3÷0.9)×L≈3.3333×L、宽2.5×L(3:4)**裁切,L为9→152的直线像素距离。相对原3L规则,裁切框宽高均扩大约11.11%,同样显示尺寸下人物缩小约10%。高度取最接近的4的倍数,宽度取高度的3/4。眉心定位沿用已扶正参考图:9号点相对位置约(49.0061%,34.4136%)。参考图仍为786×1048,两点X均为385.18784。 + +目标窗口完全在原图有效区域内才进行标准裁切。标准窗口不足时,偏长图片(宽/高<3/4)采用原图有效范围内最大的3:4窗口,尽量贴近眉心目标位置并保留完整人脸,`status=aspect_crop`;人脸放不进3:4窗口时回退仅扶正并返回原因。其余图片空间不足时仍仅扶正、去除旋转空角。不放大小图。**所有叠图与坐标使用响应中的处理后底图 `input_image_*`**,调用方应使用该底图叠加;`normalize_image=false` 可关闭此预处理。 + +`framing`包含处理前后尺寸、旋转角、是否标准裁切、跳过原因、`source_to_processed`/`processed_to_source`仿射矩阵。`status`区分`reference_crop`(标准窗口)、`aspect_crop`(长图3:4裁切)和`rotation_only`(仅扶正);`standard_crop=false`时仍可能有`aspect_crop=true`。`aspect_crop_skip_reason`说明长图无法保留人脸时的回退原因。`original_image_*`保留EXIF方向修正后的上传图,原图坐标可通过逆矩阵还原。 + +### 输入 + +图片参数见「通用约定 → 图片传参字段」。专属参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| gender | string | **是** | 性别:`male` / `female`。决定发型集合(female 7 / male 7)。缺失/非法返回 `1004` | +| hair_style | string | **是** | 发型序号,**逗号分隔多选**(如 `1,2,3`),决定返回哪些发际线类型。female:1=ellipse, 2=flower, 3=heart, 4=straight, 5=wave, 6=bigflower, 7=clasicalflower;male:1=ellipse, 2=inverse_arc, 3=m, 4=straight, 5=heart, 6=Softpetal, 7=wave。缺失/越界/非法返回 `1007` | +| use_mask | bool | 否 | 生发是否启用 inpaint 遮罩,默认 `true`。`false` 时用干净原图生成(空遮罩、不烧模板黑线),供测试对比 | +| prompt | string | 否 | ComfyUI 提示词,默认「填充遮罩区域的头发,人物增加一点磨皮」,会替换工作流节点 60 的文本 | +| normalize_image | bool | 否 | 新增可选开关,默认false:保留原图尺寸与坐标。传true开启扶正和裁切;规则测试页显式传true,对比调试页默认开启 | +| generate_grow_image | bool | 否 | 是否生成生发效果图(ComfyUI 生发,全流程最耗时),默认 `true`。传 `false` 时跳过生发,各发型 `grown_image_*` 恒为 `null`,仅返回设计叠图、中心点与高度规则数据,可大幅降低耗时 | + +> 生发划线与遮罩直接取新位置的 middle 曲线;旧规则的头发边界与模板用于保留形状和倾斜,不决定新中心高度。计算使用未取整的像素高度,与四庭测量采用同一 Y 方向;测量主流程失败时用发际线管线的同编号关键点计算,`measurement_source` 标明来源。目标越出图片或无法对齐时返回 1007,不静默回退旧规则。 + +### 高度规则与对比 + +- **参数与字段**:原有8个请求参数、响应外层结构和所有旧返回字段均保留。`use_mask=true`、默认prompt、`generate_grow_image=true`保持原值。新功能只新增可选参数和返回字段;旧请求未传`normalize_image`时保留原图坐标,high/low按已约定保留null,worker仍返回`*_base64`,网关仍返回对应`*_url`。 +- `height_rule`:`middle_height_px`、`lower_height_px`、`target_upper_height_px`、`target_upper_height_cm`(可空)、`target_center`、`brow_center`、`nose_bottom`、`chin_tip`、`measurement_source`。 +- 每项 `height_placement`:`old_center`、`new_center`、`shift_x_px`、`shift_y_px`(正=下移)、`target_error_px`。浮点坐标保留精度,旧中心点字段继续返回取整像素。 +- `face_measure` 为处理后底图的实际测量值,未被设计目标覆盖。 +- `guide_landmarks`:9、150、152、162、251、389 号 MediaPipe 点的图片像素坐标,供对比页绘制参考框。参考框左/右边分别经过162/389,底边经过9,高度为9→152距离的70%;两侧均固定平行9→152。 +- 规则页:`/static/test_interface52.html`;旧/新规则对比:`/static/test_interface52_compare.html`。 +- 规则页每个发型依次展示未裁切原图、middle、裁切发际线叠加、生发图。“裁切发际线叠加”由页面按`guide_landmarks`生成:在相同处理后底图上叠加矩形内的middle PNG,矩形外保持透明;左/右边过162/389,底边过9,高度0.7×9→152,左右边平行9→152。 +- 对比调试接口:`POST /api/v1/debug/hairline-height-compare`,图片三选一、gender、hair_style;默认只出旧/新曲线,可选 `generate_grow_image=true` 额外验证新规则生发。 +- 测试清单:`tests/fixtures/hairline_height_cases.json`(用户指定30张);批量运行:`./venv/bin/python scripts/compare_hairline_height.py`,默认全部30张、男女各7型,共420组。每人一行,未裁切原图放在最前,后接一张可切换样式的旧规则图和全部新规则样式。可用 `--cases 1,2,3,4,5` 指定部分案例。 + +### 输出(data) + +| 字段 | 类型 | 说明 | +|------|------|------| +| hairline_images | object[] | **选中发型**列表,**数量 = 所选发型数**,元素见下表 | +| best_hairline_center_point | object \| null | **首个选中发型**的 **middle 档**发际线曲线「面部中间点」坐标,当前底图像素:`{ "x": number, "y": number }` | +| high_hairline_center_point | object \| null | 已停用,固定 `null` | +| low_hairline_center_point | object \| null | 已停用,固定 `null` | +| input_image_url | string | 当前底图,发际线叠图、参考点和测量值均使用该图的坐标系 | +| original_image_url | string | EXIF方向修正后的上传原图 | +| width / height | int | 当前底图的实际像素宽 / 高 | +| coordinate_space | string | 开启预处理时为 `processed_image`,关闭时为 `original_image` | +| framing | object \| null | 裁切状态、旋转角、前后尺寸、轴线误差及双向仿射矩阵;关闭预处理时为 `null` | +| height_rule | object | 中庭/下庭、目标上庭高度、目标中心及测量来源,详见“高度规则与对比” | +| guide_landmarks | object | 9、150、152、162、251、389 号点的当前底图像素坐标;参考框实际使用9、152、162、389 | +| face_measure | object \| null | **复用接口1**的四庭七眼测量**数值**(不含标注图)。独立流程,测量失败(无人脸/非正面/分割失败)时为 `null`,不影响发际线主结果。字段结构见下表 | + +`hairline_images` 元素: + +| 字段 | 类型 | 说明 | +|------|------|------| +| hairline_type | string | 发际线类型 key:`ellipse`/`flower`/`heart`/`straight`/`wave`/`bigflower`/`clasicalflower`(female),`ellipse`/`m`/`straight`/`inverse_arc`/`heart`/`Softpetal`/`wave`(male) | +| image_middle_url | string | middle 档发际线曲线**透明 PNG** URL(仅曲线,透明底,**不含人物**,需叠加 `input_image_url` 显示) | +| image_high_url | null | 已停用,保留字段为 `null` | +| image_low_url | null | 已停用,保留字段为 `null` | +| grown_image_url | string \| null | **生发后图片** URL(ComfyUI「植发」效果图,完整人像照片,生发失败或 `generate_grow_image=false` 时为 `null`) | +| order | int | 发型序号(= 传入的 hair_style 值) | +| height_placement | object | 旧/新中心、XY移动距离、目标误差,均为未取整的像素值 | + +> worker 侧返回 `image_middle_base64` / `image_high_base64` / `image_low_base64` / `grown_image_base64`,网关落盘后改写为上表对应的 `*_url`。 +> +> 💡 `image_middle_url` 为透明底 PNG,前端需用绝对定位叠加到 `input_image_url` 上显示(参考[测试页](https://hair.xiangsilian.com/static/test_interface52.html)的 `.img-stack` 叠加结构)。`grown_image_url` 是完整人像照片,直接显示即可。worker返回上述图片的 `*_base64`,网关改写为 `*_url`。 + +`face_measure` 元素(与[接口1](#接口-1四庭七眼测量标注接口)的 `data` 同构,**不含** `annotated_image_*` 标注图字段): + +| 字段 | 类型 | 说明 | +|------|------|------| +| face_total_height_cm | number | 全脸总高度(cm)= 四庭之和 | +| four_courts | object | 四庭数据(顶/上/中/下庭 cm + 占比 ratios),结构同接口1 | +| seven_eyes | object | 七眼数据(眼宽/脸宽/两眼间距 cm + 占比 ratios + eye1~eye7 从左到右 7 段宽度),结构同接口1 | +| landmarks | object | 5 个纵向关键点像素坐标(hair_top/hairline/brow_center/nose_bottom/chin_tip),结构同接口1 | +| hairline_source | string | 发际线来源:`segmentation`(真实分割)/ `estimated`(比例估算)/ `corrected`(发际线贴近头顶、分割不可靠,已按 0.95×中庭反推修正) | +| head_pose | object | 头部姿态角度(yaw/pitch/roll,单位:度) | +| left_position | object | MediaPipe 21 号关键点坐标(左脸定位点),当前底图像素:`{ "x": int, "y": int }` | +| right_position | object | MediaPipe 251 号关键点坐标(右脸定位点,与 21 号镜像),当前底图像素:`{ "x": int, "y": int }` | + +> `eye1`~`eye7` 为从左到右共 7 段宽度,eye1=左耳外段、eye7=右耳外段,某侧耳朵不可见时对应段为 `null`。详见接口1说明。 + +### 网关响应结构示例(normalize_image=false) + +此例未开启裁切,坐标基于原图;`face_measure=null`时仍可按发际线关键点计算目标。网关对接口52的所有空图片字段均返回`*_url: null`。worker直连时图片字段名对应为`*_base64`,图片内容为base64。 + +```json +{ + "code": 0, + "message": "success", + "request_id": "mock-request-id", + "data": { + "hairline_images": [ + { + "hairline_type": "ellipse", + "image_middle_url": "https://hair.xiangsilian.com/static/annotations/mid1.png", + "image_high_url": null, + "image_low_url": null, + "grown_image_url": "https://hair.xiangsilian.com/static/annotations/grown1.jpg", + "order": 1, + "height_placement": { + "old_center": {"x": 540.0, "y": 450.0}, + "new_center": {"x": 540.0, "y": 430.0}, + "shift_x_px": 0.0, "shift_y_px": -20.0, "target_error_px": 0.0 + } + } + ], + "best_hairline_center_point": {"x": 540, "y": 430}, + "high_hairline_center_point": null, + "low_hairline_center_point": null, + "input_image_url": "https://hair.xiangsilian.com/static/annotations/input.jpg", + "original_image_url": "https://hair.xiangsilian.com/static/annotations/original.jpg", + "width": 1080, "height": 1440, + "coordinate_space": "original_image", + "framing": null, + "height_rule": { + "name": "court_average", "formula": "上庭 = (中庭 + 下庭) / 2", + "measurement_source": "hairline_landmarks", + "brow_center": {"x": 540.0, "y": 740.0}, + "nose_bottom": {"x": 540.0, "y": 1050.0}, + "chin_tip": {"x": 540.0, "y": 1360.0}, + "middle_height_px": 310.0, "lower_height_px": 310.0, + "target_upper_height_px": 310.0, "target_upper_height_cm": null, + "target_center": {"x": 540.0, "y": 430.0} + }, + "guide_landmarks": { + "9": {"x": 540.0, "y": 740.0}, "150": {"x": 470.0, "y": 1340.0}, + "152": {"x": 540.0, "y": 1360.0}, "162": {"x": 300.0, "y": 680.0}, + "251": {"x": 770.0, "y": 630.0}, "389": {"x": 780.0, "y": 680.0} + }, + "face_measure": null + } +} +``` + +开启`normalize_image=true`后,`coordinate_space=processed_image`,`framing`包含前述裁切状态和坐标变换,所有发际线图片须叠加到`input_image_url`。原始图片仅用于展示和坐标回溯。 + +--- + ## 接口 9:头发遮罩生成接口 **说明**:输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试)。 @@ -702,6 +854,7 @@ | 3 B 端生发 | `/api/v1/hair/grow-b` | 划线图片 | 检测到的发际线 + 生发后图片 | | 4 用户特征 | `/api/v1/face/features` | 用户照片 | 6 个用户特征字段(脸形/眉形/年龄/动静/性别/基因风格),网关本机实现 | | 5 发际线 PNG | `/api/v1/hairline/generate` | 用户照片 + gender + hair_style(多选) | 每个选中发型 middle/high/low 三档发际线叠图 + 生发图 + 最合适发际线面部中间点坐标 | +| 52 发际线新规则 | `/api/v1/hairline/generate-52` | 用户照片 + gender + hair_style(多选) | 每个选中发型的新规则 middle 设计叠图 + 生发图 + 目标中心坐标;high/low 旧字段为 null | | 9 头发遮罩 | `/api/v1/head/mask` | 用户照片 | 头发遮罩 + 分步可视化(BiSeNet/SegFormer 双路) | | 10 外缘膨胀带 | `/api/v1/head/band` | 用户照片 | 头部外缘带状遮罩 + 分步可视化 | | 11 发际线生发 | `/api/v1/hairline/grow` | 用户照片 + hairline_id | 压低发际线图 + 全步骤可视化(不含重绘) | @@ -716,7 +869,7 @@ - 图片传参:**文件 / URL / base64 三种都支持,严格互斥**(必须且只能传一个,base64 需带 `data:image/...;base64,` 前缀)。 - 默认单人;检测到 2~3 人报错提示客户端;单文件 **≤ 1 MB**;格式仅 **JPG / PNG**。 -- 所有坐标基准:**原图像素**。 +- 坐标基准:默认**原图像素**;接口52开启预处理时使用响应中的**处理后底图像素**。 - 接口 2:提供「美颜」开关 `beauty_enabled`。 - 接口 3:仅传**划线图**(`marked_image_*` 三选一),需返回发际线类型(`custom`)。 - 接口 4:`data` 返回**一个 JSON 字符串**,字段不固定,可随时调整。 diff --git a/gateway/app.py b/gateway/app.py index b96cc37..56d396b 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -208,6 +208,8 @@ async def index(): "if3_hair_grow_b": "/static/test_interface3.html", "if4_features": "/static/test_interface4.html", "if5_hairline": "/static/test_interface5.html", + "if52_hairline": "/static/test_interface52.html", + "if52_compare": "/static/test_interface52_compare.html", "if6_measure_v2": "/static/test_interface6.html", "if7_hair_grow_v2": "/static/test_interface7.html", "if9_head_mask": "/static/test_interface9.html", @@ -591,6 +593,12 @@ async def hairline_generate(request: Request): return await _proxy(request, "/api/v1/hairline/generate") +@app.post("/api/v1/hairline/generate-52", tags=["人脸分析"]) +async def hairline_generate_52(request: Request): + """接口52:新高度规则、扶正裁切与发际线生成""" + return await _proxy(request, "/api/v1/hairline/generate-52") + + @app.post("/api/v1/hair/grow-v2", tags=["生发"]) async def hair_grow_v2(request: Request): """接口7:C端生发 v2(add_hair2 工作流)""" diff --git a/gateway/forward.py b/gateway/forward.py index 2e6aa40..704c35b 100644 --- a/gateway/forward.py +++ b/gateway/forward.py @@ -88,6 +88,8 @@ def rewrite_base64_to_url( obj: Any, public_base_url: str, static_dir: str, + *, + rewrite_null_images: bool = False, ) -> Any: """递归遍历响应 JSON,将所有 *_base64 字段改写为 *_url。 @@ -95,10 +97,16 @@ def rewrite_base64_to_url( - 支持 data: URI 和原始 base64 两种格式 - 解码 base64 → 保存到 static_dir/{uuid}.png - 删除 *_base64 字段,新增 *_url 字段指向公网 URL + - 接口52显式开启 rewrite_null_images,空图片也返回 *_url: null;旧接口保持原空字段 """ if isinstance(obj, dict): new_dict: Dict[str, Any] = {} for key, value in obj.items(): + if rewrite_null_images and key.endswith("_base64") and value is None: + # Deprecated/optional image fields must keep their external + # *_url names even when no image is generated. + new_dict[key[:-7] + "_url"] = None + continue if key.endswith("_base64") and isinstance(value, str): img_bytes = _decode_base64_value(value) if img_bytes is not None: @@ -118,10 +126,12 @@ def rewrite_base64_to_url( logger.warning("字段 %s 的值无法解码为图片,保留", key) new_dict[key] = value else: - new_dict[key] = rewrite_base64_to_url(value, public_base_url, static_dir) + new_dict[key] = rewrite_base64_to_url( + value, public_base_url, static_dir, rewrite_null_images=rewrite_null_images) return new_dict elif isinstance(obj, list): - return [rewrite_base64_to_url(item, public_base_url, static_dir) for item in obj] + return [rewrite_base64_to_url(item, public_base_url, static_dir, + rewrite_null_images=rewrite_null_images) for item in obj] else: return obj @@ -248,7 +258,9 @@ async def proxy_request(request: Request, path: str) -> JSONResponse: ) # 递归改写 base64 图片字段 - rewritten = rewrite_base64_to_url(worker_json, public_base_url, static_dir) + rewritten = rewrite_base64_to_url( + worker_json, public_base_url, static_dir, + rewrite_null_images=path == "/api/v1/hairline/generate-52") return JSONResponse( status_code=resp.status_code, diff --git a/gateway/stub_worker.py b/gateway/stub_worker.py index 8745d6c..3aa05ad 100644 --- a/gateway/stub_worker.py +++ b/gateway/stub_worker.py @@ -7,6 +7,7 @@ - POST /api/v1/hair/grow-b → mock 响应 - POST /api/v1/face/features → mock 响应 - POST /api/v1/hairline/generate → mock 响应 +- POST /api/v1/hairline/generate-52 → 新规则 mock 响应 鉴权:校验 X-Internal-Token 头,不匹配返回 401。 并发模拟:可通过 ?delay=N 让接口 sleep N 秒(默认 1)。 @@ -213,6 +214,7 @@ def create_app(port: int, password: str) -> FastAPI: # --- 接口5 --- @app.post("/api/v1/hairline/generate") + @app.post("/api/v1/hairline/generate-52") async def hairline_generate( request: Request, x_internal_token: Optional[str] = Header(default=None), @@ -245,6 +247,14 @@ def create_app(port: int, password: str) -> FastAPI: ], "best_hairline_center_point": {"x": 540, "y": 430}, } + if request.url.path.endswith("generate-52"): + for item in data["hairline_images"]: + item["image_high_base64"] = None + item["image_low_base64"] = None + data.update(high_hairline_center_point=None, low_hairline_center_point=None, + face_measure=None, framing=None, height_rule=None, + guide_landmarks={}, input_image_base64=_png, original_image_base64=_png, + coordinate_space="original_image", width=1, height=1) return mock_ok(data, delay=delay) return app diff --git a/hair-worker.service b/hair-worker.service index 91f5acb..992959e 100644 --- a/hair-worker.service +++ b/hair-worker.service @@ -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 diff --git a/hairline/face_landmarks.py b/hairline/face_landmarks.py index 04300e0..d592338 100644 --- a/hairline/face_landmarks.py +++ b/hairline/face_landmarks.py @@ -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 diff --git a/hairline/framing.py b/hairline/framing.py new file mode 100644 index 0000000..cbaa50e --- /dev/null +++ b/hairline/framing.py @@ -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 diff --git a/hairline/height_rule.py b/hairline/height_rule.py new file mode 100644 index 0000000..63184ee --- /dev/null +++ b/hairline/height_rule.py @@ -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 diff --git a/hairline/reference/framing_profile.json b/hairline/reference/framing_profile.json new file mode 100644 index 0000000..c9a1c81 --- /dev/null +++ b/hairline/reference/framing_profile.json @@ -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" +} diff --git a/hairline/service.py b/hairline/service.py index 50cd7c0..939263a 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -22,6 +22,7 @@ from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve from .marker_detect import detect_marker_hairline, path_to_curve_mask +from .height_rule import height_target, clean_curve, place_curve, inpaint_from_curve import base64 import io @@ -178,7 +179,7 @@ def extract_502(image_bgr: np.ndarray): return ctx["points"], ctx["valid"] -def extract_context(image_bgr: np.ndarray): +def extract_context(image_bgr: np.ndarray, landmarks: np.ndarray | None = None): """照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。 发际线几何使用射线检测 + 人脸对应点对齐 + 头部轮廓钳制: @@ -189,7 +190,8 @@ def extract_context(image_bgr: np.ndarray): fallback 点不会再跑出头部轮廓。 """ rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) - landmarks = get_landmarker().detect(rgb) + if landmarks is None: + landmarks = get_landmarker().detect(rgb) if landmarks is None: return None parse_map = get_parser().parse(rgb) @@ -538,6 +540,79 @@ def generate_hairline_pngs(image_bgr: np.ndarray, gender: str, return {"images": images, "best_centers": best_centers} +def generate_hairline_pngs_52(image_bgr: np.ndarray, gender: str, + hair_styles: list[int], use_mask: bool = True, + prompt: str | None = None, + generate_grow_image: bool = True, + height_reference: dict | None = None, + compare_rules: bool = False, + precomputed_landmarks: np.ndarray | None = None): + """接口52:每种 middle 曲线中点对齐到眉心上方 (中庭+下庭)/2。 + + 只读取 middle 贴图,high/low 保留空值。生发直接复用移动后的预览曲线, + 不再使用位置不同的黑模板。compare_rules 仅为调试页附带旧规则叠图。 + 接口2仍使用原有管线。 + """ + if gender not in ("male", "female"): + raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") + if not hair_styles: + raise ValueError("hair_styles 必填且不能为空") + ctx = extract_context(image_bgr, landmarks=precomputed_landmarks) + if ctx is None: + return None + h, w = image_bgr.shape[:2] + uv, ext_faces = load_ext_mesh() + rule = height_target(ctx["landmarks"], w, h, height_reference) + textures = get_texture_map()[gender] + + # use_mask=False:干净原图+空遮罩与贴图无关,只跑一次 ComfyUI,选中项复用 + # generate_grow_image=False:完全跳过生发(最耗时),grown_png 恒为 None + shared_grown = None + if generate_grow_image and not use_mask: + shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt) + + images, best_centers = [], None + for s in hair_styles: # s = 1-indexed 发型序号 + key, mid_path = textures[s - 1] + old = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, load_texture_rgba(mid_path)) + overlay, placement = place_curve(old, rule["target_center"], ctx["landmarks"]) + overlays = {"middle": overlay, "high": None, "low": None} + if not generate_grow_image: + grown_png = None + elif not use_mask: + grown_png = shared_grown + else: + grown_png = None + try: + marked, mask = inpaint_from_curve( + image_bgr, ctx["landmarks"], ctx["parse_map"], overlay) + buf = io.BytesIO() + compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1) + grown_png = comfyui.run(buf.getvalue(), prompt=prompt) + except Exception as ex: # 单张生发失败不丢弃设计曲线 + logger.warning("接口52 生发失败 type=%s:%s", key, ex) + # Flux may round dimensions to a multiple of its latent stride. + # Restore the input size so the preview and generated photo align. + if grown_png: + grown_png = _upscale_png_to(grown_png, w, h) + item = {"hairline_type": key, "order": s, "overlays": overlays, + "grown_png": grown_png, "height_placement": placement} + if compare_rules: + item["legacy_overlay"] = clean_curve(old) + images.append(item) + if best_centers is None: + center = placement["new_center"] + best_centers = {"middle": (round(center["x"]), round(center["y"])), + "high": None, "low": None} + guide_landmarks = { + str(index): {"x": float(ctx["landmarks"][index, 0] * w), + "y": float(ctx["landmarks"][index, 1] * h)} + for index in (9, 150, 152, 162, 251, 389) + } + return {"images": images, "best_centers": best_centers, "height_rule": rule, + "guide_landmarks": guide_landmarks} + + # 接口3 送 ComfyUI 前限边,降低峰值显存,避免与接口2 切换时把 Flux 挤出。 # 统一 prompt 后 Flux 不再被 CLIP 挤出,接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。 _GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024")) diff --git a/ip_access.example.json b/ip_access.example.json new file mode 100644 index 0000000..bfc93a7 --- /dev/null +++ b/ip_access.example.json @@ -0,0 +1,3 @@ +{ + "allowed_ips": ["49.232.51.171"] +} diff --git a/ip_access.py b/ip_access.py new file mode 100644 index 0000000..ddae89f --- /dev/null +++ b/ip_access.py @@ -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}) diff --git a/run_worker.sh b/run_worker.sh index b2a0bd8..e91f538 100755 --- a/run_worker.sh +++ b/run_worker.sh @@ -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 diff --git a/scripts/calibrate_hairline_framing.py b/scripts/calibrate_hairline_framing.py new file mode 100644 index 0000000..cc53d45 --- /dev/null +++ b/scripts/calibrate_hairline_framing.py @@ -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)) diff --git a/scripts/compare_hairline_height.py b/scripts/compare_hairline_height.py new file mode 100644 index 0000000..ee314d9 --- /dev/null +++ b/scripts/compare_hairline_height.py @@ -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()) diff --git a/static/framing_reference.png b/static/framing_reference.png new file mode 100644 index 0000000..4b47a83 Binary files /dev/null and b/static/framing_reference.png differ diff --git a/static/hairline_height_compare.css b/static/hairline_height_compare.css new file mode 100644 index 0000000..570a3a7 --- /dev/null +++ b/static/hairline_height_compare.css @@ -0,0 +1,33 @@ +:root{font-family:Inter,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;color:#1c2e33;background:#f2f5f4;font-synthesis:none;--teal:#087f83;--amber:#dd7d25;--muted:#64767b;--line:#dce4e2} +*{box-sizing:border-box}body{margin:0}main{max-width:1420px;padding:36px 32px 30px;margin:auto}button,input,select{font:inherit}button,a,select,input{touch-action:manipulation}button{cursor:pointer}a{color:inherit;text-decoration:none}h1,h2,h3,p{margin:0}h1{font-size:34px;line-height:1.4;font-weight:700;letter-spacing:-1px}h2{font-size:23px;line-height:1.5}p{color:var(--muted);font-size:14px;line-height:1.8}.eyebrow{font-size:12px;font-weight:700;letter-spacing:2px;color:var(--teal);margin-bottom:10px}.page-header{display:flex;justify-content:space-between;align-items:center;gap:24px;margin-bottom:28px}.page-header p{margin-top:10px}.button{border:1px solid var(--line);padding:11px 18px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;white-space:nowrap;background:white}.button:hover{border-color:#95b9b4}.button.primary{background:var(--teal);color:white;border-color:var(--teal)}.button:disabled{opacity:.55;cursor:wait}.rule-card{background:#e4f0eb;border:1px solid #c5ddd2;border-radius:13px;padding:24px 26px;display:flex;justify-content:space-between;gap:24px;align-items:center}.pill{font-size:11px;background:#c9e4d6;color:#205d49;border-radius:5px;display:inline-block;padding:3px 8px;margin-bottom:10px;font-weight:700}.rule-card h2{font-size:25px;color:#234e41}.rule-card p{margin-top:8px;color:#4d6f63}.rule-note{font-size:12px;color:#476259;line-height:2.1;flex-shrink:0}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;vertical-align:middle;margin-right:7px}.dot.old{background:var(--amber)}.dot.new{background:var(--teal)}.dash{display:inline-block;width:18px;border-top:2px dashed #6b7780;margin-right:7px;vertical-align:middle}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:20px 0}.stats>div{background:white;border:1px solid var(--line);border-radius:10px;padding:17px 20px}.stats span,.stats small{display:block;font-size:12px;color:var(--muted)}.stats strong{display:block;font-size:27px;font-weight:650;line-height:1.7;font-variant-numeric:tabular-nums}.stats small{font-size:11px}.toolbar{display:flex;gap:15px;flex-wrap:wrap;align-items:end;background:white;border:1px solid var(--line);border-radius:10px;padding:16px 20px;position:sticky;top:0;z-index:3;box-shadow:0 4px 12px #173b2e05}.toolbar label,.custom-form label{display:flex;flex-direction:column;gap:7px;font-size:11px;color:var(--muted)}select{background:#fff;border:1px solid #cfdad7;border-radius:6px;padding:9px 30px 9px 10px;color:#263a3d;font-size:13px;min-height:37px;max-width:230px}.toolbar .check{flex-direction:row;align-items:center;font-size:12px;height:38px;gap:5px;white-space:nowrap}.check input{accent-color:var(--teal)}.segmented{display:flex;background:#f0f4f2;border-radius:7px;padding:3px}.segmented button{border:none;background:none;color:var(--muted);font-size:12px;padding:9px 12px;border-radius:5px}.segmented .active{background:white;color:var(--teal);box-shadow:0 1px 4px #153d2520;font-weight:700}.status{min-height:24px;margin:15px 0 10px;font-size:12px}.status.error{color:#b64831}.results{display:flex;flex-direction:column;gap:24px}.case-card{background:white;border:1px solid var(--line);border-radius:12px;overflow:hidden}.case-header{display:flex;justify-content:space-between;align-items:start;gap:12px;padding:18px 20px 12px}.case-header h3{font-size:16px;line-height:1.5}.case-header p{font-size:11px;margin-top:5px}.case-number{display:inline-block;font-size:10px;letter-spacing:.6px;color:var(--muted);margin-bottom:5px}.badge{font-size:11px;border:1px solid #cfe3da;color:#377252;background:#f1f8f4;padding:5px 8px;border-radius:5px;white-space:nowrap}.badge.warn{color:#a14f28;border-color:#f0d7c9;background:#fff5ec}.metric-line{display:flex;gap:12px;flex-wrap:wrap;padding:0 20px 14px;font-size:11px;color:#627175}.metric-line b{font-weight:600;color:#334e52}.image-pair{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:#edf1ef}.image-pair.overlay{grid-template-columns:1fr}.image-pair figure{margin:0;background:#f6f8f7;min-width:0}.image-pair figcaption{padding:8px 13px;background:#fbfcfb;font-size:11px;font-weight:600}.image-pair figcaption.old{color:#a85c18}.image-pair figcaption.new{color:var(--teal)}canvas{display:block;width:100%;height:auto;min-height:100px}.case-bottom{padding:12px 20px;display:flex;justify-content:space-between;font-size:11px;color:#657878;border-top:1px solid #edf1ef}.case-bottom a{text-decoration:underline;text-underline-offset:3px}.error-card{padding:24px;background:#fff5ef;color:#a14f28;border:1px solid #edcfc0;border-radius:10px}.tools-card{background:white;padding:24px;border:1px solid var(--line);border-radius:12px;margin-top:26px}.tools-heading{display:flex;justify-content:space-between;align-items:center;gap:20px}.tools-heading h2{font-size:19px}.tools-heading p{margin-top:5px;font-size:12px}.custom-form{display:flex;align-items:end;gap:16px;flex-wrap:wrap;margin-top:20px;padding-top:20px;border-top:1px solid var(--line)}.custom-form input[type=file]{font-size:13px;color:#314a4a;max-width:100%}.footnote{font-size:11px;margin-top:20px;line-height:1.8}footer{color:#74877e;font-size:11px;text-align:center;padding:28px 0 0;line-height:2}.empty{padding:40px;text-align:center;grid-column:1/-1;color:var(--muted);border:1px dashed #b9cfc4;border-radius:12px}button:focus-visible,a:focus-visible,select:focus-visible,input:focus-visible{outline:3px solid #82bfb5;outline-offset:3px} +@media(min-width:1500px){.results{gap:24px}}@media(max-width:1050px){.results{grid-template-columns:1fr}.rule-card{align-items:start}.rule-note{font-size:11px}.stats>div{padding:14px}.toolbar{position:static}}@media(max-width:650px){main{padding:22px 14px}.page-header{align-items:start;flex-direction:column;gap:14px}h1{font-size:26px}.rule-card{padding:18px;flex-direction:column;gap:13px}.rule-card h2{font-size:20px}.stats{grid-template-columns:repeat(2,1fr);gap:9px}.stats strong{font-size:24px}.toolbar{gap:10px;padding:13px}.toolbar select{max-width:175px}.tools-heading{align-items:start;flex-direction:column}.custom-form{align-items:stretch;flex-direction:column}.custom-form select{max-width:100%}.case-header{padding:15px}.metric-line{padding:0 15px 12px;gap:8px}.case-bottom{padding:12px 15px;gap:10px;flex-wrap:wrap}.rule-note{line-height:1.9}.status{font-size:11px}} +.case-group-heading{grid-column:1/-1;font-size:20px;margin-top:12px;padding:12px 0 4px;border-top:1px solid var(--line);color:#284a43} +.case-group-heading:first-child{margin-top:0;border-top:0;padding-top:0} + +.person-row{min-width:0;background:#eaf0ed;border:1px solid var(--line);border-radius:12px;padding:16px} +.person-row-header{display:flex;justify-content:space-between;align-items:center;gap:14px;margin-bottom:14px;flex-wrap:wrap} +.person-row-header .case-group-heading{border:0;margin:0;padding:0;font-size:18px} +.row-controls{display:flex;align-items:center;gap:8px}.row-controls span{font-size:11px;color:var(--muted);margin-right:6px} +.row-controls button{width:34px;height:32px;border:1px solid #c8d9d1;background:#fff;color:var(--teal);border-radius:6px} +.row-controls button:disabled{opacity:.3;cursor:default} +.person-styles{display:flex;align-items:stretch;gap:14px;overflow-x:auto;overflow-y:hidden;scroll-snap-type:x proximity;overscroll-behavior-x:contain;scrollbar-color:#8ba99b #dce7e0;scrollbar-width:auto;padding-bottom:14px} +.person-styles>.case-card{flex:0 0 min(440px,100%);width:min(440px,100%);scroll-snap-align:start} +@media(max-width:650px){.person-row{padding:12px}.person-row-header .case-group-heading{font-size:16px}.row-controls span{font-size:10px}} + +.person-row{--comparison-card-width:min(300px,calc((100vw - 80px)/2))} +.person-body{display:flex;gap:14px;align-items:flex-start;min-width:0} +.old-reference-card{flex:0 0 var(--comparison-card-width);width:var(--comparison-card-width);border:1px solid #e7d2b6;background:white;border-radius:12px;overflow:hidden} +.old-reference-card .case-header{min-height:110px}.old-reference-label{color:#a85c18;font-weight:650} +.person-body>.person-styles{flex:1;min-width:0} +.person-styles>.case-card{flex:0 0 var(--comparison-card-width);width:var(--comparison-card-width);cursor:pointer} +.person-styles>.case-card .case-header{min-height:110px} +.case-card.selected{border-color:#178d80;box-shadow:inset 0 0 0 1px #178d80} +.select-style{border:0;background:#edf7f2;color:#187164;padding:4px 7px;border-radius:4px;font-size:10px;white-space:nowrap} +.old-reference-card .case-bottom{font-size:10px;line-height:1.6} +.person-styles>.original-reference-card{flex:0 0 var(--comparison-card-width);width:var(--comparison-card-width);border:1px solid #bdccca;background:white;border-radius:12px;overflow:hidden;scroll-snap-align:start} +.original-reference-card .case-header{min-height:110px}.original-reference-label{color:#456267;font-weight:650}.original-reference-card img{display:block;width:100%;height:auto}.original-reference-card .case-bottom{font-size:10px;line-height:1.6} +.person-styles>.old-reference-card{position:sticky;left:0;z-index:1;scroll-snap-align:start} +.failed-reference-card{flex:0 0 var(--comparison-card-width);width:var(--comparison-card-width);padding:20px;border:1px solid #edcfc0;background:#fff5ef;color:#a14f28;border-radius:12px;scroll-snap-align:start}.failed-reference-card h3{font-size:15px;line-height:1.6}.failed-reference-card p{font-size:12px;color:#975a40;margin-top:12px;overflow-wrap:anywhere} +@media(max-width:650px){.person-body{gap:10px}.person-styles{gap:10px}.case-header{padding:12px}.case-header h3{font-size:14px}.metric-line{font-size:10px;padding-left:12px;padding-right:12px}.case-bottom{padding:10px 12px;flex-wrap:wrap}.person-row{--comparison-card-width:calc((100vw - 80px)/2)}} + +.framing-standard{margin-top:16px;padding:15px 20px;border:1px solid var(--line);background:white;border-radius:10px;color:#315c4e;font-size:13px}.framing-standard summary{cursor:pointer;font-weight:600}#framingStandard{display:flex;align-items:start;gap:24px;margin-top:16px}#framingStandard img{width:220px;max-width:40%;height:auto;border-radius:8px}#framingStandard strong{display:block;margin-bottom:10px}#framingStandard p{font-size:12px}.framing-info{font-size:11px;color:#45685a;line-height:1.9;margin:-4px 0 14px}.framing-info a{text-decoration:underline;margin-left:10px;white-space:nowrap}@media(max-width:650px){#framingStandard{flex-direction:column}#framingStandard img{max-width:100%;width:220px}} diff --git a/static/hairline_height_compare.js b/static/hairline_height_compare.js new file mode 100644 index 0000000..ebcae89 --- /dev/null +++ b/static/hairline_height_compare.js @@ -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=`
处理前完整图片
左侧保留完整上传原图。
'; + 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=`与右侧选中的形状对应
目标上庭 (${fixed(rule.middle_height_px)} + ${fixed(rule.lower_height_px)}) ÷ 2 = ${fixed(rule.target_upper_height_px)} px
参考图9号点 (${fixed(p.brow_9.x)}, ${fixed(p.brow_9.y)})
参考图152号点 (${fixed(p.chin_152.x)}, ${fixed(p.chin_152.y)})
两点X相同;旋转 ${fixed(p.rotation_degrees,3)}°
当前裁切:高约 ${Number(fixed(p.height_per_face_length,4))}×L,宽 ${Number(fixed(p.width_per_face_length,4))}×LL 为9号点到152号点的像素距离。
眉心位置:横向 ${fixed(p.brow_x_ratio*100,2)}%,纵向 ${fixed(p.brow_y_ratio*100,2)}%
用户图先扶正,有空间时按上述尺寸裁切;长图保留人脸裁为3:4,其余空间不足时仅扶正。保留实际像素,不放大图片。
/api/v1/hairline/generate-52中下庭均值定位 middle,支持9→152扶正、人像裁切和矩形发际线预览。接口5保留原有三档实现;新功能使用此独立接口。
+基础图片参数、gender、hair_style、use_mask、prompt、generate_grow_image沿用接口5。新增可选normalize_image,默认false,传true开启扶正与裁切;测试页显式开启。
返回height_rule、framing、guide_landmarks、原图及处理后底图。middle和坐标使用当前底图,high/low字段为null。标准裁切高约3.3333L、宽2.5L;长图在保留人脸的前提下裁为3:4。
/api/v1/face/measure-v2 v2diff --git a/static/test_interface52.html b/static/test_interface52.html new file mode 100644 index 0000000..e56ff29 --- /dev/null +++ b/static/test_interface52.html @@ -0,0 +1,418 @@ + + +
+ + +上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。
+ + +用同一张照片、同一个形状,查看旧规则与中下庭均值规则的位置差异。
从眉心向上定位,所有形状的中点对齐同一高度。各发型保留自身轮廓,叠加在扶正后的底图上。
正在加载30个案例的对比结果…
+ +上传新图片,或重新运行清单全部30张。每人先显示未裁切原图,再显示旧、新规则;对比只生成定位曲线,无需等待生发。
high、low 已停用;接口旧字段保留为 null。当前页的“旧规则”是本次高度修改前的定位逻辑。旧、新规则使用同一张处理后底图。
+新规则对比已迁移至接口52:打开接口52对比页
diff --git a/tests/fixtures/hairline_height_cases.json b/tests/fixtures/hairline_height_cases.json new file mode 100644 index 0000000..a5423e1 --- /dev/null +++ b/tests/fixtures/hairline_height_cases.json @@ -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"} + ] +} diff --git a/tests/test_api.py b/tests/test_api.py index f531688..06c7b8a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ H = {"X-Internal-Token": "testpass"} @pytest.fixture(scope="module") def client(): # with 触发 lifespan:加载模型单例(detector + 尽力加载 segmenter) - with TestClient(app_module.app) as c: + with TestClient(app_module.app, client=("127.0.0.1", 50000)) as c: yield c @@ -189,6 +189,40 @@ def test_growb_success(client, monkeypatch): HLGEN = "/api/v1/hairline/generate" +def test_hairline5_restored_schema_has_only_historical_parameters(client): + schema = client.get("/openapi.json", headers=H).json() + ref = schema["paths"][HLGEN]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"]["$ref"] + fields = schema["components"]["schemas"][ref.rsplit("/", 1)[-1]]["properties"] + assert set(fields) == {"image_file", "image_url", "image_base64", "gender", "hair_style", + "use_mask", "prompt", "generate_grow_image"} + assert fields["use_mask"]["default"] is True + assert fields["generate_grow_image"]["default"] is True + assert fields["prompt"]["default"] == "填充遮罩区域的头发,人物增加一点磨皮" + + +@pytest.mark.parametrize("gender", ["female", "male"]) +def test_hairline5_restored_three_levels_and_original_coordinate_contract(client, gender): + from pathlib import Path + photo = Path(fixture("frontal.jpg")).read_bytes() + body = client.post(HLGEN, headers=H, files={"image_file": ("frontal.jpg", photo, "image/jpeg")}, + data={"gender": gender, "hair_style": "1,3", "generate_grow_image": "false"}).json() + assert body["code"] == 0, body + data = body["data"] + assert set(data) == {"hairline_images", "best_hairline_center_point", "high_hairline_center_point", + "low_hairline_center_point", "face_measure"} + expected = _cv2.imdecode(_np.frombuffer(photo, _np.uint8), _cv2.IMREAD_COLOR).shape[:2] + for key in ("best_hairline_center_point", "high_hairline_center_point", "low_hairline_center_point"): + assert set(data[key]) == {"x", "y"} + assert isinstance(data[key]["x"], int) and isinstance(data[key]["y"], int) + for item in data["hairline_images"]: + assert set(item) == {"hairline_type", "image_middle_base64", "image_high_base64", "image_low_base64", + "grown_image_base64", "order"} + assert item["grown_image_base64"] is None + for level in ("middle", "high", "low"): + image = _cv2.imdecode(_np.frombuffer(base64.b64decode(item[f"image_{level}_base64"]), _np.uint8), _cv2.IMREAD_UNCHANGED) + assert image.shape == (*expected, 4) + + def test_hairline_gen_missing_gender_1004(client): files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} assert client.post(HLGEN, headers=H, files=files).json()["code"] == 1004 @@ -241,6 +275,181 @@ def test_hairline_gen_female(client, monkeypatch): assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys()) +HLGEN52 = "/api/v1/hairline/generate-52" + + +def test_hairline52_legacy_form_parameters_and_defaults_are_preserved(client): + schema = client.get("/openapi.json", headers=H).json() + body = schema["paths"][HLGEN52]["post"]["requestBody"]["content"]["multipart/form-data"]["schema"] + body = schema["components"]["schemas"][body["$ref"].rsplit("/", 1)[-1]] + fields = body["properties"] + legacy = {"image_file", "image_url", "image_base64", "gender", "hair_style", + "use_mask", "prompt", "generate_grow_image"} + assert legacy.issubset(fields) + assert not set(body.get("required", [])) - legacy + for key in ("image_file", "image_url", "image_base64", "gender", "hair_style"): + assert any(choice.get("type") == "string" for choice in fields[key].get("anyOf", [fields[key]])) + assert fields["use_mask"]["type"] == "boolean" and fields["use_mask"]["default"] is True + assert fields["generate_grow_image"]["type"] == "boolean" and fields["generate_grow_image"]["default"] is True + assert fields["prompt"]["default"] == "填充遮罩区域的头发,人物增加一点磨皮" + assert fields["normalize_image"]["default"] is False + + +@pytest.mark.parametrize("image_kind", ["file", "base64", "url"]) +def test_hairline52_legacy_request_keeps_fields_and_original_coordinates(client, monkeypatch, image_kind): + import contextlib + from pathlib import Path + from types import SimpleNamespace + import httpx + import hairline.comfyui as comfy + + raw = Path(fixture("frontal.jpg")).read_bytes() + calls = [] + def generate(*args, **kwargs): + calls.append(kwargs) + return _PNG_1x1 + monkeypatch.setattr(comfy, "run", generate) + form = {"gender": "male", "hair_style": "1", "use_mask": "false", "prompt": "legacy prompt"} + kwargs = {"headers": H, "data": form} + if image_kind == "file": + kwargs["files"] = {"image_file": ("frontal.jpg", raw, "image/jpeg")} + elif image_kind == "base64": + form["image_base64"] = "data:image/jpeg;base64," + base64.b64encode(raw).decode() + else: + url = "https://legacy.example.test/frontal.jpg" + form["image_url"] = url + response = httpx.Response(200, content=raw, request=httpx.Request("GET", url)) + def get_image(actual_url): + assert actual_url == url + return response + monkeypatch.setattr(httpx, "Client", lambda **kw: contextlib.nullcontext(SimpleNamespace(get=get_image))) + # No new parameters: old clients keep their original-size overlays. + body = client.post(HLGEN52, **kwargs).json() + assert body["code"] == 0, body + assert {"code", "message", "request_id", "data"}.issubset(body) + data = body["data"] + assert {"hairline_images", "best_hairline_center_point", "high_hairline_center_point", + "low_hairline_center_point", "face_measure"}.issubset(data) + assert data["coordinate_space"] == "original_image" and data["framing"] is None + assert data["input_image_base64"] == data["original_image_base64"] + original = _cv2.imdecode(_np.frombuffer(raw, _np.uint8), _cv2.IMREAD_COLOR) + expected = original.shape[:2] + assert (data["height"], data["width"]) == expected + item = data["hairline_images"][0] + assert {"hairline_type", "image_middle_base64", "image_high_base64", "image_low_base64", + "grown_image_base64", "order"}.issubset(item) + assert item["image_high_base64"] is None and item["image_low_base64"] is None + assert data["high_hairline_center_point"] is None and data["low_hairline_center_point"] is None + for key in ("image_middle_base64", "grown_image_base64"): + image = _cv2.imdecode(_np.frombuffer(base64.b64decode(item[key]), _np.uint8), _cv2.IMREAD_UNCHANGED) + assert image.shape[:2] == expected + assert len(calls) == 1 and calls[0]["prompt"] == "legacy prompt" + + +@pytest.mark.parametrize("normalize_image", [True, False]) +def test_hairline52_height_compare_uses_same_new_rule(client, monkeypatch, normalize_image): + import hairline.comfyui as comfy + def unexpected_generation(*args, **kwargs): + pytest.fail("Position comparison must not invoke ComfyUI by default") + monkeypatch.setattr(comfy, "run", unexpected_generation) + with open(fixture("frontal.jpg"), "rb") as photo: + response = client.post("/api/v1/debug/hairline-height-compare", + files={"image_file": ("frontal.jpg", photo, "image/jpeg")}, + data={"gender": "male", "hair_style": "1,3", "normalize_image": str(normalize_image).lower()}) + data = response.json() + assert data["code"] == 0, data + data = data["data"] + assert [r["order"] for r in data["results"]] == [1, 3] + assert set(data["guide_landmarks"]) == {"9", "150", "152", "162", "251", "389"} + assert data["guide_landmarks"]["9"]["y"] < data["guide_landmarks"]["152"]["y"] + expected_size = (data["height"], data["width"]) + base = _cv2.imdecode(_np.frombuffer(base64.b64decode(data["input_base64"]), _np.uint8), _cv2.IMREAD_COLOR) + assert base.shape[:2] == expected_size + assert data["coordinate_space"] == ("processed_image" if normalize_image else "original_image") + if normalize_image: + assert data["framing"]["enabled"] is True + assert data["framing"]["axis_x_error_px"] < 1e-8 + assert data["guide_landmarks"]["9"]["x"] == pytest.approx(data["guide_landmarks"]["152"]["x"], abs=1e-8) + else: + assert data["framing"] is None + original = _cv2.imread(fixture("frontal.jpg")) + assert base.shape == original.shape + for result in data["results"]: + assert result["height_placement"]["target_error_px"] <= 1 + assert result["grown_image_base64"] is None + for key in ("old_overlay_base64", "new_overlay_base64"): + assert base64.b64decode(result[key])[:8] == b"\x89PNG\r\n\x1a\n" + overlay = _cv2.imdecode(_np.frombuffer(base64.b64decode(result[key]), _np.uint8), _cv2.IMREAD_UNCHANGED) + assert overlay.shape[:2] == expected_size + + +def test_hairline52_gen_missing_gender_1004(client): + files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} + assert client.post(HLGEN52, headers=H, files=files).json()["code"] == 1004 + + +def test_hairline52_gen_missing_hairstyle_1007(client): + # hair_style 已改为必填(同接口2):只给 gender、不给 hair_style → 1007 + files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} + assert client.post(HLGEN52, headers=H, files=files, data={"gender": "female"}).json()["code"] == 1007 + + +def test_hairline52_gen_female(client, monkeypatch): + # mock ComfyUI:验证处理后底图、发际线和生发图使用相同尺寸与坐标。 + import hairline.comfyui as comfy + monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1) + + files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} + body = client.post(HLGEN52, headers=H, files=files, + data={"gender": "female", "hair_style": "1,3", "normalize_image": "true"}).json() + assert body["code"] == 0, body + d = body["data"] + assert d["coordinate_space"] == "processed_image" + assert d["framing"]["enabled"] is True + assert d["framing"]["axis_x_error_px"] < 1e-8 + expected_size = (d["height"], d["width"]) + for value in (d["input_image_base64"], d["hairline_images"][0]["image_middle_base64"], + d["hairline_images"][0]["grown_image_base64"]): + image = _cv2.imdecode(_np.frombuffer(base64.b64decode(value), _np.uint8), _cv2.IMREAD_UNCHANGED) + assert image.shape[:2] == expected_size + imgs = d["hairline_images"] + # 只返回选中发型,order = 发型序号 + assert [x["order"] for x in imgs] == [1, 3] + assert [x["hairline_type"] for x in imgs] == ["ellipse", "heart"] + # Only the designed middle curve is generated; deprecated fields stay null. + assert base64.b64decode(imgs[0]["image_middle_base64"])[:8] == b"\x89PNG\r\n\x1a\n" + assert all(x["image_high_base64"] is None and x["image_low_base64"] is None for x in imgs) + assert d["high_hairline_center_point"] is None and d["low_hairline_center_point"] is None + rule = d["height_rule"] + assert rule["target_upper_height_px"] == pytest.approx( + (rule["middle_height_px"] + rule["lower_height_px"]) / 2) + for x in imgs: + assert x["height_placement"]["new_center"]["y"] == pytest.approx(rule["target_center"]["y"], abs=1) + assert base64.b64decode(imgs[0]["grown_image_base64"])[:3] == b"\xff\xd8\xff" + assert "image_middle_url" not in imgs[0] + c = d["best_hairline_center_point"] + assert 0 <= c["x"] < d["width"] and 0 <= c["y"] < d["height"] + # face_measure:复用接口1测量数值(容错,失败为 null;本用例正常 → 必须有完整结构) + fm = d["face_measure"] + assert fm is not None, "face_measure 不应为 null(正常正面照)" + assert set(["face_total_height_cm", "four_courts", "seven_eyes", + "landmarks", "hairline_source", "head_pose"]).issubset(fm.keys()) + # 不应含标注图字段(接口52 只要数值,不要画线图) + assert "annotated_image_base64" not in fm + assert "annotated_image_url" not in fm + # 子结构 + assert set(["top_court_cm", "upper_court_cm", "middle_court_cm", + "lower_court_cm", "ratios"]).issubset(fm["four_courts"].keys()) + assert set(["eye_width_cm", "face_width_cm", "inter_eye_distance_cm", + "ratios"]).issubset(fm["seven_eyes"].keys()) + # 七眼 eye1~eye7 键必须存在(eye1/eye7 在耳朵不可见时可为 null) + assert set([f"eye{i}" for i in range(1, 8)]).issubset(fm["seven_eyes"].keys()) + assert set(["hair_top", "hairline", "brow_center", + "nose_bottom", "chin_tip"]).issubset(fm["landmarks"].keys()) + assert fm["hairline_source"] in ("segmentation", "estimated", "corrected") + assert set(["yaw", "pitch", "roll"]).issubset(fm["head_pose"].keys()) + + # 接口4(用户特征)已迁到网关本机实现(直接调豆包),不再在 worker; # 其测试随实现一起在网关侧做,worker 这边不再覆盖。 diff --git a/tests/test_framing.py b/tests/test_framing.py new file mode 100644 index 0000000..882c57b --- /dev/null +++ b/tests/test_framing.py @@ -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) diff --git a/tests/test_gateway_hairline_routes.py b/tests/test_gateway_hairline_routes.py new file mode 100644 index 0000000..96b8986 --- /dev/null +++ b/tests/test_gateway_hairline_routes.py @@ -0,0 +1,27 @@ +"""The legacy interface 5 and new interface 52 forward to separate worker paths.""" +import asyncio +import importlib + +import pytest +from starlette.requests import Request + + +@pytest.mark.parametrize("name,path", [ + ("hairline_generate", "/api/v1/hairline/generate"), + ("hairline_generate_52", "/api/v1/hairline/generate-52"), +]) +def test_distinct_hairline_forwarding(monkeypatch, name, path): + gateway = importlib.import_module("gateway.app") + request = Request({"type": "http", "method": "POST", "path": path, "headers": []}) + calls = [] + + async def proxy(actual_request, actual_path): + assert actual_request is request + calls.append(actual_path) + return {"forwarded_to": actual_path} + + monkeypatch.setattr(gateway, "_proxy", proxy) + endpoint = getattr(gateway, name) + assert next(route for route in gateway.app.routes if getattr(route, "path", None) == path).endpoint is endpoint + assert asyncio.run(endpoint(request)) == {"forwarded_to": path} + assert calls == [path] diff --git a/tests/test_gateway_nullable_images.py b/tests/test_gateway_nullable_images.py new file mode 100644 index 0000000..5493c3f --- /dev/null +++ b/tests/test_gateway_nullable_images.py @@ -0,0 +1,69 @@ +"""Nullable/deprecated worker images retain their documented gateway keys.""" +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from starlette.requests import Request + +from gateway.forward import rewrite_base64_to_url + + +def test_nullable_image_fields_keep_external_names(tmp_path): + value = {"hairline_images": [{"image_high_base64": None, "image_low_base64": None, + "grown_image_base64": None}], + "high_hairline_center_point": None, "face_measure": None} + actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path), rewrite_null_images=True) + assert actual == {"hairline_images": [{"image_high_url": None, "image_low_url": None, + "grown_image_url": None}], + "high_hairline_center_point": None, "face_measure": None} + assert not list(tmp_path.iterdir()) + + +def test_nonempty_images_still_rewrite_alongside_null_fields(tmp_path): + import base64 + import io + from PIL import Image + buf = io.BytesIO() + Image.new("RGBA", (2, 2), (255, 255, 255, 0)).save(buf, format="PNG") + value = {"image_middle_base64": base64.b64encode(buf.getvalue()).decode(), + "image_high_base64": None} + actual = rewrite_base64_to_url(value, "https://example.test", str(tmp_path), rewrite_null_images=True) + assert actual["image_middle_url"].startswith("https://example.test/static/annotations/") + assert actual["image_high_url"] is None + assert len(list(tmp_path.iterdir())) == 1 + + +def test_legacy_nullable_image_field_names_remain_unchanged(tmp_path): + value = {"hairline_images": [{"grown_image_base64": None}], "face_measure": None} + assert rewrite_base64_to_url(value, "https://example.test", str(tmp_path)) == value + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("path,expected_key", [ + ("/api/v1/hairline/generate", "grown_image_base64"), + ("/api/v1/hairline/generate-52", "grown_image_url"), +]) +def test_proxy_rewrites_null_images_only_for_interface52(tmp_path, monkeypatch, path, expected_key): + import gateway.config as config + import gateway.forward as forward + + monkeypatch.setattr(config, "get_config", lambda: { + "dispatch": {"request_timeout_seconds": 10}, "shared_password": "test-token", + "public_base_url": "https://example.test", "static_dir": str(tmp_path), + }) + monkeypatch.setattr(forward, "acquire_worker", AsyncMock(return_value=SimpleNamespace(url="http://worker"))) + monkeypatch.setattr(forward, "release_worker", AsyncMock()) + worker_response = httpx.Response(200, json={"code": 0, "data": {"hairline_images": [ + {"grown_image_base64": None}]}}) + monkeypatch.setattr(forward, "get_client", lambda: SimpleNamespace(request=AsyncMock(return_value=worker_response))) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + request = Request({"type": "http", "method": "POST", "path": path, "headers": []}, receive) + response = asyncio.run(forward.proxy_request(request, path)) + assert response.status_code == 200 + assert json.loads(response.body)["data"]["hairline_images"] == [{expected_key: None}] diff --git a/tests/test_hairline_height.py b/tests/test_hairline_height.py new file mode 100644 index 0000000..bdd91e2 --- /dev/null +++ b/tests/test_hairline_height.py @@ -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()) diff --git a/tests/test_ip_access.py b/tests/test_ip_access.py new file mode 100644 index 0000000..9ddad1a --- /dev/null +++ b/tests/test_ip_access.py @@ -0,0 +1,101 @@ +import json +from ipaddress import ip_address + +import pytest +from fastapi.testclient import TestClient +from starlette.requests import Request + +import ip_access as access + + +@pytest.fixture +def policy(tmp_path, monkeypatch): + path = tmp_path / "access.json" + path.write_text(json.dumps({"allowed_ips": []})) + monkeypatch.setattr(access, "CONFIG_PATH", path) + monkeypatch.setattr(access, "local_addresses", lambda: {ip_address("192.168.10.2"), ip_address("::1")}) + return path + + +def request(host, headers=None, port=50000): + return Request({"type": "http", "path": "/api/v1/hairline/generate-52", "scheme": "http", + "client": (host, port), "headers": [(k.encode(), v.encode()) for k, v in (headers or {}).items()]}) + + +@pytest.mark.parametrize("host", ["127.0.0.1", "::1", "::ffff:127.0.0.1", "192.168.10.2"]) +def test_only_actual_local_addresses_allowed_by_default(policy, host): + assert access.check_access(request(host))[0] + + +@pytest.mark.parametrize("host", ["203.0.113.9", "192.168.10.3", "0.0.0.0", "invalid"]) +def test_other_addresses_denied(policy, host): + assert not access.check_access(request(host))[0] + + +def test_whitelist_ipv4_ipv6_cidr_and_hot_reload(policy): + policy.write_text(json.dumps({"allowed_ips": ["203.0.113.9", "198.51.100.0/24", "2001:db8::/64"]})) + for host in ["203.0.113.9", "::ffff:203.0.113.9", "198.51.100.12", "2001:db8::12"]: + assert access.check_access(request(host))[0] + policy.write_text('{"allowed_ips": []}') + assert not access.check_access(request("203.0.113.9"))[0] + + +def test_configured_public_whitelist_is_exact(policy): + policy.write_text('{"allowed_ips": ["49.232.51.171"]}') + assert access.check_access(request("49.232.51.171"))[0] + assert not access.check_access(request("49.232.51.172"))[0] + + +@pytest.mark.parametrize("header", ["x-forwarded-for", "x-real-ip", "forwarded"]) +def test_forwarding_headers_cannot_override_socket_peer(policy, header): + assert not access.check_access(request("203.0.113.9", {header: "127.0.0.1"}))[0] + assert access.check_access(request("127.0.0.1", {header: "203.0.113.9"}))[0] + + +def test_gateway_is_allowed_independently_of_end_user_ip(policy): + policy.write_text('{"allowed_ips": ["49.232.51.171"]}') + assert access.check_access(request("49.232.51.171", {"x-forwarded-for": "203.0.113.9"}))[0] + assert not access.check_access(request("203.0.113.9", {"x-forwarded-for": "49.232.51.171"}))[0] + + +def test_rewritten_peer_or_invalid_policy_fail_closed(policy): + assert not access.check_access(request("127.0.0.1", port=0))[0] + policy.write_text('{"allowed_ips": ["not-an-ip"]}') + assert not access.check_access(request("127.0.0.1"))[0] + policy.write_text('invalid JSON') + assert not access.check_access(request("127.0.0.1"))[0] + + +def test_worker_guard_covers_all_interfaces_before_auth(policy, monkeypatch): + import app as worker + monkeypatch.setattr(worker, "ACCEPT_PASSWORDS", ["access-test"]) + headers = {"X-Internal-Token": "access-test"} + with TestClient(worker.app, client=("203.0.113.9", 50000)) as client: + assert client.post('/api/v1/hairline/generate-52').status_code == 403 + assert client.post('/api/v1/hairline/generate-52/', headers=headers).status_code == 403 + assert client.post('/api/v1/debug/hairline-height-compare').status_code == 403 + assert client.post('/api/v1/hairline/generate', headers=headers).status_code == 403 + assert client.post('/api/v1/face/measure', headers=headers).status_code == 403 + assert client.post('/api/v1/redraw', headers=headers).status_code == 403 + assert client.get('/health').status_code == 403 + assert client.get('/openapi.json').status_code == 403 + policy.write_text('{"allowed_ips": ["203.0.113.9"]}') + assert client.post('/api/v1/hairline/generate-52', headers=headers).json()['code'] == 1004 + assert client.post('/api/v1/hairline/generate-52').status_code == 401 + + +def test_gateway_does_not_apply_worker_ip_policy(policy, monkeypatch): + import importlib + gateway = importlib.import_module('gateway.app') + calls = [] + async def proxy(request, path): + calls.append(path) + return {'code': 0} + monkeypatch.setattr(gateway, '_proxy', proxy) + # Deliberately skip lifespan: no workers or configuration are needed. + client = TestClient(gateway.app, client=('203.0.113.9', 50000)) + assert client.post('/api/v1/hairline/generate-52').json()['code'] == 0 + assert client.post('/api/v1/hairline/generate').json()['code'] == 0 + assert client.post('/api/v1/hair/grow').json()['code'] == 0 + assert client.get('/static/test_interface52.html').status_code == 200 + assert calls == ['/api/v1/hairline/generate-52', '/api/v1/hairline/generate', '/api/v1/hair/grow']