diff --git a/app.py b/app.py index 5a4acd2..b2b6fa5 100644 --- a/app.py +++ b/app.py @@ -1146,6 +1146,92 @@ async def head_mask( return err(1007, f"处理失败:{ex}") +# --------------------------------------------------------------------------- +# 接口 10:头部外缘膨胀带遮罩 +# --------------------------------------------------------------------------- + +@app.post( + "/api/v1/head/band", + summary="接口10 头部外缘膨胀带遮罩", + tags=["人脸分析"], + description=f""" +先和接口9 一样得到**内缩后的基准遮罩**(含额头的闭合区域外缘朝151内缩 erode_cm、底线不动,默认1.2cm), +在此基础上生成一条沿头部外缘的膨胀带遮罩: + +1. 取基准遮罩的**外轮廓线**,去掉贴着底部分割线的那一段(只留头发/头部外缘弧线)。 +2. 把外轮廓线膨胀成带子(**总宽 dilate_cm,默认 2cm,可调**;半径=总宽/2,虹膜标定换算像素)。 +3. 裁到分割线以上(不越过底线)。 + +{_image_fields_desc} + +返回 `data` 内含 `steps_common`(关键点/分割线、上半区)与 `bisenet` / `segformer` +两组结果(各含 `base_mask` / `contour` / `band_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, + "dilate_cm": 2.0, + "dilate_radius_px": 48, + "image_size": {"width": 1080, "height": 1440}, + "steps_common": {"landmarks_baseline_url": SAMPLE_IMAGE_URL, + "upper_region_url": SAMPLE_IMAGE_URL}, + "bisenet": {"base_pixels": 96000, "band_pixels": 42000, + "base_mask_url": SAMPLE_IMAGE_URL, + "contour_url": SAMPLE_IMAGE_URL, + "band_overlay_url": SAMPLE_IMAGE_URL, + "mask_url": SAMPLE_IMAGE_URL}, + "segformer": {"base_pixels": 97000, "band_pixels": 43000, + "base_mask_url": SAMPLE_IMAGE_URL, + "contour_url": SAMPLE_IMAGE_URL, + "band_overlay_url": SAMPLE_IMAGE_URL, + "mask_url": SAMPLE_IMAGE_URL}, + }, + } + } + }, + }, + }, +) +async def head_band( + 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内缩距离(厘米,同接口9),默认 1.2"), + dilate_cm: float = Form(default=2.0, description="外轮廓线膨胀后带子总宽(厘米),默认 2.0"), +): + """接口10:头部外缘膨胀带遮罩 + 分步可视化""" + 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_band import generate_head_band, NoFaceError + try: + data = await run_in_threadpool(generate_head_band, image, erode_cm, dilate_cm) + except NoFaceError: + return err(1001, "无法识别人像") + return ok(data) + except Exception as ex: # noqa: BLE001 + logger.exception("接口10 处理异常") + return err(1007, f"处理失败:{ex}") + + # --------------------------------------------------------------------------- # 健康检查 # --------------------------------------------------------------------------- diff --git a/face_analysis/head_band.py b/face_analysis/head_band.py new file mode 100644 index 0000000..b507569 --- /dev/null +++ b/face_analysis/head_band.py @@ -0,0 +1,123 @@ +"""接口10:头部外缘膨胀带遮罩。 + +先和接口9 一样得到**内缩后的基准遮罩**(含额头的闭合区域外缘朝151内缩 erode_cm、底线不动,默认1.2cm), +在这个基础上: +1. 取基准遮罩的**外轮廓线**(1px),去掉贴着底部分界线的那一段(只留头发/头部外缘弧线)。 +2. 把这条外轮廓线膨胀成带子(半径 = dilate_cm/2,即带子**总宽 ≈ dilate_cm**,默认 2cm)。 +3. 裁到分界线以上(不越过底线)。 +输出这条带子作为 mask。BiSeNet / SegFormer 两套并排对比,分步可视化。 + +两个可调参数:erode_cm(同接口9 的内缩,默认1.2)+ dilate_cm(带子总宽,默认2)。 +复用 `head_mask` 的构件,避免重复实现。 +""" +import cv2 +import numpy as np + +from face_analysis.detector import detector +from face_analysis.calibration import estimate_scale_factor +from face_analysis.head_mask import ( + BASELINE_IDX, ERODE_CM, NoFaceError, + _baseline_points, _upper_region_mask, _fill_to_baseline, _largest_cc, + _bisenet_hair_mask, _segformer_hair_mask, + _erode, _overlay, _draw_baseline, _b64png, _mask_png, +) + +DILATE_CM = 2.0 # 膨胀后带子总宽(厘米,默认;半径 = 总宽/2;可由入参覆盖) + + +def _dilate(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.dilate(mask_bool.astype(np.uint8), k).astype(bool) + + +def _baseline_line_mask(baseline_pts, w, h, thickness): + """底部分割线(含左右水平延长线)画成一条带厚度的线,用于从外轮廓里剔除底边。""" + m = np.zeros((h, w), np.uint8) + y_l = baseline_pts[0][1] + y_r = baseline_pts[-1][1] + chain = [(0, y_l)] + baseline_pts + [(w - 1, y_r)] + for a, b in zip(chain[:-1], chain[1:]): + cv2.line(m, a, b, 1, thickness) + return m.astype(bool) + + +def _outer_contour_no_bottom(region, baseline_band): + """区域外轮廓(1px)去掉贴着底部分界线的那一段。""" + contour = region & ~_erode(region, 1) + return contour & ~baseline_band + + +def _model_result(image_bgr, hair_mask, upper, baseline_pts, baseline_band, r_erode, r_dilate, w): + """单个分割模型的分步结果(内缩后基准遮罩 / 外轮廓线 / 膨胀带 / 纯遮罩)。""" + top_fill = _fill_to_baseline(hair_mask, upper) # 含额头,延伸到图底 + base = _largest_cc(_erode(top_fill, r_erode) & upper) # 接口9 内缩后的基准遮罩 + contour = _outer_contour_no_bottom(base, baseline_band) # 外轮廓,去底线 + band = _largest_cc(_dilate(contour, r_dilate) & upper) # 膨胀成带、裁到底线以上 + return { + "base_pixels": int(base.sum()), + "band_pixels": int(band.sum()), + "base_mask_base64": _b64png( + _draw_baseline(_overlay(image_bgr, base, (255, 150, 0)), baseline_pts, w)), + "contour_base64": _b64png( + _draw_baseline(_overlay(image_bgr, _dilate(contour, 2), (0, 255, 0)), baseline_pts, w)), + "band_overlay_base64": _b64png( + _draw_baseline(_overlay(image_bgr, band, (0, 0, 255)), baseline_pts, w)), + "mask_base64": _mask_png(band), + } + + +def generate_head_band(image_bgr, erode_cm=ERODE_CM, dilate_cm=DILATE_CM): + """接口10 完整管线。返回可直接进 ok() 的 data dict。 + + erode_cm:基准遮罩外缘朝151 内缩距离(厘米,同接口9),页面可调,默认 1.2cm。 + dilate_cm:外轮廓线膨胀后带子总宽(厘米),页面可调,默认 2cm。 + 未检出人脸抛 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)) + dilate_cm = max(0.0, float(dilate_cm)) + px_per_cm = estimate_scale_factor(landmarks, w, h) + r_erode = int(round(erode_cm * px_per_cm)) # 内缩半径 + r_dilate = int(round((dilate_cm / 2.0) * px_per_cm)) # 膨胀半径 = 总宽/2 + baseline_pts = _baseline_points(landmarks, w, h) + upper = _upper_region_mask(baseline_pts, w, h) + # 剔除底边用的分界线带:几像素宽即可,独立于膨胀/内缩半径 + baseline_band = _dilate(_baseline_line_mask(baseline_pts, w, h, 5), 2) + + 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_erode, + "dilate_cm": round(dilate_cm, 2), + "dilate_radius_px": r_dilate, + "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, + baseline_band, r_erode, r_dilate, 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 7a8f08e..eed7c3f 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -211,6 +211,7 @@ async def index(): "if6_measure_v2": "/static/test_interface6.html", "if7_hair_grow_v2": "/static/test_interface7.html", "if9_head_mask": "/static/test_interface9.html", + "if10_head_band": "/static/test_interface10.html", }, } @@ -600,3 +601,9 @@ async def hair_grow_v2(request: Request): async def head_mask(request: Request): """接口9:头发遮罩生成 + 分步可视化""" return await _proxy(request, "/api/v1/head/mask") + + +@app.post("/api/v1/head/band", tags=["人脸分析"]) +async def head_band(request: Request): + """接口10:头部外缘膨胀带遮罩 + 分步可视化""" + return await _proxy(request, "/api/v1/head/band") diff --git a/static/test_interface10.html b/static/test_interface10.html new file mode 100644 index 0000000..4f8ba8a --- /dev/null +++ b/static/test_interface10.html @@ -0,0 +1,286 @@ + + +
+ + +