如果你也在折腾 MMDetection,大概率会遇到这样一个需求:训练好的模型不光要处理单张图片,还要拿它去检测一段视频,并且把检测结果保存成新的视频文件。官方 demo 目录里通常只有处理单张图片的脚本,想把视频检测跑起来,要么自己敲代码,要么去找别人封装好的轮子。我最初的想法很简单——在图片 demo 外面套一层视频循环不就完事了吗?真正动手之后才发现,视频检测并保存这个需求,比想象中多踩了三个坑:视频编码器配置、颜色空间转换、输出尺寸对齐。这篇文章就是我给 demo 目录添加 video_demo.py 的完整记录,从设计思路到逐段代码,再到参数调优和问题排查,一次性说清楚。
1. 为什么官方 demo 缺一个 video_demo.py:从 image_demo 的技能边界说起
1.1 image_demo.py 到底干了什么
先回头看仓库里自带的图片 demo。它做的事情非常集中:读入一张图片,加载模型,调用推理接口拿到结果,然后把检测框画在原图上,最终保存成一张新图片。整个过程是单帧、单张、单次推理,不存在视频流的概念。
这个脚本最核心的流程可以概括为:模型初始化一次,图片读取一次,推理一次,绘图一次,写文件一次。所有环节都是一次性操作,所以它可以写得很简洁,也不需要关心资源的持续占用问题。视频检测恰恰是在这个基础上增加了“重复”和“持续”两个维度:持续读取、持续推理、持续写入。
1.2 直接在 image_demo 上改会踩哪些坑
很多人(包括当时的我)会觉得,把图片循环换成视频循环,代码量应该很小。但实际上有几个坑是隐藏的:
第一,图片 demo 最终调用模型自带的绘制方法把结果写到指定路径。这个绘制方法内部会做窗口显示、结果归一化等操作,在视频场景里逐帧调用会非常慢,而且一旦在无显示环境(比如服务器)运行,OpenCV 的窗口函数可能会直接卡住或报错。
第二,图片 demo 的输入输出都是静态图,没有编码器、帧率、分辨率的概念。视频保存必须用 VideoWriter 指定编码格式、帧率和帧尺寸。这三项任何一个不对,输出文件要么直接打不开,要么时长不对,要么内容偏色。
第三,图片 demo 做一次推理就退出了,不需要关心显存和内存的释放。但视频可能是几千帧,如果每一帧的结果都保存在内存里不去清理,长视频跑到一半就可能撑爆内存。
1.3 视频检测的整体需求拆分
动手写脚本之前,我习惯先把需求拆开。视频检测并保存,本质上需要以下五个模块:模型初始化、视频读取、逐帧推理、结果绘制、视频写出。
模型初始化是复用 init_detector,不需要重写。视频读取用 OpenCV 的 VideoCapture 就可以。逐帧推理调用的是和图片 demo 相同的 inference_detector 接口。结果绘制这一块要绕开模型自带的 show_result,改为自己用 cv2 画框。视频写出则要关注编码器、帧率、尺寸三件事。
想清楚这五个模块之后,脚本的结构其实就清晰了。难点不在于“推理”,而在于“视频读写”这两个平时容易被忽略的环节。所以这篇文章的标题虽然是 video_demo.py,但有一半篇幅其实是在讲视频保存的工程细节。
2. video_demo.py 完整实现:从初始化到逐帧写出的关键代码
2.1 流程设计与模块划分
我最终写的脚本,整体流程是:解析参数,加载模型,打开视频文件,读取帧的宽高和帧率,初始化 VideoWriter,进入循环读帧,每读一帧就推理一次,把结果画上去,写进输出文件,直到视频结束,最后释放所有资源。
参数部分我用了 argparse,保留 --config、--checkpoint、--input、--output、--score-thr、--device 这几个常用参数。这样无论你是用自己的训练权重还是测试模型的权重,都能通过命令行传参,不用改代码。
这里有一个设计上的取舍:为什么不把绘制检测框的代码继续交给模型的 show_result?原因我在刚才提过,视频场景下逐帧走它内部那一套东西既慢又容易出问题。自己用 cv2 画框,虽然代码多写几行,但控制力强得多,阈值过滤、颜色选择、是否显示类别文字,都变得可控。
2.2 核心代码分段拆解
模型初始化部分,和图片 demo 没有任何区别:
from mmdet.apis import init_detector, inference_detector model = init_detector(config_path, checkpoint_path, device='cuda:0')这里需要提醒一点:config 文件和 checkpoint 文件必须配套。如果你用的是别人训练好的权重,最好用权重对应的 config 文件去初始化,否则即使不报错,检测出来的类别和框也可能完全不对。
视频读取和参数获取:
cap = cv2.VideoCapture(video_path) 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))fps 和 width、height 这三个值必须从源视频读取,而不是自己硬编码。原因后面会详细说,这里先记住一个原则:写入参数要和输入源对齐。
VideoWriter 初始化:
fourcc = cv2.VideoWriter_fourcc(*'mp4v') writer = cv2.VideoWriter(output_path, fourcc, fps, (width, height))拿到这些参数之后,进入循环:
frame_idx = 0 while True: ret, frame = cap.read() if not ret: break result = inference_detector(model, frame) # 手动绘制检测框 for class_id, bboxes in enumerate(result): for bbox in bboxes: if bbox[4] < args.score_thr: continue x1, y1, x2, y2 = [int(v) for v in bbox[:4]] cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) label = f'{class_id} {bbox[4]:.2f}' cv2.putText(frame, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) writer.write(frame) frame_idx += 12.3 完整脚本参考
把上面的代码拼起来,加上参数解析和资源释放,就是一个能跑的 video_demo.py:
import argparse import cv2 from mmdet.apis import inference_detector, init_detector def parse_args(): parser = argparse.ArgumentParser(description='video demo') parser.add_argument('--config', type=str, required=True) parser.add_argument('--checkpoint', type=str, required=True) parser.add_argument('--input', type=str, required=True) parser.add_argument('--output', type=str, default='output.mp4') parser.add_argument('--score-thr', type=float, default=0.3) parser.add_argument('--device', type=str, default='cuda:0') return parser.parse_args() def main(): args = parse_args() model = init_detector(args.config, args.checkpoint, device=args.device) cap = cv2.VideoCapture(args.input) if not cap.isOpened(): raise IOError('无法打开视频文件,检查路径和视频格式') 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)) fourcc = cv2.VideoWriter_fourcc(*'mp4v') writer = cv2.VideoWriter(args.output, fourcc, fps, (width, height)) if not writer.isOpened(): raise IOError('VideoWriter 初始化失败,检查编码器和输出路径') frame_idx = 0 while True: ret, frame = cap.read() if not ret: break result = inference_detector(model, frame) for class_id, bboxes in enumerate(result): for bbox in bboxes: if bbox[4] < args.score_thr: continue x1, y1, x2, y2 = [int(v) for v in bbox[:4]] cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) label = f'{class_id} {bbox[4]:.2f}' cv2.putText(frame, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) writer.write(frame) frame_idx += 1 cap.release() writer.release() print(f'处理完成,共 {frame_idx} 帧,输出文件:{args.output}') if __name__ == '__main__': main()一个值得注意的细节是推理结果的遍历方式。在 MMDetection 常用版本中,inference_detector 的返回值是“按类别拆开的检测框集合”,每个类别对应一个数组,数组的每一行是[x1, y1, x2, y2, score]。所以用enumerate(result)拿到类别编号,再遍历该类别下的所有框,逐框过滤分数。这个写法在不同版本之间相对稳定,这也是我推荐手动遍历而不是直接取result[0]的原因。
3. 视频保存最容易翻车的三个位置:编码器、颜色空间、输出尺寸
3.1 编码器 fourcc 选择不对,文件直接写不出来
第一个让我折腾了大半个小时的坑就是 fourcc。OpenCV 的 VideoWriter 必须指定一个四字符编码码,比如 mp4v、avc1、XVID,不同的扩展名要和不同的编码器搭配。
实测下来比较稳的组合是这样:
| 输出格式 | fourcc | 备注 |
|---|---|---|
| .mp4 | mp4v | 兼容性最好,绝大多数环境能写出 mp4 |
| .mp4 | avc1 | 部分环境不支持,会提示编码器缺失 |
| .avi | XVID | 老牌组合,文件体积偏大 |
| .mov | mp4v | 也可以,但部分播放器表现一般 |
如果你发现输出文件是 0 字节,或者程序跑完没有任何报错但文件打不开,优先怀疑 fourcc 和环境解码器的兼容问题。我通常的做法是先用短视频测试,分别换 mp4v 和 XVID 各试一次,看哪个能稳定写出来,再用它跑长视频。
很多人在这一步走了弯路,因为 OpenCV 的 VideoWriter 初始化时不一定报错,甚至返回的 writer.isOpened() 也可能是 True,但实际写出来的文件根本没法播放。这也是为什么我在脚本里加了if not writer.isOpened()这个检查,至少能拦截一部分初始化失败的情况。
3.2 颜色空间搞反,检测框和画面整体偏色
第二个坑是颜色空间。OpenCV 的 VideoCapture 读出来的 frame 是 BGR 顺序,而 MMDetection 模型内部期望的输入是 RGB 顺序,同时在预处理阶段会做归一化。也就是说,推理接口本身会处理这个转换,我们不需要在外部手动改。
但问题是,很多人画框时想先展示一下,于是不自觉地把 frame 转成了 RGB 再操作。一旦你把 frame 从 BGR 转成 RGB,再画框、写文件,输出视频里所有物体的颜色都会变蓝偏色,看起来非常奇怪,像加了一层色调滤镜。
正确做法是:读出来的 frame 保持 BGR 不动,直接传给 inference_detector,返回的检测框坐标也是对应原始尺寸的。画框时也用 BGR 顺序画,比如绿色框就是(0, 255, 0),写进 VideoWriter 时仍然保持 BGR。整个链路不要做任何额外的颜色转换。
如果你确实要在画框时调用 cv2.cvtColor 做颜色转换,那写入前一定要再转回 BGR。这个转换看似无关紧要,但一旦忘记转回来,输出视频的颜色就全不对了,而且视频不像图片那样能一眼看出问题,经常是跑完整个视频才发现。
3.3 帧率和输出尺寸必须和源视频对齐
第三个坑是尺寸。VideoWriter 初始化时需要传入 frameSize,也就是 (width, height)。这个尺寸必须和你实际写入的每一帧形状完全一致。如果视频源的宽高是 1920×1080,但你在初始化时传了 1280×720,OpenCV 在写入时会出错,或者文件写出来只有前几帧能看。
有一个更隐蔽的情况:某些视频的帧率通过cap.get(cv2.CAP_PROP_FPS)拿到的值可能是 0.0。这通常发生在一些编码不标准的视频上。如果 fps 是 0,VideoWriter 初始化时不会明显报错,但输出视频的时长会异常。遇到这种情况,可以手动兜底一个默认帧率,比如 25.0 或 30.0。
另外,宽高在读取后要转成 int。因为cap.get返回的是 float,而 VideoWriter 的 frameSize 需要整数元组。如果不转,OpenCV 在某些版本中也能跑,但在另一些版本里会直接抛异常。这种低级错误排查起来反而最浪费时间,所以我干脆在脚本里先转好。
4. 从能跑通到跑得好:显存、漏检和结果导出的调优记录
4.1 长时间运行要注意显存与内存占用
视频检测和图片检测一个很大的区别在于运行时间。一个 10 分钟的视频可能有上万帧,模型要重复推理上万次。虽然推理引擎本身不参与梯度计算,不会积累梯度图,但如果你在一帧推理完成后把大尺寸的 tensor 一直保留在变量里,随着循环推进,显存占用会缓慢上升,最终可能导致“CUDA out of memory”。
我的建议是,循环体内尽量复用变量名,推理结果用完就丢掉,不要把所有帧的检测结果都存到 list 里。如果你需要保存分析数据,单独用一个轻量结构记录坐标和分数就好,不要保留原始的特征图或者整帧数据。
还有一个常见的工程问题:跑长视频时如果中途显存崩溃,前面所有处理都白费了。稳妥的做法是先跑一小段视频验证流程,比如截取 30 秒的片段,确定模型、编码器、绘制逻辑都没问题,再跑完整视频。这个习惯能帮你省下很多无意义的等待时间。
4.2 输出视频检测不到目标,按这个顺序排查
如果你发现输出视频完全没有检测框,或者框的数量明显少于预期,先别急着怀疑代码。我遇到过的原因一般有三种:
第一种是分数阈值设得太高。默认的 score_thr 我习惯设为 0.3,如果你的模型本身置信度偏低,可以降到 0.1 或者 0.2 试试,很多人为了“看起来干净”把阈值调到 0.5 以上,结果小目标或者遮挡目标全被过滤掉。
第二种是模型和数据集不匹配。比如 config 里注册的是 80 类的模型,但 checkpoint 是在自定义数据集上训练的,这时推理结果里的类别编号和画框对应的类别名就对不上。排查方法是用同一张图片分别跑图片 demo 和视频脚本,对比两类输出结果。如果图片 demo 能出框、视频代码不出框,问题一定在视频链路;如果图片 demo 都不出框,就要回头检查权重和 config 的匹配。
第三种是读取帧本身出了问题。比如视频编码问题导致某些帧解码后是空的,cap.read()返回了 False 直接跳出循环。可以在循环里加一个简单的统计,打印读取到的帧数和检测到目标的帧数,就能快速定位是解码问题还是模型推理问题。
4.3 同时导出结构化检测结果,而不只是输出视频
很多时候,你检测视频不只是为了得到一段画了框的视频,还需要分析每帧的目标位置、类别和置信度。这时可以在画框的同时,把检测结果写进一个 JSON 或 CSV 文件。
最简单的结构就是按帧号记录:
records = [] records.append({ 'frame': frame_idx, 'detections': [ {'class_id': int(class_id), 'score': float(bbox[4]), 'bbox': [int(v) for v in bbox[:4]]} for bbox in bboxes if bbox[4] >= args.score_thr ] })全程逻辑和画框保持一致,只是把画框的代码换成了记录数据的代码。如果视频很长,不要每帧都往 JSON 里写,最好每隔一定帧数批量写一次,或者干脆用 pandas 逐行追加到文件,避免内存里积累太多数据。
把所有检测结果导出来之后,后续做统计、画轨迹、算目标出现频次都方便很多。这是我在实际项目里反复用到的一个扩展点,也是视频 demo 从“能看”变成“能用”的关键一步。
最后分享一个我踩过多次的经验:视频检测脚本里,真正影响项目进度的往往不是模型的推理精度,而是视频读写的那些边角细节。fourcc 选错、颜色空间没转对、尺寸没对齐,这三个问题任意一个出现,都能让你花掉比模型调优更多的时间。我的习惯是先把一小段视频完整跑通,从读取到保存都验证一遍,再上长视频和复杂场景。