# -*- coding: utf-8 -*- """发际线区域 mask 自动识别(4 种方案) 需求约束: - 只取额头部分的发际线(前面,不要侧面/鬓角) - 下面不超过眉毛(用眉毛峰值点作下界) - 左右不超过耳朵/太阳穴(用太阳穴点作左右界) - 上面不超过已有头发(用头发mask的上边界作上界) 4 种方案: 1. boundary_band: 头发mask边界带(现有方案,沿头发外轮廓的带) 2. mediapipe: MediaPipe 478关键点 → 额头多边形 3. landmark_1k: 现有1k模型关键点 → 几何裁剪额头区 4. deeplab: 复用3分类DeepLab的skin/hair边界 所有方案输入输出统一: 输入: origin_matting(头发mask 0-255), landmarks_1k(np.array), img(BGR), band_width(int) 输出: (mask_8u, info_str, debug_images_dict) mask_8u: 发际线mask, 0或255, 与img同尺寸 info_str: 方案说明文字 debug_images_dict: {label: ndarray} 用于前端展示中间产物 """ import os import cv2 import numpy as np # MediaPipe 模型路径 MP_MODEL = "/home/ubuntu/change_hair/project/hair_service_sd/weights/mediapipe/face_landmarker.task" _mp_detector = None def detect_hairline(origin_matting, landmarks_1k, img, band_width=15, method="mediapipe", height_ratio=0.432, width_ratio=0.144, corner_ratio=0.25, vertical_offset=0.0): """统一入口:按 method 调用对应方案。 height_ratio/width_ratio/corner_ratio/vertical_offset 仅 landmark_1k 方案使用。 vertical_offset: 重绘区上下平移比例(相对额头高度,正值下移,负值上移) 返回 (mask_8u, info_str, debug_images) """ if method == "boundary_band": return _method_boundary_band(origin_matting, landmarks_1k, band_width) elif method == "mediapipe": return _method_mediapipe(img, band_width) elif method == "landmark_1k": return _method_landmark_1k(origin_matting, landmarks_1k, band_width, height_ratio, width_ratio, corner_ratio, vertical_offset) elif method == "deeplab": return _method_deeplab(img, origin_matting, landmarks_1k, band_width) else: raise ValueError(f"未知方法: {method},可选: boundary_band/mediapipe/landmark_1k/deeplab") # ============================================================ # 方案1: 边界带法(现有方案) # ============================================================ def _method_boundary_band(origin_matting, landmarks_1k, band_width): """头发mask的形态学边界带(沿整个头发外轮廓的带,不限于额头)。 注意:这是最宽的方案,会包含鬓角/发尾的边界。 """ bw = max(1, int(band_width)) kernel = np.ones((bw, bw), np.uint8) mask = _boundary_band(origin_matting, kernel) info = f"边界带法(band_width={bw}):沿整个头发外轮廓的带,带宽≈{2*bw}px。注意:会包含鬓角/侧面边界" debug = {"原头发mask": origin_matting, "边界带(重绘区)": mask} return mask, info, debug # ============================================================ # 方案2: MediaPipe 478关键点 → 额头多边形 # ============================================================ def _method_mediapipe(img, band_width): """用 MediaPipe Face Mesh 的额头/太阳穴/眉毛关键点构造额头多边形。 额头多边形顶点(MediaPipe索引): 发际线侧:10(顶), 151(上沿), 104(右太阳穴上), 333(左太阳穴上), 346(右发际侧) 眉毛峰值(下界):70(左眉峰), 300(右眉峰) 眉间(下界中):8(眉间上) 多边形 = [左太阳穴上 → 顶 → 右太阳穴上 → 右发际侧 → 右眉峰 → 眉间 → 左眉峰 → 左太阳穴下] 然后膨胀成带(band_width)。 """ global _mp_detector try: if _mp_detector is None: import mediapipe as mp from mediapipe.tasks import python from mediapipe.tasks.python import vision os.environ.setdefault("GLOG_minloglevel", "3") # 抑制 mediapipe 日志 base = python.BaseOptions(model_asset_path=MP_MODEL) opts = vision.FaceLandmarkerOptions(base_options=base, num_faces=1) _mp_detector = vision.FaceLandmarker.create_from_options(opts) import mediapipe as mp h, w = img.shape[:2] mp_img = mp.Image(image_format=mp.ImageFormat.SRGB, data=cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) res = _mp_detector.detect(mp_img) if not res.face_landmarks: return _fallback_empty("MediaPipe未检测到人脸") lm = res.face_landmarks[0] # 额头多边形关键点索引(顺时针) # 左太阳穴上(104) → 顶(10) → 右太阳穴上(333) → 右发际侧(346) # → 右眉峰(300) → 眉间(8) → 左眉峰(70) → 回到左太阳穴 poly_indices = [104, 103, 67, 109, 10, 337, 332, 333, 299, 346, 300, 293, 8, 63, 105, 107, 70, 63] poly_indices = [104, 109, 10, 338, 333, 346, 300, 8, 70] # 精简版 pts = [] for idx in poly_indices: if idx < len(lm): p = lm[idx] pts.append((int(p.x * w), int(p.y * h))) if len(pts) < 3: return _fallback_empty("MediaPipe关键点不足") # 画多边形 mask = np.zeros((h, w), dtype=np.uint8) pts_arr = np.array(pts, dtype=np.int32) cv2.fillPoly(mask, [pts_arr], 255) # 关键点可视化(用于debug图) vis = img.copy() for i, idx in enumerate(poly_indices): if idx < len(lm): p = lm[idx] x, y = int(p.x * w), int(p.y * h) cv2.circle(vis, (x, y), 4, (0, 255, 255), -1) cv2.putText(vis, str(idx), (x+4, y-4), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1) cv2.polylines(vis, [pts_arr], True, (0, 255, 0), 2) # 膨胀成带(让边缘羽化) bw = max(1, int(band_width)) mask_band = cv2.dilate(mask, np.ones((bw, bw), np.uint8)) info = f"MediaPipe法(478点):额头多边形(顶点10/104/333等)+眉毛峰70/300作下界,膨胀带={bw}px。约束:下不超眉毛,左右不超太阳穴" debug = {"MediaPipe关键点+多边形": vis, "额头多边形": mask, "膨胀后(重绘区)": mask_band} return mask_band, info, debug except Exception as e: return _fallback_empty(f"MediaPipe失败: {e}") # ============================================================ # 方案3: 现有1k关键点 → 圆角矩形额头区 # ============================================================ def _method_landmark_1k(origin_matting, landmarks_1k, band_width, height_ratio=0.432, width_ratio=0.144, corner_ratio=0.25, vertical_offset=0.0): """用现有1k模型的关键点(眉毛/眼睛/脸轮廓)构造【圆角矩形】额头区。 形状参数(可调): - height_ratio: 高度缩放比(相对原矩形高度,越小越窄) - width_ratio: 左右各扩展比例(相对眉宽,越大越宽) - corner_ratio: 圆角半径比例(相对短边) - vertical_offset: 上下平移比例(相对额头高度,正值下移) 下界:眉毛最高点(137索引 121:129 右眉, 129:137 左眉) 左右界:脸轮廓太阳穴(向左右扩展) 上界:头发mask的上边界 """ from utils.landmark_processor import pts_1k_to_137 try: lm137 = pts_1k_to_137(np.asarray(landmarks_1k)) h, w = origin_matting.shape[:2] # 下界:眉毛峰值(137索引 121:129 右眉, 129:137 左眉) brow_y = [] for seg in [lm137[121:129], lm137[129:137]]: if len(seg) > 0: brow_y.append(int(seg[:, 1].min())) # 眉毛最高点(y最小) if not brow_y: return _fallback_empty("1k关键点无眉毛点") brow_top = min(brow_y) # 取最高的眉毛点 # 左右界:眉尾向外扩展(左右长一点) left_brow_x = int(lm137[129:137][:, 0].min()) # 左眉最左 right_brow_x = int(lm137[121:129][:, 0].max()) # 右眉最右 brow_width = right_brow_x - left_brow_x # 左右各扩展 width_ratio(向两侧扩,默认0.144) extend = int(brow_width * width_ratio) left = max(0, left_brow_x - extend) right = min(w, right_brow_x + extend) # 上界:头发mask在左右界范围内每列的最上像素 sub_hair = origin_matting[:, left:right+1] if right > left else origin_matting hair_rows = np.where(sub_hair.max(axis=1) > 30)[0] hair_top = int(hair_rows.min()) if len(hair_rows) > 0 else 0 # 基础矩形 raw_top = max(0, hair_top) raw_bottom = min(h, brow_top) raw_left = max(0, left) raw_right = min(w, right) if raw_bottom <= raw_top or raw_right <= raw_left: return _fallback_empty("额头矩形无效") # ★ 上下短一点:以矩形中心为基准,高度缩到 height_ratio cy = (raw_top + raw_bottom) / 2.0 half_h = (raw_bottom - raw_top) / 2.0 * height_ratio # ★ vertical_offset 上下平移(正值下移,相对额头高度) forehead_h = raw_bottom - raw_top offset_px = forehead_h * vertical_offset top = int(max(0, cy - half_h + offset_px)) bottom = int(min(h, cy + half_h + offset_px)) # ★ 圆角矩形 corner_radius = int(min(bottom - top, raw_right - raw_left) * corner_ratio) mask = _rounded_rect((h, w), raw_left, top, raw_right, bottom, corner_radius) # 可视化关键点 + 圆角矩形描边 vis = np.zeros((h, w, 3), dtype=np.uint8) vis[:, :, 1] = origin_matting # 头发mask用绿色显示 cv2.rectangle(vis, (raw_left, raw_top), (raw_right, raw_bottom), (60, 60, 60), 1) # 原矩形(灰) # 描圆角矩形边 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cv2.drawContours(vis, contours, -1, (0, 0, 255), 2) # 圆角矩形(红) for seg, color in [(lm137[121:129], (255, 255, 0)), (lm137[129:137], (255, 255, 0))]: for p in seg: cv2.circle(vis, (int(p[0]), int(p[1])), 3, color, -1) # 膨胀 bw = max(1, int(band_width)) mask_band = cv2.dilate(mask, np.ones((bw, bw), np.uint8)) info = (f"1k关键点法(圆角矩形):原矩形[{raw_left},{raw_top},{raw_right},{raw_bottom}] → " f"高度×{height_ratio}(上下短) 宽度+{width_ratio*2*100:.0f}%(左右长) 圆角r={corner_radius} " f"下移{offset_px:.0f}px(offset={vertical_offset})。" f"下界=眉毛峰 左右界=眉尾扩展 上界=头发顶") debug = {"1k关键点+圆角矩形(灰=原矩形/红=优化后)": vis, "圆角矩形mask": mask, "膨胀后(重绘区)": mask_band} return mask_band, info, debug except Exception as e: return _fallback_empty(f"1k关键点失败: {e}") def _rounded_rect(size, x1, y1, x2, y2, r): """画圆角矩形 mask(白色填充)。r=圆角半径。""" h, w = size mask = np.zeros((h, w), dtype=np.uint8) r = max(1, min(r, (x2 - x1) // 2, (y2 - y1) // 2)) # 中间矩形(去掉四角的圆角区) cv2.rectangle(mask, (x1 + r, y1), (x2 - r, y2), 255, -1) cv2.rectangle(mask, (x1, y1 + r), (x2, y2 - r), 255, -1) # 四个角的扇形 cv2.ellipse(mask, (x1 + r, y1 + r), (r, r), 180, 0, 90, 255, -1) # 左上 cv2.ellipse(mask, (x2 - r, y1 + r), (r, r), 270, 0, 90, 255, -1) # 右上 cv2.ellipse(mask, (x1 + r, y2 - r), (r, r), 90, 0, 90, 255, -1) # 左下 cv2.ellipse(mask, (x2 - r, y2 - r), (r, r), 0, 0, 90, 255, -1) # 右下 return mask # ============================================================ # 方案4: 复用3分类DeepLab(skin/hair边界) # ============================================================ def _method_deeplab(img, origin_matting, landmarks_1k, band_width): """用项目现有的3分类DeepLab(bg/skin/hair)找skin与hair的交界线。 发际线 = skin区域中紧邻hair的像素带。 用 Evaluator(gpu, nclass=3).eval(image, landmark1k) 得到分割图。 """ _deeplab_model = getattr(_method_deeplab, "_model", None) try: import torch from core.seg.hairseg_single_model import Evaluator if _deeplab_model is None: model_root = os.path.join(os.path.dirname(__file__), "weights") model_path = os.path.join(model_root, "deeplabv3_hair512_360_0520_wl.pth") _deeplab_model = Evaluator(gpu_id=0, output_img_size=512, nclass=3, seg_model_path=model_path) _method_deeplab._model = _deeplab_model # 缓存 with torch.no_grad(): seg = _deeplab_model.eval(img, np.asarray(landmarks_1k)) # 0=bg, 128=skin, 255=hair if seg is None: return _fallback_empty("DeepLab分割失败") skin = ((seg > 60) & (seg < 200)).astype(np.uint8) * 255 # skin hair = (seg > 200).astype(np.uint8) * 255 # hair # 发际线带 = skin侧紧邻hair的带:skin膨胀后 ∩ hair的边界带 bw = max(2, int(band_width)) kernel = np.ones((bw, bw), np.uint8) hair_band = _boundary_band(hair, kernel) # hair边界带 skin_dilate = cv2.dilate(skin, kernel) # skin膨胀 hairline = cv2.bitwise_and(hair_band, skin_dilate) # 二者交集≈发际线 # 用眉毛点约束下界(不跑到眉毛以下) from utils.landmark_processor import pts_1k_to_137 lm137 = pts_1k_to_137(np.asarray(landmarks_1k)) brow_y = [] for s in [lm137[121:129], lm137[129:137]]: if len(s) > 0: brow_y.append(int(s[:, 1].min())) if brow_y: brow_top = min(brow_y) hairline[brow_top:, :] = 0 # 可视化分割结果(伪彩色) seg_vis = cv2.applyColorMap(seg, cv2.COLORMAP_JET) info = f"DeepLab法(3分类):skin/hair交界线带(band={bw}px),下界=眉毛峰。复用现有triseg模型,无需新依赖" debug = {"3分类(蓝=bg/绿=skin/红=hair)": seg_vis, "skin/hair交界带(发际线)": hairline} return hairline, info, debug except ImportError as e: return _fallback_empty(f"DeepLab模块未找到: {e}") except Exception as e: return _fallback_empty(f"DeepLab失败: {e}") # ============================================================ # 工具函数 # ============================================================ def _boundary_band(mask, kernel): """形态学梯度边界带:dilate - erode""" binary = (mask > 10).astype(np.uint8) * 255 dilated = cv2.dilate(binary, kernel) eroded = cv2.erode(binary, kernel) return cv2.subtract(dilated, eroded) def _fallback_empty(reason): """方案失败时的空mask回退""" return None, f"⚠️ {reason}", {} def make_overlay(img, mask, alpha=0.45, color=(0, 0, 255)): """把 mask 半透明叠加到 img 上(红色高亮重绘区域)。 参数: img: BGR ndarray mask: 灰度mask (0/255 或 0-255) alpha: 透明度 0~1(mask区域的颜色强度) color: 高亮颜色 BGR,默认红色 返回: 叠加后的 BGR ndarray """ if mask is None: return img.copy() m = (mask > 10).astype(np.float32) if m.ndim == 3: m = m[:, :, 0] overlay = img.copy().astype(np.float32) # mask区域混入颜色 for c in range(3): overlay[:, :, c] = overlay[:, :, c] * (1 - m * alpha) + color[c] * (m * alpha) # mask边界描边(更清晰) m_u8 = (m * 255).astype(np.uint8) contours, _ = cv2.findContours(m_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cv2.drawContours(overlay.astype(np.uint8), contours, -1, color, 1) return np.clip(overlay, 0, 255).astype(np.uint8)