
1. 实验整体思路与格式解析数据压缩课程做到第二个实验基本都会让你碰一次“真实世界的像素格式转换”读一张 BMP转成 YUV然后交给后续的编码环节用。我在做这个实验之前一直觉得 YUV 就是个“视频专用格式”等真正把 BMP 逐字节读完、再按照采样规则算出 U、V 分量之后才意识到这里面藏着很多“不亲自动手永远不知道”的细节。这篇博文我就把整个实现过程拆开揉碎讲清楚适合正好卡在这个实验上的同学也适合那些想补一补图像格式底层知识的开发者。先说这个实验到底在做什么。输入是一张 24 位真彩色的 BMP 文件输出是一个 YUV 文件常见要求是 4:2:0 采样也就是每四个像素共享一组 UV 分量。为什么要做这件事因为视频编码器几乎都是围绕 YUV 工作的RGB 转 YUV 是进入编码器之前必经的一步。你在这个实验里写的代码本质上就是一条简化版的软件编码器前处理链路。BMP 本身是 Windows 上最常见的无压缩图像格式结构非常直白先是文件头再是信息头接着是调色板真彩色图一般没有最后是像素数据。但直白不等于没有坑光是“行对齐”这一个规则就能让不少人写的转换器在图片宽度不是 4 的倍数时输出错乱图像。我在第一次做实验时随手按“每行像素数 × 3”去读数据结果宽度 53 像素的测试图直接斜着撕裂了。后来才意识到 BMP 规定每行字节数必须对齐到 4 字节。YUV 这边实验里最常用的是 YUV 4:2:0 Planar 排列也叫 I420。所谓 Planar 是指 Y、U、V 三个平面分开存放先连续存完所有像素的 Y 值再存 U最后存 V。4:2:0 不是说没有 V 分量而是说 U 和 V 在水平方向和垂直方向上都按 2:1 采样也就是每 2x2 的像素块只保留一个 U 和一个 V。这样算下来一帧图像的 Y 分量大小是宽×高U 分量是 (宽/2) × (高/2)V 分量也是 (宽/2) × (高/2)总大小是宽×高×1.5 字节。理解这个采样逻辑是理解整个实验的关键。你可以把 YUV 想象成人眼的视觉偏好人对亮度 Y 的敏感度远高于对色度 U/V 的敏感度所以色度可以少存一点视觉上几乎察觉不到。4:2:0 正好是 VCR、DVD、大多数视频网站都使用的采样格式也是 H.264/H.265 的默认输入格式之一。1.1 BMP 与 YUV 的联系从像素存储到分量平面BMP 的像素数据存储方式是自底向上的。也就是说文件里第一行像素对应图像的最下面一行最后一行像素对应图像的最上面一行。如果你在转换时没有注意到这一点转出来的 YUV 文件用播放器打开后会发现图像是上下颠倒的。这点在后续读取时需要决定是否做垂直翻转——通常为了编码器方便我会在读取 BMP 时就按“从最后一行读起”的方式直接转成自顶向下的顺序。BMP 的像素顺序是 B、G、B也就是每个像素三个字节依次存放蓝色、绿色、红色分量。注意不是 RGB而是 BGR。我第一次写的时候就在转换公式里直接用了 R、G、B 的名称结果算出来的 Y 分量整体偏了图像发紫。换过来之后才正常。这种小坑特别值得记录下来因为调试颜色问题远比调试崩溃要难得多。YUV 的存储则完全不同。它把亮度与色度拆开同一时刻所有像素的 Y 值连续排列之后才是色度。比如一个 640×480 的图Y 平面是 640×480 个字节U 平面是 320×240 个字节V 平面是 320×240 个字节。整个文件的字节顺序就是 YYYYY... UUUU... VVVV...。这种布局也叫 I420是后续实验里送入编码器的常见格式。1.2 为什么选 4:2:0 而不选 4:4:4课程实验里一般会明确要求输出 4:2:0但很少有人解释“为什么不直接存 4:4:4”。我自己的理解是4:4:4 虽然能完整保留色度信息但文件大小和 RGB 一样是宽×高×3 字节完全失去了“压缩预处理”的意义。4:2:0 将色度砍掉 3/4总大小降到 1.5 字节每像素这正好配合后面要学的 DCT 变换、量化、熵编码形成一条完整的压缩链路。当你自己动手做 4:2:0 采样时会遇到一个非常实际的问题U、V 分量到底取哪几个像素的平均值常见的做法是取 2×2 块内四个像素的 U 或 V 的平均值。但边界处理怎么办比如宽度是奇数时最右边一列凑不齐 2 像素。我查了很多资料实验中最稳妥的做法是对右边界做“复制填充”也就是用最后一列的像素补齐 2 像素再进行平均。这样不会漏掉像素也不会产生越界访问。提示如果你只想快速核验结果可以先强制使用偶数宽度、偶数高度的测试图把边界问题暂时忽略。但最终交作业前务必处理奇数宽高因为测试图片往往不会那么配合。2. BMP 读取的底层细节与 C 实现要点写 BMP 读取器的第一步是熟悉文件格式。BMP 文件头是 14 字节的 BITMAPFILEHEADER信息头是 40 字节的 BITMAPINFOHEADER。我用 C 实现时会直接用结构体来对应这两个头但这里有个重要事项结构体存在内存对齐直接fread进结构体可能导致字段错位。更安全的做法是逐个字节读取并手动拼接尤其是跨平台或严格校验时。我给出的建议是写一个BMPReader类内部用ifstream以二进制方式打开文件先读取文件头两个字节检查是否为B和M。然后读取文件大小、像素数据偏移量。信息头里重点读取宽度、高度、位深度、压缩方式。对实验来说压缩方式必须是 0BI_RGB也就是无压缩否则直接报错退出。2.1 BMP 头结构解析与字节序注意BMP 是小端字节序所以读取多字节整数时在 x86 平台上直接按顺序读没问题但如果后续要移植到某些大端平台就需要自己处理字节序。为了代码清晰我通常自定义read_u16和read_u32函数从文件流中读取指定字节再移位拼接这样能保证逻辑完全可控也便于在读取时打印关键字段用于调试。头信息的核心字段包括字段偏移大小含义bfType02固定 0x4D42即 BMbfOffBits104像素数据起始偏移biWidth184图像宽度像素biHeight224图像高度像素biBitCount282每像素位数实验用 24biCompression3040 表示不压缩biSizeImage344像素数据大小可为 0其中biHeight如果是负数表示图像是自顶向下存储的如果是正数则是自底向上。这在读取时是一个真正的分支点。2.2 行对齐规则与像素数据偏移BMP 像素区的每一行字节数必须对齐到 4 字节。计算公式是rowSize ceil(biWidth * biBitCount / 8 / 4) * 4对于 24 位图rowSize ((biWidth * 3 3) / 4) * 4例如宽度 53 像素时每行原始需要 159 字节对齐后变成 160 字节每行末尾多出一个填充字节。读取时必须跳过这个字节否则下一行的起始位置会偏移导致画面斜切。我封装了一个函数std::vectoruint8_t readPaddedRow(std::istream in, int widthBytes, int padding) { std::vectoruint8_t row(widthBytes); in.read(reinterpret_castchar*(row.data()), widthBytes); if (padding 0) { in.ignore(padding); } return row; }这个函数把“读取真实像素数据”和“跳过填充字节”分离后续主逻辑干净很多。2.3 将 24 位 BGR 像素转换为 YUV 分量的计算公式YUV 转换最常用的是 BT.601 标准中的有限范围也就是视频范围Y 范围 16~235UV 范围 16~240公式但很多实验为了简化直接用完整范围 0~255。如果你不想让输出播放时出现对比度异常优先采用完整范围公式因为它不需要做 clamping 之外的处理也便于检查数值。完整范围 BGR 转 YUV 的公式如下Y 0.299 * R 0.587 * G 0.114 * B U -0.169 * R - 0.331 * G 0.500 * B 128 V 0.500 * R - 0.419 * G - 0.081 * B 128注意公式里的 R、G、B 分别是红绿蓝分量但 BMP 存的是 B、G、R所以读取时要先做分量交换uint8_t b pixel[0]; uint8_t g pixel[1]; uint8_t r pixel[2];用整数定点数可以避免浮点误差我先把系数放大到 1000 倍计算后再除以 1000实测结果与浮点版本几乎一致但速度更快也更容易理解。int y (299 * r 587 * g 114 * b) / 1000; int u (-169 * r - 331 * g 500 * b) / 1000 128; int v (500 * r - 419 * g - 81 * b) / 1000 128;随后用std::clamp将 Y、U、V 裁剪到 0~255 区间。U、V 的 128 是一个固定偏移目的是将原本有符号的色差信号平移到无符号字节范围内。3. 4:2:0 采样策略与 C 工程结构设计实现 4:2:0 采样核心是把 Y 平面和 UV 平面分开计算。我倾向于把所有中间数据先存放成三个独立的std::vectoruint8_t分别是 Y 平面、U 平面、V 平面最后统一写入文件。这样做的好处是逻辑清晰也便于你用外部工具逐平面查看结果。具体流程先按顺序遍历每一行每一列像素计算出该像素的 Y、U、V 值将 Y 放入 Y 平面U、V 分别存入临时数组。当完成全部像素后再对 U、V 临时数组进行 2×2 平均降采样。另一种方法是边遍历边降采样但那样需要额外维护当前 2×2 块的状态代码会复杂不少。对于实验来说先存全分辨率再降采样是最容易写对的。3.1 全分辨率 Y 与 UV 的生成代码我写的核心循环结构大致如下int width info.biWidth; int height abs(info.biHeight); int ySize width * height; int uvWidth (width 1) / 2; int uvHeight (height 1) / 2; std::vectoruint8_t Y(ySize); std::vectoruint8_t U_full(width * height); std::vectoruint8_t V_full(width * height); for (int row 0; row height; row) { for (int col 0; col width; col) { int srcIndex row * rowSize col * 3; uint8_t b data[srcIndex 0]; uint8_t g data[srcIndex 1]; uint8_t r data[srcIndex 2]; Y[row * width col] clamp((299 * r 587 * g 114 * b) / 1000); U_full[row * width col] clamp((-169 * r - 331 * g 500 * b) / 1000 128); V_full[row * width col] clamp((500 * r - 419 * g - 81 * b) / 1000 128); } }这里 U_full、V_full 的大小与 Y 相同是为了简化寻址。实际文件输出时不会输出这个中间数组只用它来计算平均值。实验阶段的代码省一点内存无关紧要重点是可读性和正确性。3.2 UV 平均降采样的边界处理当完成全分辨率 UV 计算后进入降采样阶段。对于 uvHeight 上的每个 uvRow对应源像素区域的行范围是 [uvRow2, uvRow21]但如果原始 height 是奇数最后一块的行范围可能只有一行。同理宽度为奇数时最后一块只有一列。处理方式是对边界做重复也就是把最后一行或最后一列复制一份参与平均。for (int uvRow 0; uvRow uvHeight; uvRow) { for (int uvCol 0; uvCol uvWidth; uvCol) { int r0 uvRow * 2; int c0 uvCol * 2; int r1 std::min(r0 1, height - 1); int c1 std::min(c0 1, width - 1); int uSum 0; int vSum 0; for (int r r0; r r1; r) { for (int c c0; c c1; c) { uSum U_full[r * width c]; vSum V_full[r * width c]; } } int cnt (r1 - r0 1) * (c1 - c0 1); U[uvRow * uvWidth uvCol] static_castuint8_t(uSum / cnt); V[uvRow * uvWidth uvCol] static_castuint8_t(vSum / cnt); } }注意std::min在这里的作用就是复制边界像素。如果原始宽高都是偶数r1 r0 1c1 c0 1cnt 恒为 4不会触发边界分支。这就是为什么纯偶数图可以忽略边界处理但奇数图必须用这种策略。3.3 输出 I420 文件的写入方式I420 文件写完 Y 平面后接着写 U再写 V。写文件时最好用二进制模式避免 Windows 下换行符被转换。代码很简单std::ofstream out(output.yuv, std::ios::binary); out.write(reinterpret_castchar*(Y.data()), Y.size()); out.write(reinterpret_castchar*(U.data()), U.size()); out.write(reinterpret_castchar*(V.data()), V.size()); out.close();如果实验要求输出 YUV420 且是“交错存储”的另一种格式比如 NV12那么排列顺序会变成先 Y 平面然后 UV 交错存储。这种情况我会单独加一个分支。但绝大多数数据压缩课程默认的 .yuv 文件就是 I420也就是 Y 后接 U 后接 V这一点在动手前最好和实验文档确认清楚。4. 实操全流程演示从测试图到播放验证与其零散贴代码不如完整跑一遍流程。我建议准备一张 640×480 的 BMP 测试图因为尺寸是 16 的倍数方便肉眼验证也规避了边界分支的干扰。先用微信或者任意截图工具截一张色彩丰富的图再用画图软件另存为 24 位 BMP就能得到符合要求的输入文件。完整流程分五步走读取 BMP 头信息打印 width、height、bitCount、compression、dataOffset。读取像素数据到std::vectoruint8_t同时做上下翻转如果 biHeight 0。遍历像素生成 Y、U_full、V_full。对 U_full、V_full 做 4:2:0 平均降采样。写入 Y、U、V 平面到 .yuv 文件用 YUV 播放器或 ffplay 验证。4.1 主函数流程与参数校验示例我写了一个简单的命令行工具接受输入 BMP 路径、输出 YUV 路径、以及一个可选参数控制是否输出日志。主流程如下int main(int argc, char* argv[]) { if (argc 3) { std::cerr Usage: bmp2yuv input.bmp output.yuv\n; return 1; } BMPReader reader; if (!reader.load(argv[1])) { std::cerr Failed to load BMP: argv[1] \n; return 1; } auto [width, height] reader.getDimensions(); std::cout Image: width x height \n; auto Y std::vectoruint8_t(width * height); auto U std::vectoruint8_t(((width 1) / 2) * ((height 1) / 2)); auto V std::vectoruint8_t(((width 1) / 2) * ((height 1) / 2)); convertBGRToYUV(reader.getPixelData(), width, height, Y, U, V); writeYUV(argv[2], Y, U, V); std::cout Written Y.size() U.size() V.size() bytes to argv[2] \n; return 0; }输出文件的字节数应该等于width * height * 3 / 2。如果算出来不一致多半是 UV 平面尺寸出错优先检查(width 1) / 2是否需要向上取整。4.2 用播放器验证转换效果Windows 下我习惯用 ffplay 查看裸 YUV 文件。命令ffplay -f rawvideo -pixel_format yuv420p -video_size 640x480 output.yuv注意三个参数必须同时正确尤其是-pixel_format yuv420p与 I420 对应如果写成了 yuv444p图像会花掉这是格式不匹配的典型现象。看到图像颜色正常、没有撕裂、没有上下颠倒就说明转换成功。Linux 或 macOS 下同样可以使用 ffplay。如果没有安装 ffmpeg也可以自己写一个简单的 YUV 查看器但那显然会拖慢实验进度。实操心得这一步千万别跳过。我见过很多同学代码写完、文件也生成了但没有用播放器验证最后提交的实验结果其实是错乱的 YUV 数据。只有真正看到图像才能证明你的采样与排列完全正确。4.3 参数与耗时统计参考我测试了一张 1920×1080 的 BMP纯 C 单线程实现Release 模式下转换耗时大约 20~30 毫秒输出文件大小约 3.1 MB 1920×1080×1.5 字节。这个速度对于实验完全够用。如果遇到超大图性能瓶颈主要在内存分配和文件读取而不是转换计算。如果后续实验要求实时处理可以用-O2编译并启用循环展开、使用uint32_t替代逐字节计算甚至可以用 SIMD 指令一次处理 16 个像素。但实验阶段不建议过早优化先保证输出正确。5. 常见问题与排查技巧实录写这个实验时踩过不少坑我把典型的几个问题整理成速查表方便大家对照排查。5.1 BMP 打开失败或头部校验错误现象程序读取文件头两个字节不是 “BM”。这个问题的根源通常有两种一是文件不是 BMP 格式比如你另存为了 JPEG 或 PNG二是文件路径不对读取到了错误文件。检查方式是打印文件头的十六进制内容确认前两个字节是42 4D。还有一个不太常见但容易踩的坑信息头大小 biSize 不是 40。某些软件生成的 BMP 会使用 BITMAPV4HEADER 或 BITMAPV5HEADER大小为 108 或 124 字节。虽然像素偏移量字段仍然存在但如果你直接按 40 字节信息头去读宽高会读到错误位置。解决办法是使用bfOffBits字段跳转到像素数据开头而不是假设信息头大小固定。5.2 图像颜色偏蓝或偏红但形状正常这种问题几乎可以断定是 RGB 分量顺序颠倒。BMP 存储的是 BGR你在转换公式时如果直接用data[0]当作 R就会让蓝色和红色互换。排查方法取图像中一块纯红色区域打印其三个分量的字节值应该是 R255G0B0但 BMP 里存的是 B255G0R0。若打印结果相反马上检查读取索引。5.3 输出图像上下颠倒如果播放 YUV 时看到图像是倒的说明你没有处理 BMP 的自底向上存储。信息头biHeight为正时像素第一行是图像底部。一个简单的处理是读取完所有像素后将整帧的行顺序翻转。不要逐行翻转文件读取顺序那样容易与填充字节搞混。我更倾向于先把所有原始像素读入一个大数组然后按行交换时只交换行的起始指针或复制行数据。if (height 0) { for (int row 0; row height / 2; row) { int opposite height - 1 - row; std::swap_ranges(data.begin() row * rowSize, data.begin() row * rowSize rowSize, data.begin() opposite * rowSize); } }5.4 生成文件大小不对比预期少或多I420 文件大小必须是width * height * 3 / 2。如果少了常见于 UV 平面尺寸计算错误比如把(width 1) / 2写成了width / 2导致奇数宽时 U 平面少一行数据。如果多了可能是你在写入时把行填充字节也写入文件了。记住YUV 文件不包含任何填充字节只有纯像素数据。我建议在代码中加入自动断言assert(outputFileSize width * height * 3 / 2);一旦失败立即定位是尺寸计算问题还是写入顺序问题。5.5 播放器显示绿屏或花屏这是 YUV 格式与播放器预期不一致的典型症状。常见情况是输出顺序实际是 YV12即 V 在前U 在后但播放器按 I420 解读或者宽度、高度参数传入错误。检查方法是把输出文件的前几百个字节打印出来Y 平面每个像素应该在 16~235 之间如果是有限范围如果你看到 128 左右的大片数值说明你打印的位置已经是 U 或 V 平面了。还有一种可能是你使用了有限范围公式但播放器用完整范围解码导致对比度异常。图像看起来灰蒙蒙的但形状正常。这种时候要么改公式要么在播放器里设置-vf scaleout_rangefull两者取其一即可。5.6 大图转出来内存占用高BMP 读取和 YUV 转换如果一次性把所有数据加载到内存一张 4K 图可能需要几十 MB这在实验机上完全没问题。但如果你要连续处理很多图片建议边读边转边写不要保留原始像素和全分辨率 U/V 中间数组。我的中间方案是为 U_full、V_full 分配与图像大小相当的空间这对一张 1920×1080 的图是多出约 4 MB无压力。只要不是嵌入式环境不必强行优化。提醒如果你看到内存占用异常高先检查是否把输出 YUV 数据也整个加载进内存再写文件。正确做法是生成一个平面写一个平面而不是攒三个平面后一次性写。6. 实验之外后续扩展与个人经验这个实验做完后我对图像格式转换有了更直观的理解。以前看到视频编码器的输入是 YUV420觉得不过是个格式而已。现在明白了从摄像头采集到的 RAW RGB 到编码器输入之间其实要经历颜色空间转换、色度采样、内存重排这么几步。每一步都有各自的参数和边界条件任何一个环节出错最终画面都会有各种光线或色彩异常。如果学有余力我建议你再做两个小扩展。第一个扩展是支持读取 8 位调色板 BMP。这种 BMP 的像素数据存储的是调色板索引需要先读取调色板表再将索引映射为 RGB之后才能走已有的 RGB→YUV 流程。这个扩展能让你理解索引图像与真彩色图像的本质区别。第二个扩展是输出 NV12 格式。NV12 是很多硬件编码器直接支持的格式在 Android 和视频硬件领域非常常见。它和 I420 的 Y 平面完全一样只是 UV 平面是交错的先 U 后 V 成对排列。修改输出代码只需要调整 UV 循环写入逻辑不需要改转换核心。我在实际做实验时的体会是与其把代码写完就不管不如刻意制造几个“错误版本”再调试一次。比如故意不翻转行序或者故意用错误的行对齐去读然后去播放器里看输出图像有什么特征。经过这样的对比你对每一种异常症状的记忆会比看十遍文档都深刻。后续做视频编码的大实验时你几乎不会在格式处理这种基础环节上翻车。最后分享一个小技巧调试 BMP 读取是否正确可以先用画图软件生成一张纯色图比如全红色。然后将转换后的 YUV 播放出来如果画面是纯色且颜色正确说明颜色转换、采样、存储都没问题。再用一张左右半屏不同颜色的图检查水平方向是否有错位用一张上中下不同颜色的图检查垂直方向与行序处理。这种“分区域测试法”能在五分钟内帮你定位绝大多数格式错误。