简介:YUV_Viewer 是一款面向图像处理、视频编码开发者及相关方向学习者的 YUV 数据查看工具,重点解决 yuv422 与 yuv420 格式文件难以直观预览和校验的问题。压缩包共 16 个文件,约 553KB,以 3 个 h 头文件、2 个 cpp 源文件及 vcproj、sln 工程文件为主体,便于梳理 VC 工程组织与进行二次开发;同时附有 2 个 yuv 样本、可执行 exe、ReadMe 说明和图标等辅助文件,可直接运行并对照源码观察数据读取与显示流程。已有 381 人学习,在 YUV 格式入门与调试场景中具备一定参考价值。借助样本与源码,读者能够理解 YUV422/420 的采样差异、图像显示与色彩空间转换思路,并可用于视频处理算法调试、图像质量检查或编码实验中的快速验证,也能从工程配置和源码结构中获取排查显示异常、验证数据布局的线索。
1. YUV 文件为什么打不开:一个没有文件头的裸数据容器
拿到一个几 GB 的.yuv文件,双击没反应,拖进播放器要么黑屏要么满屏马赛克——这是查看 YUV 文件时最常见的第一个跟头。原因不复杂:YUV 裸流不像 MP4 那样带容器头,文件里只有一帧接一帧的像素数据,宽、高、像素格式、帧率这些元信息全都不在里面。播放器不知道一行有多少像素,自然只能瞎猜。这份 YUV 查看器要解决的正是这个问题:把宽高、像素格式、起始偏移这些参数交回给人来填,然后按正确的步长把字节流切成一帧帧画出来。做视频编解码、摄像头调试、图像算法前后处理比对的人,几乎每周都要和这种文件打交道,工具顺不顺手,直接决定排查效率。它不挑文件来源,抓包导出的、编码器 dump 的、算法中间结果落盘的,都能往里塞。
2. 拆开 YUV 的存储布局:I420、NV12、YUYV 到底差在哪
在参数面板上乱点之前,得先知道自己手里的字节流是按什么顺序排的。同样一个 1920×1080 的文件,当成 I420 看是正常画面,当成 NV12 看就是紫绿错位;当成 YUYV 看,画面会被横向拉成两倍宽再切成两半。这不是工具的问题,是布局理解错了。这一章把三种最常见的排布方式拆开讲,后面填参数时心里才有底。
2.1 平面格式与打包格式:Y、U、V 怎么排队
YUV 的核心是把亮度(Y)和色度(U、V)分开存。平面格式(planar)的做法是三块数据各占一段连续内存:先存完整的 Y 平面,再存 U 平面,最后存 V 平面。I420、YV12、YUV422p、YUV444p 都属于这一类,区别只在 U、V 的先后顺序和色度平面的尺寸。YV12 就是把 I420 里的 U 和 V 调了个个儿,很多工具里这两个选项挨着放,选错了画面整体会偏紫或者偏绿,但轮廓还是对的。
打包格式(packed)则是把 Y、U、V 交织在同一行里。YUYV(也叫 YUY2)一个像素对占 4 个字节:Y0 U Y1 V,两个 Y 共用一组 UV;UYVY 只是把 U 和 V 的位置对调。半平面格式(semi-planar)是折中方案,Y 单独一个平面,UV 交错成一块,NV12 是 UVUV 交替,NV21 是 VUVU 交替。安卓摄像头预览吐出来的经常是 NV21,编码器内部又常转成 NV12,这两个名字只差一个字母,画面表现却完全不同。
选型上有个经验判断:如果文件来自编码器码流分析或 ffmpeg 的-pix_fmt输出,大概率是 I420 或 NV12;如果来自某个采集卡的裸 dump,先怀疑 YUYV/UYVY;如果是移动端抓的,优先试 NV21。拿不准的时候不要死磕一个选项,把同一帧在几种格式下各看一遍,能看出人脸的通常是 planar,能看出但颜色发灰发紫的往往是 UV 顺序错了。
2.2 帧大小与帧偏移:算错一个字节就整屏错位
查看器内部靠帧大小做 seek,帧大小算错,往后翻的每一帧都会错位。8bit 下各格式的单帧字节数可以直接算出来:
| 像素格式 | 常见别名 | 单帧字节数(W×H) | 1920×1080 实际值 |
|---|---|---|---|
| I420 / YUV420p | I420、YV12(UV 互换) | W×H×3/2 | 3,110,400 |
| NV12 / NV21 | semi-planar 420 | W×H×3/2 | 3,110,400 |
| YUV422p | I422 | W×H×2 | 4,147,200 |
| YUYV / UYVY | YUY2、packed 422 | W×H×2 | 4,147,200 |
| YUV444p | I444 | W×H×3 | 6,220,800 |
| P010 / P016 | 10bit、16bit 容器 | W×H×3(每样本 2 字节) | 6,220,800 |
表里最后一行是个高频误区:10bit 数据不是按 10 个 bit 紧凑打包的,而是每个样本占 2 个字节,高 10 位有效、低 6 位补零或者当对齐位。所以一个 1080p 的 P010 文件,大小恰好和 8bit 的 YUV444p 一样,光看文件体积区分不出来,必须结合位深参数一起判断。
帧大小确定之后,第 N 帧的字节偏移就是N × 帧大小。有些采集卡或者硬件编码器输出的每一行末尾带 padding,比如 1920 宽实际按 2048 字节对齐,文件总大小就不是帧大小的整数倍。这种情况下查看器如果不支持 stride 参数,画面会呈现斜向撕裂,每隔一行往右偏一截。遇到这种文件,先用文件总大小除以候选帧大小,能整除才说明没有 padding;除不尽就得回去查采集配置里的 stride 或 pitch 设置。
2.3 色度抽样与位深:4:2:0、4:2:2 和 10bit 的差别
4:2:0 表示每 2×2 个像素共用一组 UV,色度平面在宽高两个方向都减半;4:2:2 只在水平方向减半,垂直方向保持全分辨率;4:4:4 不做抽样,三个平面尺寸一致。肉眼看,4:2:0 在纯色块边缘会有轻微的色彩渗出,4:2:2 的红色文字边缘更锐利,4:4:4 基本和 RGB 无异。调试压缩算法时,如果对比的是 4:2:0 前后的画质,查看器里必须把格式选对,否则你看到的劣化里有一半是参数填错造成的。
位深这块,8bit 和 10bit 的画面在普通显示器上肉眼差别不大,但直方图和渐变区域能看出来:10bit 的天空渐变过渡更平滑,8bit 容易出现色带。查看器如果只按 8bit 解释 10bit 数据,画面会整体发暗、对比度异常,因为高字节被当成了独立的像素值。反过来,把 8bit 数据按 10bit 读,会看到每隔一个像素就跳变的条纹。判断方法很简单:看文件大小和分辨率的比值,W×H×1.5对应 8bit 420,W×H×3才可能是 10bit,先算再选,比盲试快得多。
色彩空间也要留意。BT.601 和 BT.709 的转换系数不同,同一份 YUV 用错矩阵,肤色会明显偏黄或偏粉。还有 limited range(16–235)和 full range(0–255)的区别,前者按 full range 解释会发灰、对比度不足,后者按 limited 解释会过曝丢暗部。查看器里一般有这两个开关,默认值不一定适合你的文件,看画面发灰就先切 range,看颜色不对再切矩阵。
3. YUV_Viewer 上手:一次把参数配对
工具本身不复杂,复杂的是参数之间的依赖关系。宽、高、像素格式、起始偏移这四项任意一项错了,画面都不对,而且错误表现各不相同,很容易在几个错误之间来回打转。这一章按首次加载、逐帧浏览、导出比对三个动作顺序走一遍,每一步都说清楚为什么这么设。
3.1 首次加载:四个必填参数怎么定
打开文件之前先把四个值写在纸上:宽、高、像素格式、起始帧。宽高从文件名或者生成日志里找,很多 dump 脚本会把1920x1080_yuv420p直接写进文件名,这是最省事的。找不到就问生成方,或者用第 6 章的体检脚本反推。
加载流程一般是这样:
# 先确认文件体积,反推单帧大小是否合理 ls -l capture_1080p.yuv # 3110400000 字节 / 3110400 = 1000 帧,整除说明无 padding体积能整除候选帧大小,说明宽高和格式的假设至少是自洽的。然后在查看器里依次填入 1920、1080,像素格式选 I420,起始帧填 0。画面出来之后先看三件事:人脸肤色是否自然、纯色区域有没有横向条纹、画面上下有没有重复的半个头。肤色偏但轮廓正常,是 UV 顺序或色彩矩阵的问题;横向条纹多半是 stride 没对齐;上下重复则说明高度填错了,实际可能是 1088 而不是 1080,编码器为了宏块对齐会补 8 行。
起始帧这个参数容易被忽略。有些文件前面带了几百字节的自定义头,或者是多路流拼接的,真正的像素数据不在偏移 0 的位置。如果第 0 帧看起来像随机噪声,但第 1 帧开始正常,就说明帧大小算对了、只是起点偏了,把起始偏移按帧大小往前推即可。
提示:填参数时养成先填宽高、再选格式、最后调偏移的顺序。反过来先选格式,宽高填错时画面也会花,两种错误叠在一起很难分辨。
3.2 逐帧浏览与时间轴定位
画面正常之后,逐帧翻是最常用的动作。查看器一般支持方向键单帧步进、PgUp/PgDn 跳十帧、直接输入帧号跳转。这里有个细节:帧号从 0 开始还是从 1 开始,不同工具不一样,和生成端的日志对不上时先确认这个基准。我一般会在已知内容的一帧上做个标记,比如找第 100 帧有场景切换,然后在工具里跳到 100 和 99 各看一次,确定基准后再做后续比对。
大文件的时间轴拖动依赖随机 seek,而 seek 的准确性完全取决于帧大小。如果拖动之后画面内容和预期差了几帧,不是工具坏了,是帧大小有零头被截断了。比如 1918×1080 这种非标准宽度,W×H×1.5算出来是小数,工具取整的方式不同就会累积误差。遇到非偶数宽高,先确认生成端是不是按 1920 补齐的,补齐了就按 1920 看,别按实际内容宽度看。
播放功能在调试场景里用得不多,但检查帧间连续性时有用。把播放速度调到 0.25 倍,观察运动物体的边缘有没有拖影或者跳变,能快速判断是不是丢了帧。丢帧的表现是时间轴上匀速运动的物体突然往前跳一截,而帧号还是连续的,这说明文件本身在写入时漏了帧,不是查看器的问题。
3.3 导出单帧与前后对比
定位到问题帧之后,通常要导出来放进报告或者和同事对齐。导出时注意两点:一是导出的是解码后的 RGB 还是原始 YUV 切片,前者方便直接看,后者方便二次分析;二是导出时的色彩转换参数要和查看器里看到的一致,否则你截图给别人,别人看到的颜色和你不一样。
常见做法是导出 PNG 并保留原始分辨率,不要缩放。缩放会引入插值,把本来要看的色度渗透、块效应抹掉。如果只是做前后帧对比,可以连续导出相邻的 3 到 5 帧,用图片查看器快速切换,比在视频工具里来回拖更直观。对于算法调试,我习惯把处理前和处理后的同一帧导出成同尺寸 PNG,再用逐像素差值看差异分布,比肉眼判断靠谱。
导出目录最好按分辨率_格式_帧号命名,比如1920x1080_i420_f0100.png。调试周期一长,文件名不带参数,过两天自己都忘了这张图是什么条件下导出的,回头复现又得重来一遍。
4. 命令行交叉验证:用 ffmpeg 和 ffplay 反推参数
图形界面适合快速看,但它给不了像素级的证据。当查看器里画面不对、又说不清是参数错还是文件本身坏的时候,退到命令行做交叉验证是最快的路径。ffmpeg 系列工具对 rawvideo 的支持很完整,参数和查看器里的四项一一对应,可以用来互相印证。
4.1 ffplay 一行命令直接播
ffplay 播裸流需要显式告诉它格式、尺寸和帧率:
ffplay -f rawvideo \ -pixel_format yuv420p \ -video_size 1920x1080 \ -framerate 25 \ capture_1080p.yuv-f rawvideo声明这是无容器裸流,缺了它 ffmpeg 会按扩展名猜,.yuv猜不出来直接报错。-pixel_format对应查看器里的像素格式,可选值包括yuv420p、nv12、nv21、yuyv422、uyvy422、yuv422p、yuv444p、p010le。-video_size就是宽高,写成宽x高,别写反。-framerate只影响播放速度,不影响单帧内容,但设成 1 会让它几乎逐帧停住,适合慢慢看。
如果 ffplay 能正常播而查看器花屏,说明文件没问题,是查看器参数填错了;反过来 ffplay 也花,先怀疑宽高或格式判断错。两个工具同时错的可能性很低,交叉一次就能定位问题在哪一侧。
还有一个用法是限制播放帧数,只看前几帧:
ffplay -f rawvideo -pixel_format nv12 -video_size 1280x720 \ -framerate 30 -t 0.2 input_nv12.yuv-t 0.2按 30fps 算大约 6 帧,用来快速确认文件开头是否正常,避免大文件从头播到尾浪费时间。
4.2 ffmpeg 转 PNG 做像素级对照
要拿证据的时候,转成图片最直接:
# 抽第 100 帧转 PNG,-vf select 的帧号从 0 开始 ffmpeg -f rawvideo -pixel_format yuv420p -video_size 1920x1080 \ -i capture_1080p.yuv \ -vf "select=eq(n\,100)" -frames:v 1 \ -pix_fmt rgb24 frame_0100.png # 指定色彩参数,避免默认矩阵导致偏色 ffmpeg -f rawvideo -pixel_format yuv420p -video_size 1920x1080 \ -color_range tv -colorspace bt709 \ -i capture_1080p.yuv \ -vf "select=eq(n\,100)" -frames:v 1 \ -pix_fmt rgb24 frame_0100_bt709.png第一条命令里select=eq(n\,100)是 ffmpeg 的滤镜语法,逗号要转义,含义是只保留第 100 帧。-frames:v 1确保只输出一张图,不加的话会把后面所有帧都转出来。-pix_fmt rgb24指定输出为 8bit RGB,PNG 编码器不认识 YUV,必须转。
第二条多出来的-color_range tv和-colorspace bt709是色彩元信息。tv表示 limited range(16–235),pc表示 full range。这两个参数不写,ffmpeg 会按默认值处理,默认值不一定和你的文件一致,导致导出的图和查看器里看到的颜色有偏差。做像素级比对时,两边必须用同一套色彩参数,否则差值里混进了色彩转换的误差,看不出真正的问题。
导出的 PNG 和查看器里同一帧并排看,如果颜色一致但内容不一致,说明帧号基准不同;如果内容一致但颜色不同,问题在色彩参数;如果两边都花,回到第 2 章重新算帧大小。
4.3 Python 脚本读帧与批量抽帧
需要批量处理或者做自动化校验时,用 numpy 直接读字节最灵活:
import numpy as np from PIL import Image W, H = 1920, 1080 FRAME_SIZE = W * H * 3 // 2 # I420: Y 平面 + 两个 1/4 色度平面 def read_i420_frame(path, index): with open(path, "rb") as f: f.seek(index * FRAME_SIZE) # 依赖帧大小做随机定位 buf = f.read(FRAME_SIZE) if len(buf) < FRAME_SIZE: raise ValueError(f"第 {index} 帧不完整,文件可能被截断") y = np.frombuffer(buf[:W * H], dtype=np.uint8).reshape(H, W) u = np.frombuffer(buf[W * H:W * H + W * H // 4], dtype=np.uint8).reshape(H // 2, W // 2) v = np.frombuffer(buf[W * H + W * H // 4:], dtype=np.uint8).reshape(H // 2, W // 2) # 色度上采样到全分辨率,最近邻即可,调试用不追求插值质量 u_full = np.repeat(np.repeat(u, 2, axis=0), 2, axis=1) v_full = np.repeat(np.repeat(v, 2, axis=0), 2, axis=1) # BT.601 full range 转 RGB,limited range 需先把 Y 归一化到 0-255 yf = y.astype(np.float32) uf = u_full.astype(np.float32) - 128.0 vf = v_full.astype(np.float32) - 128.0 r = yf + 1.402 * vf g = yf - 0.344136 * uf - 0.714136 * vf b = yf + 1.772 * uf return np.clip(np.stack([r, g, b], axis=-1), 0, 255).astype(np.uint8) rgb = read_i420_frame("capture_1080p.yuv", 100) Image.fromarray(rgb).save("frame_0100_py.png")FRAME_SIZE必须和文件的实际排布一致,I420、NV12 虽然都是W×H×1.5,但 UV 的读取方式不同,NV12 要把后半段按(H//2, W)读进来再拆成交错的 U 和 V。seek那一步是整段脚本的核心,索引乘错一帧,后面全错位,所以读完之后校验len(buf)是必要的防御。
上采样用np.repeat是最近邻,画面会有轻微块状感,但调试时看的是内容和颜色对不对,插值质量不影响判断。如果要和查看器逐像素比对,把同样的最近邻逻辑用在两边,差异才有意义。转换矩阵写死的是 BT.601,如果你的文件是 BT.709,系数要换成1.5748、0.1873、0.4681那一组,否则肤色会偏。limited range 的处理是在代入公式前做(y - 16) * 255 / 219,色度做(u - 128) * 255 / 224,这两步漏掉,画面会明显发灰。
批量抽帧时把这个函数套一层循环就行,但要注意每次open都重新打开文件的开销。帧数多的时候改成一次打开、循环内部seek,能省掉大量系统调用。抽出来的帧按序号命名存盘,后面做 PSNR 或者差值对比时直接按文件名配对。
5. 踩坑记录:花屏、偏色、错帧的排查清单
这一章的东西基本都是从翻车里攒出来的。每一条都按现象、原因、解决三段写,遇到对应症状直接对号入座,比从头推一遍快。
5.1 画面类问题:花屏、偏色、条纹
现象一:满屏彩色噪点,完全看不出内容。原因通常是宽高填反了,1920×1080 填成 1080×1920,工具按错误的行宽切字节,每一个像素都错位。解决方法是回到文件名或生成日志确认宽高顺序,实在没有记录就用体检脚本枚举常见分辨率,能整除且画面正常的那组就是对的。
现象二:轮廓正确但整体偏紫或偏绿。这是 UV 顺序反了,I420 当成 YV12 看,或者 NV12 当成 NV21 看。像素的亮度信息在 Y 平面里,所以轮廓不受影响,只有色彩错乱。切一下 UV 顺序开关,或者换一个像素格式选项,画面立刻恢复正常。这个坑在跨平台抓文件时特别常见,因为不同平台的默认排布不一样。
现象三:画面发灰、对比度低,暗部细节还在但整体像蒙了一层。这是 limited range 被当成 full range 解释了。Y 的取值范围是 16–235,直接当 0–255 用,黑不够黑、白不够白。查看器里切到 limited range 或者 BT.601/709 对应的 range 选项即可。反过来,full range 按 limited 解释会过曝,高光一片死白,暗部细节全丢。
现象四:画面每隔一行向右偏一截,呈斜向撕裂。这是 stride 和宽度不一致。采集端按 2048 字节对齐,实际内容宽度只有 1920,每行末尾 128 字节是填充。查看器不支持 stride 参数时无法正确处理。解决办法是用 ffmpeg 按 stride 重排一遍,或者回去改采集配置关掉对齐填充。判断方法还是文件体积除以帧大小,除不尽基本就是这个问题。
5.2 操作类问题:卡顿、帧号对不上、大文件打不开
现象五:拖时间轴卡死或者内存暴涨。大文件全量加载进内存是最常见的原因,几个 GB 的 1080p 文件轻松吃掉几 GB 内存。正常做法是按需 seek 读单帧,内存里只保留当前帧和缓存的前后几帧。如果工具本身是全量加载的,先确认有没有流式模式开关;没有的话,用 ffmpeg 按帧号切出小段再看,比如-ss配合-frames:v截取 100 帧。
现象六:帧号和生成端日志对不上,差一帧。帧号基准不同,有的工具从 0 开始有的从 1 开始,还有的把文件开头的自定义头也算作一帧。解决办法是找一个已知内容的帧做锚点,比如场景切换那一帧,在两边分别看它的序号,差值记下来,后续换算即可。这个细节不做记录,每次比对都要重新确认一遍,很耗时间。
现象七:文件明明存在,工具却提示格式不支持。部分查看器按扩展名过滤,.yuv不在白名单里就打不开。改成.yuv420p或者用「打开所有文件」的方式绕过。另一类情况是文件路径带中文或空格,老版本工具解析路径时截断,把文件挪到纯英文短路径下再试。
注意:每次换文件都重新核对一遍宽高和格式,不要因为上一个文件是 1080p I420 就默认这一个也是。参数是会「继承」的,继承错了比从头填还难发现。
6. 进阶:用体检脚本给 YUV 文件做一次参数反推
手上没有生成日志、文件名也没带参数的时候,最稳的办法是让文件自己「交代」。核心思路只有一个:合法的 YUV 文件,总字节数应该能被单帧字节数整除。把常见分辨率、常见格式组合枚举一遍,能整除的组合通常只剩一两个,再逐个渲染确认,比盲试快得多。
import os # (宽, 高, 格式名, 每像素字节数),只列 8bit 常见组合 CANDIDATES = [ (1920, 1080, "I420/NV12", 1.5), (1280, 720, "I420/NV12", 1.5), (3840, 2160, "I420/NV12", 1.5), (640, 480, "I420/NV12", 1.5), (1920, 1080, "YUV422p/YUYV", 2.0), (1280, 720, "YUV422p/YUYV", 2.0), (1920, 1080, "YUV444p/P010", 3.0), (1280, 720, "YUV444p/P010", 3.0), ] def diagnose(path): size = os.path.getsize(path) hits = [] for w, h, fmt, bpp in CANDIDATES: frame = int(w * h * bpp) # 帧大小取整,非偶数宽高会有零头 if frame and size % frame == 0: hits.append((w, h, fmt, size // frame)) return hits for w, h, fmt, frames in diagnose("unknown.yuv"): print(f"{w}x{h} {fmt:14s} {frames} 帧")bpp是每像素平均字节数,I420 和 NV12 是 1.5,422 系列是 2.0,444 和 10bit 是 3.0。size % frame == 0这一条能过滤掉绝大部分错误组合,但过滤不掉的要小心:1920×1080 的 I420 和 1280×720 的 YUV444p 帧大小碰巧接近,整除条件可能同时满足,所以脚本给出候选之后必须实际渲染一帧确认。
脚本只能给出「体积自洽」的组合,给不出「内容正确」的答案。真正的确认还是靠看:肤色是否自然、文字边缘是否锐利、运动物体有没有拖影。我一般的顺序是先用脚本把候选压到两三个,再在查看器里逐个渲染第 0 帧和第 100 帧,两个位置都对得上才敢下结论。
还有一个容易被忽略的点:文件可能不是从第 0 帧开始的。录制中断、拼接、追加写入都会让文件头部多出一段不完整数据,这时候size % frame会除不尽,但去掉几十 KB 之后就能整除。碰到除不尽又确信宽高没问题的文件,试着从文件末尾往前按帧大小切,看最后几帧是否正常,能判断是头部多了还是尾部截断了。
从那以后我每次拿到来源不明的 YUV 文件,都强制先跑一遍体积整除检查再打开查看器,宁可多花三十秒,也不要在参数面板上来回试十分钟。这套流程不复杂,但能省掉大量「以为是文件坏了、其实是参数错了」的无效排查。希望帮到你。
本文还有配套的精品资源,点击获取