简介:一份基于MODNet与ONNX的Python部署方案,面向需要图像、视频及摄像头实时抠图的开发者,解决无需trimap即可自动分离前景与背景的落地问题。资源共11个文件,压缩包26.29MB,包含Python主程序(main.py、image_display.py)、ONNX格式模型、若干png/jpeg测试图片与视频样本,便于直接运行与结果对比。目前已有1941人学习下载,适合具备Python与深度学习推理基础、希望快速上手MODNet的读者。压缩包内目录划分清晰,images/提供测试图、output/存放处理结果、onnx_model/保存模型、video/放入测试视频,可完整体验从模型加载、预处理到推理输出的流程。MODNet在发丝级细节上表现优秀,适用于视频编辑、虚拟背景、直播等场景;同时需注意CPU执行较慢,推荐配置GPU以提升实时处理性能。 做这个ModNet部署的起因其实挺实际——有朋友问能不能搞一套轻量级的人像抠图方案,别上分割模型那套重型框架,就想要个能在普通电脑上跑起来、既能处理单张图也能实时处理视频和摄像头画面的工具。我调研了一圈,最后选定ModNet作为核心模型,推理引擎用ONNX Runtime,语言自然就是Python,一把梭到底。
先说结论:这套方案完整跑通了三个场景——单张人像图抠图、离线视频逐帧抠像、摄像头实时抠像。整个工程下来,核心就是一个Python类加一个ONNX模型,代码量不大。但对新手来说坑确实不少,尤其是模型转换和预处理这两块,网上资料说法不一,我踩了一圈坑才把流程理顺。下面按我自己的实操路径来写,从方案选型到环境搭建,再到三个场景的代码实现,最后是性能和常见问题。适合想做AI抠图的朋友参考,也适合第一次接触ONNX部署的同学看看PyTorch模型是怎么变成跨平台推理服务的。
1. 项目概述与方案选型
1.1 matting 和 segmentation 的区别,为什么选 ModNet
先澄清一个容易混淆的概念。图像分割(segmentation)输出的是硬掩码,每个像素要么是前景要么是背景,像素值就是0或1;而matting输出的是alpha matte,取值范围在0到1之间连续变化,专门用来刻画头发丝、半透明纱巾这类边缘细节。换句话讲,分割给的是“这是人”的判断,matting给的是“这个像素有多像人”的置信度。做抠图合成时,alpha值直接参与前景背景的加权混合,边缘自然就柔和很多。
ModNet这个名字是Matting Object Detection Network的缩写,2020年发布,核心卖点就是轻量实时。整模型基于MobileNetV2主干,权重文件只有二十多MB,输入分辨率512x512,在普通CPU上单帧推理能控制在100毫秒以内。相比U2Net、BackgroundMattingV2这些模型,ModNet在速度和体积上优势明显,而且官方直接给了训练好的肖像抠图权重,不需要自己准备数据集训练,拿来就能用。
1.2 为什么选 ONNX Runtime + Python
模型确定之后,部署框架的选择其实没必要纠结。ONNX Runtime是微软开源的推理引擎,支持PyTorch、TensorFlow等主流框架导出的ONNX模型,跨平台跑,Windows、Linux、macOS通吃。用它的理由很简单:第一,ONNX是模型交换格式,以后换推理后端不用重新导出;第二,ONNX Runtime对CPU推理做了深度优化,还有OpenVINO、TensorRT这类加速插件可以切换;第三,Python接口成熟,配合OpenCV处理图像简直顺手,整个推理链路写起来非常短。
有人可能会问,为什么不直接用PyTorch推理?说实话,如果只是自己跑着玩,PyTorch完全够用。但如果考虑部署到别的机器、嵌入到别的项目,总不能每台机器都装一套PyTorch环境。ONNX Runtime是个独立的推理库,安装包小、依赖少,部署成本低一个量级。而且ONNX Runtime在CPU上的推理速度通常比PyTorch的CPU模式还要快,这点我在后面实测数据里会体现。
2. 环境准备与模型获取
2.1 环境依赖安装
整个项目依赖非常克制,三个包就够:
pip install onnxruntime opencv-python numpy如果你用的是conda环境,建议先建一个干净环境再装,避免和别的项目冲突:
conda create -n matting python=3.9 conda activate matting pip install onnxruntime opencv-python numpy版本方面没有特殊要求,onnxruntime 1.14以上的版本都行,opencv推荐4.5以上。唯一要注意的是Python版本不要太新,3.10以下最稳妥,有些旧版onnxruntime对3.11的兼容有点问题。我最早在Python 3.11上装onnxruntime 1.13踩过坑,编译报错,换3.9就一路顺畅。
2.2 模型下载与 ONNX 转换
ModNet官方仓库(GitHub上的ZhuGeKexin/ModNet)提供了PyTorch训练的checkpoint文件,主要有两个:
| 权重文件 | 训练场景 | 特点 |
|---|---|---|
| modnet_photographic_portrait_matting.ckpt | 静态图像 | 单帧推理,结构简单 |
| modnet_webcam_portrait_matting.ckpt | 摄像头/视频 | 包含时序分支,输出隐藏状态 |
这里有个重要的选择点。webcam版权重包含一个V分支(时序分支),推理时需要把上一帧的hidden state作为额外输入,这样能保证视频帧之间的alpha平滑不闪烁。但引入时序状态会让ONNX导出和部署复杂不少,得维护两个输入输出。如果只是做本地工具、对画面稳定性要求不是极端苛刻,直接用photographic权重逐帧推理就够了,省心。我实际测下来,静态权重跑视频会有轻微的边缘抖动,但离远了看不明显,多数场景能接受。
我在项目里用的是photographic权重。导出ONNX的脚本核心片段如下:
import torch from src.models.modnet import ModNet model = ModNet(backbone='mobilenetv2', pretrained=True).eval() ckpt = torch.load('modnet_photographic_portrait_matting.ckpt', map_location='cpu') model.load_state_dict(ckpt['state_dict']) dummy_input = torch.randn(1, 3, 512, 512) torch.onnx.export( model, dummy_input, 'modnet.onnx', input_names=['input'], output_names=['output'], opset_version=11, dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}} )导出时opset_version建议11以上,低于11某些算子可能不被支持。dynamic_axes把batch维度设置成动态,这样导出后的模型既能跑单张图,也能跑batch推理,灵活性大一些。注意,如果加载权重时遇到键名不匹配,检查一下ckpt['state_dict']里的key是否带module.前缀,带的话用load_state_dict(new_ckpt, strict=False)也能过。我第一次转就栽在这上面,调了半天才发现是权重键名前缀的问题。
3. 核心代码:预处理、推理、后处理
3.1 预处理细节,最容易翻车的地方
预处理是整个部署流程里最容易被忽视也最容易出错的部分。ModNet的训练管道里,图像会先缩放到512x512,然后除以255归一化到[0,1],再按ImageNet数据集的均值方差做标准化,均值是[0.485, 0.456, 0.406],标准差是[0.229, 0.224, 0.225]。这套标准化参数来自ImageNet分类任务的统计值,很多视觉模型都在用,但如果你忘了做这一步或者参数写错,输出的alpha会歪得离谱,人物的边缘糊成一片。
另一个坑是通道顺序。OpenCV读图默认是BGR顺序,而ModNet训练时用的是RGB顺序,所以预处理时一定要做一次通道翻转。我封装了这样一个预处理函数:
def preprocess(self, bgr_img): h, w = bgr_img.shape[:2] # 保持长宽比的 letterbox 缩放 scale = 512 / max(h, w) new_w, new_h = int(w * scale), int(h * scale) resized = cv2.resize(bgr_img, (new_w, new_h)) canvas = np.zeros((512, 512, 3), dtype=np.uint8) y_off = (512 - new_h) // 2 x_off = (512 - new_w) // 2 canvas[y_off:y_off + new_h, x_off:x_off + new_w] = resized # BGR -> RGB,归一化,标准化 img = canvas[:, :, ::-1].astype(np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) img = (img - mean) / std img = img.transpose(2, 0, 1)[None, ...] return img.astype(np.float32), scale, x_off, y_off, (h, w)我选择了letterbox方式而不是直接拉伸到512x512,原因是直接拉伸会让宽幅照片里的人脸变形,影响抠图质量。letterbox会在保持长宽比的前提下缩放到512内的最大尺寸,四周用0像素填充。填充的这部分在推理时会被网络判定为背景,所以后处理阶段必须把padding区域裁掉,否则生成的alpha边缘会有一圈黑框。这个逻辑看着简单,实际代码里忘了处理padding是新手最常见的错误之一。
3.2 推理与后处理,alpha合成全流程
推理本身很简单,ONNX Runtime的接口就一个run方法。后处理的关键在于把输出从512x512恢复回原图分辨率,然后做前景背景合成。
class MattingEngine: def __init__(self, onnx_path): so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL self.session = ort.InferenceSession( onnx_path, so, providers=['CPUExecutionProvider'] ) self.input_name = self.session.get_inputs()[0].name def predict_alpha(self, bgr_img): tensor, scale, x_off, y_off, (h, w) = self.preprocess(bgr_img) out = self.session.run(None, {self.input_name: tensor})[0] alpha = out[0, 0] # 输出形状是 [1, 1, 512, 512] # 裁掉 padding 区域 new_h = int(h * scale) new_w = int(w * scale) alpha = alpha[y_off:y_off + new_h, x_off:x_off + new_w] alpha = cv2.resize(alpha, (w, h), interpolation=cv2.INTER_LINEAR) return np.clip(alpha, 0, 1) @staticmethod def composite(bgr, alpha, bg_bgr=None): a = alpha[..., None] if bg_bgr is None: b, g, r = cv2.split(bgr) a8 = (a * 255).astype(np.uint8) return cv2.merge([b, g, r, a8]) # BGRA 透明背景 bg = cv2.resize(bg_bgr, (bgr.shape[1], bgr.shape[0])) fused = bgr * a + bg * (1 - a) return fused.astype(np.uint8)这段代码是整个项目的核心骨架。predict_alpha接收一帧BGR图像,返回和原图同尺寸的alpha矩阵,值域在0到1之间。composite函数做两件事:如果不提供背景图,就输出带透明通道的BGRA四通道图,用于保存PNG;如果提供了背景图,就做一个标准的alpha blending,背景自动缩放成和前景一样大小。alpha blending的公式就是像素级加权求和,前景乘以alpha再加上背景乘以(1-alpha),理解成两张图叠在一起就好。
有个小细节需要注意:推理输出的alpha数值不一定严格在0~1区间内,会有略微的越界,合成前做一次np.clip防止后面的uint8转换产生溢出噪点。这个不处理的话,极少数像素颜色会异常偏亮或偏暗,虽然一般人不注意,但强迫症患者看不下去。
4. 三种场景的完整实现
4.1 图像 matting:静态图抠图
图像抠图是最直接的场景,读图、推理、合成、保存,四步搞定。
def image_matting(img_path, save_path, onnx_path='modnet.onnx'): engine = MattingEngine(onnx_path) img = cv2.imread(img_path) alpha = engine.predict_alpha(img) rgba = engine.composite(img, alpha) # 透明背景 cv2.imwrite(save_path, rgba)这里有个使用习惯要提一下。cv2.imwrite保存四通道PNG时是按BGRA顺序写入的,但很多看图软件和网页默认按RGBA解析,有的软件打开颜色会偏色。跨平台使用建议在保存前转成RGBA:
rgba_bgra = engine.composite(img, alpha) rgba = cv2.cvtColor(rgba_bgra, cv2.COLOR_BGRA2RGBA) cv2.imwrite('output.png', rgba)这样导出的PNG在正常浏览器、看图工具里颜色就正确了。图像场景下我对精度比较满意,头发丝区域虽然比专业商业软件略逊一筹,但胜在速度快,一张1920x1080的图从读入到保存大概三百毫秒,批量抠图完全可行。
4.2 视频 matting:离线视频抠像
视频matting本质就是逐帧调用图像matting,但有一个绕不开的问题:视频文件是三通道的,没法直接保存透明通道。解决办法是给每一帧合成到纯色背景或者自定义背景图上,然后写回视频。
def video_matting(src, dst, onnx_path='modnet.onnx', bg_path=None, solid_bg=(0, 255, 0)): engine = MattingEngine(onnx_path) cap = cv2.VideoCapture(src) fps = cap.get(cv2.CAP_PROP_FPS) width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) writer = cv2.VideoWriter( dst, cv2.VideoWriter_fourcc(*'mp4v'), fps, (width, height), ) bg = cv2.imread(bg_path) if bg_path else None frame_idx = 0 while True: ret, frame = cap.read() if not ret: break alpha = engine.predict_alpha(frame) if bg is not None: frame = engine.composite(frame, alpha, bg) else: solid = np.full_like(frame, solid_bg) frame = engine.composite(frame, alpha, solid) writer.write(frame) frame_idx += 1 cap.release() writer.release()实际跑视频时几个参数要提前想清楚。视频编码器我用的是mp4v,输出文件是MP4格式,兼容性最好;如果你要输出带透明通道的序列帧(比如后续进AE合成),改成保存PNG序列帧就行。另一个优化点是:视频处理是按真实帧率逐帧跑的,普通1080p视频在CPU上大约每秒处理6到8帧,意味着25fps的视频一秒钟内容要跑4秒左右,建议处理前先用低分辨率版本测试流程通不通,避免浪费大量时间跑完才发现合成参数不对。
4.3 摄像头 matting:实时预览
摄像头场景最考验速度,也最影响体验。目标只有一个:让预览画面看起来流畅,延迟在可接受范围内。
def camera_matting(cam_id=0, onnx_path='modnet.onnx', bg_path=None): engine = MattingEngine(onnx_path) cap = cv2.VideoCapture(cam_id) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_AUTOFOCUS, 0) bg = cv2.imread(bg_path) if bg_path else None while True: ret, frame = cap.read() if not ret: break frame = cv2.flip(frame, 1) # 镜像,更符合自拍习惯 alpha = engine.predict_alpha(frame) if bg is None: solid = np.full_like(frame, (120, 120, 220)) result = engine.composite(frame, alpha, solid) else: result = engine.composite(frame, alpha, bg) cv2.imshow('Matting', result) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()摄像头画布我固定到640x480,这个分辨率做实时推理是个平衡点。分辨率再高,CPU推理时间就压不住,画面会明显卡顿;再低的话,边缘细节损失严重,抠图质量肉眼可见下降。640x480在普通笔记本CPU上大概能跑到12到15帧,虽然没有满帧丝滑,但作为工具够用了,属于“能明显感觉有点卡但不影响使用”的范畴。
把摄像头输入分辨率限制在640x480还有一个隐藏好处——推理前的letterbox缩放开销也一并省了不少。摄像头画面通过cap.set设置分辨率后,读出来的帧本身就是接近4:3的比例,缩放时变形很小。
5. 性能优化与常见问题
5.1 实时性能优化:三板斧
摄像头场景帧率不够,最直接的办法不是换模型,而是改推理输入尺寸。ModNet输入默认512x512,如果把letterbox的目标尺寸从512降到384甚至320,推理时间几乎能砍半。我在384x384输入下实测,帧率能到22左右,边缘质量虽有下降,但摄像头预览画面本来就小,感知不明显。
第二个优化手段是换执行提供程序。如果你用的是Intel CPU,可以改用OpenVINO做推理后端:
session = ort.InferenceSession( onnx_path, providers=['OpenVINOExecutionProvider', 'CPUExecutionProvider'] )OpenVINO对Intel平台有专门的算子优化,同样条件下推理速度通常能再提升30%到50%。注意安装时要先装好openvino包:pip install openvino。如果是NVIDIA显卡,还可以用TensorRT EP,但配置要复杂一些,摄像头场景我不推荐,因为TensorRT加载引擎的初始化时间比较长。
第三板斧是跳帧。摄像头高帧率其实是一种资源浪费,因为视觉连续帧之间变化很小,alpha结果也基本一致。可以每两帧或每三帧才推理一次,中间跳过的帧直接复用上一次的alpha结果。加上这个策略,显示器刷新率是60Hz的情况下,预览看起来依然连贯,但CPU占用率能降一个台阶。
5.2 常见问题排查与避坑记录
我整理了一份自己踩坑过程中的排查清单,按遇到概率排了序:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 输出alpha全黑或全白 | 预处理没做RGB转换或均值方差不对 | 检查是否有BGR2RGB、是否正确归一化 |
| 人物边缘有一圈黑/白边框 | letterbox的padding区域没裁干净 | 后处理时按scale和offset裁剪 |
| 视频保存后画面颜色发怪 | 保存PNG透明图时BGRA/RGBA顺序不对 | 用cv2.cvtColor转成RGBA再保存 |
| 摄像头预览画面卡顿严重 | 输入分辨率太高或未开启任何加速 | 降到640x480、换OpenVINO、跳帧 |
| 加载ONNX时报opset错误 | 导出的opset版本太老 | 导出时opset_version=11或更高 |
| 摄像头画面不停闪烁 | 自动曝光/自动白平衡在波动 | 使用cap.set关闭自动曝光和自动对焦 |
| 视频每帧alpha边缘抖动明显 | 逐帧独立推理缺少时序一致性 | 换官方video权重或对alpha做时间维平滑 |
关于最后一个问题多说两句。如果视频抠像的画面稳定性是刚需,可以考虑对alpha做指数移动平均,用前一帧的alpha和后一帧的alpha加权:
alpha_smooth = 0.7 * alpha_current + 0.3 * alpha_prev这样一个简单的后处理就能把边缘的帧间闪烁压下去。我试过0.5/0.8之类的权重,0.7/0.3这个比例比较均衡,既保留了响应速度,又不会让动作出现拖影。权重调得越大越平滑,但人物快速移动时会产生类似“残影”的效果,需要根据实际场景微调。
6. 写在最后的一点经验
整套流程跑完,最大的感受是:模型选型和部署框架确实重要,但真正决定项目能不能落地的往往是预处理细节和场景适配。ModNet本身很成熟,ONNX Runtime也很稳定,中间的坑几乎都在“图像怎么喂进去、输出怎么用起来”这些看起来不起眼的环节上。这也是为什么我强烈建议,如果你第一次做ONNX部署,不要一上来就端到端跑一个大工程,先拿一张图把预处理、推理、后处理的全链路调通,再去扩展视频和摄像头。
另外,ModNet的权重在室内光线均匀、主体居中的场景下表现最好,逆光、全身照、复杂的多人场景效果会打折。如果你的业务场景比较特殊,比如要抠商品而不是人像,建议自己去采集数据微调模型,ModNet的可训练性还是挺好的。这套基于ONNX Runtime的部署代码可以原样复用,换成你自己的权重文件就行,这也是当初选择ONNX格式的核心价值——模型和推理框架解耦,后续迭代完全不用动工程代码。
本文还有配套的精品资源,点击获取