"""接口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