尧图精选

YUV420采样原理与存储格式详解:从I420到NV12

🕒 发布时间:2026/10/1 14:26:08 📁 来源:尧图网络
做视频处理的这几年YUV420是被问得最多的一个概念。很多人第一次接触编码器时看到YUV420P、NV12这些名词第一反应是YUV我懂420那不就是四个亮度才配一个颜色画面还能看吗这个疑惑很自然因为光看名字确实容易得出这样的结论但真相和这句话之间差一个关键点——对“采样”的理解。这篇文章我想把YUV420的采样原理和存储布局彻底讲透把我在实际项目里踩过的坑也一并交代清楚。无论你是刚开始学音视频开发还是已经天天跟FFmpeg、硬件编码器打交道应该都能从中拿走一些东西。1. 先搞清楚YUV到底在说什么1.1 亮度与色度分离的设计思想很多人学视频处理第一个概念就是像素等于RGB。在显示器上确实这么玩每个像素用红绿蓝三个分量混合出颜色。可一旦进入摄像头采集、视频编码、传输链路大家反而不太直接用RGB而是转成YUV。原因并不神秘人眼对亮度变化的感知远比颜色变化敏感。YUV体系把图像的“亮度细节”和“颜色细节”拆开亮度用Y表示颜色用U和V表示U也叫CbV也叫Cr。这样做的好处非常直观颜色信息可以在不引起肉眼明显劣化的前提下做更大幅度的压缩。打个比方一张黑白照片你能看清所有纹理和细节但一旦颜色模糊一点只要明暗边界还在你依然能准确认出画面里的物体。YUV体系正是利用了这种视觉特性把“像素”拆成了“主次分明”的两部分。这个设计思想从模拟电视时代就开始了一直延续到今天的数字视频。你会发现H.264、H.265这些编码器内部处理时核心还是YUV而不是RGB。理解了这一点再看后面的采样率和存储格式逻辑就顺了。1.2 YUV和RGB的分工差异RGB和YUV都是把颜色拆成三个通道但拆法完全不同。RGB三个通道是等权的丢掉任何一个画面都会缺色少R偏青少G偏紫少B偏黄。YUV则是有主次的Y承载了绝大部分感知信息U和V只负责“染色”。所以你可以把Y想象成画面的骨架UV想象成皮肤表面的颜料。常见的YUV采样格式有4:4:4、4:2:2、4:2:0等。4:4:4是每一个亮度样本都有对应的完整色度样本信息量跟RGB差不多适合高质量做图、做特效、做调色。4:2:2砍掉一半色度广电和专业视频设备经常用。4:2:0最狠直接砍掉四分之三的色度但它也是视频压缩领域使用最广的格式——几乎所有消费级视频编码H.264、H.265默认都是它。看到这里你大概能感受到4:2:0不是“偷工减料”的产物而是长期工程权衡后选出来的性价比之王。2. “4:2:0”的真相为什么是4组Y配1组UV2.1 三个数字的真实含义先把结论摆出来4:2:0里的“4”是亮度样本的参考宽度“2”表示第一行色度样本在水平方向按2:1采样“0”表示第二行没有额外汇出的色度样本。合在一起就是色度在水平和垂直两个方向都做了1/2采样。总体效果是每4个亮度样本构成的一个2×2小块共享一组U、V分量。很多人容易把4:2:0理解成“4个像素横向排成一排共用一个UV”这其实不太准确。更严谨的说法是“2×2的亮度块共享一组UV”。这两种说法在存储阶段才会暴露差异——因为没有UV采样的那一行并不是没有颜色而是颜色要从相邻行的UV里取。举个例子取一片宽8高4的像素区域。Y样本数量是8×432个。按4:2:0采样U和V在水平方向减半、垂直方向也减半也就是4×28个。于是U、V各8个样本Y有32个比例正好是4:1。这才是“4组Y对应1组UV分量”的严格来源。这里还要顺带提一下4:1:1和4:2:0的区别。4:1:1只有水平方向做4:1采样垂直方向不降色度数量是宽/4×高。4:2:0则是水平2:1、同时垂直2:1。两者总数据量看似差不多但采样位置和画质特征不一样。4:2:0更符合人眼特性也更契合现代块编码的习惯所以最终在压缩体系中全面胜出。2.2 一个2×2像素块模型就足够了我给人讲这个概念的时候从来不堆术语直接画一个2×2的块。四个亮度位置分别叫Y00、Y01、Y10、Y11它们共享一组色度U00、V00。整幅图像上U平面和V平面相当于各贴了一层缩小一倍的马赛克每一个色度值都对应亮度平面上相应的2×2区域。这层“马赛克”带来的影响是画面里剧烈变色的细小边缘会被抹平一点这也是4:2:0在专业调色场景下不受欢迎的原因。但绝大多数显示场景里人眼并不会察觉尤其视频是动态的注意力又被画面内容吸引色度的小幅损失几乎无感。这个模型还有一个实用推论对YUV420做任何几何操作比如旋转、缩放、裁剪都不能只处理Y平面。如果只转了YUV还在原地画面颜色就彻底乱了。我见过不少新手拿YUV数据当灰度图处理做完以后发现整幅图色彩错位半天找不出原因其实就是没意识到亮度平面和色度平面必须同步操作。2.3 为什么说这是“最划算”的采样率从人眼生理特性来看视觉系统对高频色度信息非常不敏感。研究很早就发现人眼的色彩分辨率大约只有亮度分辨率的三分之一到四分之一。4:2:0把色度降到1/4恰好卡在感知门槛附近再配合编码和传输能省下非常可观的带宽。算一组实际数字一帧1080p的RGB24原始数据是1920×1080×3字节约622万字节。转成YUV420后变成1920×1080×1.5字节约311万字节。存储和带宽直接减半。如果只降到4:2:2只能减少约1/6。你说做视频编码的人会选哪个答案不言而喻。3. 存储方式详解平面、半平面、交错3.1 一帧YUV420到底占多少字节先给公式后面所有布局理解都建立在这个公式上。设帧宽W、高H且暂时假定W、H都是偶数这个条件很关键后面会专门讲。Y平面大小 W × HU平面大小 (W/2) × (H/2) W × H / 4V平面大小 (W/2) × (H/2) W × H / 4总大小 W × H (W × H) / 4 (W × H) / 4 W × H × 3 / 2也就意味着每像素占1.5字节。给个常见分辨率1280×720Y有921600字节U和V各230400字节总计1382400字节。1920×1080一帧则正好是3110400字节。这些数字在做内存分配、文件解析、网络封包时非常常用。写代码时可以直接用每像素1.5字节这个结论。3.2 I420/YU12最经典的纯平面布局I420是FFmpeg和libyuv里的“默认交互格式”另一个名字叫YU12。布局极其简单先存全部Y再存全部U再存全部V。数据在内存里的顺序长这样Y[0] Y[1] ... Y[WH-1]U[0] U[1] ... U[WH/4-1]V[0] V[1] ... V[W*H/4-1]读取平面数据时要注意行号Y平面第n行的偏移量是 n×WU、V平面的第m行偏移量是 m×(W/2)。换句话说当你从文件里读出一帧I420时前三行Y之后跟着的是一个宽度减半的U平面再之后是同样尺寸的V平面。3.3 YV12换个顺序别认错YV12的布局是“先全部Y再全部V再全部U”。和I420的唯一差别就是U、V平面调换了前后位置。这个区别在写字节流、设置编码器输入时非常致命因为很多编码器只接受其中某一种顺序传错了画面会整体偏色而且是紫绿紫绿那种一眼就能发现的错。遇到类似颜色错乱的问题我的第一反应永远不是去怀疑算法而是先确认平面顺序是不是给对了。别笑这个坑我亲眼见过有人排查了整整一天最后发现只是把U和V写反了。3.4 NV12和NV21半平面交错存储如果说I420是纯平面那NV12就是半平面。Y依然单独一整块但U、V不再分成两个独立平面而是交错放在同一个缓冲区里每个色度样本对按U、V、U、V……的顺序排列。NV21则反过来按V、U、V、U……排列。这两种格式同样非常常见。Android老版本Camera预览回调默认就是NV21iOS的CVPixelBuffer和绝大多数硬件编码器的输入则常见NV12。硬件偏爱NV12原因很简单硬件处理单元通常以2字节为最小单位同时读写U和V交错存储更方便访存更高效。纯平面的U/V分离访问需要两次跨平面跳跃对硬件流水线不友好。3.5 一张表理清所有常见格式格式别名Y平面U/V存储常见使用场景I420YU12独立U平面在前V平面在后FFmpeg、libyuv、软件处理YV12-独立V平面在前U平面在后部分播放器、旧编码器NV12-独立UV交错U在低地址Android Camera、iOS、硬件编码NV21-独立VU交错V在低地址老Android预览回调四行代表四种真实存在的字节排布。你只需要记住“名字不同、顺序不同”就够了千万别自己加戏。4. 实战中绕不开的那些坑4.1 stride对齐问题你以为的一行不是真正的一行这是YUV420开发里最容易被忽略的问题。很多编码器或采集设备并不会像教科书一样把一行数据排满W个字节而是会做内存对齐确保每一行字节数是某个数值的整数倍比如16、32、64多余出来的字节属于padding填充。这个真正的一行字节数叫stride也叫pitch。于是数据在内存里的实际排布变成了第0行: [有效像素W字节][padding字节]第1行: [有效像素W字节][padding字节]第2行: [有效像素W字节][padding字节]如果你拿stride当width处理数据或者反过来拿width去跳过一行画面会出现斜线、绿边、雪花噪点看起来非常诡异。处理原则很明确所有访问Y/U/V平面的操作都要用stride来定位每一行的起点而不是用逻辑宽高。一个典型的按行拷贝示例// 按stride逐行拷贝避免padding被误当像素 void copy_y_plane(uint8_t *dst, const uint8_t *src, int w, int h, int stride) { for (int row 0; row h; row) { memcpy(dst row * w, src row * stride, w); } }同理在做Format转换时也必须给libyuv这类库传对stride。很多转换函数都有src_stride和dst_stride这样的参数你传0表示默认等于width一旦底层真的做了对齐结果就会错位。所以拿到一帧数据时第一件事就是向数据源确认它的stride是多少别想当然。4.2 奇偶尺寸、裁剪与缩放的陷阱YUV420要求宽和高都是偶数否则UV平面没法按整数块划分。如果你死活给了一个奇数宽的帧U和V平面的大小只能向下取整Y与UV的对应关系就会错位画面右边缘常出现一条彩色竖边。裁剪的时候更明显。假设在一张1920×1080帧里裁一个100×100的区域起始坐标是(37, 41)。此时新的UV平面怎么生成很多封装库会直接报错或者生成错乱的画面合理的做法是让裁剪的坐标和尺寸都向偶数对齐。比如37对齐成36或3841对齐成40或42。实在需要精确到奇数的区域就别硬拿YUV裁剪了先转成RGBA处理完再转回来代价是慢一点但结果可控。再提一个缩放的问题。YUV420的缩放同样不能简单地把Y缩小一倍、UV缩小一倍就完事。正确做法是使用libyuv的缩放接口让其内部统一处理Y和UV的采样对应关系。手动缩放时最容易出现的问题是色度平面缩放后被错误地再次采样导致颜色边界出现锯齿。4.3 平台偏好别和编码器的“脾气”对着干做Android Camera开发的人对这种苦头体会最深。旧版本Camera预览回调给的是NV21新版本Camera2的ImageReader给出的可能是YUV_420_888这种灵活分布的内存Y/U/V可能是三个独立的ByteBuffer甚至不在同一个缓冲区里。如果直接把字节流硬塞给一个只认I420的编码器多半要出事故。FFmpeg这边的情况好一些libavutil定义了标准的AV_PIX_FMT_YUV420P通常对应I420布局。但要注意有些硬件编解码器底层要求NV12这时候你还要在I420和NV12之间做一次转换。我的实践经验是业务层统一用一种内部格式我个人习惯用I420所有外部数据源进入业务边界时统一转换一次。这一条看似多了一次内存拷贝实际能省下无数定位问题的调试时间。不要把“反正都是YUV420”当成借口不同布局之间必须显式转换。4.4 一次微型验证RGB转YUV420的换算过程讲了这么多理论给一个能在本地亲手验证的例子。假设一个4×4的纯红色块R255G0B0。按BT.601标准公式换算Y 0.299×R 0.587×G 0.114×B ≈ 76U(Cb) 128 - 0.168736×R - 0.331264×G 0.5×B ≈ 85V(Cr) 128 0.5×R - 0.418688×G - 0.081312×B ≈ 255纯红色下Y76U85V255。如果编码成I420布局16个Y样本全是76U平面4个样本全是85V平面4个样本全是255。写一小段Python验证一下偏移量import numpy as np w, h 4, 4 y np.full((h, w), 76, dtypenp.uint8) u np.full((h // 2, w // 2), 85, dtypenp.uint8) v np.full((h // 2, w // 2), 255, dtypenp.uint8) i420 np.concatenate([y.reshape(-1), u.reshape(-1), v.reshape(-1)]) y_len w * h uv_len (w // 2) * (h // 2) print(总字节数:, len(i420), 预期:, w * h * 3 // 2) print(Y区: 0 ~, y_len - 1) print(U区:, y_len, ~, y_len uv_len - 1) print(V区:, y_len uv_len, ~, y_len uv_len * 2 - 1) print(前16字节:, i420[:16], 全为76) print(接下来4字节:, i420[y_len:y_len 4], 全为85) print(最后4字节:, i420[y_len uv_len:], 全为255)输出会验证前16字节是76接下来4字节是85最后4字节是255。如果生产环境里哪个环节出现颜色偏色用这种方式去比对数据很快就能定位是不是平面顺序填错了。最后分享一个我形成多年的工作习惯凡是涉及YUV420的代码写完之后我都会打印前几行若干字节做人工比对或者用已知纯色图去验证。处理尺寸永远先对齐偶数。回看那些难缠的颜色错位和花屏问题八成都能在这两步里找到答案。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →