把 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 | import android.graphics.ImageFormat |
这个示例用于说明地址计算,不是最高性能的实现:逐像素边界检查较贵,生产版本可以按行校验后批量读取,并复用内存。读取前采用 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 泄漏。
另外应保留失败帧的安全元数据,例如尺寸、裁剪区域、步长和会话号,帮助定位设备差异;生产日志不必保存包含个人内容的原始相机画面。
本文未给出设备性能数据。可复现的合成步长测试用于验证内存寻址,真机时序测试用于验证生命周期与吞吐,两者缺一不可。