初始化:换发型/换发色/训练发型服务

包含:
- hair_service_sd: 主服务(换发型/换发色/生发,端口8801)
- photo_service: LoRA调度+训练(端口32678)
- hair_grow_service: 调试测试页(端口8888,含4个测试页)
- 批量训练脚本(batch_train_hairstyles.py)
- 发际线mask自动识别(hairline_mask.py,4种方案)
- 手绘mask换发型(hair_swap_manual.py)
- 文档:README.md + LARGE_FILES.md + docs/

大文件(模型权重200G、训练数据123G)已排除,见 LARGE_FILES.md
OSS/COS密钥已脱敏为环境变量,原文件备份在本地
This commit is contained in:
xsl
2026-07-07 13:53:52 +08:00
commit 443cfa298f
312 changed files with 67065 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
# -*- 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/xsl/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分类DeepLabskin/hair边界)
# ============================================================
def _method_deeplab(img, origin_matting, landmarks_1k, band_width):
"""用项目现有的3分类DeepLabbg/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~1mask区域的颜色强度)
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)