CameraX 图像分析:读懂 YUV 平面步长,才能正确处理背压与帧生命周期


把 image.planes 的 ByteBuffer 依次拼接成 width × height × 1.5 字节,再交给图像库,是一种看似合理却不可靠的做法。相同代码可能在一台手机正常,在另一台出现绿边、斜纹或越界。原因在于 YUV_420_888 描述的是采样格式与平面关系,不保证内存恰好是紧密排列的 I420 或 NV21。

像素坐标如何变成字节下标

对某个平面,rowStride 表示相邻两行起点的字节距离,pixelStride 表示同一行相邻采样的字节距离。一般寻址公式为:

1
offset = planeBase + y * rowStride + x * pixelStride

YUV_420_888 的平面顺序为 Y、U、V,Y 平面的 pixelStride 为 1,U、V 可能是独立平面,也可能表现为带步长的半平面布局。Android 格式定义给出了保证范围。Image.Plane API定义了两个步长。

例如图像宽 1,280、高 720,但 Y 的 rowStride 为 1,344,每行中间会跨过 64 字节填充。直接读取前 921,600 字节会把填充当成像素,并逐行错位。U/V 平面采用 2×2 色度下采样,色度坐标通常需要以原始图像坐标除以 2,不能先裁剪后随意改变奇偶对齐。

缓冲区 limit 也未必包含最后一行全部尾部填充。只读取有效采样位置,比假定 rowStride × height 全部可读更稳妥。

先问算法是否真的需要 RGB

亮度直方图、边缘强度和一部分清晰度指标可以直接处理 Y 平面。先转 Bitmap,再转灰度,通常多出颜色转换、内存分配和一份完整拷贝;对每秒几十帧的管线,这些开销很容易超过算法本身。

但 Y 只是编码亮度分量,不应直接称作线性光强。曝光、相机 ISP、色彩范围和传递函数都可能影响数值。需要与光学照度或跨设备测量比较时,还需要标定和稳定采集条件,不能把灰度均值直接当作物理测量结果。

下面以裁剪区域的 Y 均值为例。适用于 Android、Kotlin 与 CameraX 1.3 以上的常见 ImageAnalysis API,假设输出保持 YUV_420_888。代码未在设备或工程中编译运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import android.graphics.ImageFormat
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import java.util.concurrent.Executors

fun meanLuma(image: ImageProxy): Double {
require(image.format == ImageFormat.YUV_420_888)
val crop = image.cropRect
require(crop.width() > 0 && crop.height() > 0)
val plane = image.planes[0]
val buffer = plane.buffer.duplicate()
val base = buffer.position()
var sum = 0L

for (y in crop.top until crop.bottom) {
for (x in crop.left until crop.right) {
val offset = base.toLong() + y.toLong() * plane.rowStride +
x.toLong() * plane.pixelStride
require(offset >= base && offset < buffer.limit().toLong())
sum += buffer.get(offset.toInt()).toInt() and 0xff
}
}
return sum.toDouble() / (crop.width().toLong() * crop.height())
}

val analyzerExecutor = Executors.newSingleThreadExecutor()
val analysis = ImageAnalysis.Builder()
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()

analysis.setAnalyzer(analyzerExecutor) { image ->
try {
val mean = meanLuma(image)
val timestamp = image.imageInfo.timestamp
// 将数值和时间戳写入有界结果通道;不要直接操作 View。
// 这里不保留 image、Plane 或 ByteBuffer。
} finally {
image.close()
}
}

这个示例用于说明地址计算,不是最高性能的实现:逐像素边界检查较贵,生产版本可以按行校验后批量读取,并复用内存。读取前采用 duplicate,避免改变原始缓冲区游标;它仍共享底层内存,所以不能延长图像的有效期。

close 的时机决定谁还拥有这帧

ImageProxy 是需要归还的相机资源。同步分析可以像示例那样在 finally 中 close;异步识别如果仍持有底层图像,则必须等识别真正结束再关闭。不能启动一个后台任务后立即 close,又让任务继续读取 buffer。

另一种方式是在同步阶段把需要的数据复制到自己拥有的数组,然后关闭 ImageProxy;后续处理只使用副本。这样生命周期更清楚,但必须控制副本数量。如果每帧复制后向无界协程队列投递任务,CameraX 端看起来没有积压,应用内存仍会持续增长。

异常路径和提前返回都必须归还帧。若算法代码抛出异常,finally 可以释放资源,但还应在应用层分类和记录错误,不能让每帧失败悄无声息。退出页面时清除 Analyzer、解除所拥有用例的绑定,并在工作结束后关闭该页面自己创建的 Executor;不要关闭其他页面共享的线程池或无差别解绑全部相机用例。

背压决定看最新状态,还是保留处理顺序

KEEP_ONLY_LATEST 适合预览与实时识别,分析慢时以新帧替代积压帧;BLOCK_PRODUCER 可以排队,但队列满会影响同一相机上的其他用例,包括预览。CameraX 图像分析指南解释了两种模式及释放要求。

假设相机每秒 30 帧,算法每帧耗时 70 毫秒,串行吞吐约每秒 14 帧。长度 6 的队列不能消除每秒约 16 帧的缺口,只会让结果越来越旧,最终阻塞或丢弃。若业务只关心此刻二维码内容,最新帧优先通常比“按顺序分析半秒前的画面”更符合需求。

如果任务是必须逐帧检测的测量流程,就要降低采集率、缩小 ROI、使用更快算法,或改用有明确完整性保证的采集方案。背压策略无法把超过算力预算的算法变成无损实时系统。

旋转、裁剪与色彩转换不能混为一谈

imageInfo.rotationDegrees 表示分析结果需要怎样旋转以匹配目标方向,不意味着内存已经按屏幕方向旋转。cropRect 也在分析图像的坐标系中。将检测框叠加到 PreviewView 时,需要同时考虑裁剪、旋转、缩放和前摄镜像;直接乘一个宽度比例通常不够。

需要 RGB 时,还应核对转换库接受的输入布局、颜色矩阵和范围。U/V 互换、忽略 pixelStride,以及把 limited-range 当 full-range,可能产生不同类型的颜色错误。先用标准色块和人工构造平面验证寻址,再验证颜色模型,能减少同时排查多个变量的难度。

结果也有生命周期,不能让旧帧覆盖新会话

页面关闭后,已经开始的异步识别可能仍会返回。如果用户又打开相机或切换前后摄像头,旧任务的结果不能直接写进新预览。可以为每次绑定生成会话 ID,发布结果时检查当前会话,并在每个会话内只接受时间戳更新的结果。取消减少无用工作,提交点的身份检查阻止过期结果,两者解决不同问题。

测量延迟时也要区分分析耗时和结果年龄。算法本身只算 20 毫秒,不代表用户看到的是 20 毫秒前的画面,前面的队列和后面的 UI 通道都可能积压。相机时间戳与应用计时必须先确认使用兼容时钟基准,不能直接把一个墙上时钟毫秒数减去未知基准的纳秒值。

内存预算可以先做数量级估算:1,280×720 的紧密 YUV420 原始有效样本约 1.32 MiB,RGBA 约 3.52 MiB。若每帧都各保留三份临时副本,30 帧每秒会产生明显分配压力,实际值还受行填充和图像库对象影响。复用缓冲区可以降低分配,但必须等上一消费者完全归还后再写入,不能用一个共享数组同时喂给多个未完成任务。

当算法只处理中央区域时,先在 Y 平面读取 ROI 往往比先复制整帧再裁剪更省数据搬运。但检测结果坐标必须加回裁剪偏移,再经过显示变换;省掉拷贝却忘记坐标原点,会让计算正确的检测框显示在错误位置。

从人工缓冲区到真机的验证路径

先把读取逻辑抽成纯函数,输入 buffer、宽高、裁剪矩形和步长。构造宽 4、高 3、rowStride 为 8 的 Y 平面,让有效像素为 0 到 11、填充全部为 255,断言均值为 5.5。再裁剪内部区域,验证结果不含填充,最后一行不包含完整填充时也不会越界。

真机上记录每帧时间戳、分析开始与结束时间、结果发布时间及关闭计数。人为让算法变慢,检查 KEEP_ONLY_LATEST 下是否保持结果新鲜,而不是只看 FPS。快速切前后台、旋转和切摄像头,确认没有未关闭帧、过期结果覆盖新会话或 Executor 泄漏。

另外应保留失败帧的安全元数据,例如尺寸、裁剪区域、步长和会话号,帮助定位设备差异;生产日志不必保存包含个人内容的原始相机画面。

本文未给出设备性能数据。可复现的合成步长测试用于验证内存寻址,真机时序测试用于验证生命周期与吞吐,两者缺一不可。


 上一篇
云服务器内存排查:从 RSS、页缓存到 cgroup OOM 的证据链 云服务器内存排查:从 RSS、页缓存到 cgroup OOM 的证据链
内存接近满载不等于即将宕机,进程被杀也不一定是整机内存不足。用小内存云主机上接口与构建任务冲突的案例,建立主机、进程和服务限额的证据链,区分缓存增长、并发放大与真实泄漏。
2026-10-18
下一篇 
Compose 性能分析:重组次数、稳定性与掉帧之间到底有什么关系 Compose 性能分析:重组次数、稳定性与掉帧之间到底有什么关系
重组次数多不必然意味着卡顿,稳定性标记也不能代替测量。本文以可筛选笔记列表为例,分析状态读取位置、列表身份和强跳过的行为,用确定性工作负载建立从系统 Trace 到代码修改的性能证据链。
2026-10-18
  目录