2. 运动物体检测基础:背景建模、帧差法、光流法、运动物体检测的数学基础

各位同学,欢迎来到第二章。

上一章我们聊了动态环境对SLAM系统的冲击。说白了,就是那些乱跑的行人、晃动的树叶、飞驰的汽车,它们会让你的特征点匹配一塌糊涂,甚至直接导致定位漂移。那怎么把这些“捣乱分子”揪出来呢?

这一章,我们就来啃下运动物体检测这块硬骨头。我会从最经典的背景建模讲起,再到简单粗暴的帧差法,最后聊聊光流法。嗯,这些都是基础,但基础不牢,地动山摇。我个人习惯是,不管做多高级的算法,这些底层的数学直觉必须得有。

2.1 背景建模:给场景拍一张“素颜照”

背景建模的思路很直观。你想想看,如果摄像头固定不动,那背景是不是基本不变的?变化的只有前景物体,比如人、车。那我们就先给场景拍一张“素颜照”,也就是背景模型。然后,每一帧新图像来了,跟这张“素颜照”一对比,不一样的地方就是运动物体。

最简单的做法:均值背景建模

我刚开始做项目时,就用的这个。取前N帧图像,对每个像素位置求个平均值,作为背景。代码大概长这样:

// 假设有N帧灰度图,每帧大小相同
cv::Mat background = cv::Mat::zeros(height, width, CV_32FC1);
for (int i = 0; i < N; ++i) {
    cv::Mat frame;
    // 读取第i帧到frame
    background += frame;
}
background /= N; // 得到均值背景

然后,新帧来了,直接做差分:

cv::Mat diff;
cv::absdiff(currentFrame, background, diff);
cv::threshold(diff, foregroundMask, 30, 255, cv::THRESH_BINARY);

阈值30是我凭经验给的。为什么是30?因为传感器有噪声,光照会微变,留点余量。但这个方法有个致命伤——如果场景里一直有车停着,它会被“学习”进背景里。我曾经在停车场测试,一辆车停了5分钟,再开走,背景里就多了一个“车洞”。

注意: 均值背景建模对光照变化极其敏感。室内灯光频闪、室外云遮太阳,都会导致大量误检。别问我怎么知道的,都是泪。

2.2 帧差法:简单粗暴,但有效

帧差法就更直接了。我不建背景模型了,我直接用相邻两帧或三帧做差分。运动物体在相邻帧之间位置变了,像素值自然就变了。

两帧差分:

cv::Mat diff;
cv::absdiff(frame_t, frame_t_1, diff);
cv::threshold(diff, motionMask, 25, 255, cv::THRESH_BINARY);

你看,代码比背景建模还短。但问题也很明显:

  • 只能检测到物体的边缘轮廓,内部是空洞。因为物体内部颜色变化不大。
  • 如果物体运动太慢,两帧之间位移小于一个像素,就检测不到了。
  • 如果物体运动太快,会出现“鬼影”。

三帧差分: 稍微改进一点。取连续三帧,做两次差分,再取交集。这样可以减少“鬼影”和空洞。

cv::Mat diff1, diff2, motionMask;
cv::absdiff(frame_t, frame_t_1, diff1);
cv::absdiff(frame_t_1, frame_t_2, diff2);
cv::bitwise_and(diff1, diff2, motionMask);
cv::threshold(motionMask, motionMask, 20, 255, cv::THRESH_BINARY);

帧差法在SLAM里有什么用?我个人的经验是,它适合做快速的前景/背景粗筛。比如在ORB-SLAM的跟踪线程里,我可以先用帧差法标记出可能是动态的区域,然后在这些区域里降低特征点提取的阈值,或者干脆不提取。这样能省下不少计算量。

小技巧: 帧差法的阈值不要固定死。可以统计一下整帧差分的均值或中位数,然后乘以一个系数(比如3~5)作为自适应阈值。这样能应对光照的缓慢变化。

2.3 光流法:追踪像素的“运动轨迹”

光流法,听起来高大上,其实核心思想就一句话:找到同一个像素点在前后两帧图像中的位置变化。这个位置变化量,就是光流向量。

数学基础:亮度恒定假设

光流法有个基本假设:同一个空间点,在相邻两帧图像上的像素亮度是不变的。用数学公式写出来就是:

I(x, y, t) = I(x + dx, y + dy, t + dt)

其中 I 是像素亮度,(x,y) 是像素坐标,t 是时间。把这个公式用泰勒展开,忽略高阶项,就得到了光流约束方程:

Ix * u + Iy * v + It = 0

这里 Ix, Iy 是图像梯度,It 是时间梯度,u = dx/dt, v = dy/dt 就是我们要求的光流向量。

一个方程有两个未知数,解不了。所以需要引入额外的约束条件。最经典的是Lucas-Kanade方法,它假设一个局部窗口内所有像素的运动是一致的。然后解一个超定方程组。

LK光流法实战:

// 使用OpenCV的calcOpticalFlowPyrLK
std::vector<cv::Point2f> prevPts, nextPts;
std::vector<uchar> status;
std::vector<float> err;

// 假设prevPts是上一帧提取的特征点
cv::calcOpticalFlowPyrLK(prevGray, currGray, prevPts, nextPts, status, err);

// 根据status筛选跟踪成功的点
for (size_t i = 0; i < status.size(); ++i) {
    if (status[i]) {
        // 计算位移向量
        cv::Point2f flow = nextPts[i] - prevPts[i];
        float magnitude = cv::norm(flow);
        // 如果位移过大,可能是动态点
        if (magnitude > threshold) {
            // 标记为动态
        }
    }
}

在SLAM里,光流法常用来做特征点跟踪。但要注意,如果场景里有运动物体,那些物体上的特征点光流向量会跟背景上的明显不同。我们可以利用这个差异来检测动态区域。

核心思路: 在SLAM系统中,如果相机自身在运动,那么所有静态点会遵循同一个运动模型(比如单应矩阵或基础矩阵)。而动态点会违背这个模型。所以,光流法检测动态物体的本质,就是找那些“不合群”的特征点。

2.4 三种方法的对比与选择

好了,三种方法都讲完了。我整理了一个表格,方便你对比:

方法 计算量 适用场景 主要缺点 SLAM中的典型用法
背景建模 固定摄像头 无法适应光照变化和背景更新 预处理,生成静态地图
帧差法 极低 快速运动物体 空洞、鬼影、对慢速物体不敏感 快速粗筛,降低特征提取区域
光流法 中高 相机运动或静止 对纹理要求高,计算量大 特征点跟踪,动态点剔除

我个人建议,在实际的SLAM项目中,不要只用一种方法。我习惯的做法是:先用帧差法做快速粗筛,把明显是动态的区域标记出来;然后在这些区域里,用光流法做精细的跟踪和验证。这样既保证了实时性,又提高了准确率。

嗯,这一章的内容就到这里。运动物体检测是动态SLAM的第一道防线。你想想看,如果连哪些像素是运动的都搞不清楚,后面的处理就无从谈起。所以,这些基础方法虽然简单,但一定要吃透。

避坑指南: 我曾经在一个光照剧烈变化的走廊里测试,帧差法和背景建模都失效了。后来我加了图像预处理——先做直方图均衡化,再计算差分。效果好了很多。所以,预处理步骤千万别省。
运动物体检测方法对比与选择 输入图像序列 连续帧 / 多帧 背景建模 均值 / 高斯混合模型 帧差法 两帧 / 三帧差分 光流法 LK / Farneback 动态区域掩码 前景 / 运动像素标记 SLAM前端:特征筛选 / 跟踪

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