From 38161d1b500a4b22c4a1c0b1fc1004d7708bf9fa Mon Sep 17 00:00:00 2001 From: xsl Date: Mon, 15 Jun 2026 00:20:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8E=A5=E5=8F=A35):=20=E5=8F=91=E9=99=85?= =?UTF-8?q?=E7=BA=BFPNG=E7=94=9F=E6=88=90(=E7=9C=9F=E5=AE=9E=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=EF=BC=8C=E6=9B=BF=E6=8D=A2Mock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用接口2 预览管线:gender 必填 → 该性别全部发际线叠加图(同接口2预览) + 最佳(order=1)发际线曲线的面部中间点坐标。无生发(不调 ComfyUI)。 - hairline/service.py: generate_hairline_pngs——N张发际线叠图 + best_center(面部中轴眉心x × order1曲线在该处的y) - app.py: /hairline/generate 真实实现,新增 gender 必填(非法→1004), 返回 hairline_images[].image_base64 + best_hairline_center_point; 无人脸→1001;重活线程池 - 接口文档/OpenAPI: 接口5 新增 gender 入参 + 输出改 base64(网关改url) - 测试: test_api 接口5(gender必填/N张/center点),44全绿 实测(5090): female 5张叠图 + center{x,y},~2.5s(无Flux)。 Co-Authored-By: Claude Opus 4.8 --- app.py | 64 ++++++++++++++++++++++++++++++++++++--------- docs/接口文档.md | 14 ++++++---- hairline/service.py | 34 +++++++++++++++++++++++- tests/test_api.py | 20 ++++++++++++++ 4 files changed, 114 insertions(+), 18 deletions(-) diff --git a/app.py b/app.py index 1ddf691..5a5de66 100644 --- a/app.py +++ b/app.py @@ -721,10 +721,15 @@ async def face_features( --- +**入参**:新增必填 `gender`(`male`/`female`),决定返回的发际线集合(female 5 / male 4)。 + **返回说明**: -- `hairline_images`:发际线 PNG 列表,数量 N 不固定,已按合适度**从高到低排序**(`order=1` 最合适) -- `best_hairline_center_point`:最合适发际线的面部中间点坐标,以**原图像素**为基准(左上角为原点,x 向右,y 向下) +- `hairline_images`:发际线叠加图列表(发际线曲线叠加在用户照片上,同接口2预览), + 数量 = 该性别的发际线类型数,本期按贴图顺序 `order=1..N`(暂不计算合适度)。 + worker 返回 `image_base64`,网关落盘后改写为 `image_url`。 +- `best_hairline_center_point`:最佳(`order=1`)发际线曲线的**面部中间点**坐标, + 以**原图像素**为基准(左上角为原点,x 向右,y 向下)。 """, responses={ 200: { @@ -737,8 +742,8 @@ async def face_features( "request_id": "mock-request-id", "data": { "hairline_images": [ - {"image_url": SAMPLE_IMAGE_URL, "order": 1}, - {"image_url": SAMPLE_IMAGE_URL, "order": 2}, + {"image_base64": "iVBORw0KGgo...", "order": 1}, + {"image_base64": "iVBORw0KGgo...", "order": 2}, ], "best_hairline_center_point": {"x": 540, "y": 430}, }, @@ -760,15 +765,50 @@ async def hairline_generate( image_file: Optional[UploadFile] = File(default=None, description="上传图片文件(JPG/PNG,≤ 1 MB)"), 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(必填)"), ): - data = { - "hairline_images": [ - {"image_url": SAMPLE_IMAGE_URL, "order": 1}, - {"image_url": SAMPLE_IMAGE_URL, "order": 2}, - ], - "best_hairline_center_point": {"x": 540, "y": 430}, - } - return ok(data) + if gender not in ("male", "female"): + return err(1004, "gender 必填且只能为 male / female") + + raw, e = await resolve_image_bytes(image_file, image_url, image_base64) + if e is not None: + return e + if len(raw) > MAX_FILE_BYTES: + return err(1006, "文件超出 1 MB 限制") + + image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + if image is None: + return err(1008, "图片格式不支持(仅 JPG / PNG)") + + h, w = image.shape[:2] + short_side, long_side = min(w, h), max(w, h) + if short_side < MIN_SHORT_SIDE or long_side < MIN_LONG_SIDE: + return err(1002, "人像分辨率过低") + + try: + from fastapi.concurrency import run_in_threadpool + from hairline.service import generate_hairline_pngs + + res = await run_in_threadpool(generate_hairline_pngs, image, gender) + if res is None: + return err(1001, "无法识别人像") + + hairline_images = [] + for it in res["images"]: + _ok, png = cv2.imencode(".png", it["image_bgr"]) + hairline_images.append({ + "image_base64": base64.b64encode(png.tobytes()).decode(), + "order": it["order"], + }) + c = res["best_center"] + data = { + "hairline_images": hairline_images, + "best_hairline_center_point": ({"x": c[0], "y": c[1]} if c else None), + } + return ok(data) + except Exception as ex: # noqa: BLE001 + logger.exception("接口5 处理异常") + return err(1007, f"处理失败:{ex}") # --------------------------------------------------------------------------- diff --git a/docs/接口文档.md b/docs/接口文档.md index 1ccf3d4..96b1073 100644 --- a/docs/接口文档.md +++ b/docs/接口文档.md @@ -315,21 +315,25 @@ ### 输入 -图片参数见「通用约定 → 图片传参字段」。本接口无其他专属参数。 +图片参数见「通用约定 → 图片传参字段」。专属参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| gender | string | **是** | 性别:`male` / `female`。决定返回的发际线集合(female 5 / male 4) | ### 输出(data) | 字段 | 类型 | 说明 | |------|------|------| -| hairline_images | object[] | N 张用户发际线 PNG,**数量 N 不固定**,已按合适度排序,元素见下表 | -| best_hairline_center_point | object | 最合适发际线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | +| hairline_images | object[] | N 张发际线叠加图(发际线曲线叠在用户照片上,同接口2预览),**数量 = 该性别发际线数**,本期按贴图顺序,元素见下表 | +| best_hairline_center_point | object | 最佳(order=1)发际线曲线的「面部中间点」坐标,原图像素:`{ "x": number, "y": number }` | `hairline_images` 元素: | 字段 | 类型 | 说明 | |------|------|------| -| image_url | string | 发际线 PNG 图片 URL | -| order | int | 排序序号(1 = 最合适,依次递增) | +| image_url | string | 发际线叠加图 URL(worker 返回 `image_base64`,网关落盘后改写为 url) | +| order | int | 排序序号(本期固定 `1..N`,暂不计算合适度) | ### 响应示例(当前 Mock 返回值) diff --git a/hairline/service.py b/hairline/service.py index aeea964..2377a4d 100644 --- a/hairline/service.py +++ b/hairline/service.py @@ -16,7 +16,7 @@ from .face_landmarks import FaceLandmarker from .face_parsing import FaceParser from .hairline_2d import sample_hairline, smooth_hairline 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 +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 @@ -161,6 +161,38 @@ def generate_grow_results(image_bgr: np.ndarray, gender: str): return results +def generate_hairline_pngs(image_bgr: np.ndarray, gender: str): + """接口5:该性别全部发际线叠图(同接口2预览) + 最佳(order1)发际线曲线的面部中间点。 + + Returns: {"images":[{hairline_type,order,image_bgr}], "best_center":(x,y)};无人脸 None。 + """ + if gender not in ("male", "female"): + raise ValueError(f"gender 必须是 male/female,收到 {gender!r}") + ctx = extract_context(image_bgr) + if ctx is None: + return None + h, w = image_bgr.shape[:2] + uv, ext_faces = load_ext_mesh() + lm = ctx["landmarks"] + # 面部中轴 x = 眉心(9/151 中点) + face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w) + + textures = get_texture_map()[gender] + images, best_center = [], None + for order, (key, path) in enumerate(textures, start=1): + white = load_texture_rgba(path) + preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv, white) + images.append({"hairline_type": key, "order": order, "image_bgr": preview}) + if order == 1: # 最佳发际线曲线的中点(面部中轴处的发际线 y) + overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white) + ys, xs = np.where(overlay[:, :, 3] > 40) + if xs.size: + near = np.abs(xs - face_cx) <= max(2, int(w * 0.02)) + col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]] + best_center = (int(round(face_cx)), int(round(float(col_ys.mean())))) + return {"images": images, "best_center": best_center} + + def generate_grow_b(marked_bgr: np.ndarray, original_bgr: np.ndarray): """接口3:检测医生手绘发际线 → 遮罩 → 原图重画干净线 → ComfyUI 生发。 diff --git a/tests/test_api.py b/tests/test_api.py index 5ef7366..7e9ab39 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -136,6 +136,26 @@ def test_growb_success(client, monkeypatch): assert "best_hairline_image_url" not in d +HLGEN = "/api/v1/hairline/generate" + + +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 + + +def test_hairline_gen_female(client): + files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} + body = client.post(HLGEN, headers=H, files=files, data={"gender": "female"}).json() + assert body["code"] == 0, body + d = body["data"] + assert [x["order"] for x in d["hairline_images"]] == [1, 2, 3, 4, 5] + assert base64.b64decode(d["hairline_images"][0]["image_base64"])[:8] == b"\x89PNG\r\n\x1a\n" + c = d["best_hairline_center_point"] + assert 0 <= c["x"] <= 682 and 0 <= c["y"] <= 811 # 落在原图范围内 + assert "image_url" not in d["hairline_images"][0] + + def test_success_structure(client): r = _post(client, "frontal.jpg") body = r.json()