12. OpenCV视频处理:视频读取与写入、帧处理、运动检测与背景减除

视频处理,说白了就是跟一串连续的图片打交道。每一帧就是一张图,但连起来就有了时间维度。我刚开始接触OpenCV时,觉得视频读写不就是打开文件、循环读帧嘛,结果踩了不少坑——编码格式不对、写入卡顿、运动检测全是噪点……今天咱们就把这些硬骨头啃下来。

12.1 视频读取与写入:基础操作

OpenCV用VideoCapture读视频,用VideoWriter写视频。这两个类封装了底层编解码,但有几个细节不注意,代码就跑不通。

12.1.1 读取视频文件或摄像头

读取本地文件,传文件路径就行。读取摄像头,传设备ID(通常是0)。我个人习惯先检查是否成功打开,不然后面读帧会崩。

cv::VideoCapture cap("test.mp4");
if (!cap.isOpened()) {
    std::cerr << "打开视频失败" << std::endl;
    return -1;
}

cv::Mat frame;
while (cap.read(frame)) {
    // 处理每一帧
    cv::imshow("Frame", frame);
    if (cv::waitKey(30) == 27) break; // ESC退出
}
cap.release();
小技巧: 读取摄像头时,可以用cap.set(cv::CAP_PROP_FRAME_WIDTH, 640)设置分辨率。但注意,不是所有摄像头都支持你设置的值,最好先读一下实际值。

12.1.2 写入视频文件

写入视频需要指定编码器、帧率、尺寸。编码器用cv::VideoWriter::fourcc()生成四个字符的编码标识。我常用'X','V','I','D''M','J','P','G',前者压缩比高,后者兼容性好。

cv::VideoWriter writer("output.avi",
    cv::VideoWriter::fourcc('X','V','I','D'),
    30.0,
    cv::Size(640, 480));

if (!writer.isOpened()) {
    std::cerr << "写入视频失败" << std::endl;
    return -1;
}

cv::Mat frame;
while (cap.read(frame)) {
    writer.write(frame); // 写入帧
}
writer.release();
注意: 写入的帧尺寸必须和VideoWriter初始化时一致,否则会写入失败或生成损坏文件。我曾经因为resize忘记改尺寸,折腾了半天才发现。

12.2 帧处理:逐帧操作的艺术

视频处理的核心就是帧处理。每一帧都是一张cv::Mat,你可以做任何图像操作:缩放、滤波、颜色转换、边缘检测……但要注意性能,别让处理速度跟不上帧率。

12.2.1 帧预处理流水线

我通常把帧处理分成三步:降噪 → 颜色转换 → 尺寸归一化。降噪用高斯模糊,颜色转换转成灰度(很多算法只需要亮度信息),尺寸归一化到固定大小。

cv::Mat processFrame(const cv::Mat& src) {
    cv::Mat gray, blurred, resized;
    cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
    cv::GaussianBlur(gray, blurred, cv::Size(5,5), 1.5);
    cv::resize(blurred, resized, cv::Size(320, 240));
    return resized;
}

你想想看,如果每帧都做全尺寸处理,720p的视频每秒30帧,CPU很快就扛不住了。缩小到320x240,处理量直接降到原来的1/6,效果差别不大。

12.2.2 帧率控制与跳帧

有时候我们不需要处理每一帧,比如做运动检测时,每秒处理10帧就够了。可以用cap.set(cv::CAP_PROP_POS_FRAMES, frameIndex)跳帧,或者简单计数。

int frameCount = 0;
while (cap.read(frame)) {
    if (frameCount % 3 == 0) { // 每3帧处理一次
        processFrame(frame);
    }
    frameCount++;
}
经验之谈: 跳帧处理时,别忘了更新waitKey()的延时。如果跳帧后帧率变低,延时可以适当减小,否则视频播放会变慢。

12.3 运动检测:让程序“看见”变化

运动检测,说白了就是找两帧之间的差异。最简单的办法是帧差法,但噪声多。我更喜欢用背景减除,OpenCV提供了专门的类。

12.3.1 帧差法

帧差法就是相邻帧相减,取绝对值,再阈值化。优点是速度快,缺点是对光照变化敏感,且运动物体内部容易出现空洞。

cv::Mat prevFrame, currFrame, diff, mask;
cap.read(prevFrame);
cv::cvtColor(prevFrame, prevFrame, cv::COLOR_BGR2GRAY);

while (cap.read(currFrame)) {
    cv::cvtColor(currFrame, currFrame, cv::COLOR_BGR2GRAY);
    cv::absdiff(prevFrame, currFrame, diff);
    cv::threshold(diff, mask, 30, 255, cv::THRESH_BINARY);
    cv::imshow("Motion", mask);
    prevFrame = currFrame.clone();
    if (cv::waitKey(30) == 27) break;
}
避坑指南: 帧差法一定要用clone()复制前一帧,不能直接赋值。直接赋值是浅拷贝,修改当前帧会影响前一帧。我曾经在这里栽过跟头,排查了半天。

12.3.2 背景减除:MOG2与KNN

OpenCV提供了cv::BackgroundSubtractor接口,实现类有MOG2和KNN。MOG2基于高斯混合模型,能适应光照渐变;KNN基于最近邻,对动态背景(如树叶晃动)更鲁棒。

cv::Ptr<cv::BackgroundSubtractor> bgSubtractor =
    cv::createBackgroundSubtractorMOG2(500, 16, true);
// 或者 cv::createBackgroundSubtractorKNN(500, 400.0, false);

cv::Mat frame, fgMask;
while (cap.read(frame)) {
    bgSubtractor->apply(frame, fgMask);
    cv::imshow("FG Mask", fgMask);
    if (cv::waitKey(30) == 27) break;
}

MOG2的构造函数参数:history(历史帧数,默认500)、varThreshold(方差阈值,越大越不敏感)、detectShadows(是否检测阴影)。我一般把varThreshold设到16~32之间,太低会引入太多噪点。

12.4 运动检测与背景减除的实战流程

下面这张图展示了完整的运动检测流程,从读取视频到输出结果。我习惯把每一步都封装成函数,方便调试和复用。

运动检测与背景减除流程 读取视频帧 预处理(灰度+模糊) 背景减除(MOG2/KNN) 后处理(形态学+轮廓) 输入:视频文件或摄像头 降噪、缩小尺寸、转灰度 生成前景掩码 开运算去噪、找轮廓画框

12.4.1 后处理:形态学操作与轮廓提取

背景减除得到的前景掩码通常有噪点,需要用开运算(先腐蚀后膨胀)去除小斑点,再用闭运算填充空洞。然后找轮廓,用外接矩形框出运动区域。

cv::Mat kernel = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(5,5));
cv::morphologyEx(fgMask, fgMask, cv::MORPH_OPEN, kernel);
cv::morphologyEx(fgMask, fgMask, cv::MORPH_CLOSE, kernel);

std::vector<std::vector<cv::Point>> contours;
cv::findContours(fgMask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

for (const auto& contour : contours) {
    double area = cv::contourArea(contour);
    if (area < 500) continue; // 过滤小区域
    cv::Rect rect = cv::boundingRect(contour);
    cv::rectangle(frame, rect, cv::Scalar(0, 255, 0), 2);
}
关键参数: 面积阈值500是根据640x480分辨率调的。如果分辨率变了,阈值也要按比例调整。我一般用frame.total() * 0.001作为动态阈值。

12.4.2 阴影检测与去除

MOG2自带阴影检测,掩码中阴影部分像素值为127(前景为255,背景为0)。如果你不需要阴影,可以把它置为0。

cv::threshold(fgMask, fgMask, 128, 255, cv::THRESH_BINARY);

但注意,这样会把真正的暗色运动物体也误删。更好的做法是用cv::compare(fgMask, 127, shadowMask, cv::CMP_EQ)单独处理阴影区域。

12.5 性能优化与实战建议

视频处理最怕卡顿。我总结了几条优化原则:

  • 降低分辨率: 处理前先缩小到320x240或更小,运动检测精度影响不大。
  • 使用ROI: 如果运动只发生在画面局部,只处理那一块区域。
  • 多线程: 读取帧和处理帧分开线程,用队列缓冲。OpenCV的VideoCapture读取本身是阻塞的,但可以配合std::thread
  • 选择合适算法: 静态背景用帧差法,动态背景用MOG2或KNN。KNN对树叶晃动、水面波纹更鲁棒,但计算量稍大。
我的习惯: 在项目初期先用帧差法快速验证,如果效果不行再换MOG2。别一上来就上复杂算法,调试起来很痛苦。

12.6 常见问题与避坑

问题 原因 解决方案
写入视频打不开 编码器不支持或尺寸不匹配 换用'M','J','P','G'编码,检查帧尺寸
运动检测全是噪点 阈值太低或未做形态学处理 提高varThreshold,增加开运算
检测到阴影 MOG2阴影检测默认开启 设置detectShadows=false或后处理去除
处理速度跟不上帧率 每帧处理太耗时 缩小尺寸、跳帧、用多线程

嗯,视频处理这块内容不少,但核心就是帧操作+背景建模。你只要把帧差法和MOG2跑通,再配合形态学去噪,大部分场景都能应付。我在做智能安防项目时,就是用这套流程检测入侵物体,效果挺稳的。

公众号:蓝海资料掘金营,微信deep3321