5.11. 帧差分

帧差分将每一帧新图像与一个存储的参考帧进行比较,以找出场景中发生变化的部分。它是各类监视场景中是否发生事件的摄像头应用的主力——例如运动触发的拍摄、入侵警报、"当有物体移动时保存一段视频"等——而它完全由前文介绍过的逐像素运算构成:先求绝对差,再做阈值化,最后做区域搜索,并在每一帧上运行。

5.11.1. 基本流程

第一阶段是获取参考帧。在接近启动的某个时刻——理想情况下是当场景处于代表"无变化"的状态时——应用捕获一帧并将其保留下来。这一帧成为后续每次捕获都将与之比较的基准。

reference = csi0.snapshot().copy()

.copy() 很重要。csi0.snapshot() 本身返回的 Image 的缓冲区位于帧缓冲区中,下一次调用 snapshot 会将其覆盖。.copy() 会为参考帧分配一个独立的缓冲区,让这一帧的像素在下次捕获之后依然存在。

第二阶段在每一帧上运行:捕获一幅新图像,然后计算它与参考帧之间的绝对差。这正是 difference() 所做的事:

current = csi0.snapshot()
current.difference(reference)

在这次调用之后,current 保存的图像中,非零像素标记了自参考帧拍摄以来场景发生变化的每一个位置,且每个像素的幅度与该位置变化的程度成正比。

第三阶段对差分图像进行阈值化。原始差分总会包含一些噪声:来自传感器散粒噪声的微小亮度波动、光照漂移引起的梯度变化、摄像头轻微抖动带来的亚像素抖动。一次阈值处理——使用 binary() 并将阈值设置在该噪声底之上——只保留大到足以算作真实运动的变化,丢弃其余部分,从而生成一幅二值图像,其非零像素即为实际发生变化的位置。

第四阶段从该二值掩码中提取连通区域——形成连续斑块的相邻非零像素群。find_blobs() 用一次调用即可完成这一工作,返回一个运动区域列表,每个区域都带有边界框和像素计数,供应用的其余部分使用。

一张横向流程图。最左侧的两个面板是并排放置的参考帧和当前帧,二者之间有一个加号标记。一个箭头从这对图像指向第三个标有 difference 的面板,其中在暗背景上有几个明亮的斑块。一个箭头从那里指向第四个面板,显示差分图像的二值阈值化版本,相同的斑块此时呈纯白色。最后一个箭头指向第五个面板,显示带注释的二值掩码,每个斑块周围都绘制了矩形边界框。

帧差分流程:一个参考帧加上一个当前帧得到一幅差分图像;阈值化将差分转换为表示变化位置的二值掩码;连通区域步骤将掩码转换为运动区域列表。

5.11.2. 内存中与磁盘上的参考帧

基本流程将参考帧保存在 RAM 中。当参考帧是在脚本的本次运行中捕获、且只需在脚本持续运行期间存在时,这是正确的做法。

对于长期运行的应用——例如应在断电重启后恢复变化检测的摄像头,或需要检测自某个较早时刻以来任何变化的间歇性脚本——参考帧必须比正在运行的脚本存活得更久。其做法是将参考帧保存到磁盘:

csi0.snapshot().save("/sdcard/reference.bmp")

并在每次运行开始时将其加载回来:

reference = image.Image("/sdcard/reference.bmp")

差分逻辑本身不变;改变的只是参考帧在两次捕获之间存放的位置。一些改进可以自然地扩展这种基于磁盘的变体——例如按定时器自动重新捕获参考帧、可选的滚动平均以跟踪缓慢的光照漂移——但其核心的替换是一样的。

5.11.3. 光源隔离

同样的减法模式也出现在一个略有不同的场景中:将光源从场景的其余部分中隔离出来。其诀窍是捕获一个"熄灯"参考帧——一帧在被检测对象(红外信标、屏幕像素、状态指示灯)点亮时拍摄的画面——然后从后续每一帧中减去该参考帧。结果是:凡是两次捕获中场景相同的地方亮度都为零,只有光源实际点亮的地方才有非零亮度。

5.11.4. 选择 difference 还是 sub

关于选择哪种算术运算的一点实用说明。difference() 返回变化的绝对值——不带符号——这使它对两个方向上的变化(变亮或变暗)都敏感,代价是无法告诉应用变化朝哪个方向发生。对于纯运动检测而言,这是正确的选择:任何移动的物体都值得关注,无论亮度是往哪个方向变化的。

对于光源检测,被点亮的像素总是比熄灯参考帧更亮,因此 sub()(带有在零处的钳位)是更诚实的选择。凡是当前帧比参考帧更暗的地方(这往往是未点亮值附近的传感器噪声)都会被钳位为零,而不会报告一个虚假的"灯亮了"信号。