简介YUV_Viewer 是一款面向图像处理、视频编码学习者与开发者的 YUV 数据查看工具主要用于打开并显示采集到的 YUV422 与 YUV420 图像文件帮助读者直观核对原始数据效果、调试视频处理算法并观察两种色度采样格式的差异前者两相邻像素共享色度分量后者进一步降低色度分辨率适合在实验中对照理解。压缩包共 16 个文件约 553KB内含 h 与 cpp 源码、yuv 样本数据、exe 可执行程序以及 vcproj、sln 工程配置和 txt 说明文档既可直接运行也便于研读实现。已有 381 人学习下载。借助随包源码与样例读者可梳理 YUV 文件的读取、显示与色彩空间转换流程并参考亮度、对比度、缩放、像素测量等功能的实现用于视频编码实验、图像质量检查和二次开发入门可作为课程设计、算法验证与采集数据核验的辅助工具。1. 为什么裸 YUV 值得单独做个查看器从「能打开」到「看得对」拿到一段后缀是.yuv的文件双击打不开拖进播放器只显示黑屏用十六进制编辑器看又是一大片没有边界的数据——这是查看YUV文件时最常见的第一个跟头。反直觉的地方在于能显示不等于看得对宽高、像素格式、stride 任意一个参数填错画面都能“看起来有图像”但颜色、边缘和帧序已经错了。YUV_Viewer 这类工具的价值不是多一个播放器而是把裸数据缺少的头信息显式摆到参数面板上让你用已知图案把显示链路校准。适合做图像算法、编解码、摄像头调试的人。2. 拆开显示链路I420、NV12、YUYV 的帧切分与 stride 对齐2.1 YUV 文件的「三无」本质无头、无宽高、无帧率YUV 裸流和 MP4、MKV 这类容器文件最大的区别是它没有文件头。没有 magic number没有宽高字段没有帧率也没有色彩矩阵标记。文件里只有按某种排列堆起来的 Y、U、V 字节。查看器打开文件时第一件事不是“解码”而是按你给的参数把字节流切成帧再把 YUV 转成 RGB 显示出来。这意味着一个很现实的后果同一段文件用 1920×1080 的 I420 去读和用 1280×720 的 NV12 去读都能出图。前者可能是对的后者可能只是“看起来像一张图”。如果宽高填错画面会斜切、重复、比例失真如果格式填错颜色会整体偏绿、偏紫或者把亮度当成色度。YUV_Viewer 的界面通常会把 width、height、format、frame count、offset、stride 这几个参数摆出来不是为了让你多填几步而是因为裸流本来就没有这些信息必须由外部补上。我一般会先确认三件事这段流是不是纯 YUV有没有自定义头宽高是不是偶数尤其是 4:2:0文件大小能不能被单帧大小整除。三件事对上了再谈颜色准不准。否则后面调色彩矩阵都是白费力气。2.2 从 planar 到 packedI420/NV12/YV12/YUYV 的排布差异YUV 的格式名字很多但落到字节排列上先分两大类planar 和 packed。planar 是把 Y 存成一块U 存成一块V 存成一块packed 是把 Y、U、V 交错在同一个像素组里。查看器如果只支持其中一种另一种就会被读成花屏或者出现很规律的斜纹。格式排列方式每帧字节数近似常见场景I420 / YUV420PY 平面 U 平面 V 平面width × height × 1.5编码器输入、开源工具默认输出YV12Y 平面 V 平面 U 平面width × height × 1.5部分摄像头、旧版采集卡NV12Y 平面 UV 交错平面width × height × 1.5硬件编码、移动端采集NV21Y 平面 VU 交错平面width × height × 1.5部分 Android 相机输出YUYV422Y0 U Y1 V 交错width × height × 2USB 摄像头、视频采集UYVY422U Y V Y 交错width × height × 2专业采集设备YUV444Y、U、V 各占一个字节width × height × 3图像处理中间结果最容易混淆的是 I420 和 YV12两者都是三平面区别只是 U、V 平面的先后顺序。NV12 和 NV21 也一样都是 Y 平面后面跟一个交错平面区别是 UV 还是 VU。很多“颜色发绿”的现场不是色彩矩阵错了而是把 NV12 当成了 NV21或者把 I420 当成了 YV12。先切格式再看颜色顺序不能反。packed 格式的坑在另一个地方它的“每像素字节数”不是 1.5而是 2。把 YUYV422 当成 I420 读第一帧还能勉强看出轮廓第二帧开始就完全错位因为帧大小算少了。查看器如果支持格式下拉框切换一次就能验证如果不支持只能靠脚本先转成它认识的格式。2.3 帧切分与 stride 对齐最容易看错的两行参数裸 YUV 的帧切分看起来简单算出单帧字节数然后按这个大小往后读。真正容易翻车的是 stride。逻辑宽度是 1920不代表每一行只占 1920 字节。摄像头、编码器、GPU 纹理经常把行对齐到 16、32、64 甚至 256 字节多出来的部分叫 padding。你按 1920 去切第一行就偏了后面每一行都会跟着偏画面就会出现斜切或者右侧重复。我通常用下面这个函数先核对帧大小参数不多但每个都对应一个现场问题def frame_size(width, height, fmt, y_strideNone, uv_strideNone): 返回单帧字节数stride 默认等于逻辑宽度。 y_stride y_stride or width uv_stride uv_stride or (width // 2) fmt fmt.lower() if fmt in (i420, yv12, nv12, nv21): # 4:2:0Y 占满UV 各占 1/4stride 可能大于逻辑宽高 y_size y_stride * height uv_size uv_stride * (height // 2) return y_size uv_size * 2 if fmt in (yuyv, yuy2, uyvy): # packed 4:2:2每 2 个像素占 4 字节 return y_stride * height * 2 if fmt yuv444: return y_stride * height * 3 raise ValueError(funsupported format: {fmt})width和height是逻辑分辨率也就是你希望在查看器里看到的画面大小y_stride是 Y 平面每一行实际占用的字节数uv_stride是 UV 平面每一行实际占用的字节数。4:2:0 的 UV 平面宽高都是 Y 的一半所以默认uv_stride取width // 2。如果采集端给的是 2048 对齐那y_stride要填 2048uv_stride也要按实际对齐值填不能继续用 960。有了帧大小切帧就是顺序读def iter_frames(path, width, height, fmt, y_strideNone, uv_strideNone, limitNone): size frame_size(width, height, fmt, y_stride, uv_stride) with open(path, rb) as f: index 0 while True: buf f.read(size) if len(buf) size: break yield index, buf index 1 if limit and index limit: break这段代码里size必须和文件实际排布一致否则f.read(size)每次都会从错误的位置开始。limit只用于快速看前几帧不参与帧大小计算。如果文件带自定义头比如某些设备会在前面加几十字节的私有信息就要在打开文件后先seek(offset)或者在读取时跳过offset。查看器里的 offset 参数就是干这个的不是用来“微调颜色”的。3. 把 YUV_Viewer 跑起来依赖、启动参数与第一帧验证3.1 运行环境与依赖清单如果拿到的是打包好的可执行文件直接运行即可依赖部分可以跳过。如果拿到的是源码先看入口脚本和requirements.txt不要急着全局装包。我一般会新建一个虚拟环境把依赖隔离在项目目录里避免和系统里的其他版本打架。python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt这段命令做三件事创建虚拟环境、激活环境、安装依赖。source那一行在 Windows 上不适用换成注释里的激活脚本。虚拟环境的好处是查看器依赖的数组库和图像库版本不会污染其他项目。装完之后先跑一次入口脚本的--help或者直接启动界面确认没有报缺库再开始填参数。常见依赖一般包括数值数组库、图像读写库如果查看器带界面还会有 GUI 框架。检查方式不是看版本号而是看能不能 importpython -c import numpy, PIL; print(deps ok)能打印deps ok就说明基础链路通了。如果报ModuleNotFoundError先回到requirements.txt看包名不要凭记忆猜。3.2 参数怎么填宽高、格式、偏移与帧数查看器打开文件时参数面板里最关键的几项如下。不同资源的字段名可能略有差异认准含义即可参数含义常见值填错的表现width逻辑宽度1920画面横向拉伸或斜切height逻辑高度1080画面纵向压缩或重复format像素格式i420 / nv12 / yuyv颜色错乱、帧错位offset文件头偏移0第一帧就是花的stride行对齐字节数等于 width 或更大斜纹、右侧噪点frames帧数自动计算只能看一部分或读越界帧数不要手填先算。公式是(文件大小 - offset) // 单帧大小。余数如果不为 0要么 offset 不对要么帧大小算错要么文件尾部有附加信息。下面这段脚本可以在打开查看器之前先跑一遍import os w, h 1920, 1080 frame_size w * h * 3 // 2 path clip.yuv size os.path.getsize(path) print(frame_size, frame_size) print(frames, size // frame_size) print(tail bytes, size % frame_size)frame_size是按 I420 紧密排列算的tail bytes是除不尽的余数。余数为 0 不代表格式一定对但余数很大时一定有问题。比如 1920×1080 的 I420 单帧是 3110400 字节文件大小除以它余了几百字节先怀疑 offset再怀疑格式。查看器里如果显示的总帧数和这个脚本算出来的差一帧通常是最后一帧不完整被丢掉了不一定是工具 bug。3.3 用一张已知色卡验证颜色顺序填完参数不要直接拿业务流看先用一张自己生成的已知图案验证。灰度阶梯图可以同时验证宽高、帧切分和 Y 平面UV 四象限图可以验证 U、V 顺序。先看灰度再看颜色排查范围会小很多。import numpy as np w h 16 y np.zeros((h, w), dtypenp.uint8) for i, val in enumerate([16, 81, 145, 235]): y[:, i * 4:(i 1) * 4] val u np.full((h // 2, w // 2), 128, dtypenp.uint8) v np.full((h // 2, w // 2), 128, dtypenp.uint8) with open(gray_16x16_i420.yuv, wb) as f: f.write(y.tobytes()) f.write(u.tobytes()) f.write(v.tobytes())这段代码生成一个 16×16 的 I420 文件Y 平面是四根竖条灰度从暗到亮U、V 都置为 128也就是中性色度。用查看器打开时宽高填 16 和 16格式选 I420帧数 1。正常应该看到四根竖条从左到右变亮。如果竖条变成横条宽高填反了如果竖条带颜色说明 UV 不是中性值或者格式不是 I420。再用四象限验证 U、V 顺序y np.full((h, w), 128, dtypenp.uint8) u np.full((h // 2, w // 2), 128, dtypenp.uint8) v np.full((h // 2, w // 2), 128, dtypenp.uint8) u[:h // 4, :w // 4] 192 # 左上提高 Cb偏蓝 v[:h // 4, w // 4:w // 2] 192 # 右上提高 Cr偏红 u[h // 4:h // 2, :w // 4] 64 # 左下降低 Cb偏黄 v[h // 4:h // 2, w // 4:w // 2] 64 # 右下降低 Cr偏青 with open(uv_16x16_i420.yuv, wb) as f: f.write(y.tobytes()) f.write(u.tobytes()) f.write(v.tobytes())打开后看四个象限的冷暖方向左上偏蓝、右上偏红、左下偏黄、右下偏青。如果左上出现红色而不是蓝色说明 U、V 平面接反了优先检查是不是把 I420 当成了 YV12或者把 NV12 当成了 NV21。这个测试图不依赖具体色彩矩阵只看方向足够判断顺序。4. 批量看序列与导出帧索引、PNG 对比和测试流生成4.1 帧索引、播放与步进单帧看对了接下来才是序列。查看器一般会提供帧索引、上一帧、下一帧、播放和暂停。帧索引从 0 开始还是从 1 开始不同工具不一样但文件偏移一定按 0 算。比如界面显示第 10 帧实际 seek 的偏移是9 × frame_size。这一点在写脚本核对时很容易差一帧表现就是“查看器里第 10 帧和脚本导出的第 10 帧对不上”。我习惯先用脚本把某一帧的 Y 平面单独导出来和查看器里看到的亮度对一遍。Y 平面不涉及 UV 转换出问题最容易定位import numpy as np from PIL import Image def read_i420_y(path, width, height, index): frame_size width * height * 3 // 2 with open(path, rb) as f: f.seek(index * frame_size) y np.frombuffer(f.read(width * height), dtypenp.uint8) return y.reshape(height, width) y read_i420_y(clip.yuv, 1920, 1080, 10) Image.fromarray(y, modeL).save(clip_frame010_y.png)seek(index * frame_size)把文件指针移到目标帧开头read(width * height)只读 Y 平面reshape(height, width)还原成图像。这里没有处理 stride所以只适用于紧密排列的 I420。如果采集端有行对齐要先按y_stride * height读再切片取前width列。导出的灰度图可以用来检查边缘、纹理和块效应不受颜色转换干扰。4.2 导出 PNG 与对比截图查看器里看到的颜色是转换后的结果真正要定位问题时最好把 Y 平面和 UV 平面分开看。Y 平面看结构和噪声UV 平面看色度块和颜色溢出。很多编码问题在 RGB 截图里不明显回到 YUV 平面反而一眼能看出来。如果是两段流对比比如原始流和编码后解码流不要只靠肉眼切换。可以用 Y 平面算 PSNR先给一个数值结论再用查看器看差异区域import numpy as np def psnr(a, b): mse np.mean((a.astype(np.float32) - b.astype(np.float32)) ** 2) if mse 0: return 99.0 return 10 * np.log10(255 ** 2 / mse) # a、b 是同一帧的 Y 平面形状一致 # print(psnr(a, b))a和b必须是同一帧、同一分辨率、同一裁剪范围。PSNR 高不代表颜色一定对但 PSNR 突然掉到很低说明这一帧的结构已经偏了先查帧对齐再查格式。查看器负责“看”脚本负责“给数”两边对得上才靠谱。4.3 用脚本批量生成测试流业务流不方便反复试参数时自己生成一段已知运动的测试流更直接。下面这段代码生成 30 帧 I420每帧一个白色方块横向移动背景是深灰。用查看器步进时方块应该逐帧右移不应该跳帧、回退或者出现残影。import numpy as np w, h, n 320, 240, 30 with open(moving_square_i420.yuv, wb) as f: for i in range(n): y np.full((h, w), 16, dtypenp.uint8) x (i * 8) % (w - 40) y[80:160, x:x 40] 235 u np.full((h // 2, w // 2), 128, dtypenp.uint8) v np.full((h // 2, w // 2), 128, dtypenp.uint8) f.write(y.tobytes()) f.write(u.tobytes()) f.write(v.tobytes())w、h是分辨率n是帧数x是方块左上角横坐标i * 8控制每帧移动 8 像素。方块大小是 40×80所以取模用w - 40避免越界。这个流只有亮度变化UV 保持中性适合验证帧切分和播放顺序。如果查看器里方块移动方向反了先看帧索引是不是递减如果方块边缘出现绿色或紫色说明 UV 被误读了回到格式设置。5. 避坑与排查颜色发绿、画面斜切、帧数不对的五个现场5.1 现象整体偏绿或偏紫肤色明显不对现象很直观人脸发绿天空发紫或者画面整体像蒙了一层色纸。原因通常是 U、V 顺序错了常见于 NV12 和 NV21 互换、I420 和 YV12 互换。另一种原因是色彩矩阵或量化范围不匹配比如把 limited range 当成 full range表现更多是对比度发灰而不是色相跑偏。解决顺序是先格式、后矩阵。用上一节的 UV 四象限图确认左上是不是蓝色、右上是不是红色。如果方向反了切换格式不要先调饱和度。方向对了但肤色仍偏再检查查看器有没有 BT.601 / BT.709 的选项以及 limited / full 的选项。很多现场把“颜色不对”全归咎于矩阵实际上第一步的平面顺序就错了。5.2 现象画面斜切、右侧重复边缘有规律噪点现象是图像像被斜着推了一段右边出现一条重复的竖条或者每一行都向右错一点。原因几乎都是 stride 不等于 width但查看器按 width 切行。采集端为了对齐可能把 1920 的行宽补到 2048你按 1920 去读每一行都会少读 128 字节下一行就从错误的位置开始。解决办法是填真实 stride或者先用工具把流重排成紧密排列。重排时注意 UV 平面的 stride 也要一起处理不能只改 Y。一个快速判断方法是看画面斜切的角度是否固定固定斜切通常是 stride 问题随机花屏更像是格式或帧大小问题。如果查看器没有 stride 输入框就先转成紧密排列的 I420 再看不要在原始流上硬调。5.3 现象只能看第一帧步进就花屏或错位第一帧正常点下一帧就错位说明单帧大小算错了。常见原因有三个宽高是奇数但按 4:2:0 算把 YUYV422 当成了 I420把 10bit 数据当成了 8bit。4:2:0 要求宽高都能被 2 整除奇数宽度会让 UV 平面大小出现半个像素不同工具的处理方式还不一样很容易错位。解决时先跑帧大小脚本看文件大小能不能被单帧大小整除。余数很大就换一个格式再算一次。比如同一段文件按 I420 算余 100 字节按 YUYV422 算余 0那它大概率是 packed 格式。不要忽略“最后一帧不完整”的情况有些文件尾部带附加信息查看器会丢掉最后一点数据帧数看起来少一帧但不影响前面的帧。5.4 现象10bit 文件发灰高光一片白暗部并阶10bit 的 YUV 经常不是按 10bit 紧凑存储而是每个分量放在 16bit 容器里低 2 位补零或者放低位数据。查看器如果按 8bit 读会把这些字节当成亮度画面就会发灰、对比度异常细节像被压平。另一种情况是字节序反了读出来的值忽大忽小。先看原始值的范围import numpy as np raw np.frombuffer(open(clip_10bit.yuv, rb).read(), dtypeu2) print(raw.min(), raw.max())如果最大值在 1023 附近说明是 10bit 存在 16bit 容器里如果最大值到 65535可能是 16bit 数据或者字节序不对。查看器如果有 10bit / 16bit 选项直接选没有的话读入后右移 2 位再显示。注意右移前不要做饱和裁剪否则高光会提前截断。5.5 现象大文件一打开就卡死内存飙高几 GB 的 YUV 一次性读进内存界面很容易卡死。原因不是文件太大而是读取方式太粗。查看器如果每次都把整个文件读成字节数组再切片显示内存占用会随着文件大小线性上涨。解决方式是内存映射或者按帧读取只保留当前帧和前后几帧缓存。import mmap with open(clip.yuv, rb) as f: mm mmap.mmap(f.fileno(), 0, accessmmap.ACCESS_READ) frame mm[index * frame_size:(index 1) * frame_size]mmap把文件映射到虚拟内存读取时按页加载不会一次性占满物理内存。frame是当前帧的字节切片用完就可以释放。如果查看器本身不支持内存映射至少避免在步进时重复打开文件把文件句柄和帧大小缓存起来也能减少卡顿。6. 进阶用法用参考流和哈希把查看器接进调试链查看器最容易停在“看一眼觉得没问题”这一步。真正省时间的做法是把它接进一条固定检查链先用参考流确认参数再用哈希定位第一帧差异最后才用肉眼看看差异区域。我通常先用常见视频工具生成一段已知参数的参考 YUV再用下面的脚本算每一帧的哈希。原始流和解码流逐帧对比第一个哈希不同的帧就是问题起点不用从第 1 帧开始盲看到第 300 帧。import hashlib def frame_md5(path, frame_size, index): with open(path, rb) as f: f.seek(index * frame_size) return hashlib.md5(f.read(frame_size)).hexdigest() frame_size 1920 * 1080 * 3 // 2 print(frame_md5(ref.yuv, frame_size, 0)) print(frame_md5(out.yuv, frame_size, 0))frame_size必须先按真实格式算对index从 0 开始。两个哈希一致说明这一帧字节级相同不一致再回到查看器里看 Y 平面和 UV 平面判断是亮度偏了还是色度偏了。哈希不告诉你“哪里错”但能告诉你“从哪一帧开始错”这比肉眼找帧快得多。我的固定检查链是先算帧大小和尾部余数再用灰度阶梯图验证宽高和格式再用 UV 四象限验证平面顺序最后用参考流哈希定位第一帧差异。从那以后我每次拿到新的 YUV 流都强制走一遍不再靠“看起来差不多”下结论。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?