Files
hair/face_analysis/hair_segmenter.py
T
xslandClaude Opus 4.8 a9439e4975 接口1: 标注图人头最左/最右竖线改用耳朵分割外缘
- 最左/最右竖线由「头发轮廓」改为同一 BiSeNet 的耳朵类(7/8)外缘,
  看不到耳朵(被头发/侧脸遮挡→掩膜空)则该侧不画线
- 外耳轮廓常被误标成头发: 从耳朵外缘沿紧邻前景(耳∪发)按脸宽自适应
  外扩回收(上限 face_w*0.045, 遇背景间隙即停)
- 先按人脸包围盒裁剪再分割: BiSeNet 在紧裁人脸上训练, 整张全身/街拍图
  脸偏小会严重欠分割、丢耳朵; 裁剪后映射回原图, 耳朵稳定可分
- segment_hair_and_ears() 单次推理同出 hair_mask + ear_mask

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:36:23 +08:00

208 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""方案 BBiSeNet 头发/耳朵分割 + 发际线/头顶定位。
加载 face-parsing BiSeNetCelebAMask-HQ 19 类,hair=17、l_ear=7、r_ear=8),对整图
做像素级语义分割:得到头发 mask 用于沿面部中轴线扫描真实发际线与头顶,并得到耳朵
mask 供标注图取人头最左/最右竖线(耳朵外缘)。GPU 可用时走 CUDA,否则 CPU。
单例加载权重,避免每请求重载。详见技术方案 §1.4 / §4.0。
"""
import os
import cv2
import numpy as np
# ⚠️ torch / torchvision / BiSeNet 仅在 HairSegmenter.__init__ 内惰性导入,
# 使本模块的纯 numpy 函数 locate_hairline_by_segmentation 可在无 torch/GPU
# 的环境(如 Tier-1 合成几何测试、方案 A only 降级版)被安全导入。
_WEIGHTS = os.path.join(os.path.dirname(__file__), "weights", "79999_iter.pth")
HAIR_CLASS = 17 # CelebAMask-HQ 19 类中 hair 的索引
EAR_CLASSES = (7, 8) # 7=l_ear / 8=r_ear(类名以人为参照,图像左右另行判定,不依赖类名)
N_CLASSES = 19
_INPUT_SIZE = 512 # BiSeNet 推理输入边长
def _select_device(torch):
"""选择推理设备:优先 CUDA,但实测一次小算子确认当前 GPU 架构被本 torch 支持。
场景:本机为 RTX 5090sm_120/Blackwell),而 torch 2.2.2+cu121 仅编译到 sm_90
.cuda() 会在执行时抛 "no kernel image is available"。此处用一次小 matmul 探测,
失败则回退 CPUBiSeNet CPU 推理 ~0.31s/张,方案B 仍可用)。
换装支持 sm_120 的 torchcu128)后会自动改用 GPU,无需改代码。
可用环境变量 FORCE_CPU=1 强制 CPU。
"""
import os as _os
if _os.getenv("FORCE_CPU") == "1" or not torch.cuda.is_available():
return torch.device("cpu")
try:
_ = (torch.zeros(8, 8, device="cuda") @ torch.zeros(8, 8, device="cuda")).cpu()
return torch.device("cuda")
except Exception: # noqa: BLE001 GPU 架构不被支持 → 回退 CPU
return torch.device("cpu")
class HairSegmenter:
"""BiSeNet 头发分割封装。建议经 get_segmenter() 取单例。"""
def __init__(self, weights_path=_WEIGHTS):
import torch
import torchvision.transforms as transforms
from face_analysis.bisenet_model import BiSeNet
self._torch = torch
self.device = _select_device(torch)
self.net = BiSeNet(n_classes=N_CLASSES)
state = torch.load(weights_path, map_location="cpu")
self.net.load_state_dict(state)
self.net.to(self.device)
self.net.eval()
self._to_tensor = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
])
def _parse(self, image_bgr):
"""整图语义分割,返回原图尺寸的类别图(H×W int,值为 018 类别号)。"""
torch = self._torch
h, w = image_bgr.shape[:2]
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
resized = cv2.resize(rgb, (_INPUT_SIZE, _INPUT_SIZE),
interpolation=cv2.INTER_LINEAR)
inp = self._to_tensor(resized).unsqueeze(0).to(self.device)
with torch.no_grad():
out = self.net(inp)[0] # 主输出 (1, C, 512, 512)
parsing = out.squeeze(0).argmax(0).cpu().numpy() # (512, 512) 类别图
# 还原到原图尺寸(最近邻保持类别边界)
return cv2.resize(parsing.astype(np.int32), (w, h),
interpolation=cv2.INTER_NEAREST)
def segment_hair(self, image_bgr):
"""返回 hair_maskH×W boolTrue=头发),尺寸同输入原图。"""
return self._parse(image_bgr) == HAIR_CLASS
def _parse_face_cropped(self, image_bgr, face_box):
"""按人脸框裁剪后再分割,结果映射回原图尺寸(裁剪外填背景 0)。
BiSeNet 在 CelebAMask-HQ「紧裁对齐人脸」上训练,整张大场景图(全身/街拍,
脸只占一小块、背景复杂)会严重欠分割、丢耳朵。先按人脸放大裁剪,让脸接近
训练分布,耳朵/头发分割明显更稳。裁剪含足够上/侧边距以纳入发顶与双耳。
"""
h, w = image_bgr.shape[:2]
x0, y0, x1, y1 = face_box
fw, fh = max(1.0, x1 - x0), max(1.0, y1 - y0)
cx0 = int(max(0, x0 - fw * 0.8)); cx1 = int(min(w, x1 + fw * 0.8))
cy0 = int(max(0, y0 - fh * 1.0)); cy1 = int(min(h, y1 + fh * 0.5))
if cx1 - cx0 < 2 or cy1 - cy0 < 2:
return self._parse(image_bgr)
full = np.zeros((h, w), dtype=np.int32)
full[cy0:cy1, cx0:cx1] = self._parse(image_bgr[cy0:cy1, cx0:cx1])
return full
def segment_hair_and_ears(self, image_bgr, face_box=None):
"""单次推理返回 (hair_mask, ear_mask),均为 H×W bool,尺寸同原图。
ear_mask = 左耳(7) ∪ 右耳(8);耳朵被头发/侧脸遮挡时对应区域天然为空,
正好用于「看不到耳朵就不画线」的判定。两类合并、左右按图像位置另判,
不依赖以人为参照的类名(详见 EAR_CLASSES 注释)。
face_box=(x0,y0,x1,y1)(人脸关键点包围盒像素坐标)给定时先按人脸裁剪再
分割(见 _parse_face_cropped),整张大场景图也能稳定分出耳朵;不给则整图分割。
"""
if face_box is None:
parsing = self._parse(image_bgr)
else:
parsing = self._parse_face_cropped(image_bgr, face_box)
hair_mask = parsing == HAIR_CLASS
ear_mask = np.isin(parsing, EAR_CLASSES)
return hair_mask, ear_mask
_segmenter = None
def get_segmenter():
"""惰性单例:首次调用时加载权重(并占用显存),后续复用。"""
global _segmenter
if _segmenter is None:
_segmenter = HairSegmenter()
return _segmenter
def locate_hairline_by_segmentation(hair_mask, brow_center_x, image_height):
"""从头发 mask 定位发际线与头顶。
Args:
hair_mask: H×W bool/uint8True=头发。
brow_center_x: 面部中轴线 x(像素)。
image_height: 图高(保留参数,便于后续边界判断)。
Returns:
(hairline_y, hair_top_y) 像素坐标;失败返回 None(交给方案 A 兜底)。
"""
if hair_mask is None:
return None
mask = np.asarray(hair_mask).astype(bool)
if mask.sum() == 0:
return None
w = mask.shape[1]
cx = int(round(brow_center_x))
cx = max(0, min(cx, w - 1))
# 中轴线附近窄列带(±3px)求稳,避免单列噪声
band = mask[:, max(0, cx - 3): min(w, cx + 4)]
col = band.any(axis=1)
hair_rows = np.where(col)[0]
if hair_rows.size == 0:
return None
# 发际线:中轴线列带上头发区域最靠下的行(头发→皮肤交界,y 向下为正)
hairline_y = int(hair_rows.max())
# 头顶:整张头发 mask 的最高点(最小 y),用全图更鲁棒
top_rows = np.where(mask.any(axis=1))[0]
hair_top_y = int(top_rows.min())
# 合理性校验:头顶必须严格在发际线上方
if hair_top_y >= hairline_y:
return None
return hairline_y, hair_top_y
if __name__ == "__main__":
import sys
from face_analysis.detector import detector
from face_analysis.calibration import normalized_to_pixel
from face_analysis.face_mesh_landmarks import GLABELLA_9, GLABELLA_151
path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg"
img = cv2.imread(path)
if img is None:
print(f"无法读取图片: {path}")
sys.exit(1)
h, w = img.shape[:2]
segmenter = get_segmenter()
print("device:", segmenter.device)
mask = segmenter.segment_hair(img)
print("hair pixels:", int(mask.sum()))
lms = detector.detect(img)
if lms is None:
print("未检出人脸,跳过定位")
sys.exit(0)
lm = lms.landmark
bx = (normalized_to_pixel(lm[GLABELLA_9], w, h)[0]
+ normalized_to_pixel(lm[GLABELLA_151], w, h)[0]) / 2
by = (normalized_to_pixel(lm[GLABELLA_9], w, h)[1]
+ normalized_to_pixel(lm[GLABELLA_151], w, h)[1]) / 2
res = locate_hairline_by_segmentation(mask, bx, h)
if res is None:
print("定位失败(将回退方案 A)")
else:
hairline_y, hair_top_y = res
print(f"brow_y={by:.1f} hairline_y={hairline_y} hair_top_y={hair_top_y}")
print("自洽校验 hair_top_y < hairline_y < brow_y:",
hair_top_y < hairline_y < by)
# dump mask 预览
os.makedirs("tests/output", exist_ok=True)
cv2.imwrite("tests/output/hair_mask.png", (mask.astype(np.uint8) * 255))
print("mask 预览已存 tests/output/hair_mask.png")