From 97749970359438a1b11e2345726b3cc53ceaa61a Mon Sep 17 00:00:00 2001 From: xsl Date: Tue, 7 Jul 2026 23:09:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A39=EF=BC=9A=E5=A4=B4=E5=8F=91?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E7=94=9F=E6=88=90=20+=20=E5=88=86=E6=AD=A5?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/v1/head/mask(worker + 网关代理)与测试页 test_interface9.html: - MediaPipe 关键点连成额头分割线(21,68,104,69,108,151,337,299,333,298,251, 左端21/右端251 水平延伸到图片边缘),分割线以上为上半区。 - 头发分割 BiSeNet 与 SegFormer 两套并排对比;每列从最顶端头发向下填充到分割线, 得到含额头的闭合区域(不从发际线割断)。 - 外缘朝中心点151内缩 erode_cm(默认1.2cm,页面可调,虹膜标定换算像素)、底线不动。 - 复用现有 detector/hair_segmenter/SegFormer 单例(只读推理),无新依赖;纯新增, 不改动既有接口。 顺带修复接口2 遗留测试 test_grow_female_returns_5:hair_style 自 cb1989c 起必填, 补上 hair_style=1,2,3,4,5。全套 42 passed。 Co-Authored-By: Claude Opus 4.8 --- app.py | 85 +++++++++++ face_analysis/head_mask.py | 223 +++++++++++++++++++++++++++++ gateway/app.py | 7 + static/test_interface9.html | 278 ++++++++++++++++++++++++++++++++++++ tests/test_api.py | 3 +- 5 files changed, 595 insertions(+), 1 deletion(-) create mode 100644 face_analysis/head_mask.py create mode 100644 static/test_interface9.html diff --git a/app.py b/app.py index 5f62525..5a4acd2 100644 --- a/app.py +++ b/app.py @@ -1061,6 +1061,91 @@ async def hairline_generate( return err(1007, f"处理失败:{ex}") +# --------------------------------------------------------------------------- +# 接口 9:头发遮罩生成 +# --------------------------------------------------------------------------- + +@app.post( + "/api/v1/head/mask", + summary="接口9 头发遮罩生成", + tags=["人脸分析"], + description=f""" +输入一张含人头的照片,生成头发遮罩,并返回**每一步的可视化图**(供对比调试): + +1. MediaPipe 关键点 → 底部分割线(关键点 21,68,104,69,108,151,337,299,333,298,251 的连线, + 21 水平延伸到最左边、251 延伸到最右边)。 +2. 分割线以上为「上半区」。 +3. 头发分割(**BiSeNet** 与 **SegFormer** 两套);从每列最顶端头发向下填充到分割线, + 得到**含额头**的闭合区域(头发+额头,不割断)。 +4. 外缘朝中心点 151 内缩 **erode_cm(默认 1.2cm,可调)**(虹膜标定换算像素)、底线不动 → 最终遮罩。 + +{_image_fields_desc} + +返回 `data` 内含 `steps_common`(关键点/分割线、上半区)与 `bisenet` / `segformer` +两组结果(各含 `hair_mask` / `closed_region` / `final_overlay` / `mask`)。经网关时所有 +`*_base64` 图片字段会被落盘改写为 `*_url`。 +""", + responses={ + 200: { + "description": "成功", + "content": { + "application/json": { + "example": { + "code": 0, + "message": "success", + "request_id": "mock-request-id", + "data": { + "px_per_cm": 48.12, + "erode_cm": 1.2, + "erode_px": 58, + "image_size": {"width": 1080, "height": 1440}, + "steps_common": {"landmarks_baseline_url": SAMPLE_IMAGE_URL, + "upper_region_url": SAMPLE_IMAGE_URL}, + "bisenet": {"hair_pixels": 123456, "closed_pixels": 110000, "mask_pixels": 98765, + "hair_mask_url": SAMPLE_IMAGE_URL, + "closed_region_url": SAMPLE_IMAGE_URL, + "final_overlay_url": SAMPLE_IMAGE_URL, + "mask_url": SAMPLE_IMAGE_URL}, + "segformer": {"hair_pixels": 130000, "closed_pixels": 112000, "mask_pixels": 99000, + "hair_mask_url": SAMPLE_IMAGE_URL, + "closed_region_url": SAMPLE_IMAGE_URL, + "final_overlay_url": SAMPLE_IMAGE_URL, + "mask_url": SAMPLE_IMAGE_URL}, + }, + } + } + }, + }, + }, +) +async def head_mask( + 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, 前缀)"), + erode_cm: float = Form(default=1.2, description="外缘朝中心151内缩的距离(厘米),默认 1.2"), +): + """接口9:头发遮罩生成 + 分步可视化""" + raw, e = await resolve_image_bytes(image_file, image_url, image_base64) + if e is not None: + return e + + image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) + if image is None: + return err(1008, "图片格式不支持(仅 JPG / PNG)") + + try: + from fastapi.concurrency import run_in_threadpool + from face_analysis.head_mask import generate_head_mask, NoFaceError + try: + data = await run_in_threadpool(generate_head_mask, image, erode_cm) + except NoFaceError: + return err(1001, "无法识别人像") + return ok(data) + except Exception as ex: # noqa: BLE001 + logger.exception("接口9 处理异常") + return err(1007, f"处理失败:{ex}") + + # --------------------------------------------------------------------------- # 健康检查 # --------------------------------------------------------------------------- diff --git a/face_analysis/head_mask.py b/face_analysis/head_mask.py new file mode 100644 index 0000000..4e5b865 --- /dev/null +++ b/face_analysis/head_mask.py @@ -0,0 +1,223 @@ +"""接口9:头发遮罩生成。 + +流程(详见需求讨论): +1. MediaPipe 关键点检测。 +2. 底部分割线 = 关键点 [21,68,104,69,108,151,337,299,333,298,251] 的连线(左端21→中心151→右端251), + 再把左端点 21 水平延伸到图片最左边、右端点 251 水平延伸到图片最右边。 +3. 上半区 = 分割线以上区域(多边形填充:左边缘→弧线→右边缘→上边缘闭合)。 +4. 头发分割:BiSeNet 与 SegFormer 各出一张 hair_mask(两套供对比)。 +5. 闭合区域(含额头):每列从最顶端头发像素向下填充到分割线,把头发与画线之间的额头皮肤 + 也包进来(不再从发际线割断),底边即分割线。 +6. 外缘内缩 erode_cm(默认 1.2cm,可调)、底线不动:对「填充到图底的实心块」做半径 r 的腐蚀, + 再与上半区相交。腐蚀只把外轮廓(顶/两侧)朝内(朝 151)收 r;平底边是相交后才产生的, + 所以底线纹丝不动。cm→像素用虹膜标定(calibration.estimate_scale_factor)。 + +对外返回每一步叠加在原图上的可视化图(base64 PNG,data URI),供测试页逐步展示。 +""" +import base64 + +import cv2 +import numpy as np + +from face_analysis.detector import detector +from face_analysis.calibration import estimate_scale_factor, normalized_to_pixel + +# 底部额头弧线关键点(图像上从左到右:左端 21 → 中心 151 → 右端 251) +BASELINE_IDX = [21, 68, 104, 69, 108, 151, 337, 299, 333, 298, 251] +CENTER_IDX = 151 # 内缩方向的目标点(额头中心) +ERODE_CM = 1.2 # 外缘内缩距离(厘米,默认;可由入参覆盖) +SEGFORMER_HAIR = 13 # jonathandinu/face-parsing 中 hair 类索引 + + +class NoFaceError(Exception): + """未检测到人脸。""" + + +# --------------------------------------------------------------------------- +# 几何:分割线与上半区 +# --------------------------------------------------------------------------- + +def _px(landmarks, idx, w, h): + p = landmarks.landmark[idx] + return (int(round(p.x * w)), int(round(p.y * h))) + + +def _baseline_points(landmarks, w, h): + """额头弧线各关键点的像素坐标(按 BASELINE_IDX 顺序,左→右)。""" + return [_px(landmarks, i, w, h) for i in BASELINE_IDX] + + +def _upper_region_mask(baseline_pts, w, h): + """分割线以上区域(bool,H×W)。 + + 多边形顶点:左上角 →(0, y54)→ 弧线各点 →(w-1, y284)→ 右上角,闭合后填充。 + 其中 54→左边缘、284→右边缘为两段水平延长线。 + """ + x54, y54 = baseline_pts[0] + x284, y284 = baseline_pts[-1] + poly = [(0, 0), (0, y54)] + baseline_pts + [(w - 1, y284), (w - 1, 0)] + mask = np.zeros((h, w), np.uint8) + cv2.fillPoly(mask, [np.array(poly, np.int32)], 1) + return mask.astype(bool) + + +# --------------------------------------------------------------------------- +# 头发分割(两套) +# --------------------------------------------------------------------------- + +def _bisenet_hair_mask(image_bgr, landmarks, w, h): + """BiSeNet(接口1 同款):先按人脸框裁剪再分割,稳住小脸大图。""" + from face_analysis.hair_segmenter import get_segmenter + pxs = [normalized_to_pixel(p, w, h) for p in landmarks.landmark] + face_box = (min(p[0] for p in pxs), min(p[1] for p in pxs), + max(p[0] for p in pxs), max(p[1] for p in pxs)) + hair_mask, _ear = get_segmenter().segment_hair_and_ears(image_bgr, face_box=face_box) + return np.asarray(hair_mask, dtype=bool) + + +def _segformer_hair_mask(image_bgr): + """复用接口2/3 的 SegFormer 单例(hairline.service.get_parser), + 共用权重与设备策略(SEG_DEVICE,默认 cpu;本机 5090 上 CUDA 内核不可用故走 CPU)。""" + from hairline.service import get_parser + rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) + labels = get_parser().parse(rgb) + return labels == SEGFORMER_HAIR + + +# --------------------------------------------------------------------------- +# 形态学 & 可视化 +# --------------------------------------------------------------------------- + +def _erode(mask_bool, r): + """圆盘核腐蚀半径 r(像素)。r<=0 原样返回。""" + if r <= 0: + return mask_bool.copy() + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1)) + return cv2.erode(mask_bool.astype(np.uint8), k).astype(bool) + + +def _overlay(image, mask_bool, color, alpha=0.45): + """把纯色以 alpha 叠加到 mask 区域上(非 mask 区域保持原样)。""" + out = image.copy() + if mask_bool.any(): + out[mask_bool] = (out[mask_bool] * (1 - alpha) + + np.array(color, np.float32) * alpha).astype(np.uint8) + return out + + +def _draw_baseline(image, baseline_pts, w): + """画分割线(含左右水平延长线)+ 关键点,中心点 151 标红。""" + out = image.copy() + y54 = baseline_pts[0][1] + y284 = baseline_pts[-1][1] + chain = [(0, y54)] + baseline_pts + [(w - 1, y284)] + for a, b in zip(chain[:-1], chain[1:]): + cv2.line(out, a, b, (0, 255, 255), 2, cv2.LINE_AA) + for idx, p in zip(BASELINE_IDX, baseline_pts): + col = (0, 0, 255) if idx == CENTER_IDX else (0, 200, 0) + cv2.circle(out, p, 4, col, -1, cv2.LINE_AA) + cv2.putText(out, str(idx), (p[0] + 4, p[1] - 6), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, col, 1, cv2.LINE_AA) + return out + + +def _b64png(bgr): + """BGR 图 → data URI(PNG base64)。gateway 会把 *_base64 字段落盘改成 *_url。""" + ok, buf = cv2.imencode(".png", bgr) + return "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode() + + +def _mask_png(mask_bool): + """纯遮罩:白(255)为遮罩、黑为背景。""" + m = (mask_bool.astype(np.uint8)) * 255 + return _b64png(cv2.cvtColor(m, cv2.COLOR_GRAY2BGR)) + + +# --------------------------------------------------------------------------- +# 主入口 +# --------------------------------------------------------------------------- + +def _largest_cc(mask_bool): + """保留最大连通域,去掉背景里孤立的杂散头发列。空掩膜原样返回。""" + m = mask_bool.astype(np.uint8) + if m.sum() == 0: + return mask_bool + n, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8) + if n <= 2: # 只有背景 + 一个前景 + return mask_bool + largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) + return labels == largest + + +def _fill_to_baseline(hair_mask, upper): + """含额头的实心区域:每列从最顶端头发像素向下填充(延伸到图底,未按基线裁剪)。 + + 这样头发与画线之间的额头皮肤被包进闭合区域(不再被割断);未裁剪到基线是为了 + 后续腐蚀时底线不动(腐蚀在延伸到图底的实心块上做,再与上半区相交切平底边)。 + """ + has_hair = (hair_mask & upper).astype(np.uint8) + return np.maximum.accumulate(has_hair, axis=0).astype(bool) + + +def _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w): + """单个分割模型的分步结果(闭合区域 / 最终遮罩 / 可视化)。""" + top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底 + closed = _largest_cc(top_fill & upper) # 闭合区域:头发+额头,底=基线 + final = _largest_cc(_erode(top_fill, r) & upper) # 外缘朝151内缩 r、底线不动 + return { + "hair_pixels": int(hair_mask.sum()), + "closed_pixels": int(closed.sum()), + "mask_pixels": int(final.sum()), + "hair_mask_base64": _b64png(_overlay(image_bgr, hair_mask, (255, 150, 0))), + "closed_region_base64": _b64png( + _draw_baseline(_overlay(image_bgr, closed, (255, 150, 0)), baseline_pts, w)), + "final_overlay_base64": _b64png( + _draw_baseline(_overlay(image_bgr, final, (0, 0, 255)), baseline_pts, w)), + "mask_base64": _mask_png(final), + } + + +def generate_head_mask(image_bgr, erode_cm=ERODE_CM): + """接口9 完整管线。返回可直接进 ok() 的 data dict。 + + erode_cm:外缘朝 151 内缩的距离(厘米),页面可调,默认 1cm。 + 未检出人脸抛 NoFaceError。单个分割模型异常不影响另一个(记为 {"error": ...})。 + """ + h, w = image_bgr.shape[:2] + landmarks = detector.detect(image_bgr) + if landmarks is None: + raise NoFaceError() + + erode_cm = max(0.0, float(erode_cm)) + px_per_cm = estimate_scale_factor(landmarks, w, h) + r = int(round(erode_cm * px_per_cm)) + baseline_pts = _baseline_points(landmarks, w, h) + upper = _upper_region_mask(baseline_pts, w, h) + + baseline_viz = _draw_baseline(image_bgr, baseline_pts, w) + data = { + "px_per_cm": round(float(px_per_cm), 4), + "erode_cm": round(erode_cm, 2), + "erode_px": r, + "image_size": {"width": w, "height": h}, + "baseline_landmarks": [ + {"index": idx, "x": p[0], "y": p[1]} + for idx, p in zip(BASELINE_IDX, baseline_pts) + ], + "steps_common": { + "landmarks_baseline_base64": _b64png(baseline_viz), + "upper_region_base64": _b64png(_overlay(baseline_viz, upper, (0, 200, 0))), + }, + } + + seg_fns = { + "bisenet": lambda: _bisenet_hair_mask(image_bgr, landmarks, w, h), + "segformer": lambda: _segformer_hair_mask(image_bgr), + } + for name, fn in seg_fns.items(): + try: + hair_mask = fn() + data[name] = _model_result(image_bgr, hair_mask, upper, baseline_pts, r, w) + except Exception as ex: # noqa: BLE001 单模型失败不影响整体 + data[name] = {"error": f"{type(ex).__name__}: {ex}"} + return data diff --git a/gateway/app.py b/gateway/app.py index 9eb07c3..7a8f08e 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -210,6 +210,7 @@ async def index(): "if5_hairline": "/static/test_interface5.html", "if6_measure_v2": "/static/test_interface6.html", "if7_hair_grow_v2": "/static/test_interface7.html", + "if9_head_mask": "/static/test_interface9.html", }, } @@ -593,3 +594,9 @@ async def hairline_generate(request: Request): async def hair_grow_v2(request: Request): """接口7:C端生发 v2(add_hair2 工作流)""" return await _proxy(request, "/api/v1/hair/grow-v2") + + +@app.post("/api/v1/head/mask", tags=["人脸分析"]) +async def head_mask(request: Request): + """接口9:头发遮罩生成 + 分步可视化""" + return await _proxy(request, "/api/v1/head/mask") diff --git a/static/test_interface9.html b/static/test_interface9.html new file mode 100644 index 0000000..c054b76 --- /dev/null +++ b/static/test_interface9.html @@ -0,0 +1,278 @@ + + + + + +接口9 — 头发遮罩生成 测试页 + + + +
+

接口9 — 头发遮罩生成

+
MediaPipe 关键点 → 额头分割线 → 上半区 → 头发分割(BiSeNet / SegFormer) → 闭合区域(含额头) → 外缘内缩(可调) → 遮罩。每一步可视化,两套分割并排对比。
+ +
+
+ + + +
+
+ + + +
+
默认 1.2cm。数值越大,遮罩外缘朝中心点 151 收得越多(底线不动)。改动后点「提交测试」重新计算;设置会自动记住。
+
+ + + + +
+ + + + + + diff --git a/tests/test_api.py b/tests/test_api.py index edcc3bb..8983feb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -83,7 +83,8 @@ def test_grow_female_returns_5(client, monkeypatch): monkeypatch.setattr(comfy, "run", lambda *a, **k: _PNG_1x1) files = {"image_file": ("frontal.jpg", open(fixture("frontal.jpg"), "rb"), "application/octet-stream")} - r = client.post(GROW, headers=H, files=files, data={"gender": "female"}) + # hair_style 必填(cb1989c 起):指定全部 5 种发型以验证完整管线 + r = client.post(GROW, headers=H, files=files, data={"gender": "female", "hair_style": "1,2,3,4,5"}) body = r.json() assert body["code"] == 0, body results = body["data"]["results"]