做计算机视觉的朋友应该都碰到过这类需求:拿到一张人像图,想把人脸的眉毛、眼睛、嘴唇、头发、背景精确地分离出来。这个任务有个专属名词,叫做 face-parsing(人脸解析),本质上是对人脸图像做像素级语义分割,把每个像素归类到对应的五官或部件类别。目前落地最常用的一套组合就是 BiSeNet 搭配19类标签,模型跑一次,就能得到一张完整的人脸部位掩膜,直接用于美颜、虚拟试妆、背景替换等场景。这篇文章我不打算讲高深理论,而是从部署实操的角度,把环境准备、模型推理、后处理、常见坑位一步步拆开讲,目标是你照着操作就能跑通并拿到想要的19类分割结果。
1. face-parsing是什么:19类人脸语义分割的任务与价值
1.1 人脸解析 vs 普通分割:像素级理解要做的事
人眼看到一张人脸,能瞬间分辨出哪里是眼睛、哪里是嘴唇,但让机器做到这件事并不容易。人脸解析属于语义分割的一个细分方向,它要求模型对图像中的每一个像素打上一个类别标签,比如这个像素属于左眼、那个像素属于头发,而不是像目标检测那样只画一个框。
普通语义分割通常只区分物体的大类,比如人、车、树,而 face-parsing 更抠细节:同样是皮肤区域,还要区分脸颊、下巴、额头;同样是毛发,还要区分眉毛和头发。这种细粒度划分对模型能力的要求高了一截,因为相邻类别的颜色和纹理可能非常接近,比如左右眉毛区域,外观几乎对称,模型要具备很强的空间感知能力才能区分左右。
在应用层面,人脸解析是很多上层功能的地基。美颜App想单独提亮眼部区域,需要知道眼睛在哪里;视频会议想替换人脸背景,需要区分前景人脸与背景;虚拟试妆想让口红精准覆盖嘴唇,就得精确找到唇部边界。没有像素级的分割结果,这些精细操作就只能靠传统图像处理硬抠,效果和鲁棒性都很难保证。
1.2 19个类别到底怎么划分
BiSeNet 人脸解析常用的标准数据集是 CelebAMask-HQ,它把一张人脸图像划分成19个语义类别。这个类别定义在训练和推理时必须完全一致,否则输出的像素值对应的含义就全乱了。
这里贴一份常用的类别索引对照表,实际部署时最好把这份映射写成一个常量字典,方便后处理时查表:
| 索引 | 类别 | 索引 | 类别 |
|---|---|---|---|
| 0 | 背景 | 10 | 上嘴唇 |
| 1 | 皮肤 | 11 | 下嘴唇 |
| 2 | 左眉毛 | 12 | 头发 |
| 3 | 右眉毛 | 13 | 帽子 |
| 4 | 左眼睛 | 14 | 脸颊(预留给装饰) |
| 5 | 右眼睛 | 15 | 下巴 |
| 6 | 眼镜 | 16 | 脖子 |
| 7 | 左耳朵 | 17 | 衣服 |
| 8 | 右耳朵 | 18 | 耳饰 |
| 9 | 口唇 | - | - |
注意,索引13到17这几个类别在不同开源版本里可能存在调整,有的项目会把“脸颊”和“下巴”合并成“下脸区域”。下载模型权重时一定要确认它对应的类别顺序是否与上面一致,最稳妥的办法是读取训练代码里的标签列表,或者直接跑一张测试图检查输出。我遇到过最典型的坑就是:从某个仓库下载的 ONNX 模型声称是19类,实际跑出来索引15对应的是左脸颊而不是下巴,结果后处理时整个掩膜都歪了。
这个19类划分几乎覆盖了人像处理的主流需求:皮肤区域用来做磨皮,嘴唇区域做口红换色,头发区域做染发预览,眼镜区域做墨镜合成,背景区域做抠图替换。一次性拿到全部类别,下游就不用反复调用多个模型了。
2. BiSeNet选型与分析:为什么它能成为人脸解析的主力
2.1 BiSeNet核心结构:空间路径与语义路径协同
BiSeNet 是百度提出的双向分割网络,整体设计逻辑很直接:语义分割既要保留高分辨率的空间细节(比如眼睛边缘的锐利度),又要获取足够大的感受野来理解上下文(比如头发周围是背景),这两者通常是矛盾的。BiSeNet 用两条并行路径解决这个矛盾。
空间路径(Spatial Path)由三层卷积组成,步长分别设置为1、2、4,它能保留1/8分辨率的原始空间细节,让眉毛、眼角、唇线这些小结构不至于被过度下采样而模糊掉。语义路径(Semantic Path)则采用类似轻量级分类网络的骨架,连续下采样到1/32分辨率,虽然空间信息丢失严重,但每个位置的特征都蕴含了大范围的上下文信息,能帮助模型判断“这块红色区域到底是嘴唇还是皮肤”。
两条路径的特征最终通过注意力优化模块(Feature Fusion Module)融合,再丢到解码器里逐级上采样恢复到原始分辨率。这种结构的好处是:空间细节和语义信息各自走各自擅长的路子,最后合并,训练起来收敛快,推理开销也不大,非常契合人脸解析这种对边缘精度和实时性都有要求的任务。
2.2 部署视角下的优劣势评估
我部署过不少分割网络,综合对比下来,BiSeNet 在几个维度上对我特别友好。
第一是模型体积。标准的 BiSeNet 配合 ResNet18 骨架,转换成 ONNX 之后大概在 100MB 左右,如果换用 MobileNet 这类轻量骨架,可以压到 30MB 以内。对比一些大分割模型动辄几百MB的体量,BiSeNet 在移动端和嵌入式设备上的可操作性要高很多。我用 TensorRT 对 ResNet18 版做 FP16 加速后,在 GTX 1660 上单张512x512推理只要 8ms 左右,放到手机端配合 NCNN 也能跑到 30fps 上下。
第二是效果够用。人脸部位类别不太多,不像自动驾驶场景动辄几十上百类,BiSeNet 这种两路深度的设计完全能覆盖。在 CelebAMask-HQ 上,常规训练出来的 mIoU 能到85以上,对部署来说这个精度已经足够支撑美颜滤镜和特效合成。
第三是转换友好。BiSeNet 的基本运算都是卷积、批归一化、ReLU、双线性上采样这类标准算子,ONNX 导出不会遇到奇怪的定制算子。对比某些带可变形卷积或自定义ROI的网络,BiSeNet 在 TensorRT、OpenVINO、NCNN 这些推理框架上几乎都能无痛迁移,这也让我省了不少适配时间。
不过它也有短板。对非常极端的姿态、大面积遮挡(比如手挡半张脸)或超大逆光场景,BiSeNet 分割出的边界偶尔会有毛刺。部署时如果对边界精度要求高,可以加一层轻量的边缘优化后处理,或者用更高的输入分辨率跑推理,但速度和效果的平衡需要自己拿捏。
3. 部署实战全过程:从模型加载到推理输出
3.1 环境依赖与模型准备
我这次演示用的组合是 Python + ONNX Runtime,为啥选它?因为 ONNX Runtime 跨平台,CPU、GPU都能跑,安装一条命令搞定,而且它不需要锁定 CUDA 版本,比直接加载 PyTorch 模型在服务器上部署要省心得多。如果你在嵌入式设备上跑,可以换成 NCNN 或 MNN,但推理流程的基本逻辑是一样的。
环境准备就两样东西:Python 3.8以上,装好 onnxruntime-gpu 和 OpenCV。打开终端执行:
pip install onnxruntime-gpu opencv-python numpy如果只打算用CPU跑,把 onnxruntime-gpu 换成 onnxruntime 就行。
模型文件我建议直接找 ResNet18 版本的 BiSeNet ONNX 权重,网上很多开源的 face-parsing 仓库都会附带转好的 ONNX 文件,也可以从 PyTorch 权重自行导出。导出时特别留意输入输出张量的结构:输入一般是[1,3,512,512]的归一化张量,输出是[1,19,512,512]的 logits,19 对应类别数。如果导出脚本里带 softmax 层,输出就会变成概率分布,两者在后处理时的处理方式不同,心里要有数。
3.2 Python推理代码逐段拆解
先说整体流程:读取图片 → 预处理 → ONNX推理 → 后处理(argmax、上采样、调色板映射) → 可视化。下面我贴一段可以直接跑的代码,每一步的细节都在注释里:
import cv2 import numpy as np import onnxruntime as ort # 类别颜色调色板,按索引对应RGB PALETTE = [ (0, 0, 0), # 0 背景 (204, 0, 0), # 1 皮肤 (0, 204, 0), # 2 左眉毛 (0, 0, 204), # 3 右眉毛 (204, 0, 204), # 4 左眼睛 (0, 204, 204), # 5 右眼睛 # ... 其余类别自行补充 ] def preprocess(img, size=(512, 512)): # 保持纵横比缩放,剩余部分用灰色填充 h, w = img.shape[:2] scale = min(size[0] / w, size[1] / h) nw, nh = int(round(w * scale)), int(round(h * scale)) resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR) canvas = np.full((size[1], size[0], 3), 128, dtype=np.uint8) x_offset = (size[0] - nw) // 2 y_offset = (size[1] - nh) // 2 canvas[y_offset:y_offset + nh, x_offset:x_offset + nw] = resized # BGR -> RGB,归一化到[0,1] rgb = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 # HWC -> CHW 并加batch维度 tensor = rgb.transpose(2, 0, 1)[np.newaxis, ...] return tensor, (scale, x_offset, y_offset, w, h) def inference(onnx_path, img): session = ort.InferenceSession(onnx_path, providers=['CUDAExecutionProvider', 'CPUExecutionProvider']) input_name = session.get_inputs()[0].name output_name = session.get_outputs()[0].name tensor, meta = preprocess(img) result = session.run([output_name], {input_name: tensor})[0] # result shape: [1, 19, 512, 512],取batch0 logits = result[0] # 在通道维上取最大值索引 pred = np.argmax(logits, axis=0).astype(np.uint8) return pred, meta def postprocess(pred, meta, orig_shape): scale, x_offset, y_offset, w, h = meta # 裁剪掉填充区域 crop = pred[y_offset:y_offset + h, x_offset:x_offset + w] # 缩放回原图大小 mask = cv2.resize(crop, (orig_shape[1], orig_shape[0]), interpolation=cv2.INTER_NEAREST) # 映射为彩色图 vis = np.zeros((*mask.shape, 3), dtype=np.uint8) for idx, color in enumerate(PALETTE): vis[mask == idx] = color return mask, vis if __name__ == '__main__': img = cv2.imread('face.jpg') pred, meta = inference('bisenet_19.onnx', img) mask, vis = postprocess(pred, meta, img.shape[:2]) cv2.imwrite('mask.png', mask) cv2.imwrite('vis.png', vis) print('done, unique classes:', np.unique(mask))这段代码的关键点有三个。
第一,预处理采用“等比缩放+灰边填充”而不是直接拉伸到512×512。直接拉伸会把人脸压扁变形,分割边缘跟着走样。填充的灰色区域虽然不是人脸,但模型在训练时见过大量不同比例的图,对边缘的灰边也有一定适应性。推理完再根据缩放比例把填充部分裁掉,这样得到的掩膜跟原图坐标严格对应。
第二,np.argmax是在第0维,也就是19个类别通道上做选择。这里要注意 logits 的通道轴位置,不同模型导出的布局可能不一样,最好加一句print(result.shape)确认是[1, 19, 512, 512],如果是[1, 512, 512, 19],就需要先转置。
第三,后处理缩放时必须用INTER_NEAREST最近邻插值,不能用线性插值,因为这是类别索引图,线性插值会在两个类别的边界上产生不存在的中间值,导致掩膜出现杂色像素。
3.3 后处理与可视化:把类别张量变成彩色掩膜
拿到 argmax 的结果后,数据形态是一张灰度标签图,每个像素的值是0到18的整数。想直观查看效果,就得把标签图映射成彩色图。上面的代码用了自定义的调色板,实际工作中更省事的做法是直接用 OpenCV 自带的applyColorMap,不过它只有几种固定映射,显示效果一般。我建议自己准备一份24色的调色板,把相近的类别用相近颜色表示,比如左眼和右眼用不同深浅的蓝,嘴唇用红色系,这样肉眼检查分割质量时一目了然。
除了可视化,后处理里还有一个容易忽略的点:坐标对齐。如果你的下游业务需要精确的像素位置,比如在嘴唇区域上色,那么后处理返回的 mask 必须和原图保持完全相同的尺寸。上面代码已经做了缩放还原,但注意缩放时宽高比例可能导致目标坐标有小偏差,如果需要亚像素级精度,可以直接在预处理时把图片 reszie 到固定尺寸,不做等比缩放,这样掩膜天然对齐,代价是人脸比例变形导致分割质量下降。实际项目中两者需要权衡。
4. 踩坑实录:常见问题与性能优化技巧
4.1 高频问题排查表
部署过程中遇到的各种问题,大部分集中在上文提到的几个环节上。我这里整理了一份踩坑速查表,每个问题都是我或身边同事真实遇到过的案例。
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 输出全是0/背景 | 输入没有做归一化,或者通道顺序弄反了 | 检查是否除以255,确认输入是RGB且经过标准化 |
| 掩膜边缘有双影 | 预处理用了拉伸缩放,后处理插值选错 | 改用等比填充,或者后处理用INTER_NEAREST |
| 类别对不上:嘴唇预测成了皮肤 | 模型文件与类别顺序不一致 | 用一张标准人脸图跑推理,对比输出和真实类别 |
| CPU推理要1秒以上 | 输入分辨率太大,或者模型未量化 | 降低输入到256或384,尝试INT8量化 |
| ONNX Runtime报错找不到算子 | 模型用了过旧的算子版本 | 升级onnxruntime,或重新导出模型指定opset=12 |
| 内存不够/显存溢出 | batch大小或输入尺寸过高 | 设置单batch推理,小图分块预测 |
其中“类别对不上”是最隐蔽的坑。有一次我拿开源权重部署,结果头发区域全部被识别成帽子,排查了半天,发现是那个权重把帽子类放在索引12,头发放13,和标准顺序反了。如果你也遇到类似现象,别慌,跑一张带明显发型特征的图,把输出每个类别的像素数打印出来,跟真实特征一对比就知道错在哪了。
另一个隐蔽问题是 OpenCV 和 PyTorch 的通道顺序差异。OpenCV 读进来是 BGR,如果你直接喂给按 RGB 训练的模型,颜色通道反了,分割结果通常会乱套,典型表现是发色偏蓝。网上有些示例代码没做转换,跟着抄就踩坑。
4.2 提高推理速度的三个方向
如果想把 face-parsing 落地到实时场景,比如视频流里的背景替换,推理速度就是头等大事。我实测下来,有三个方向性价比最高。
第一个方向是降低输入分辨率。很多人一上来就用512×512,其实人脸分割对边缘要求没那么极致,把输入压到256×256,推理时间能缩短一半以上,而mIoU可能只掉一两个点。我一般先用256调试功能,最后上线前再根据业务要求权衡。
第二个方向是切换推理引擎。ONNX Runtime 在CPU上其实不算快,换成 TensorRT 的 FP16 推理,在NVIDIA显卡上能有4到6倍提速。转换方法不复杂,用trtexec --onnx=bisenet.onnx --fp16就能生成 engine 文件,之后在代码里用 TensorRT Python API 加载。如果部署在服务端,这是性价比最高的优化。
第三个方向是模型骨架替换。把 ResNet18 换掉 MobileNetV3 或 BiSeNet 自带的轻量骨架,参数量骤降。代价是训练需要重新来一轮,但效果损失通常可控,尤其是人脸这种相对固定结构的任务。
另外一个小技巧是批量推理。如果后端有多路视频流,与其单帧逐次调用,不如把多帧拼成一个batch同时推理。ONNX Runtime 支持动态 batch,只要模型导出时设置动态维度就行。批量推理能摊平 CPU/GPU 的调度开销,吞吐量提升非常明显。
5. 应用场景扩展:人脸解析能玩出什么花
5.1 美颜与虚拟试妆
拿到了19类掩膜后,美颜功能就变成了“按区域做图像处理”。比如磨皮,只需要在皮肤和脸颊区域做高斯滤波,但保留眼睛、眉毛、嘴唇区域不动,这样既能淡化痘印,又不会把五官细节糊掉。OpenCV 里用cv2.bilateralFilter可以在目标掩膜区域内做保边平滑,配合cv2.seamlessClone做无缝融合,效果比全局磨皮自然得多。
虚拟试妆直接把颜色填充到对应掩膜上就行。上唇和下唇类别分别处理,可以模拟咬唇妆、渐变唇的效果;眉毛区域重新着色,可以预览染眉;头发区域做色相替换,可以展示染发颜色。只要掩膜够准,试妆效果就非常贴近真实。
有一个细节:填充颜色时不要直接对原图赋值,否则会有明显的边界生硬感。建议用原图画一条曲线调整色相饱和度,再通过掩膜混合到原图上,边缘用很小的羽化(比如对掩膜做一次比原尺寸小一点的卷积)过渡一下。
5.2 背景替换与特效合成
背景替换是最常见的人像应用。先用 face-parsing 得到背景类别(索引0),把背景区域抠掉,换绿幕、换风景图、加模糊都可以。和传统抠图(比如人像分割)相比,人脸解析的优势在于同时还能拿到头发边缘的精细遮罩。头发丝这种半透明区域,单纯靠像素分割可能会把碎发丢光,但 BiSeNet 的19类里头发是单独一类,配合一点边缘羽化,换背景后的效果会自然很多。
更进阶的玩法是各部件独立编辑。比如做一个“换眼镜”功能:先用 mask 把眼镜区域抠掉,再用一个眼镜素材贴上去,由于有分割掩膜,眼镜的旋转角度、阴影方向都能对应上。类似地,还可以做牙齿美白(需要额外训练牙齿类别,或在口唇区域内做颜色调整)、刘海遮罩、时尚特效等。
这些扩展步骤其实都是在mask和原图之间做像素级操作。掌握了 face-parsing 的推理流程,后续任何产品功能都只是叠加不同的图像处理算法。这也是我强烈建议把人脸解析做成一个独立微服务的原因:上游输出统一结构的结果,下游各种玩法按需调用,互不干扰。
6. 部署后的几点真经验
最后说几句我在实操里积累的小体会。第一次跑通 face-parsing 不难,真正花时间的是把类别顺序、预处理规范和坐标系跟下游对齐。很多人觉得后处理就是np.argmax那一下子,但恰恰是这些不起眼的细节决定了模型的输出能不能被业务直接用。我建议拿到一个新权重后,第一件事不是接业务,而是先跑几张不同人种、不同光照的测试图,把每个类别的可视化结果存下来人工看一遍,确认19类的语义完全符合预期再继续。
还有一点是模型更新流程要制度化。人脸解析模型重新训练后,类别顺序、输入尺寸、归一化参数都可能变化,如果没有一份版本化的配置清单,下次部署时百分之百会踩坑。我的习惯是把类别字典、归一化均值、输入尺寸写进一个 YAML 文件,代码统一从配置读取,避免硬编码。这套流程我已经用了很久,省掉了无数重复调试时间。