
前两天有个做图像算法的朋友甩给我一段画面一群人在室内开会人脸泛着一层青绿色的光红色衣服看着像深蓝蓝色文件夹又偏偏偏紫。他信誓旦旦说解码器没问题数据是标准的 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 是亮度UCb是蓝色差VCr是红色差。分离之后亮度分量保持全分辨率色度分量可以大幅降采样而肉眼几乎察觉不到损失。这就是整套 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 块占用的字节数是 4Y 1U 1V 6 字节平均到每个像素是 1.5 字节。对比 RGB 的 3 字节/像素直接砍掉一半。一张 1920×1080 的帧格式每像素字节单帧大小1920×1080相对 RGB 占比RGB243.06,220,800 B约 5.93 MB100%YUV4222.04,147,200 B约 3.96 MB66.7%YUV4201.53,110,400 B约 2.97 MB50%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 V11YV12 呢总大小完全一样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-planarUV 交错。但现实是几乎所有主流的像素格式枚举里YUV420P这个值都被赋予了具体的、带顺序的含义。举最常见的例子通用转码框架里yuv420p这个枚举值实际对应的内存布局就是 Y、U、V也就是 I420。因为它在历史上先被实现、先被广泛使用后面的人就默认了。这就是典型的实现即标准。问题出在跨系统对接的时候。A 系统内部用yuv420p表示 I420 顺序导出的文档写输出 YUV420PB 系统收到文档以为 YUV420P 就是泛指但它的解码模块拿到这个 buffer 后按 YV12 顺序解释——两个系统各自都没错契约却断了。这种事故在跨团队协作里极其常见而且往往在上线前的联调阶段才暴露。我的做法比较土但有效在所有接口定义里永远不写 YUV420P 这种含糊的词直接写 I420 或者 YV12并且注明平面顺序。多打几个字能省掉后续几天的排查。3.2 半平面格式的插入让命名体系彻底乱掉NV12 和 NV21 的出现让这套命名雪上加霜。它们是 semi-planarY 平面单独一块UV 两块交错在一块第一个块占 W×H第二个块占 W×H/2其中 U 和 V 交替排列。NV12 是 UVUV 交替NV21 是 VUVU 交替。这两个格式在移动端几乎是标配——摄像头预览、硬件解码输出、GPU 纹理上传走的都是这一套。原因很实际半平面格式的内存访问对硬件更友好单个色度行可以一次性读进 cacheGPU 上传纹理的时候也只需要两次拷贝而不是三次。于是你会在一条链路上看到这样的景象相机输出 NV21转成 I420 送编码器编码后打包传输接收端解码出来又是 NV12转成 YV12 送某个老显示模块最后转 RGBA 上屏。同一条链路里 4:2:0 的几种排布轮番登场每一次转换都是一次潜在的顺序错误机会。我见过一个项目的转码模块里手写了六个转换函数其中两个的输入输出标注是反的跑了半年没人发现因为那条分支只在特定设备上触发。这种维护成本归根到底是命名不规范积累下来的债。3.3 一个实用的判别原则先信枚举再信文档最后信肉眼面对一个来源不明的 4:2:0 buffer我一般按这个优先级判断第一优先看代码里的枚举常量。枚举值虽然是人为定义的但它就是当前这份代码实际执行的标准比任何文档都可靠。找到那个值对应的定义处顺着看它被哪个转换函数消费一目了然。第二优先看接口文档和采集设备的规格书。注意看它写的是 FourCC 还是 pixel formatFourCC 往往更明确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_orderI420): uv_order: I420 表示 Y,U,VYV12 表示 Y,V,U y_size w * h c_size (w // 2) * (h // 2) y np.frombuffer(buf[:y_size], dtypenp.uint8).reshape(h, w) p1 np.frombuffer(buf[y_size:y_size c_size], dtypenp.uint8).reshape(h // 2, w // 2) p2 np.frombuffer(buf[y_size c_size:y_size 2 * c_size], dtypenp.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, axis0), 2, axis1).astype(np.float32) v_full np.repeat(np.repeat(v, 2, axis0), 2, axis1).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处理的实现遇到奇数尺寸要格外小心色度平面尺寸得按(w1)//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~235UV 限制在 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.017full 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 平台的硬件解码输出是 I420B 平台是 YV12而我们的转换模块写死了 I420。修复方式很简单加一个格式分发但发现问题的过程很痛苦因为两个平台的日志里都写着YUV420P。现在我的习惯是在所有跨平台模块的初始化日志里把协商到的实际格式、宽高、stride 全部打出来哪怕看起来啰嗦。出问题的时候这几行日志能省掉几个小时的猜测。还有个小技巧如果链路允许尽量在边界处统一成一种格式我个人偏好 I420因为生态支持最广把转换集中在一两个地方做而不是让各种格式在系统里到处流动。虽然多了一两次转换但可维护性和可调试性提升是值得的。性能敏感的场景再针对性地优化热点路径那是后话。注意转换集中化之后记得给每条转换路径都写单元测试用构造的纯色图比如左上红、右上绿、左下蓝、右下白验证。纯色块在格式错乱时表现最明显比真实照片更容易定位问题。最后分享个我一直在用的小办法本地留一个 4×4 的迷你 YUV 文件手工填好已知的字节值格式出错时拿它跑一遍打印出来的 RGB 值一对照就知道是哪一步错了。这个小文件我用了好几年比任何调试工具都直接。