☰
YUV420P、I420、YV12区别:U/V顺序、stride对齐与偏色排查
2026/11/3 14:25:05 网站建设 项目流程

前两天有个做图像算法的朋友甩给我一段画面:一群人在室内开会,人脸泛着一层青绿色的光,红色衣服看着像深蓝,蓝色文件夹又偏偏偏紫。他信誓旦旦说解码器没问题,数据是标准的 YUV420P,宽高也都对,只是画面颜色不对。我看了一眼就问他:你这个 YUV420P,是 I420 还是 YV12?他愣了两秒,说这俩不是一个东西吗。

这个问题我在不同项目里被问过不下十次。YV12、I420、YUV420P 这三个词在绝大多数文档里被当成同义词混着写,但它们在内存里的排布并不完全一致,其中 I420 和 YV12 的差别小到只差两个平面的先后顺序,可一旦搞混,画面就是他那副样子——细节全对,颜色全错。更麻烦的是,很多工具在命名上本身就含糊,你从 A 拿到一个标着 yuv420p 的 buffer,喂给 B 的时候 B 默认按另一套规则解释,中间没有任何报错,只有画面默默变色。

这篇东西我打算把这三个名字彻底拆开讲清楚:4:2:0 采样到底省了什么、平面格式的字节是怎么排的、YV12 这个 FourCC 名字的来历、为什么 YUV420P 严格来说是个族名而不是某一种具体排列、搞混之后的典型症状和定位方法,以及那个比 U/V 顺序更容易让人栽跟头的 stride 对齐问题。不管你是刚接触视频裸数据,还是已经在做编解码、推流、图像处理,只要手上碰过 yuv 文件,这里面的坑大概率你都踩过或者即将踩到。

1. 4:2:0 采样到底省掉了什么:三个名字共用的那套账

1.1 从 RGB 到 YUV:先解决"亮度与色彩分离"这件事

要理解三种格式的差异,得先回到它们共同的上游。摄像头传感器拿到的原始数据通常是 RGB 排列的,每个像素三个分量,各占 8 bit,一个 1080p 帧就是 1920×1080×3 约 6.2 MB。直接用 RGB 传,带宽和存储都吃不消,更关键的是人眼对色度的敏感度远低于亮度——你盯着屏幕看,能分辨出很细微的明暗过渡,但对相邻像素的颜色差异其实很迟钝。

YUV(或者更准确地叫 YCbCr)就是利用这个生理特性做的分离:Y 是亮度,U(Cb)是蓝色差,V(Cr)是红色差。分离之后,亮度分量保持全分辨率,色度分量可以大幅降采样而肉眼几乎察觉不到损失。这就是整套 4:2:0 体系的物理基础,也是为什么几乎所有消费级视频编码链路——从手机录制到网络推流再到蓝光碟——都默认走这条路。

需要说清楚一点:YUV 严格讲是模拟分量信号的叫法,数字域里我们说的"YUV420P"其实是 YCbCr 4:2:0 planar。行业内约定俗成混用,我也不打算在这篇里抠这个字眼,但你心里要有数,看规范文档时别被绕进去。

1.2 一次采样单元的账本:6 个字节对应 4 个像素

4:2:0 这个记法有点反直觉,它描述的是水平方向和垂直方向对色度都做 2:1 降采样。换成好懂的话:把一个 2×2 的像素块当成一个基本单元,这 4 个像素各自拥有独立的 Y 值,但共同分享 1 个 U 值和 1 个 V 值。

按这个规则算账,一个 2×2 块占用的字节数是 4(Y)+ 1(U)+ 1(V)= 6 字节,平均到每个像素是 1.5 字节。对比 RGB 的 3 字节/像素,直接砍掉一半。一张 1920×1080 的帧:

格式每像素字节单帧大小(1920×1080)相对 RGB 占比
RGB243.06,220,800 B(约 5.93 MB)100%
YUV4222.04,147,200 B(约 3.96 MB)66.7%
YUV4201.53,110,400 B(约 2.97 MB)50%
YUV420 10bit3.06,220,800 B100%

记住这个 1.5 倍关系,它是后面所有计算的地基。凡是拿到一段裸 YUV420 数据,帧大小必然是width × height × 3 / 2,不多不少。反过来说,如果你手上的 buffer 长度除以帧数之后不等于这个数,那要么宽高记错了,要么掺了 stride padding,要么根本不是 420。

顺便提一句 4:2:2 和 4:4:4。4:2:2 只在水平方向降采样,每 2 个像素共享一组 UV,常用于专业采集和中间格式;4:4:4 完全不降采样,色度信息完整保留,多用于影视后期和需要反复做色彩运算的场景。4:2:0 之所以在消费端一家独大,纯粹是因为它在压缩率和观感之间找到了那个性价比最高的点。

1.3 为什么 4:2:0 能成为事实标准:编码器的偏好反过来定了格式

这里有个很多人没意识到的因果链:主流视频编码标准(H.264/AVC、H.265/HEVC、VP9、AV1)在消费级 profile 上基本只支持 4:2:0 输入,编码器内部的运动搜索、变换量化都是围绕这个采样率设计的。硬件编码器更是如此,SoC 里的视频编码单元直接就把输入格式锁死成 4:2:0 平面格式,你喂别的进去它要么拒绝要么内部先转一遍。

结果就是,整条链路上每一环——摄像头输出、编解码器、渲染器、封装格式——都把 4:2:0 planar 当作默认语言。默认语言一旦确定,剩下的问题就变成了"具体怎么排布",而这恰恰是分歧产生的地方。就像大家都说普通话,但口音不同,沟通起来还是会出岔子。

提示:如果你在做跨平台的视频管线,第一件该确认的事不是分辨率也不是帧率,而是上下游各自期望的 pixel format 枚举值到底是什么,并且把它写进接口契约里,别指望默认值。

2. I420 与 YV12 的分叉点:内存里 U 和 V 谁先出场

2.1 平面格式的字节排布,逐个平面拆开看

"平面"(planar)这个词说的是存储策略:Y、U、V 三个分量各自占据一整块连续内存,而不是像 RGB 那样按像素交错。这么做的好处是编码器可以单独拿 Y 平面做运动估计,也可以对色度平面做独立的 DCT,内存访问的局部性更好。

以一个 4×4 的小图为例(宽高都是偶数,符合 4:2:0 要求),总大小是 4×4×1.5 = 24 字节。I420 的排布是这样:

I420 (等价 IYUV): 偏移 0x00 ~ 0x0F : Y 平面,16 字节 Y00 Y01 Y02 Y03 Y10 Y11 Y12 Y13 Y20 Y21 Y22 Y23 Y30 Y31 Y32 Y33 偏移 0x10 ~ 0x13 : U 平面,4 字节 <-- U 在前 U00 U01 U10 U11 偏移 0x14 ~ 0x17 : V 平面,4 字节 <-- V 在后 V00 V01 V10 V11

YV12 呢?总大小完全一样,Y 平面也完全一样,只是后两个平面调换了位置:

YV12: 偏移 0x00 ~ 0x0F : Y 平面,16 字节(与 I420 完全一致) 偏移 0x10 ~ 0x13 : V 平面,4 字节 <-- V 在前 偏移 0x14 ~ 0x17 : U 平面,4 字节 <-- U 在后

就这么点差别。Y 平面一模一样,UV 两个平面的位置对调。所以如果你的程序只读 Y 平面(比如做灰度分析、做边缘检测、做亮度直方图),把 I420 和 YV12 混着用完全没问题,一点都看不出来。但一旦你把 YUV 转成 RGB 显示,色彩就全反了。

每个平面内部又是怎么排的?U 平面和 V 平面的尺寸都是ceil(width/2) × ceil(height/2),按行优先存储。也就是说 U 平面第一行有 width/2 个字节,接着第二行,以此类推。这一点上 I420 和 YV12 是一致的,它们的平面内部都是标准的行优先。

2.2 YV12 这个名字的来历:FourCC 语境下的历史产物

I420 这个名字相对直白,I 代表 I420/IYUV 这一系的标识,420 就是采样率。而 YV12 是个更古老的 FourCC 风格的代号,它的命名逻辑是按平面出现顺序直接拼出来的:Y 平面、然后是 V 平面、然后是 12——这里的 12 指的是每像素 12 bit,也就是 1.5 字节。

所以 YV12 这个名字本身就自带说明书:读它的时候,V 在 U 前面。同理你可以推出 YU12(某些环境里对 I420 的另一种写法,Y 平面 + U 平面 + 12bit),以及 IYUV 这个 FourCC 等价于 I420。

这里就是我朋友踩坑的地方:他从一个老采集卡驱动拿到标称"YV12"的数据,代码里按 YUV420P 处理,而他那套处理代码默认按 I420 的顺序解释,于是 U 平面和 V 平面被对调读取。画面细节、边缘、运动全都正常,因为 Y 没动过;只有颜色错乱。这种"看起来像解码 bug 但其实不是"的现象最耗时间,因为你会先去怀疑编码器、怀疑色彩空间转换矩阵、怀疑显示器,最后才想到格式顺序。

我个人的经验是,凡是遇到"亮度正常、只有颜色不对"的画面问题,先别怀疑算法,直接验证 U/V 平面顺序,九成能直接命中。

2.3 快速对照表与判定方法

把几种常见的 4:2:0 planar 变体放在一张表里,对照着记最省事:

名称平面顺序每帧大小常见出处
I420Y, U, VW×H×3/2WebRTC、多数软件编码器输入、通用处理库
IYUVY, U, VW×H×3/2与 I420 等价,多见于 FourCC 标识
YU12Y, U, VW×H×3/2部分工具对 I420 的别名
YV12Y, V, UW×H×3/2部分采集设备、老式编码链路
NV12Y, UV 交错W×H×3/2移动端摄像头、硬件编解码输出
NV21Y, VU 交错W×H×3/2部分移动平台相机预览默认格式

看出来了吧,六种格式的帧大小完全一致,光看 buffer 长度你永远分不出谁是谁。判定只能靠三个途径:查上下游的接口文档、看 pixel format 枚举值、或者最原始的办法——把画面转出来肉眼看。

肉眼看也有技巧:如果颜色出现明显的红蓝对调,比如正常肤色变成青绿、红色物变成蓝紫色,那基本可以断定 U/V 顺序反了。因为 BT.601/BT.709 的转换矩阵里,U 主导蓝差、V 主导红差,两者一换,红蓝通道就互串了。

3. YUV420P 是族名而不是格式名:命名混乱背后的深层原因

3.1 那个 P 后缀的含义,以及它为什么不够用

YUV420P 里的 P 是 planar 的首字母,这个后缀唯一确定的事情是"三个分量分平面存储",它没有确定任何关于平面顺序的信息。也就是说 YUV420P 这个记号在语义上是 I420 和 YV12 的共同父类,甚至可以把 NV12 排除在外(NV12 是 semi-planar,UV 交错)。

但现实是,几乎所有主流的像素格式枚举里,YUV420P这个值都被赋予了具体的、带顺序的含义。举最常见的例子,通用转码框架里yuv420p这个枚举值,实际对应的内存布局就是 Y、U、V,也就是 I420。因为它在历史上先被实现、先被广泛使用,后面的人就默认了。这就是典型的"实现即标准"。

问题出在跨系统对接的时候。A 系统内部用yuv420p表示 I420 顺序,导出的文档写"输出 YUV420P";B 系统收到文档,以为 YUV420P 就是泛指,但它的解码模块拿到这个 buffer 后按 YV12 顺序解释——两个系统各自都没错,契约却断了。这种事故在跨团队协作里极其常见,而且往往在上线前的联调阶段才暴露。

我的做法比较土但有效:在所有接口定义里,永远不写 YUV420P 这种含糊的词,直接写 I420 或者 YV12,并且注明平面顺序。多打几个字,能省掉后续几天的排查。

3.2 半平面格式的插入,让命名体系彻底乱掉

NV12 和 NV21 的出现让这套命名雪上加霜。它们是 semi-planar:Y 平面单独一块,UV 两块交错在一块,第一个块占 W×H,第二个块占 W×H/2,其中 U 和 V 交替排列。NV12 是 UVUV 交替,NV21 是 VUVU 交替。

这两个格式在移动端几乎是标配——摄像头预览、硬件解码输出、GPU 纹理上传,走的都是这一套。原因很实际:半平面格式的内存访问对硬件更友好,单个色度行可以一次性读进 cache,GPU 上传纹理的时候也只需要两次拷贝而不是三次。

于是你会在一条链路上看到这样的景象:相机输出 NV21,转成 I420 送编码器,编码后打包传输,接收端解码出来又是 NV12,转成 YV12 送某个老显示模块,最后转 RGBA 上屏。同一条链路里 4:2:0 的几种排布轮番登场,每一次转换都是一次潜在的顺序错误机会。

我见过一个项目的转码模块里手写了六个转换函数,其中两个的输入输出标注是反的,跑了半年没人发现,因为那条分支只在特定设备上触发。这种维护成本,归根到底是命名不规范积累下来的债。

3.3 一个实用的判别原则:先信枚举,再信文档,最后信肉眼

面对一个来源不明的 4:2:0 buffer,我一般按这个优先级判断:

第一优先看代码里的枚举常量。枚举值虽然是人为定义的,但它就是当前这份代码实际执行的标准,比任何文档都可靠。找到那个值对应的定义处,顺着看它被哪个转换函数消费,一目了然。

第二优先看接口文档和采集设备的规格书。注意看它写的是 FourCC 还是 pixel format,FourCC 往往更明确(YV12 就是 YV12),而 pixel format 这种称呼很可能被实现方赋予了默认含义。

第三优先直接转出来看。写个十行的小程序,按两种顺序各转一次,把两张 RGB 保存成图片,哪张颜色正常用哪张。这个办法笨但绝对可靠,而且花的时间比翻文档还短。

注意:不要依赖"文件名后缀"或者"别人告诉我的"来判断格式。我见过一堆命名成.yuv420p的文件其实是 YV12,也见过叫.yv12的文件实际是 I420,纯粹是命名的人自己也没搞清楚。

4. 把 YV12 当 I420 用的后果:偏色现象与四步定位法

4.1 症状画像:什么样的画面表现指向 U/V 顺序错误

U/V 对调不会导致花屏、不会导致马赛克、不会导致画面撕裂,它只会让颜色错。具体表现有几个特征,认准了能省很多时间:

肤色区域变成青绿或者偏紫。这是最明显的信号,因为人脸是我们最熟悉的参照物,稍微偏一点就能看出来。正常的黄种人肤色在 RGB 里大概是 (220, 180, 160) 这个量级,U/V 一反,红蓝通道互串,会变成类似 (160, 180, 220) 的冷色调。

纯红色物体显示成蓝色,纯蓝色物体显示成红色。这个不用解释,红蓝差信号互换的直接结果。但要注意,只有饱和度高的纯色才明显,灰色和白色区域几乎看不出来,因为它们的 U/V 都接近 128(中性值),换了也一样。

绿色区域的异常反而没那么明显。因为绿色的色度坐标在 U/V 平面上比较居中,互换之后偏移量相对小。所以别拿草地和树叶当判据,容易误判。

整体亮度、对比度、细节、边缘锐度完全正常。这一点很关键——如果画面同时还伴随亮度异常,那问题就不只是 U/V 顺序了,可能是色彩范围(后面会讲)或者位深搞错了。

把这几条拼起来,画出一个"只有色相错、亮度对"的画像,基本可以锁定是平面顺序问题。

4.2 定位链路:从假设到验证的完整四步

我处理这类问题的顺序是固定的,四步走,一般十分钟内能定位。

第一步,先算帧大小对不对。拿到 buffer 总长度和帧数,除一下,看是不是等于 width×height×1.5。如果不等,先别管顺序,去查 stride 对齐(这个坑更大,下一章细讲)。如果相等,说明格式大类没错,是 4:2:0 planar 无疑。

第二步,单独把 Y 平面抽出来存成灰度图。取前 width×height 字节,按 8bit 灰度存成 PGM 或者直接当单通道图看。如果灰度图完全正常,说明 Y 平面没问题,问题必然在色度;如果灰度图本身就是花的,那说明连 Y 的排布都不对,可能是分辨率搞错了或者数据有偏移。

第三步,用两种顺序各转一次,肉眼比对。这一步是决定性的。写个小脚本,分别按 I420 和 YV12 解释同一段数据,各自转成 RGB 存图。两张图里必然有一张颜色正常,另一张红蓝错乱。正常的那张对应的顺序就是真实格式。

第四步,回到代码里改枚举。别只改调用处,要顺着调用链往上找到数据源头,确认源头声明的格式和实际产出一致。我踩过一次坑:改了转换函数的输入格式,画面正常了,但源头其实还在按错的方式拷贝数据,只是那个错误恰好被下游的修正抵消了,等到换个分辨率就又炸了。

这四步里最关键的是第三步,因为它把一个抽象的格式问题变成了一个可视化的二选一。人眼判断颜色对不对是非常可靠的,比任何自动检测都稳。

4.3 验证脚本怎么写:一段能直接抄的代码

下面这段用 Python 做验证,不依赖任何图像库之外的东西。核心思路就是按两种布局切片,喂给同一套转换逻辑:

import numpy as np def yuv420_to_rgb(buf, w, h, uv_order="I420"): """uv_order: 'I420' 表示 Y,U,V;'YV12' 表示 Y,V,U""" y_size = w * h c_size = (w // 2) * (h // 2) y = np.frombuffer(buf[:y_size], dtype=np.uint8).reshape(h, w) p1 = np.frombuffer(buf[y_size:y_size + c_size], dtype=np.uint8).reshape(h // 2, w // 2) p2 = np.frombuffer(buf[y_size + c_size:y_size + 2 * c_size], dtype=np.uint8).reshape(h // 2, w // 2) if uv_order == "I420": u, v = p1, p2 else: v, u = p1, p2 u_full = np.repeat(np.repeat(u, 2, axis=0), 2, axis=1).astype(np.float32) v_full = np.repeat(np.repeat(v, 2, axis=0), 2, axis=1).astype(np.float32) yf = y.astype(np.float32) # BT.601 limited range -> full range RGB c = yf - 16.0 d = u_full - 128.0 e = v_full - 128.0 r = 1.164 * c + 1.596 * e g = 1.164 * c - 0.392 * d - 0.813 * e b = 1.164 * c + 2.017 * d rgb = np.stack([r, g, b], axis=-1) return np.clip(rgb, 0, 255).astype(np.uint8)

用法就是读同一个文件,分别传I420和YV12,把两张图保存下来对比。注意这段用的是 limited range 的系数(减 16、乘 1.164),如果你的数据是 full range 的,这部分要改,后面第 6 章会专门说。

有个小细节值得强调:np.repeat做色度上采样用的是最近邻,这会让人脸边缘出现轻微的块状感,但验证格式完全够用。真实产品里上采样要用双线性或者更好的插值,但那是性能与画质的权衡问题,跟格式判别无关。

还有一点,如果你的宽高是奇数,w//2会算错。4:2:0 理论上要求宽高为偶数,但实际数据里确实存在用ceil处理的实现,遇到奇数尺寸要格外小心,色度平面尺寸得按(w+1)//2算。这个坑不大但很恶心,因为大部分测试素材都是标准尺寸,问题只在特定输入下才暴露。

5. 比 U/V 顺序更容易翻车的事:stride 对齐与内存越界

5.1 linesize 不等于 width 的常见场景

如果说 U/V 顺序是新手坑,那 stride 对齐就是老手也躲不开的坑。stride(也叫 pitch、linesize)指的是每一行在内存里实际占用的字节数,它往往大于图像的有效宽度,多出来的部分是对齐填充。

为什么要填充?因为硬件模块(DMA、SIMD 指令、GPU 纹理上传)通常要求每一行的起始地址按 16、32、64 甚至 256 字节对齐。比如宽度是 1920,本身就是 64 的倍数,没问题;但宽度是 1918 的裁剪结果,硬件就会要求按 1920 或者 1984 来存,多出来的字节填 0 或者保留垃圾数据。

这就导致一个致命问题:当 stride 不等于 width 时,你不能把整块 buffer 当成紧凑的连续平面来切片。如果你按w*h去切 Y 平面,切出来的内容会逐行错位,越往后偏得越多,画面表现为斜向的撕裂或者明显的斜线。

更隐蔽的是色度平面。色度平面的 stride 通常是亮度 stride 的一半(向下取整到对齐值),而不是宽度的严格一半。这两者差一点点,累积起来就是整幅画面的色彩偏移。

我见过最惨的一个案例是某硬件解码器输出 1080p 数据,linesize 是 1920 但色度平面的 stride 是 1024 而不是 960,代码里按 960 算,结果色度平面每行错 64 字节,画面呈现出非常规律的彩色斜纹,看着像信号干扰,其实是纯软件错误。

5.2 按行拷贝的正确姿势

处理带 stride 的数据,唯一正确的方法是逐行处理,永远不要假设平面内部是紧凑的。C 语言里大概长这样:

// 从带 stride 的源拷贝到紧凑目标,src 为 YUV420P(I420) 布局 void copy_with_stride(const uint8_t *src, uint8_t *dst, int width, int height, int y_stride, int uv_stride) { int cw = width / 2; int ch = height / 2; for (int i = 0; i < height; ++i) { memcpy(dst + i * width, src + i * y_stride, width); } const uint8_t *u_src = src + y_stride * height; const uint8_t *v_src = u_src + uv_stride * ch; uint8_t *u_dst = dst + width * height; uint8_t *v_dst = u_dst + cw * ch; for (int i = 0; i < ch; ++i) { memcpy(u_dst + i * cw, u_src + i * uv_stride, cw); memcpy(v_dst + i * cw, v_src + i * uv_stride, cw); } }

注意u_src的起点是y_stride * height而不是width * height,这是最容易写错的一行。同理v_src的偏移用的是uv_stride * ch。这两个地方如果按 width 算,格式对了也会花屏。

很多现成的处理库提供了带 stride 参数的接口,用的时候一定要把真实的 linesize 传进去,不要图省事传 width。省这一下,后面可能要多花一整天定位。

5.3 裁剪和缩放时的边界处理

裁剪是最容易触发对齐问题的操作。假设你从 1920×1080 的画面里裁出左上角 1918×1078 的一块,宽高都变成了偶数但都不是对齐值。这时候你有两个选择:一是保持原来的 stride 不变,只改宽高,数据处理起来简单但内存利用率低;二是重新分配一块紧凑内存,逐行拷过去,内存省了但多一次拷贝开销。

我的建议是中间处理阶段保持原 stride,只在送进最终消费者(编码器、纹理上传)之前做一次紧凑化。原因是中间阶段往往有多次处理,每次重排都要付出拷贝代价,不如集中到最后做一次。当然如果你的处理链路里有大量随机访问,紧凑布局对 cache 更友好,那就反过来做。

另一个常见问题是裁剪坐标的奇偶性。4:2:0 的色度是按 2×2 块组织的,如果你的裁剪起点是奇数坐标,色度采样点就对不上了。这时候要么把起点向下取整到偶数(会偏一个像素),要么做重采样(有额外开销)。多数实现选择前者,因为差一个像素肉眼看不出来,而重采样在实时链路上太贵。

提示:任何涉及 4:2:0 的坐标运算,先问一句"这个坐标是不是偶数"。奇数坐标在色度平面上的映射关系没有唯一解,不同实现的取舍不一样,跨库对接时很容易出现半个像素的错位。

6. 工程落地细节:转换代价、色彩范围与链路协商

6.1 I420 与 YV12 互换:几乎零成本但有个前提

好消息是 I420 和 YV12 之间的转换极其便宜。因为 Y 平面完全一样,你只需要把后两个平面整体交换位置。如果数据量是 1920×1080,需要挪动的是两个 960×540 的平面,共约 1 MB,用memcpy两次或者一次三段的memmove就搞定了,耗时在微秒级。

但注意那个前提:这个零成本互换只在两个平面各自都是紧凑存储时成立。如果带 stride,你就不能整块交换,必须逐行拷。这时候成本会上升一点,但仍然远低于任何重采样。

如果是原地交换、不额外分配内存,需要中间缓冲或者用memmove配合临时区,别直接用memcpy做重叠区域拷贝,那是未定义行为。这个细节在代码 review 时经常被忽略,跑起来大部分时候没事,偶尔在特定编译优化下出问题,非常难查。

真正贵的是 YUV 转 RGB。这个操作涉及浮点乘加、色度上采样、范围映射,1080p 一帧在普通 CPU 上单线程要几毫秒。如果链路里存在多次无谓的 YUV↔RGB 往返,性能会被吃掉一大块。我的原则是整条链路尽量保持 YUV 域,直到最后一刻上屏才转 RGB,能省下大量计算。

6.2 色彩范围:那个不会偏色但会让画面发灰的坑

搞定了平面顺序,还有一种情况会让画面看起来"不对劲"但又不是偏色:整体发灰、黑色不够黑、白色不够白、对比度偏低。这多半是色彩范围搞错了。

数字 YUV 有两个范围体系。**limited range(也叫 TV range)**把 Y 限制在 16~235,UV 限制在 16~240,留出上下余量给信号过冲;**full range(也叫 PC range)**用满 0~255。视频领域传统上用 limited,计算机图形领域传统上用 full。

如果你的数据是 limited range,却按 full range 的公式转 RGB,结果就是对比度被压缩,黑色变成深灰(16 而不是 0),白色变成浅灰(235 而不是 255)。反过来,full range 数据按 limited 转,会过曝,暗部细节全部丢失,高光一片死白。

在像素格式枚举里,这个区别通常体现在名字上:带J前缀的(比如yuvj420p)表示 full range,不带的是 limited range。这个J的来源据说是早期 JPEG 相关的历史遗留,反正在视频圈里它就是 full range 的标记。

判断自己的数据属于哪种,最直接的办法是看来源。相机采集、编码器输出、网络流,绝大多数是 limited;图像文件解码、屏幕截图、部分 GPU 渲染输出,通常是 full。拿不准就两种都转一遍对比,看哪个的黑白更自然。

转换系数也不一样。limited range 的 BT.601 系数是1.164 / 1.596 / 0.392 / 0.813 / 2.017,full range 则简化成1.0 / 1.402 / 0.344 / 0.714 / 1.772。前面那段 Python 代码用的是 limited 版本,如果数据是 full range,把c = yf - 16.0改成c = yf,系数换成 full 版本即可。

6.3 硬件链路里的格式协商:别指望默认值

最后说说硬件。用硬件编解码器的时候,输入输出格式往往是协商出来的而不是指定的。你告诉它"我要 4:2:0",它可能给你 NV12;你说"我要 planar",它可能给你 YV12。各家实现不一样,而且同一个芯片的不同驱动版本行为也可能变。

稳妥的做法是在初始化之后主动查询实际格式,而不是假设。多数硬件接口都提供了获取当前 buffer 描述的方法,包括格式枚举、宽高、stride。拿到这些信息之后再决定怎么处理,而不是写死一套假设。

我在一个跨平台项目里吃过亏:同一条代码在 A 平台上跑得好好的,换到 B 平台画面就偏色,查了半天发现 A 平台的硬件解码输出是 I420,B 平台是 YV12,而我们的转换模块写死了 I420。修复方式很简单,加一个格式分发,但发现问题的过程很痛苦,因为两个平台的日志里都写着"YUV420P"。

现在我的习惯是,在所有跨平台模块的初始化日志里,把协商到的实际格式、宽高、stride 全部打出来,哪怕看起来啰嗦。出问题的时候,这几行日志能省掉几个小时的猜测。

还有个小技巧:如果链路允许,尽量在边界处统一成一种格式(我个人偏好 I420,因为生态支持最广),把转换集中在一两个地方做,而不是让各种格式在系统里到处流动。虽然多了一两次转换,但可维护性和可调试性提升是值得的。性能敏感的场景再针对性地优化热点路径,那是后话。

注意:转换集中化之后,记得给每条转换路径都写单元测试,用构造的纯色图(比如左上红、右上绿、左下蓝、右下白)验证。纯色块在格式错乱时表现最明显,比真实照片更容易定位问题。

最后分享个我一直在用的小办法:本地留一个 4×4 的迷你 YUV 文件,手工填好已知的字节值,格式出错时拿它跑一遍,打印出来的 RGB 值一对照就知道是哪一步错了。这个小文件我用了好几年,比任何调试工具都直接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询