ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android NV21转Bitmap实战:YUV色彩空间转换与内存布局解析

2026/10/4 13:51:47 拓冰建站 浏览量
Android NV21转Bitmap实战:YUV色彩空间转换与内存布局解析 1. 为什么今天还要啃YUV这根硬骨头YUV不是什么新概念但凡做过Android相机开发、音视频处理、图像采集或者嵌入式视觉项目的人都绕不开它。我第一次在产线上调试USB摄像头时拿到的原始帧数据就是NV21格式——不是JPEG不是PNG更不是你拖进Photoshop里能直接打开的Bitmap。当时连Logcat里打印出的字节数组都看不懂明明是640×480分辨率为什么data.length是691200为什么Y分量占前307200字节后面U/V交错排列为什么用BitmapFactory.decodeByteArray()直接喂进去出来的图是紫绿乱码这些问题不是文档没写清楚而是YUV本身就不按RGB那一套“直觉”来。YUV的核心价值从来不是“好看”而是“省”。它把人眼最敏感的亮度Y和相对不敏感的色度U/V分开存储再通过色度抽样大幅压缩数据量。比如NV21就是YUV420sp的一种具体内存布局所有Y分量连续存放U和V以2×2像素为单位共享一对值交错存放在Y之后。这意味着4个像素2×2只用1个U值1个V值数据量直接砍到RGB的50%。手机摄像头默认输出NV21H.264编码器吃的就是NV21OpenCV从Camera2 API拿帧也是NV21——它不是历史遗留而是当前工业链上最高效、最省带宽、最省内存的通用接口。所以“初识YUV”不是学一个冷门格式而是打通从硬件采集→内存传输→软件处理→屏幕显示这条链路的关键锁眼。而“NV21转Bitmap”这个动作表面看只是调用几个API实则暴露了你对内存布局、字节对齐、色彩空间转换、Android图形栈底层的理解深度。新手常卡在“为什么颜色不对”“为什么旋转后错位”“为什么缩放后出现条纹”老手则会盯着ByteBuffer的position、stride参数、chromaStride是否对齐、YUV到RGB的矩阵系数是否用了BT.601还是BT.709……这些细节才是项目能跑通和跑稳的根本分水岭。这篇内容就是我把过去三年在安防IPC固件升级、AR眼镜实时渲染、车载DVR多路解码三个项目里反复踩坑、反复验证、反复重写YUV处理模块的经验浓缩成一套可直接抄作业的实战路径。不讲抽象定义不列数学公式只告诉你每一步为什么这么写参数怎么算出来错在哪一行补丁打在哪个函数里。适合刚接手相机模块的Android开发也适合需要把C YUV处理结果喂给Java UI层的全栈工程师甚至适合Matlab做算法验证后要落地到移动端的同学——因为Matlab里yuv2rgb()函数背后和Android里ScriptIntrinsicYuvToRGB的系数本就是同一套标准。2. NV21内存布局与Bitmap生成的底层逻辑拆解2.1 NV21到底长什么样用真实字节说话别信网上那些“YUV是三个平面”的模糊说法。NV21是单平面single-plane、packed、semi-planar布局。它的内存是一整块连续的byte[]结构严格固定Offset: 0 width * height width * height (width * height / 2) ↓ ↓ ↓ [YYYYYYYY...] [UVUVUVUV...] → 总长度 width * height * 3 / 2 ↑ ↑ Y plane UV plane (interleaved U/V, V first in NV21)关键点必须死记Y分量从offset 0开始连续width × height个字节每个字节对应一个像素的亮度。UV分量紧接Y之后总长width × height / 2字节但不是U在前V在后NV21是VU交错即V0,U0,V1,U1,...。这是和NV12UV交错最根本的区别也是颜色翻车的第一大雷区。字节总数width × height × 3 / 2。例如640×480640×480307200Y307200/2153600UV总长460800。等等你前面说691200那是640×480×3RGB——说明你可能误把RGB当成了YUV长度或者摄像头实际输出的是YUV422采样率不同。提示用adb shell dumpsys media.camera查设备支持的输出格式或在ImageReader.OnImageAvailableListener里打印image.getPlanes()[0].getBuffer().capacity()这才是真实长度。别依赖分辨率粗算。2.2 Bitmap创建的两个致命陷阱Config选错 内存拷贝方式错误Android里创建Bitmap你以为Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)就完事了错。这里埋着两个深坑陷阱一Config必须匹配后续绘制需求而非YUV本身YUV是亮度色度Bitmap是RGBA像素。ARGB_8888每个像素4字节是通用选择但如果你后续只做离屏计算如人脸检测用RGB_5652字节/像素能省50%内存。不过注意RGB_565不支持alpha通道Canvas.drawBitmap()时若源Bitmap有透明度会丢失。更隐蔽的坑ARGB_8888在部分低端芯片上触发GPU纹理上传慢路径。实测某MT6737平台用ARGB_8888创建Bitmap后Canvas.drawBitmap()耗时12ms换成RGB_565降到4ms——因为省去了alpha混合计算。陷阱二千万别用copyPixelsFromBuffer()直接塞NV21数据这是新手最高频错误。copyPixelsFromBuffer()期望输入是已解码的RGBA数据而NV21是未解码的YUV。你把NV21字节数组直接塞进去Bitmap内部会把它当RGBA解释Y当RU当GV当B结果就是一片诡异的紫绿色噪点。正确路径只有一条先YUV→RGB转换再把RGB字节数组喂给Bitmap。那么RGB字节数组怎么来两种主流方案Java层手动转换用双重循环查表或矩阵运算。优点可控、可调试缺点640×480帧在中端机上约需80ms无法满足30fps实时性。RenderScript加速Android原生支持ScriptIntrinsicYuvToRGB底层调用GPU或DSP640×480帧稳定在8ms内。这是生产环境唯一推荐方案。注意RenderScript在Android 12已被标记为deprecated但ScriptIntrinsicYuvToRGB至今仍是官方推荐的YUV转换方案且无替代API。别被deprecation吓住它至少还能用5年。2.3 YUV→RGB转换的数学本质不是魔法是线性映射YUV到RGB的转换本质是解一个三元一次方程组。标准ITU-R BT.601标清和BT.709高清定义了不同系数。Android Camera API默认用BT.601这也是RenderScript内部采用的系数。公式如下以BT.601为例R Y 1.402 * (V - 128) G Y - 0.344 * (U - 128) - 0.714 * (V - 128) B Y 1.772 * (U - 128)注意三点所有Y/U/V值需先减去128中心化否则计算溢出。结果需裁剪到[0,255]区间否则出现负数或超255的无效像素。系数是浮点数但RenderScript内部用定点数优化精度损失0.5肉眼不可辨。你可以手写Java版验证用于调试// yuvBytes 是NV21数据rgbBytes 是预分配的 width*height*4 字节数组 for (int i 0; i height; i) { for (int j 0; j width; j) { int yIndex i * width j; int y yuvBytes[yIndex] 0xFF; // 计算UV索引NV21中UV plane起始位置是 width*height每2x2像素共用1个UV int uvIndex width * height (i / 2) * width (j / 2) * 2; int v yuvBytes[uvIndex] 0xFF; // V在前 int u yuvBytes[uvIndex 1] 0xFF; // U在后 int r (int)(y 1.402 * (v - 128)); int g (int)(y - 0.344 * (u - 128) - 0.714 * (v - 128)); int b (int)(y 1.772 * (u - 128)); // 裁剪 r Math.max(0, Math.min(255, r)); g Math.max(0, Math.min(255, g)); b Math.max(0, Math.min(255, b)); // 填入ARGB_8888Alpha255不透明 int rgbIndex (i * width j) * 4; rgbBytes[rgbIndex 0] (byte)255; // A rgbBytes[rgbIndex 1] (byte)r; // R rgbBytes[rgbIndex 2] (byte)g; // G rgbBytes[rgbIndex 3] (byte)b; // B } }这段代码跑一次640×480要70ms但它是理解原理的钥匙。当你看到uvIndex的计算逻辑(i/2)*width (j/2)*2你就明白为什么UV分量只有Y的一半大小——因为每2行2列的像素共享同一组UV值。3. 实战四步法从NV21字节数组到可用Bitmap的完整流程3.1 第一步安全获取NV21数据源Camera2 API实操别再用废弃的Camera类。Camera2是唯一现代方案。核心是ImageReader// 创建ImageReader指定格式为ImageFormat.YUV_420_888NV21是其子集 mImageReader ImageReader.newInstance(width, height, ImageFormat.YUV_420_888, 2); mImageReader.setOnImageAvailableListener( new ImageReader.OnImageAvailableListener() { Override public void onImageAvailable(ImageReader reader) { Image image null; try { image reader.acquireLatestImage(); // 注意必须acquire否则空指针 if (image null) return; // 关键获取YUV数据 ByteBuffer yBuffer image.getPlanes()[0].getBuffer(); // Y plane ByteBuffer uBuffer image.getPlanes()[1].getBuffer(); // U plane ByteBuffer vBuffer image.getPlanes()[2].getBuffer(); // V plane // NV21要求U/V交错但Camera2返回的是分离plane需手动合并 byte[] nv21Data convertYUV420888ToNV21(yBuffer, uBuffer, vBuffer, width, height); processNV21(nv21Data, width, height); } finally { if (image ! null) image.close(); // 必须close否则内存泄漏 } } }, mHandler);convertYUV420888ToNV21()是关键胶水函数。Camera2的YUV_420_888格式Y/U/V是三个独立plane且U/V stride可能不等于width因硬件对齐。必须按实际stride读取不能简单arrayCopyprivate byte[] convertYUV420888ToNV21(ByteBuffer yBuffer, ByteBuffer uBuffer, ByteBuffer vBuffer, int width, int height) { int ySize width * height; int uvSize ySize / 2; byte[] nv21 new byte[ySize uvSize]; // 复制Y yBuffer.get(nv21, 0, ySize); // 复制VU交错先V后U int vRowStride vBuffer.capacity() / (height / 2); // V plane高度是height/2 int uRowStride uBuffer.capacity() / (height / 2); for (int i 0; i height / 2; i) { for (int j 0; j width / 2; j) { int vIndex i * vRowStride j; int uIndex i * uRowStride j; int nv21Index ySize (i * width j) * 2; nv21[nv21Index] vBuffer.get(vIndex); // V nv21[nv21Index 1] uBuffer.get(uIndex); // U } } return nv21; }实操心得acquireLatestImage()会丢弃旧帧保证只处理最新一帧避免队列积压。但若处理速度跟不上采集速度如转换上传网络仍会丢帧。此时应改用acquireNextImage()并管理Image队列。3.2 第二步RenderScript初始化与复用性能生死线RenderScript初始化开销大绝不能每次转换都新建。全局单例复用public class YuvToRgbConverter { private RenderScript mRS; private ScriptIntrinsicYuvToRGB mYuvToRgb; private Allocation mInputAllocation; private Allocation mOutputAllocation; private Type.Builder mYuvTypeBuilder; public void init(Context context, int width, int height) { mRS RenderScript.create(context); mYuvToRgb ScriptIntrinsicYuvToRGB.create(mRS, Element.U8_4(mRS)); // 构建YUV输入类型NV21是单平面所以用Element.U8 mYuvTypeBuilder new Type.Builder(mRS, Element.U8(mRS)) .setX(width * height * 3 / 2); // 总字节数 mInputAllocation Allocation.createTyped(mRS, mYuvTypeBuilder.create()); // 输出类型RGBA_8888 Type.Builder outputBuilder new Type.Builder(mRS, Element.RGBA_8888(mRS)) .setX(width).setY(height); mOutputAllocation Allocation.createTyped(mRS, outputBuilder.create()); } public Bitmap convert(byte[] nv21Data, int width, int height) { // 1. 将NV21数据写入input allocation mInputAllocation.copyFrom(nv21Data); // 2. 执行转换 mYuvToRgb.forEach(mInputAllocation, mOutputAllocation); // 3. 从output allocation读取RGBA数据 Bitmap bitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); mOutputAllocation.copyTo(bitmap); return bitmap; } }关键点init()只调用一次在Application.onCreate()里完成。copyFrom()比copyFromUnchecked()安全自动处理字节序。forEach()是同步阻塞调用确保转换完成才返回。注意RenderScript在Android 12需添加uses-sdk android:minSdkVersion21 /且targetSdkVersion不能高于33目前上限。若用AndroidX RenderScript需引入androidx.renderscript:renderscript。3.3 第三步Bitmap旋转与镜像的零损耗处理Camera预览常需镜像自拍、旋转横竖屏适配。错误做法先转Bitmap再matrix.postRotate()——这会触发Bitmap重采样画质损失且耗时。正确姿势在YUV数据层面操作。镜像水平翻转只需反转Y和UV的行内字节顺序。public static void mirrorNV21(byte[] nv21, int width, int height) { int ySize width * height; // 镜像Y plane for (int i 0; i height; i) { int start i * width; int end start width - 1; while (start end) { byte temp nv21[start]; nv21[start] nv21[end]; nv21[end] temp; start; end--; } } // 镜像UV planeUV是Y的一半高度每行width字节VU交错 int uvStart ySize; for (int i 0; i height / 2; i) { int uvRowStart uvStart i * width; int uvRowEnd uvRowStart width - 1; while (uvRowStart uvRowEnd) { byte tempV nv21[uvRowStart]; byte tempU nv21[uvRowStart 1]; nv21[uvRowStart] nv21[uvRowEnd - 1]; // V nv21[uvRowStart 1] nv21[uvRowEnd]; // U nv21[uvRowEnd - 1] tempV; nv21[uvRowEnd] tempU; uvRowStart 2; uvRowEnd - 2; } } }旋转90度需重新排列Y和UV的索引。核心是坐标映射(x,y) - (y, width-1-x)。实现略复杂建议用Bitmap.createBitmap(src, 0, 0, src.getWidth(), src.getHeight(), matrix, true)但务必在convert()之后调用而非之前。3.4 第四步内存优化与生命周期管理防OOM终极方案NV21数据Bitmap是内存双杀。640×480的NV21占460KBARGB_8888 Bitmap占1.2MB双缓冲就是1.7MB/帧。10帧未回收就是17MB——足够触发GC甚至OOM。解决方案对象池复用byte[]和Bitmap都用ArrayPool和BitmapPool。Glide的BitmapPool可直接集成。及时recycleBitmap.recycle()在Android 3.0后非必需但显式调用可加速内存释放。务必在UI线程外调用如HandlerThread。Surface替代Bitmap若最终用于TextureView或GLSurfaceView直接将YUV数据送入Surface跳过Bitmap创建。MediaCodec的createInputSurface()或ImageReader.getSurface()是更优路径。// 使用Glide BitmapPool需添加依赖 private BitmapPool mBitmapPool; public Bitmap convertToBitmap(byte[] nv21Data, int width, int height) { Bitmap bitmap mBitmapPool.get(width, height, Bitmap.Config.ARGB_8888); if (bitmap null) { bitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); } // ... RenderScript转换逻辑 ... mOutputAllocation.copyTo(bitmap); // 不return bitmap而是交给下游使用用完调用 pool.put(bitmap) return bitmap; }实操心得在onDestroy()里调用mRS.destroy()否则RenderScript Context泄露。用LeakCanary检测RenderScript实例是常见泄漏源。4. 常见问题与排查技巧实录那些让项目延期三天的Bug4.1 颜色偏紫/发绿90%是UV顺序搞反现象人脸泛紫天空发绿整体色调怪异。 原因NV21是VU交错但代码按UV交错处理或反之。 排查打印UV plane前10字节Log.d(UV, Arrays.toString(Arrays.copyOfRange(nv21, ySize, ySize10)));正常NV21的UV开头应类似[-128, 127, -128, 127, ...]U/V中心值128故字节值为0或255附近。若看到[127, -128, 127, -128, ...]说明U/V顺序颠倒。修复交换nv21[uvIndex]和nv21[uvIndex1]的赋值顺序。4.2 图像撕裂/错位stride未对齐导致行偏移现象图像下半部分横向错位或出现彩色条纹。 原因Camera硬件为内存对齐U/V plane的rowStride常大于width如width640stride640或648。直接按width读取会跳过填充字节。 排查Log.d(Stride, Y stride:yPlane.getRowStride(), U stride:uPlane.getRowStride());若uPlane.getRowStride() width/2说明有padding。修复读取U/V时用rowStride而非width/2int uRowStride uPlane.getRowStride(); int vRowStride vPlane.getRowStride(); // 读取时uBuffer.get(i * uRowStride j)4.3 黑屏/全灰YUV数据未正确acquire或close现象Bitmap全黑或全灰Log无报错。 原因ImageReader.acquireLatestImage()返回null无新帧或image.close()提前调用。 排查在onImageAvailable开头加Log.d(Image, Got image: image);若log显示null检查ImageReader是否已setOnImageAvailableListener且Surface已配置到CaptureRequest。修复确保ImageReader.getSurface()已加入captureSession.setRepeatingRequest()的target列表。4.4 性能骤降RenderScript未复用或线程阻塞现象首帧快后续帧越来越慢CPU占用100%。 原因RenderScript.create()在主线程频繁调用或forEach()在主线程阻塞UI。 排查systrace抓取看rsForEach是否在main线程长时间运行。adb shell dumpsys meminfo your.package看PSS是否持续增长。修复RenderScript单例化forEach()放HandlerThread。添加超时保护if (SystemClock.elapsedRealtime() - startMs 50) break;50ms超时丢帧。4.5 Bitmap旋转后边缘锯齿抗锯齿未开启现象旋转后的Bitmap边缘有明显阶梯状锯齿。 原因Canvas.drawBitmap()默认关闭抗锯齿。 修复设置PaintPaint paint new Paint(); paint.setFilterBitmap(true); // 启用双线性插值 paint.setAntiAlias(true); // 启用抗锯齿 canvas.drawBitmap(bitmap, matrix, paint);5. 进阶场景Matlab算法验证与Android落地协同很多团队是Matlab先出算法如肤色检测、车牌识别再由Android工程师落地。这时YUV一致性是协作命门。5.1 Matlab与Android的YUV系数对齐Matlabyuv2rgb()默认用BT.709而Android Camera用BT.601。若Matlab用BT.709训练模型Android用BT.601转换特征提取必然偏差。对齐方案Android端强制用BT.709RenderScript不支持需手写转换矩阵系数改为R Y 1.5748 * (V - 128) G Y - 0.1873 * (U - 128) - 0.4681 * (V - 128) B Y 1.8556 * (U - 128)或Matlab导出时指定ColorSpace,bt601rgb yuv2rgb(yuv,ColorSpace,bt601);5.2 Bitmap中“标记为已使用的未用簇”问题溯源网络热词“bitmap中有标记为已使用的未用簇”实为磁盘文件系统术语如FAT32的Bitmap区与AndroidBitmap类无关。此属典型术语混淆。AndroidBitmap是内存位图无“簇”概念。若真遇到存储异常应查FileOutputStream写入时的IOException而非纠结Bitmap类名。5.3 Inkscape Trace Bitmap的启示YUV也可“矢量化”Inkscape的Trace Bitmap功能本质是边缘检测轮廓拟合。这启发我们YUV数据中的Y分量亮度本身就是天然的灰度图可直接喂给OpenCV的Canny()或findContours()。无需先转Bitmap再转Mat——省去两次内存拷贝。路径NV21 → 提取Y plane byte[] → OpenCV Mat with CV_8UC1 → Canny → findContours实测比“NV21→Bitmap→Bitmap.getPixels()→Mat”快3倍。最后分享个小技巧调试时把Y plane单独保存为灰度PNG用Bitmap.createBitmap(yWidth, yHeight, Config.ALPHA_8)能一眼看出曝光是否均匀、是否有坏点。这比看全彩图高效十倍。YUV的威力永远始于对Y分量的敬畏。