彻底搞懂BMP图像格式:从文件头到像素数据逐字节解析
1. 先搞清楚BMP到底是个什么东西BMPBitmap File大概是所有图像格式里最“实在”的一种了。它不像JPEG那样用一堆数学变换把数据压实也不像PNG那样有复杂的扫描线过滤和压缩流BMP的核心思路就是“原样记录”——像素是什么颜色文件里就存什么数据连顺序都懒得变。这种设计让它在今天依然有不可替代的价值嵌入式开发、FPGA图像处理、摄像头raw数据验证、纹理提取、调色板测试……凡是需要精准控制每个像素字节的场景BMP几乎是默认的中间格式。我自己最常用BMP的地方有两个。一个是做图像处理的算法调试比方说验证一个边缘检测算子、做色彩空间转换直接把中间结果存成BMP用看图软件打开就能肉眼确认对不对比debug时打印一堆数组高效太多。另一个是给渲染引擎或者游戏引擎制作通道图R通道存粗糙度、G通道存金属度、B通道存AO这类数据不需要人眼多好看但必须逐字节可控BMP就是不二之选。这篇文章就想带着你彻底把BMP格式的存储方式吃透。从文件头开始逐字节拆开看每个字段的意义然后深入像素数据的排列规则、行对齐算法、调色板机制最后用Python手工构造和解析一张真实的BMP文件包括怎么制作通道图、怎么排查上下颠倒和花屏问题。无论你是刚入门想弄懂图片格式的新手还是做嵌入式、做图像处理的老手这篇文章都能给你能直接抄走的干货。2. BMP文件的整体骨架四个组成部分2.1 文件不是一堆像素那么简单很多人以为BMP就是“像素数组前面放个文件头”这个理解方向对但过于粗糙了。一个标准BMP文件特指BITMAPINFOHEADER版本的常见BMP由四个部分组成缺一块都可能导致打不开或者显示异常。第一部分是BITMAPFILEHEADER也就是文件头固定14个字节用来声明文件类型、文件大小和像素数据的起始偏移量。第二部分是BITMAPINFOHEADER位图信息头通常40个字节还有一个更老的OS/2版本是12字节现在已经很少见用来声明图像的宽、高、位深、压缩方式、分辨率等等。第三部分是调色板也就是颜色表只有位深小于等于8位时才需要24位和32位真彩色图没有这块。第四部分才是像素数据真正的一个个像素点按顺序排成的字节流。这里有一个非常容易踩坑的细节文件头里那个“像素数据起始偏移量”bfOffBits不是随便写的它必须等于 14文件头 40信息头 调色板字节数如果有的话然后通常不是4的倍数的话也要补零对齐。很多新手直接给它赋值541440结果用于8位带调色板的BMP时文件头就错位了看图软件要么报错要么显示乱色。2.2 用十六进制视角看一个真实文件只看定义总是空洞的我们用十六进制编辑器打开一个实际生成的24位BMP看一眼。假设这张图是2像素宽、2像素高像素数据全红RGB255,0,0那么文件开头几十个字节长这样42 4D 46 00 00 00 00 00 00 00 36 00 00 00 28 00 00 00 02 00 00 00 02 00 00 00 01 00 18 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 FF 00 00 FF 00 00 FF 00 00 FF 00 00逐个字节来解读。开头的42 4D就是ASCII字符的“BM”这是BMP文件固定的类型标识。接着的46 00 00 00是文件总大小这里是0x46也就是70字节。再后面4个字节00 00 00 00是保留字段一般填0。再往后36 00 00 00是像素数据的偏移量0x36也就是54正好等于1440说明这个文件没有调色板从文件头直接跳到像素数据。接下来从第14字节开始是BITMAPINFOHEADER。28 00 00 00表示信息头自身大小是40字节02 00 00 00是宽度2像素再一个02 00 00 00是高度2像素。01 00是平面数固定为118 00是位深也就是24位一个像素3个字节。后面还有一大串0对应压缩方式、图像大小、分辨率这些字段24位图压缩方式固定为0也就是不压缩。最后一部分: FF 00 00 FF 00 00 FF 00 00 FF 00 00是像素数据四组“FF 00 00”每组代表一个像素颜色顺序是B、G、R第一组表示蓝色通道0、绿色通道0、红色通道255。看到没有BMP里像素颜色是BGR顺序存储的这跟我们在代码里惯用的RGB恰好相反是新手最容易搞反的地方。3. 两个头结构体的字段逐个讲清楚3.1 BITMAPFILEHEADER文件头14字节的“快递面单”BITMAPFILEHEADER在C语言里一般定义成下面这个结构体#pragma pack(push, 1) typedef struct { uint16_t bfType; // 2字节文件类型必须是BM即0x4D42 uint32_t bfSize; // 4字节整个文件的大小 uint16_t bfReserved1; // 2字节保留字段置0 uint16_t bfReserved2; // 2字节保留字段置0 uint32_t bfOffBits; // 4字节像素数据相对文件开头的偏移量 } BITMAPFILEHEADER; #pragma pack(pop)注意这里必须用#pragma pack(push, 1)改成按1字节对齐否则编译器会在字段间插入填充字节导致结构体占了16字节而不是14字节你写出来的文件所有字段都会错位。这一点在C/C、Rust、Go等一切有内存对齐特性的语言里都要小心Python的struct模块用H这种格式字符串就不用担心因为它是显式按字节排布的。bfType比较特殊它虽然是uint16_t但表示的是ASCII字符“BM”。在小端序机器上写入0x4D42字节序就是42 4D正好对应“BM”。如果你在代码里看到0x4D42没头绪记住它就是“看图软件认这个文件的前提”十六进制下看到不是4242开头基本可以直接判断文件损坏或者根本不是BMP。bfSize是整个文件从第一个字节到最后一个字节的总大小就是文件实际长度。很多人填错这个值最常见的原因在于先填了头部再追加像素数据之后忘了回头更新文件大小。我自己因为这种事踩过坑之后就养成了一个习惯所有字段全部计算完成后最后再写文件头或者干脆临时生成文件头写完数据后用fseek回到开头再写一次。3.2 BITMAPINFOHEADER信息头40字节的“货物清单”40字节的BITMAPINFOHEADER字段更多我把常用字段整理成一张表方便你查阅字段名字节数含义biSize4本结构体大小固定为40biWidth4图像宽度单位是像素。正数表示从左到右、从上到下存储负数表示上下颠倒下一节详谈biHeight4图像高度单位是像素。正数表示自底向上存储负数表示自顶向下存储biPlanes2颜色平面数固定为1biBitCount2每像素位数常见取值1、4、8、16、24、32biCompression4压缩方式0表示不压缩BI_RGB1表示RLE8压缩2表示RLE4压缩biSizeImage4图像数据大小不压缩时也可以填0biXPelsPerMeter4水平分辨率单位是像素每米一般填0或2835约72DPIbiYPelsPerMeter4垂直分辨率同上biClrUsed4实际使用的调色板颜色数0表示使用全部biClrImportant4重要颜色数0表示都重要biBitCount决定了后面的数据怎么组织1位是黑白图、4位是16色、8位是256色、16位是高彩、24位是真彩、32位是真彩加Alpha。位深不是随便定的它直接影响行字节数的计算和调色板的有无。在Windows上生成BMPbiCompression我一般总是填0也就是BI_RGB不压缩。虽然BMP有RLE压缩的变体但几乎所有代码库和工具对RLE支持都一般写着写着就容易遇到编码器不兼容的问题。按我的经验真的在意压缩体积就直接选PNG或JPEG别在BMP上搞RLE收益极低还费事。3.3 正数高度和负数高度图片为什么“倒着存”这是BMP格式最迷惑人的地方之一。在常见的图片格式里JPEG、PNG的扫描线都是从图像顶部开始、逐行向下存储。BMP不一样它的存储方向由biHeight决定如果biHeight是正数图像是自底向上存储的也就是文件里的第一行像素是图像的最底下一行如果biHeight是负数才是一般人理解的自顶向下存储。这个设计有历史原因早期Windows GDI的坐标系原点在屏幕左下角y轴向上增长所以位图从下往上扫描更符合当时的图形系统。如今Windows已经全面转向原点在左上角的坐标系但BMP格式为了向后兼容依然保留了这个规则而且大量软件生成BMP时依然按“正数高度自底向上”的方式存储。这就直接导致了一个经典问题你程序里从文件头读到了宽度和高度然后不管三七二十一把像素数据按“从上到下”的行顺序填充进内存的2D数组最后画出来的图像往往是上下颠倒的。解决方法是判断biHeight的符号——如果是正数第一行数据应该放在数组的最后一行或者反过来读也可以干脆在生成文件时把biHeight写成负数强迫文件按自上而下存储省得读的时候再翻转。底行优先的解释这段还要提一下“行”的本质。在BMP像素数据里每行的字节数不是简单等于宽度 × 每像素字节数的这里还有个对齐问题咱们下一节专门讲。4. 像素数据区的存储规则对齐、BGR与位深4.1 每行必须4字节对齐看不懂就花屏BMP格式有一个硬性规定每一行像素数据的字节数必须是4的倍数。如果你的图像宽度和位深算出来的行字节数不是4的倍数那么这一行末尾要补若干个0字节补到满足对齐条件为止。先看公式。假设图像宽度为width每像素位数为bitCount那么“有效数据”的行字节数就是lineBytesRaw width * bitCount / 8但实际写入文件的每行字节数lineBytesStride得做向上取整到4的倍数lineBytesStride (lineBytesRaw 3) / 4 * 4用整数写法就是((width * bitCount 31) / 32) * 4。为什么是31因为每行要补足到32位4字节的整数倍分子加上31再整除32就是向上取整到32位边界。举个例子一张宽度为23像素的24位BMPwidth * bitCount / 8 23 * 3 69字节但69不是4的倍数所以实际每行会占((69 3) / 4) * 4 72字节末尾3个字节是填充的0。如果不补这3个字节或者读取时忘了跳过它们下一行数据就会错位图像看起来就是斜着撕裂的、色彩混乱的“花屏”效果。习惯上把这3个填充字节叫stride padding。生成文件时要补解析文件时要跳两头都不能漏。4.2 24位与32位BGR还是BGRA24位BMP每个像素占3字节存储顺序是B、G、R也就是先写蓝色字节、再写绿色字节、最后写红色字节。32位BMP每个像素占4字节顺序是B、G、R、A注意不是ARGB最后一个字节是Alpha透明通道。很多图形库比如OpenCV默认的Mat内部用BGR恰好能跟24位BMP无缝对接但你要是用普通工具、或者在3D引擎里做贴图通常得手动交换R和B通道。我平时做图像调试时的处理办法是写一个统一函数def bgr_to_rgb(data, width, height): # 输入是BMP解码后的字节串行对齐后的输出是标准RGB字节串 pixels bytearray(data) for i in range(0, len(pixels), 3): pixels[i], pixels[i2] pixels[i2], pixels[i] return bytes(pixels)注意这种转换不能直接把bytearray当数组按行处理因为如果行尾有padding你交换时会把padding当像素处理所以要么先把padding去掉再交换要么按行循环、每行仅处理有效宽度内的字节。我就是因为偷懒统一交换结果图片左边有一条诡异色带排查半天才发现是padding被误交换了。4.3 调色板位图1位、4位和8位位深只有1、4、8的时候像素数据里存的不是颜色值而是调色板索引。调色板本身在文件头之后、像素数据之前是一串RGBQUAD结构体也就是4字节一组BGRX数量由biClrUsed或位深决定。8位位图最多256个颜色调色板就有256个4字节项4位是16项1位是2项。调色板由结构体数组中每个元素的顺序决定第0项是索引0对应的颜色第1项是索引1对应的颜色。像素数据中每个字节8位图或每4个bit4位图或每个bit1位图存放的就是这些“编号”。比方说调色板第5项是红色对应字节00 00 FF那像素数据里某个字节值为5时这个像素就显示红色。4位和1位的位图还有一个隐藏规则每个字节内的高位在前还是低位在前。标准BMP规定一个字节内先出现的像素用高4位4位图或最高位1位图。如果你自己逐位解析时顺序写反了图像会呈现镜像效果或者颜色错乱。调色板位图在嵌入式开发中还挺常用的能大幅压缩显存占用但处理起来远比24位图麻烦我建议你第一次接触时先用8位图练手把索引机制搞熟了再碰4位和1位。5. 实战用Python手工生成和解析BMP5.1 从零构造一张24位BMP纸上谈兵再久不如直接写代码。下面我用Python标准库struct从零生成一张宽度为8、高度为8的纯红色BMP无调色板、24位、自顶向下存储。import struct width, height 8, 8 bit_count 24 row_padding (4 - (width * (bit_count // 8)) % 4) % 4 # 像素数据8x8的红色块每个像素 BGR (0, 0, 255) raw_pixels bytearray() for y in range(height): for x in range(width): raw_pixels bytes([0, 0, 255]) # B, G, R raw_pixels b\x00 * row_padding # 行尾部补0对齐 # 文件头和信息头 file_size 14 40 len(raw_pixels) pixel_offset 14 40 file_header struct.pack(2sIHHI, bBM, file_size, 0, 0, pixel_offset) info_header struct.pack(IiiHHIIiiII, 40, # biSize width, # biWidth -height, # biHeight负数自顶向下 1, # biPlanes bit_count, # biBitCount 0, # biCompression len(raw_pixels), # biSizeImage 0, 0, # 分辨率 0, 0) # clrUsed, clrImportant with open(red.bmp, wb) as f: f.write(file_header) f.write(info_header) f.write(raw_pixels)这段代码看着简单但有个地方值得停下来说一说我把biHeight写成负数-height实际上是刻意把头的高度字段设为负值这样存储方向就变成自上而下很多看图软件按传统正高度BMP读取时也会自动做一次翻转最终显示效果才符合直觉。同时我自己解析的时候也不用做行序翻转了。如果你更希望跟“绝大多数现有BMP”保持一致把biHeight写成height那么像素数据应该从图像的最后一行开始写。这两种写法都能产出合法文件关键是你必须清楚你的代码用的是哪种方向。注意biHeight用负数表示自上而下这在Windows官方文档里有明确说明但有些老旧的第三方BMP解析库不支持负高度字段解析出来可能是上下颠倒的。在自己写工具时最好同时兼容正负高度。5.2 制作R/G/B通道图三张图还是三通道合一制作通道图是个很常见的需求。3D美术里经常把金属度、粗糙度、AO分别塞进一张图的不同通道也就是“通道打包”。BMP因为无压缩、像素数据可精确定位特别适合用来做这类图。最简单的做法是把同一张灰度数据分别放到R、G、B通道里这样你就得到一张“看起来是彩色、实际上三个通道存了不同信息”的图。比如我想把三张灰度图合到一张BMP里可以这样写import struct width, height 64, 64 # 假设三个通道的数据各自是 width*height 个字节的灰度值 r_channel bytes((x * 255 // width for _ in range(height) for x in range(width))) g_channel bytes((y * 255 // height for y in range(height) for x in range(width))) b_channel bytes(((x y) * 255 // (width height) for y in range(height) for x in range(width))) row_size width * 3 padding (4 - row_size % 4) % 4 pixels bytearray() for y in range(height): base y * width for x in range(width): idx base x # BGR顺序先B再G再R pixels bytes([b_channel[idx], g_channel[idx], r_channel[idx]]) pixels b\x00 * padding file_header struct.pack(2sIHHI, bBM, 14 40 len(pixels), 0, 0, 14 40) info_header struct.pack(IiiHHIIiiII, 40, width, -height, 1, 24, 0, len(pixels), 0, 0, 0, 0) with open(channels.bmp, wb) as f: f.write(file_header) f.write(info_header) f.write(pixels)生成后再用支持通道查看的工具打开Photoshop、GIMP等你用“通道”面板分别看R、G、B就能看到三张独立的灰度图。这里最需要小心的是写BGR顺序时不要弄反很多人在合成时习惯性地按R、G、B的顺序写字节结果打开一看红通道的数据跑到了蓝通道里整个通道打包就是错的。如果想做带透明信息的通道可以把一个通道留给Alpha也就是32位BMP每像素4字节BGRA。这在对引擎贴图要求更严格的场景下很实用。5.3 编写BMP解析器来验证自己生成的文件生成完了就得验证最好的验证方式是自己写一个解析器把它读回来。解析流程其实很简单读前14字节得到bfOffBits读14到54字节得到biWidth、biHeight、biBitCount然后跳转到bfOffBits读像素数据。def parse_bmp(path): with open(path, rb) as f: data f.read() if data[0:2] ! bBM: raise ValueError(不是合法的BMP文件) offset struct.unpack(I, data[10:14])[0] width struct.unpack(i, data[18:22])[0] height struct.unpack(i, data[22:26])[0] bit_count struct.unpack(H, data[28:30])[0] compression struct.unpack(I, data[30:34])[0] if bit_count ! 24 or compression ! 0: raise ValueError(本解析器只支持24位非压缩BMP) abs_height abs(height) row_size ((width * 3 3) // 4) * 4 pixels [] for y in range(abs_height): row_start offset y * row_size row data[row_start:row_start width * 3] for x in range(width): b, g, r row[x*3], row[x*31], row[x*32] pixels.append((r, g, b)) return (width, abs_height, pixels)解析器的重点在于跳过行尾padding以及正确处理height的符号。我为了方便把正负高度统一成“最后得到pixels数组第0行对应图像顶部”因此在循环里如果height是正数需要把读取的行逆序放入结果。上面的示例省去了逆序逻辑如果你的业务里明确只生成负高度BMP这样写没问题要处理别人给的文件就得补上符号判断。这也是为什么我老强调“生成和解析必须双向兼容”的原因——你永远不知道手里的BMP是哪个工具写的。6. 常见问题与排查技巧实录6.1 图片上下颠倒现象生成的BMP用系统看图软件打开后上下颠倒或者程序读出来的位图是反的。原因biHeight正负号与像素数据写入顺序不匹配。正高度要求数据从底行开始负高度要求从顶行开始很多人只记得其中之一。排查方法用十六进制编辑器看文件开头第22到25字节即高度字段如果是00 00 00 00以上的正整数说明文件要求底行优先如果最高位是1负数的补码表示则是顶行优先。再回去对照你自己的行写入顺序两者必须一致。最常见的解决办法就是把像素写入循环改成for y in range(height - 1, -1, -1)从最后一行写到第一行同时保持biHeight height为正数这样跟大多数软件默认的BMP一致。6.2 图像斜切、色彩错乱现象图片像被斜着切了一刀每一行像素都跟真实位置错开了几个像素或者右侧出现彩色的杂色条纹。原因九成是行对齐padding没处理好。BMP每行必须4字节对齐生成时没补、解析时没跳都会让后续行整体错位。少部分情况是biWidth与实际写入的像素数不一致或者填充字节没有清零。排查方法先算一下理论行字节数width * 3再看是不是4的倍数。如果不是验证文件大小是否等于14 40 ceil(width*3/4)*4*height。如果不等于就要检查你自己写的row_padding逻辑了。我遇到过一个棘手的案例宽度是224位理论行字节数66不是4的倍数需要补到8。当时我脑子一热写成了width * 3 width一下子每行多了2个字节整张图就是斜的。后来养成习惯先写个计算函数并单元测试一下再动手写文件。6.3 通道图偏色或者“换通道”现象生成通道图之后用Photoshop查看发现原本存红色通道的灰度数据跑到了蓝色通道里整个色彩关系和预期完全对不上。原因BMP是BGR存储不是RGB。如果你从OpenCV或者其他BGR系库里直接拿数据拼接看起来可能没问题但如果你从RGB系库比如PIL/RGB模式、paint.net的Raw数据里取字节直接写第一个字节会被当成蓝色结果就是R和B互换。排查方法生成一张纯红色的BMP用十六进制编辑器看像素数据的第一个像素应该是FF 00 00B255, G0, R0如果看到的是00 00 FF说明你的字节顺序是从RGB的视角写的。修正方法很简单把像素数据每3个字节中第0和第2个字节交换。千万别只想着改看图软件里的通道显示那是自欺欺人。6.4 文件头偏移量算错导致打不开现象文件生成后Windows照片查看器报“照片查看器无法打开此图片”或者软件直接崩溃。用十六进制编辑器看数据都在但就是打不开。原因bfOffBits字段指向像素数据的起始位置如果算错了解析器读到的第一段“像素数据”实际上是文件头或者调色板的残留数据颜色自然就是乱的。更隐蔽的情况是调色板导致的偏移偏移错误8位图有1024字节256*4的调色板bfOffBits应该是5410241078而不是54。排查方法直接用十六进制查看器跳转到bfOffBits指向的位置看那里的数据是不是连续的像素颜色值。例如8位图跳过去应当看到索引值而不是调色板项24位图跳过去应当看到连续的BGR三元组。如果不匹配就检查bfOffBits的计算是否包含了调色板大小。这里我建议代码里统一用14 biSize palette_size推导而不是手写死54。6.5 32位图中Alpha通道灰阶异常现象32位BMP在部分看图软件里Alpha通道被当成普通透明值结果整张图看起来半透明或者导出后Alpha全部是0导致不透明区域变透明。原因部分BMP解析器对32位的约定不同。Windows标准里32位BGRA是直通AlphaStraight Alpha但一些游戏引擎和图像工具支持“预乘Alpha”Premultiplied Alpha这两种方式对同一个RGB像素值会产生不同的合成效果。很多工具在读取32位BMP后默认当成了预乘Alpha来做混合于是显示效果就跟你预期不一致。排查方法先把所有Alpha字节临时改成255再打开看是否正常。如果正常说明RGB数据本身没问题是Alpha语义没对齐这类问题通常得通过项目代码里的“IsPremultiplied”之类的开关解决。如果你只是想做通道图建议优先用未压缩的24位BMP避免Alpha语义混乱带来的坑。最后再分享一个小技巧做BMP解析和生成时最好在手边留一个小工具专门把所有字段以可读的表格形式打印出来文件大小、偏移量、宽、高、位深、压缩方式、行字节数、调色板项数。遇到任何文件显示异常先跑一遍它90%的问题当场就能定位。我自己就是在一台老笔记本上维护着这样一个脚本用Python写的总共不到一百行但每次处理外部拿到的BMP时都先过一遍省了大量试错时间。你如果也经常跟BMP打交道强烈建议花20分钟搭一个类似的工具绝对划算。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →