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() 用一次调用即可完成这一工作,返回一个运动区域列表,每个区域都带有边界框和像素计数,供应用的其余部分使用。
帧差分流程:一个参考帧加上一个当前帧得到一幅差分图像;阈值化将差分转换为表示变化位置的二值掩码;连通区域步骤将掩码转换为运动区域列表。¶
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()(带有在零处的钳位)是更诚实的选择。凡是当前帧比参考帧更暗的地方(这往往是未点亮值附近的传感器噪声)都会被钳位为零,而不会报告一个虚假的"灯亮了"信号。