YUV420 这一族的命名混乱程度,在图像处理里排得上号。你随手翻一份文档,能看到 I420、YV12、YU12、YUV420P、NV12、NV21、IYUV 一串名字,前三个看起来像三种完全不同的东西,实际拆开看,内存排布只差两个色度平面的先后顺序;而 YUV420P 严格来说根本不是一个具体格式,它是一类布局的统称。真正导致线上事故的,往往不是算法写错了,而是你把 YV12 的数据丢给了只认 I420 的解码器,结果人脸变成青蓝色、嘴唇发紫,日志里却一切正常。
我写下这些,是想把 YV12、I420、YUV420P 这三个词从“名字”层面拉回到“字节”层面:一帧数据的每个字节到底在哪、为什么会有这么多叫法、FFmpeg 里的 pix_fmt 该怎么对号入座、摄像头和编码器各自偏爱哪一种、以及你在调试时怎么用最笨但最快的办法判断手上这坨数据究竟是哪一种。内容适合刚接触视频编解码、正在做相机采集或播放器对接的同学,也适合已经有经验、但每次遇到花屏都要重新查一遍文档的老手——我自己就属于后者,所以才把踩过的坑整理成可复用的判断流程。
1. 先搞清楚 YUV420 到底省了什么
1.1 人眼给的机会:亮度比色度值钱
YUV 这套色彩表示法的出发点非常朴素:把“明暗”和“颜色”拆开存。Y 是亮度,决定你看到的轮廓、纹理、明暗关系;U 和 V 是色度,准确说是两个色差信号,分别对应蓝色差(Cb)和红色差(Cr)。这套思路最早来自模拟彩色电视要兼容黑白接收机的年代——黑白机只取 Y 就能正常出图,彩色机再把色差加回去。到了数字图像时代,这个拆分反而变成了压缩的抓手。
原因在于人眼的生理结构。视网膜上的视锥细胞分成三类,对亮度敏感的那一类数量最多、分辨率最高,对色度敏感的细胞数量少、空间分辨率低。通俗点讲,你能在很远的地方看清一个人脸的轮廓和五官位置,但很难在同样距离分辨他穿的是深红还是深棕。工程上就把这个“人眼不敏感”转化成了“数据可以少存”:色度信号不需要和亮度一样密,砍掉一大半,主观画质几乎不损失,码率却直接下降三分之一。
这个特性是所有 YUV 采样格式的基础。4:4:4是全采样,每个像素都有独立的 Y、U、V,一个像素 3 字节,无损但奢侈;4:2:2是水平方向色度减半,每两个水平相邻像素共用一个色度对,一个像素平均 2 字节;4:2:0是水平和垂直方向都减半,每 2×2 的四个像素共用一组 U、V,平均 1.5 字节。这里必须澄清一个流传很广的误解:4:2:0 里的那个“0”并不意味着垂直方向一个色度样本都不取,它只是历史记号的遗留。真实的含义是垂直方向每两行共用一行色度,色度样本总数是亮度的四分之一。
算一下就很清楚。以 1920×1080 为例,Y 平面是 1920×1080 = 2073600 字节;U 和 V 各是 960×540 = 518400 字节;一帧合计 3110400 字节,约 2.97 MB。如果换成 4:4:4,一帧就是 6220800 字节,接近 6 MB。在 30fps 下,这个差距就是每秒 90 MB 和 180 MB 的差别,对采集、传输、编码都是实打实的压力。所以凡是走硬件通路、走网络传输、走编码器的场景,4:2:0 几乎是默认选项。
1.2 数字是采样率,字母才是内存排布
关键在于:采样格式和内存布局是两个正交的维度,很多人第一次接触时把它们混成了一个概念。4:2:0 描述的是“色度取多少”,但它没规定“这些色度数据在内存里怎么摆”。同样是 4:2:0 的数据,摆法至少有三大类:
- Planar(平面式):Y 全部存完,再存一整个 U 平面,再存一整个 V 平面。三个独立的大块。I420、YV12 都属于这一类。
- Semi-Planar(半平面式):Y 单独一个平面,U 和 V 交错成一块,形如 UVUVUV……NV12(UV 交错)和 NV21(VU 交错)属于这一类。
- Packed(打包式):Y、U、V 按像素交织存放,像 YUY2、UYVY。4:2:0 一般不用打包式,因为垂直方向也要共享色度,打包会非常别扭。
这个区分看似学院派,实际决定了你写代码时怎么算偏移。Planar 格式里,U 和 V 是两个独立的指针;Semi-Planar 里,只有一个色度指针,第二个色度样本要么往后跳一个字节,要么往后跳一个像素——这就引出了后面要讲的 pixelStride 问题。YUV420P 里的那个 P,指的正是 Planar。所以从命名逻辑上说,YUV420P 是“4:2:0 采样 + 平面布局”这一类格式的统称,而 I420 和 YV12 是它的两个具体实例。
1.3 为什么会有这么多名字:一段被 FourCC 塑造的历史
要理解命名混乱,得回到 Windows 上多媒体刚起步的年代。当时不同的采集卡厂商、编解码器厂商各自约定了一套字节顺序,然后在 AVI 文件头和 DirectShow 的媒体类型里用 FourCC(四个字符的编码)来标识。微软给出了 IYUV,另一些厂商用 YV12,还有人用 YU12,这些 FourCC 都被写死进了播放器和滤镜里,形成了事实标准。后来 Linux 侧、移动端、Web 侧各自又长出一套命名,于是同一个东西被叫了七八个名字。
这里有个很典型的例子:YU12 这个 FourCC 其实就是 I420,字母顺序 Y-U-V 加上 12 bit per pixel,指的是同一份数据。同理 IYUV 也等于 I420。你在某些老设备输出的元数据里看到 YU12,不要慌,它就是 Y-U-V 顺序的平面格式。而 YV12 的字母顺序直接反映了它的内存顺序:Y 平面之后先放 V 平面,再放 U 平面。名字里的字母顺序,就是内存里的平面顺序,这是解开整个命名迷局的一把钥匙。记住这条,I420(Y-U-V)、YV12(Y-V-U)、NV12(Y 后接 UV 交错)、NV21(Y 后接 VU 交错)全部可以自己推导出来。
2. I420 与 YV12 的唯一实质差异
2.1 除了 U、V 位置,它们完全一样
把 I420 和 YV12 放在一起看,会发现它们在所有维度上都一致:都是 4:2:0 采样,都是三个独立的平面,都是一帧 1.5 字节每像素,Y 平面尺寸相同,两个色度平面的尺寸也相同。唯一的区别是 U 和 V 谁排在前面。
I420 的顺序是 Y → U → V。YV12 的顺序是 Y → V → U。
就这么一点差别。但因为平面尺寸相同,你把这坨数据喂错解码器时,不会报错、不会崩溃、不会花屏成块状,只会得到一张颜色系统性偏移的图:原本偏红的区域变成偏蓝,原本偏蓝的变成偏红,肤色变成一种诡异的青紫色。这种“看起来还能看,但颜色就是不对”的现象,是最难排查的一类问题,因为它伪装成了“色彩空间转换参数不对”或者“白平衡有问题”,把你引向错误的方向。
我见过有人为了这个现象去调 BT.601 和 BT.709 的转换矩阵,调了半天发现是 U/V 反了;也有人怀疑是显示器色域问题,换了两块屏。实际上有一个非常快的判断方法:如果整幅图的红蓝通道像是被对调了,而且绿通道大体正常,那基本可以锁定是 UV 顺序问题,因为绿色的 U、V 分量本身就接近中性值,翻转后变化最小。
2.2 为什么偏偏是这两个名字流传下来
从工程史的角度看,I420 更多出现在“标准”这一侧:它在许多开源工具链里是默认的 4:2:0 平面格式,绝大多数软件编码器接受的第一输入就是它。YV12 则更多出现在“硬件”这一侧:早期的某些采集芯片、某些显卡的覆盖层(overlay)输出、一些老式摄像头,默认吐的就是 YV12。两边各自形成了路径依赖,谁也没能吃掉谁,于是开发者就被夹在中间,必须做一次转换。
这也解释了一个很常见的现象:同一条链路上,采集端给的是 YV12,编码器要的是 I420,中间如果没有人做 swap,颜色就会错。而在某些框架里,swap 被默默做了,在另一些框架里没有,于是同一个代码在不同平台上表现不同。这类问题的根因往往不在你的代码,而在中间层的默认约定。
2.3 一张表把布局钉死
下面这张表是我自己贴在便签上的版本,把常见 4:2:0 格式的平面顺序和帧大小一次说清:
| 格式名(别名) | 采样 | 布局类型 | 平面顺序 | 帧大小(W×H 为偶数) | 典型来源 |
|---|---|---|---|---|---|
| I420(IYUV、YU12) | 4:2:0 | Planar | Y, U, V | W×H×1.5 | 多数软件编码器输入、通用处理链路 |
| YV12 | 4:2:0 | Planar | Y, V, U | W×H×1.5 | 部分采集设备、老式播放链路 |
| YUV420P | 4:2:0 | Planar | 泛指,通常等同 I420 | W×H×1.5 | 多媒体工具的像素格式命名 |
| NV12 | 4:2:0 | Semi-Planar | Y, UV 交错 | W×H×1.5 | 多数移动端相机、硬件编码器 |
| NV21 | 4:2:0 | Semi-Planar | Y, VU 交错 | W×H×1.5 | 部分移动端相机预览回调 |
注意表格里 YUV420P 那一行的“泛指”。这是最容易让人困惑的地方:在不同语境下,YUV420P 有时被当作 I420 的同义词(因为 I420 是最标准、最通用的排列),有时被当作一个类别名,涵盖 I420 和 YV12。看到 YUV420P 这个词,必须先确认上下文,是某个 API 的枚举值,还是文档里的分类描述。如果是 API 枚举,它大概率对应 I420;如果是文档里的分类,就得继续往下看它举了哪些具体格式。
3. 手算一帧数据里每个字节的坐标
3.1 没有行对齐的理想情况
先说理想情况:假设每行数据紧密排列,没有填充字节。设宽为 W、高为 H,都取偶数。那么:
- Y 平面起点偏移 0,长度 W×H,第 (x, y) 个像素在偏移 y×W + x。
- U 平面起点偏移 W×H,尺寸 (W/2)×(H/2)。
- V 平面起点偏移 W×H + W×H/4 = W×H×5/4,尺寸 (W/2)×(H/2)。
以 I420 为例,1920×1080 时,U 平面起点是 2073600,V 平面起点是 2592000,总长度 3110400。如果换成 YV12,起点不变,但偏移 2073600 处放的是 V,偏移 2592000 处放的是 U。
这些数字不难算,但在代码里手写常数是最容易出错的做法。正确姿势是把它们写成宏或者函数:
static inline size_t i420_chroma_offset(int w, int h, int plane /* 0=U, 1=V */) { size_t y_size = (size_t)w * h; size_t c_size = y_size / 4; return y_size + plane * c_size; }这样写的好处是,如果以后要改成带 stride 的版本,只需要改一处。
3.2 带上 stride,才算摸到真实世界
真实世界几乎不存在“紧密排列”的帧。GPU 纹理、SIMD 指令、DMA 传输都有对齐要求,所以每行末尾经常补几个填充字节,让行宽变成 16、32、64 甚至 256 的倍数。这个行宽叫stride(也叫 pitch、行跨距),它可能大于图像宽度。
一旦引入 stride,前面所有基于 W×H 的公式全部作废。正确的算法是:
- Y 平面占 y_stride × H 字节。
- 色度平面占 uv_stride × (H/2) 字节,其中 uv_stride ≥ W/2。
- Y 平面上第 (x, y) 个像素的偏移是 y × y_stride + x。
- 色度平面上第 (cx, cy) 个样本的偏移是 y_size + cy × uv_stride + cx。
举个具体例子。假设图像是 800×600,但硬件要求行宽对齐到 64 的倍数。Y 行宽 800 已经能被 64 整除吗?800 ÷ 64 = 12.5,不能。向上取整到 64 的倍数,得到 832。色度行宽是 400,400 ÷ 64 = 6.25,向上取整得到 448。那么:
- Y 平面大小 = 832 × 600 = 499200 字节。
- 色度平面各 448 × 300 = 134400 字节。
- 一帧总计 499200 + 134400 × 2 = 768000 字节。
对比一下紧密排列的理论值 800 × 600 × 1.5 = 720000 字节,多了 48000 字节的填充。这部分填充是无效数据,有些设备会给它填 0,有些填随机值,有些保留上一帧的残留。如果你直接把整块缓冲送去显示却不按 stride 解析,画面会出现从左上到右下逐渐偏移的斜切效果,越往右越歪,这是 stride 用错的经典特征,非常容易辨认。
提示:如果一帧画面看起来像是被“斜着剪切”了,比如每一行都比上一行往右偏一点,几乎可以百分百确定是你把 stride 当成了 width。反过来,如果画面右侧有一条竖直的杂色带,那是 stride 计算偏小,解析时越界读到了别的数据。
3.3 奇数尺寸的边界情况别忽略
4:2:0 的语义要求宽高都能被 2 整除,但现实里总有奇数尺寸的视频流。这时候色度平面的尺寸计算有两种流派:向下取整和向上取整。向下取整会丢掉最右一列和最下一行的色度信息,向上取整会在末尾多出半行。不同实现的选择不一致,导致奇数尺寸的帧在不同工具间转换时出现最底部一行或最右侧一列的色偏。
我的做法是在链路入口就把尺寸强制对齐到偶数,宁可裁掉一两个像素,也不让奇数尺寸流进下游。这一两个像素在 1080p 里根本看不出来,但能省掉后面无穷无尽的排查。如果业务上确实不能裁,那就必须在每一层都明确记录“色度尺寸采用向上取整”这个约定,并写进接口文档。
4. 用代码和工具把差异看见
4.1 手写一次 RGB 到 I420 的转换
想彻底搞懂,最好的办法是自己实现一次转换,不用任何库。下面这段 Python 用 BT.601 的全范围系数把 RGB 转成 YUV,再按 2×2 取平均做 4:2:0 下采样,最后按 I420 顺序拼接。系数来自标准的亮度方程,直接抄就行:
import numpy as np def rgb_to_i420(rgb): h, w, _ = rgb.shape r = rgb[:, :, 0].astype(np.float32) g = rgb[:, :, 1].astype(np.float32) b = rgb[:, :, 2].astype(np.float32) y = 0.299 * r + 0.587 * g + 0.114 * b u = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0 v = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0 y = np.clip(y, 0, 255).astype(np.uint8) u = np.clip(u, 0, 255).astype(np.uint8) v = np.clip(v, 0, 255).astype(np.uint8) # 2x2 块求平均,得到 4:2:0 的色度平面 u420 = u.reshape(h // 2, 2, w // 2, 2).mean(axis=(1, 3)).astype(np.uint8) v420 = v.reshape(h // 2, 2, w // 2, 2).mean(axis=(1, 3)).astype(np.uint8) return y.tobytes() + u420.tobytes() + v420.tobytes() def i420_to_yv12(i420, w, h): y_size = w * h c_size = y_size // 4 y_plane = i420[:y_size] u_plane = i420[y_size:y_size + c_size] v_plane = i420[y_size + c_size:y_size + 2 * c_size] return y_plane + v_plane + u_plane这段代码里有两个值得注意的点。第一,reshape(h//2, 2, w//2, 2)的四个轴依次是“行块序号、块内行、列块序号、块内列”,所以要对 axis=(1,3) 求平均,写错轴会得到完全错乱的色度图。第二,i420_to_yv12这个函数只做了平面交换,长度不变、Y 平面不动,这正是两者的全部差异——如果你在自己的代码库里看到有人写了一长串转换逻辑来处理这两个格式,那多半是多余的。
4.2 用命令行工具做交叉验证
写完文件之后,用多媒体工具验证是最快的。假设已经把一帧 640×480 的数据写进了frame_i420.yuv:
# 按 I420(即 yuv420p 布局)解释并转成 PNG ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -i frame_i420.yuv -frames:v 1 right.png # 故意用 I420 的解释方式去读 YV12 的数据 ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -i frame_yv12.yuv -frames:v 1 wrong.png第二条命令不会报错,会老老实实生成一张图,但颜色是错的。把right.png和wrong.png并排看,如果红蓝关系发生了系统性互换,你就亲手复现了那个最常见的 UV 顺序事故。想做反向验证也很简单,把-pix_fmt换成yv12再读同一份数据,颜色就对了:
ffmpeg -f rawvideo -pix_fmt yv12 -s 640x480 -i frame_yv12.yuv -frames:v 1 correct.png还有一个更省事的办法:很多播放工具支持直接播原始 YUV,只要指定格式和分辨率就行,可以实时看到切格式的效果,比反复导出 PNG 快得多。
注意:读原始 YUV 时如果不指定像素格式,工具会按默认值猜,猜错了就是你自己的锅。原始数据没有自描述能力,格式、分辨率、帧率这三样必须由外部提供,缺一不可。
4.3 用十六进制直接看色度平面的头几个字节
有时候你拿到的数据没有元信息,也不知道是谁生成的,这时候最原始的办法反而最有效。挑一个颜色已知的区域,比如画面左侧有一块纯红,然后分别看 U 平面和 V 平面的开头。
纯红 RGB(255, 0, 0) 代入上面的公式:Y ≈ 76,U ≈ 85,V ≈ 255。也就是说红色区域的 U 值小、V 值大,差值非常明显。如果偏移 W×H 处读到的是 255 左右,说明这个位置是 V,那就是 YV12;如果读到的是 85 左右,说明这个位置是 U,那就是 I420。这个判断不需要任何工具,只要知道画面里某块区域的颜色,一次就够。
# 跳转到 Y 平面之后,dump 64 个字节看看 xxd -s $((640*480)) -l 64 frame.yuv用这个方法之前唯一要确认的是 stride 是否等于 width,如果不等,偏移要按 stride 算。这也是为什么我在自己的调试脚本里,永远把 stride 作为必填参数而不是可选参数——省掉的那点输入成本,迟早要用排查时间还回去。
5. 各条链路上的叫法与对接细节
5.1 多媒体工具里的像素格式命名
在主流的多媒体处理工具里,你找不到叫i420的像素格式名。它用的是yuv420p,而yuv420p的实际内存布局就是 Y-U-V 顺序,也就是 I420。这一点经常让人困惑:文档说支持 I420,工具里搜不到 I420,只有 yuv420p,到底是不是一个东西?答案是肯定的。工具侧用的是“采样 + 布局”的命名法,yuv420p= 4:2:0 采样 + planar 布局,默认平面顺序为 Y、U、V。
与之并列的还有yuvj420p,这里的j表示 JPEG 风格的全范围(full range),Y 的取值范围是 0–255,而不是电视标准的 16–235。这两个格式在内存布局上完全一样,差别只在数值范围的解释上。把它们搞混的后果是画面发灰、对比度变低,而不是颜色错乱,症状和 UV 反转完全不同,可以据此区分。
还有一个需要留意的细节是色度样本的位置(chroma siting)。同样是 4:2:0,色度样本可能被认为位于 2×2 块的左上角,也可能位于块的中心。前者常见于较新的编码标准,后者常见于较老的图像格式。这个差异在绝大多数内容上肉眼难辨,但在高对比度的细线条边缘会引起轻微的色边。工具链里通常有单独的参数控制它,日常使用保持默认即可,只有在做严格的画质比对时才需要专门处理。
5.2 移动端相机回调的真实形态
移动端的相机接口是另一个高频踩坑区。以常见的相机2 API 为例,它给出的格式叫YUV_420_888,这个名字本身就带着“我不保证具体布局”的意味:它承诺的是 4:2:0 采样、YUV 三通道、每像素 8 位,但具体是平面还是半平面、两个色度通道是否交错,由设备决定。你拿到的是三个 plane 的描述结构,每个 plane 有rowStride和pixelStride两个关键字段。
pixelStride是这里的核心。如果某个色度 plane 的pixelStride是 1,说明这个 plane 里的字节是连续排列的,是真正的平面格式;如果是 2,说明它和另一个通道交错在一起,每隔一个字节才属于自己。这意味着同一份代码,在不同设备上可能面对完全不同的内存形态。正确做法是永远按rowStride和pixelStride通用地取值,不要假设“U 平面就是连续的 W/2 × H/2”。
另一个平台的相机接口则直接给出半平面格式,色度是 UV 交错的,全范围或视频范围各有对应的格式常量。这类格式本质上就是 NV12 或其全范围变体,处理时只需要一个色度指针,取样时按(u, v)成对读取即可。跨平台移植代码时,这一层的格式差异必须显式处理,不能指望框架帮你抹平。
5.3 编码器与图形接口的偏好
硬件编码器普遍偏爱半平面格式,因为 UV 交错存储对 DMA 和缓存更友好,一次读取就能拿到一对色度样本。软件编码器则普遍偏爱平面格式,因为分离的数据更适合做运动估计和残差计算。图形接口上传纹理时,通常的做法是把 Y 平面作为一个亮度纹理,把 UV 作为一个双通道纹理,在着色器里采样两次再做矩阵变换。这时候 UV 的顺序就体现在着色器的取样代码里,写反了同样会红蓝颠倒,而且因为发生在 GPU 上,CPU 侧的日志完全看不出来。
所以一条完整的链路往往是这样的:相机给半平面,中间层转成平面喂给编码器,编码器输出码流,解码后又要转回半平面交给渲染。每一次转换都是一次出错机会。我的经验是把转换点收敛到一处,所有格式转换都走同一个函数,并且在这个函数里做断言校验——比如检查输入输出的帧大小是否符合 1.5 倍关系,检查 stride 是否不小于宽度。这些断言在线上几乎不会触发,但在开发和联调阶段能省掉大量时间。
6. 问题速查与排查流程实录
6.1 症状到原因的对照表
下面这张表是我这些年攒下来的,基本覆盖了 4:2:0 相关的绝大部分显示异常。排查时先看症状,再看最可能的原因,能省掉大量猜测:
| 症状表现 | 最可能原因 | 快速验证方法 |
|---|---|---|
| 红蓝系统性互换,肤色发青 | U/V 平面顺序搞反(I420 与 YV12 混用) | 换一个 pix_fmt 重新解释同一份数据 |
| 画面整体斜切,越往右越偏 | 把 stride 当成了 width | 打印实际 stride 与 width 对比 |
| 画面右侧有竖直杂色带 | stride 算小了,解析越界 | 检查行宽对齐要求 |
| 整体发灰、对比度低 | 全范围与视频范围混淆 | 检查是否误用了 j 系列格式 |
| 画面底部一两行异常 | 奇数高度导致色度尺寸取整不一致 | 强制尺寸对齐到偶数 |
| 画面上下颠倒 | Y 平面行序问题(自底向上存储) | 翻转行序再看 |
| 边缘有轻微色边 | 色度样本位置约定不一致 | 检查色度位置参数 |
| 整帧花屏、块状错乱 | 帧大小算错或数据长度不足 | 核对缓冲长度是否等于 W×H×1.5 |
表格里最值得展开的是前两行。红蓝互换这类问题,很多人的第一反应是去查色彩矩阵,查半天查不出结果,因为矩阵是对的,只是输入数据的两个通道被调换了位置。正确的排查顺序应该是:先确认格式标识符,再确认内存布局,最后才怀疑矩阵参数。顺序搞反了,就会在错误的方向上耗掉半天。
6.2 我自己的四步定位法
遇到颜色或几何异常,我现在固定按四步走,基本能在十分钟内定位。
第一步,确认帧长度。量一下缓冲区大小,和 W×H×1.5 比。如果明显偏大,说明带 stride 或者带额外填充;如果偏小,说明数据被截断或者尺寸记错了。这一步能排除掉一半的问题。
第二步,确认平面起点。用前面说的纯色区域法,取画面里一块颜色已知的区域,看看偏移 W×H 处读到的是 U 还是 V。这一步专治红蓝互换。
第三步,确认 stride。如果画面有斜切或者右侧杂色,一定是 stride 问题。把 stride 打印出来,和 width 对比,再和常见的对齐值(16、32、64、256)对比,一眼就能看出来。
第四步,确认数值范围。如果颜色没错、几何没错,只是发灰发白,那就是范围问题。检查格式名里有没有 j 后缀,或者检查是否有单独的 range 参数被设成了全范围。
这四步的顺序是刻意设计的:从最粗粒度的长度检查开始,逐步细化到通道、到行、到数值。先排除大问题,再定位小问题,避免一上来就钻进细节。
6.3 几个反直觉的经验
第一个反直觉的点:格式名不保证布局。前面反复提到的YUV_420_888就是典型,名字里只承诺了采样方式,布局要运行时看。所以任何跨设备的代码,都不能靠格式名硬编码布局,必须读运行时参数。
第二个反直觉的点:转换不一定是必要的。很多人一看采集端给 YV12、编码器要 I420,立刻写一个逐像素转换函数。实际上这只是两个平面互换,完全可以零拷贝地交换两个指针来完成,性能差异巨大。如果框架不允许改指针,那也应该只交换两块内存,而不是逐像素遍历。
第三个反直觉的点:颜色错和画质差是两类问题。颜色系统性偏移(红蓝互换、整体偏青)几乎一定是格式或顺序问题;画质下降(模糊、色块、边缘振铃)才可能是采样率、码率、量化参数的问题。把这两类问题分开想,能显著缩短排查路径。
第四个反直觉的点:不要相信肉眼判断“哪边是红色”。在某些偏色素材上,人的色彩恒常性会骗你。我现在的做法是在测试图里固定放几个纯色块:纯红、纯蓝、纯绿、纯白。只要看一眼色块的位置对不对,就能判断通道有没有反,比看自然图像可靠得多。
最后一个实用建议:在你的调试工具里内置一个“格式探针”。输入一份未知的 YUV 数据,它依次按 I420、YV12、NV12、NV21 解释并输出四张缩略图,你挑颜色正常的那张,就知道原始格式了。这个工具我写完之后用了很多年,代码不到一百行,但每次遇到来源不明的数据都能救急。它的原理其实就是前面说的那套:同一份字节,不同的解释方式,只有一种能得到合理的肤色和天空色。