暗光下摄像头"见鬼"了:一个 2GB 内存 Android 终端的帧差唤醒踩坑记
关键词:帧差检测、运动唤醒、AGC 噪声、低功耗、自适应阈值
一、背景:为什么要在待机时做运动检测
我们做的是一个安装在智能柜上的生物识别终端(RK 主板,虹膜 + 人脸 + NFC 刷卡 + 485 串口锁板),Android 系统,整机内存只有 2GB。设备的使用场景很特别:一天 24 小时里,可能 23 个小时都没人靠近。
如果虹膜和人脸识别 SDK 全天候全速跑,有两个现实问题:
- 功耗与发热:虹膜镜头长时间开启温度会持续升高,这在我们的设备上是被实测验证过的硬伤;
- 内存压力:2GB 内存被系统吃掉大半,识别 SDK 的模型常驻已经是负担,不能再让识别链路空转。
所以架构上很自然的选择是:待机时只保留一路低分辨率人脸摄像头画面,用一个极轻量的"运动检测"判断有没有人靠近,有动静再唤醒完整的识别流程。这就是帧差唤醒(frame-diff wake-up):不跑人脸检测模型,只对比相邻两帧画面的亮度差异,成本几乎为零。
听起来很简单对吧?diff 一下像素,超过阈值就唤醒。我们第一版确实就是这么写的,白天表现完美。然后设备被装到了走廊尽头一个灯光昏暗的柜机上,诡异的事情发生了:半夜设备会自己"醒来",识别页面频繁弹出,好像有人一直在柜子前面晃------但监控里一个人都没有。
这篇文章记录的就是这个问题的完整排查和修复过程:暗光下 AGC 放大噪声导致固定阈值帧差失效的根因,以及我们最终的"亮度分档自适应阈值 + 空间聚块降噪"方案。所有阈值数据都是现场实测调出来的,希望能给做类似低功耗唤醒的同学省一些时间。
二、第一版实现与问题现象
2.1 第一版帧差检测
先交代检测的基本框架(这个框架在最终版里基本没变,变的是判定逻辑):
- 待机时人脸摄像头以 640×480 预览,一个检测线程每 80~180ms 取一帧 NV21 数据;
- 只取 Y 平面(亮度),且只取画面中央 80% 的区域(边缘容易有门框、指示灯等干扰源);
- 把 ROI 降采样成 80×60 = 4800 个采样点------不是缩放图像,是直接从原始帧里按步长抽点,零图像处理开销;
- 与上一帧的采样点逐点比较,先减去各自帧的均值(消除整体曝光漂移),再统计"变化超过阈值的采样点占比";
- 占比超过 10%,且连续 2 帧都超,才发出唤醒候选;唤醒信号有 1.5 秒的保持窗口,避免抖动。
伪代码大致是:
kotlin
// 4800 个亮度采样点,相邻帧对比
for (i in 0 until SAMPLE_SIZE) {
val cur = currentSample[i] - currentMean // 减均值,消除整体曝光变化
val prev = previousSample[i] - previousMean
if (abs(cur - prev) >= 12) changedCount++ // 固定阈值 12
}
val changedRatio = changedCount / SAMPLE_SIZE.toFloat()
if (changedRatio >= 0.10f) {
// 连续 2 帧满足 → 唤醒候选
}
MOTION_PIXEL_DIFF_THRESHOLD = 12,MOTION_CHANGED_RATIO_THRESHOLD = 0.10。白天、灯光明亮的室内,这个组合工作得很好:人走近时画面大面积变化,changedRatio 轻松冲到 30% 以上;静止场景下噪声引起的单点抖动 rarely 超过 12,changedRatio 通常在 1% 以下。
2.2 暗光下的"灵异唤醒"
问题现场的日志长这样(我们的 markMotionDetected 会打出 changedRatio 和 meanLuma 两个关键值):
ini
motion wake candidate, changedRatio=0.13, meanLuma=21
motion wake candidate, changedRatio=0.11, meanLuma=18
motion wake candidate, changedRatio=0.16, meanLuma=25
...
注意两个特征:changedRatio 只是勉强越过 10% 的阈值 (真人靠近时通常 30%+),meanLuma 非常低(0~255 的亮度均值只有 20 上下,是非常暗的场景)。
也就是说:暗光环境下,静止画面的"噪声底"就已经能把 changedRatio 推到 10% 附近,阈值形同虚设。
【配图建议:一张对比图,左侧明亮场景静止时 changedRatio 曲线贴着 0,右侧暗光场景静止时曲线在 10% 附近震荡,标注阈值线】
三、根因分析:AGC 增益震荡是"乘性"的,减均值救不了
为什么暗光下噪声会大到这种程度?这要怪相机的 AGC(自动增益控制)。
摄像头 sensor 在光线不足时会自动放大模拟增益(AGC)和曝光时间(AEC),把信号拉到可用的亮度区间。但增益放大的是信号 + 噪声 :sensor 的读出噪声、热噪声在增益 ×4、×8 之后,同像素相邻帧的抖动幅度从亮场景下的 ±2~3 变成暗场景下的 ±15 甚至更多。我们实测暗光下静止画面,同一像素相邻帧的亮度抖动频繁超过固定阈值 12------这正是第一版失效的直接原因。
那为什么"减均值"不管用?这是我们第一版设计里的一个认知盲区,值得展开讲。
减均值能消除的是加性变化:比如整体曝光偏移、灯光渐亮渐暗,表现为所有像素统一加一个偏移量 Δ,减去帧均值后 Δ 被消掉。这类变化是"加法"的。
但 AGC/AEC 的增益震荡是乘性变化 :这一帧整体增益是 g1,下一帧变成 g2,像素值从 v 变成 v × (g2/g1)。暗部像素变化小,亮部像素变化大,每帧内部的像素变化量不再均匀,减均值只能校正一个平均意义上的缩放残差,剩下的残差依然大面积超过阈值 12。
用一句话总结这个坑:
减均值校正得了加性噪声,校正不了乘性增益震荡。暗光场景下的帧差检测,不能假设"噪声幅度恒定"。
【配图建议:公式示意,加性模型 v+Δ vs 乘性模型 v×g,下方画出减均值后两者的残差对比】
四、方案演进:从"固定阈值"到"亮度分档 + 空间聚块"
想清楚了根因,思路就清晰了。我们最终落地的是两个互相独立的改进,可以单独生效、叠加使用。
4.1 改进一:按场景平均亮度分档选参
既然噪声幅度随亮度(本质随 AGC 增益)变化,那就不要用一套固定阈值打天下,按当前帧的平均亮度分档,越暗要求越苛刻:
| 档位 | 平均亮度 meanLuma | 单点 diff 阈值 | 变化比例阈值 |
|---|---|---|---|
| 正常 | ≥ 60 | 12 | 10% |
| 偏暗(DIM) | 30 ~ 60 | 22 | 15% |
| 极暗(DARK) | < 30 | 30 | 20% |
代码层面就是一个简单到不能再简单的 if-else:
kotlin
val pixelDiffThreshold: Int
val changedRatioThreshold: Float
when {
currentMean < LUMA_DARK_THRESHOLD -> { // < 30
pixelDiffThreshold = 30
changedRatioThreshold = 0.20f
}
currentMean < LUMA_DIM_THRESHOLD -> { // 30~60
pixelDiffThreshold = 22
changedRatioThreshold = 0.15f
}
else -> { // 正常光照
pixelDiffThreshold = 12
changedRatioThreshold = 0.10f
}
}
分档边界(30/60)和每档的阈值(12/22/30、10%/15%/20%)不是拍脑袋定的,是拿现场日志里 meanLuma 和误触发时的 changedRatio 分布对出来的。这也是我们坚持把 meanLuma 打进日志的原因------阈值自适应方案必须给现场留"按亮度定档"的观测手段,否则换一台设备、换一个 sensor 就又瞎了。
只改阈值能不能解决问题?大部分场景能,但我们测试时发现极暗档位下仍有零星误触发:把 diff 阈值提到 30 之后,噪声点变少了,但 AEC 震荡剧烈的瞬间,仍可能有 10% 以上的采样点同时"变化"。要彻底摁住,需要第二个武器。
4.2 改进二:空间聚块------散点噪声凑不满一个块
观察误触发的数据,我们发现一个本质差异:
- 传感器噪声在空间上是离散的:它随机散布在整个画面上,这个像素跳一下、那个像素跳一下,彼此没有相关性;
- 真人靠近在空间上是成片的:人的身体、脸进入画面,变化的是一整片连续区域。
这就是空间聚块过滤的依据:不直接数"变化采样点占比",而是先把 80×60 的采样平面划成 4×4 的小块(共 20×15 = 300 块),块内 16 个采样点里过半(≥8 个)发生变化,这个块才算"变化块",最终统计"变化块占比":
kotlin
val blocksPerRow = SAMPLE_WIDTH / BLOCK_SIZE // 80 / 4 = 20
val blockCount = blocksPerRow * (SAMPLE_HEIGHT / BLOCK_SIZE) // 300
// 复用缓冲,每帧清零,不重新分配
Arrays.fill(motionBlockCounts, 0)
for (i in 0 until SAMPLE_SIZE) {
val cur = currentSample[i] - currentMean
val prev = previousSample[i] - previousMean
if (abs(cur - prev) >= pixelDiffThreshold) {
val blockIndex = (i / SAMPLE_WIDTH / BLOCK_SIZE) * blocksPerRow +
(i % SAMPLE_WIDTH / BLOCK_SIZE)
motionBlockCounts[blockIndex]++
}
}
var changedBlocks = 0
for (count in motionBlockCounts) {
if (count >= BLOCK_SIZE * BLOCK_SIZE / 2) changedBlocks++ // 块内过半才算
}
val changedRatio = changedBlocks / blockCount.toFloat()
为什么这一招对散点噪声几乎必杀?做个粗略的概率估算:假设暗光下单个采样点"碰巧变化超过阈值"的概率是 p(实测噪声场景下 p 大概在 5%~15% 之间震荡)。一个 16 点的块里独立随机地凑出 8 个以上变化点的概率,是二项分布的尾部------p=0.1 时这个概率约十万分之一量级。散点噪声想凑满一个块,几乎不可能。
而真人靠近时,身体覆盖的是连续几十个块,每个块内几乎所有采样点都在变,轻松越过"过半"门槛。聚块过滤实质上是用"空间连续性"这个先验,把噪声和真实运动在另一个维度上拉开了距离------这比单纯调高阈值优雅得多,因为它基本不损失对真实运动的灵敏度。
代价是语义上的一个小变化:changedRatio 从"变化采样点占比"变成了"变化块占比"。日志解读时要注意,数值会比旧版偏小,调阈值时要对着新语义重新标定。
五、性能细节:2GB 内存设备上的"零分配"执念
我们的设备只有 2GB 内存,AGENTS.md 里这条军规贯穿所有开发:每帧执行的代码路径上,不允许产生额外对象分配。GC 在识别高峰期来一次 stop-the-world,体验就毁了。帧差检测跑在每秒 5~12 次的循环里,内存行为必须钉死:
- 采样缓冲只有两份 4800 字节的 byte 数组(当前帧/上一帧),帧末交换引用(swap),永不重新分配:
kotlin
val swap = previousSample
previousSample = currentSample
currentSample = swap
-
聚块计数器是一个复用的
IntArray(300),每帧Arrays.fill(counts, 0)清零,而不是new IntArray(300)。300 个 int 本身不值钱,但乘以"每帧一次 × 设备连续运行数月",省掉的是几十万次小对象分配和由此触发的 GC; -
不做任何图像缩放/拷贝 :采样是从原始 NV21 帧里按步长直接抽点,没有中间 Bitmap、没有
YuvImage、没有整帧clone(); -
唤醒候选只是一个标志位 + 截止时间戳 (
mMotionDetectedUntil = now + 1500ms),轮询方读到过期自动失效,不需要事件总线、不需要回调注册。
整个检测的单帧开销:4800 次减法 + 4800 次比较 + 300 次块统计,在一颗 RK 中端 SoC 上是微秒级,连 CPU 占用都看不到。
六、完整的最终版代码(泛化后)
把上面的碎片拼起来,这是一个去掉了业务耦合、可以直接参考的完整实现:
kotlin
class MotionDetector(
private val sampleWidth: Int = 80,
private val sampleHeight: Int = 60
) {
private val sampleSize = sampleWidth * sampleHeight
private var currentSample = ByteArray(sampleSize)
private var previousSample: ByteArray? = null
private val blockSize = 4
private val blocksPerRow = sampleWidth / blockSize
private val blockCount = blocksPerRow * (sampleHeight / blockSize)
private val blockCounts = IntArray(blockCount)
private var consecutiveFrames = 0
private var motionDetectedUntil = 0L
/** 每帧调用。yPlane 为 NV21 帧的 Y 平面,frameW/frameH 为原始帧尺寸。 */
fun onFrame(yPlane: ByteArray, frameW: Int, frameH: Int) {
// 1. 中央 80% ROI 抽点降采样(直接抽点,不缩放图像)
val roiLeft = frameW / 10
val roiTop = frameH / 10
val roiW = frameW * 8 / 10
val roiH = frameH * 8 / 10
var idx = 0
for (y in 0 until sampleHeight) {
val rowOffset = (roiTop + y * (roiH - 1) / (sampleHeight - 1)) * frameW
for (x in 0 until sampleWidth) {
currentSample[idx++] =
yPlane[rowOffset + roiLeft + x * (roiW - 1) / (sampleWidth - 1)]
}
}
val prev = previousSample ?: run {
previousSample = currentSample.copyOf()
return
}
// 2. 帧均值(用于消除加性曝光偏移 + 亮度分档)
var curMean = 0
var prevMean = 0
for (i in 0 until sampleSize) {
curMean += currentSample[i].toInt() and 0xff
prevMean += prev[i].toInt() and 0xff
}
curMean /= sampleSize
prevMean /= sampleSize
// 3. 亮度分档选阈值
val (diffTh, ratioTh) = when {
curMean < 30 -> 30 to 0.20f // 极暗
curMean < 60 -> 22 to 0.15f // 偏暗
else -> 12 to 0.10f // 正常
}
// 4. 空间聚块统计
Arrays.fill(blockCounts, 0)
for (i in 0 until sampleSize) {
val cur = (currentSample[i].toInt() and 0xff) - curMean
val prv = (prev[i].toInt() and 0xff) - prevMean
if (abs(cur - prv) >= diffTh) {
blockCounts[(i / sampleWidth / blockSize) * blocksPerRow +
(i % sampleWidth / blockSize)]++
}
}
var changedBlocks = 0
val minChangedPerBlock = blockSize * blockSize / 2
for (c in blockCounts) if (c >= minChangedPerBlock) changedBlocks++
// 5. 连续 2 帧确认 + 1.5s 保持窗口
val changedRatio = changedBlocks / blockCount.toFloat()
if (changedRatio >= ratioTh) {
if (++consecutiveFrames >= 2) {
motionDetectedUntil =
System.currentTimeMillis() + 1500L
consecutiveFrames = 0
Log.i("MotionDetector",
"wake candidate, changedRatio=$changedRatio, meanLuma=$curMean")
}
} else {
consecutiveFrames = 0
}
// 6. 双缓冲交换,零分配
val tmp = prev
previousSample = currentSample
currentSample = tmp
}
fun isMotionDetected(): Boolean =
System.currentTimeMillis() < motionDetectedUntil
}
注意线程安全:真实项目里检测跑在相机回调线程、消费方在轮询协程里,采样和判定需要加锁(我们用了一个 synchronized(motionLock)),标志位用 volatile。上面的泛化版省略了锁,接入时请按自己的线程模型补上。
七、经验总结
这个项目给我们留下了几条可以复用的经验:
-
帧差检测的敌人不是"暗",是"增益"。 暗光场景噪声大的本质是 AGC 把 sensor 噪声成倍放大。只要理解了这一点,"按亮度分档调阈值"就是顺其自然的解法------亮度均值就是 AGC 增益的一个免费代理指标,不需要去读 sensor 寄存器。
-
搞清楚你的噪声是加性还是乘性。 减均值、高通滤波这类手段只对加性噪声有效。AEC/AGC 引起的整体增益震荡是乘性的,残差无法被帧均值消除,必须在判定阈值上留出裕量。
-
空间连续性是对付散点噪声的最强先验。 真实运动成片、噪声散布,4×4 聚块 + 过半判定把这个先验变成了几乎零成本的过滤器。比提高单点阈值聪明,因为它不牺牲对真实运动的灵敏度。
-
日志即标定工具。
markMotionDetected里带上meanLuma和changedRatio,现场误触发时拉一份日志就知道该调哪一档。自适应参数方案如果不可观测,等于把调参的运气交给下一次部署。 -
小内存设备上,热路径零分配不是洁癖,是刚需。 双缓冲 swap、
Arrays.fill复用、直接抽点代替图像缩放------每条都不复杂,但要在写第一版代码时就立成规矩,事后补比事前防贵得多。
适用场景建议 :这套方案适合一切"摄像头常开但算力/功耗预算有限"的唤醒场景------智能柜、门禁、猫眼、门锁、低功耗 IPC 的前级触发。它不适合作为精确的运动分析(那是光流的活),它的定位就是一个廉价的"有没有人来"的二值哨兵,误杀率低、漏杀可接受(人站定后还有人脸检测兜底)、成本约等于零。
最后提醒一句:文中所有阈值(12/22/30、10%/15%/20%、30/60 分档)都是对着我们那颗 sensor 和镜头实测标定的,换硬件请按第七节的第 4 条重新用日志标定,不要直接抄数值。