上周帮同事看一个显示异常:一张从采集设备导出的截图,在自研的看图工具里歪成了斜条纹,同一个文件丢进系统自带看图器却完全正常。文件是 1366×768、24 位色,每行本该补 2 个字节,代码里却按width * 3累加偏移,一行错 2 字节,往下叠七百多行就成了肉眼可见的斜纹。这类问题我隔一阵就会撞上一次,而它们背后都指向同一个东西——BMP 格式那些看起来古板、却一条都不能省的规矩。
BMP(Bitmap)是数字图像处理里最“素”的一种格式:不压缩、不加密、不做熵编码,头部写清楚宽高和位深,后面就是一行行像素原样堆着。也正因为素,它成了理解图像内存布局的最佳教具——你把 BMP 读明白了,RGB 通道顺序、行对齐、调色板索引、自下而上的存储、位深换算这些概念就全通了,换成 PNG、TIFF、甚至 GPU 纹理上传,底层逻辑是同一套。这篇内容面向三类人:刚学图像处理、想搞清楚“一张图在内存里到底长什么样”的新手;需要给嵌入式屏、工业相机、算法中间结果做无损存取的一线开发者;以及被花屏、斜纹、上下颠倒折磨过、想一次性把坑填平的同行。我会从文件结构讲到手写编解码器,再到格式选型和故障排查,代码给的是能直接跑的最小实现。
1. 先把 BMP 这件事说透:文件结构与设计取舍
1.1 两段头加一块像素:BMP 的四段式布局
BMP 文件的物理结构非常规整,从头到尾就是四段:文件头、信息头、调色板(可选)、像素数组。
第一段是BITMAPFILEHEADER,固定 14 字节,只干三件事——声明“我是 BMP”、记录整个文件多大、告诉解析器像素数据从第几个字节开始。第二段是BITMAPINFOHEADER及其衍生版本,最常见的是 40 字节的 V3 头,描述图像的元信息:宽、高、位深、压缩方式、分辨率、调色板用了多少项。第三段是调色板,只有在位深 ≤ 8 时才存在,每一项 4 字节,存的是 BGR 加一个保留字节。第四段就是像素数组,从bfOffBits指向的位置一直铺到文件末尾。
这个“头 + 数据”的二分法看着朴素,实际非常好用:解析器可以先读 14 字节判断类型和偏移,再读头拿尺寸,然后一次性fseek到像素区做批量读取或者mmap映射。不需要像 PNG 那样逐块(chunk)扫描、计算 CRC,也不需要像 JPEG 那样建霍夫曼表。这也是为什么大量嵌入式项目、教学 Demo、算法中间结果导出,首选都是 BMP——解析成本几乎为零。
真正需要注意的是,头部长度不是固定的。biSize字段自己声明了信息头有多少字节:12 对应 OS/2 的 CORE 头,40 是 Windows 3.x 的 V3 头,108 是 V4,124 是 V5。你写解析器时如果硬编码offset = 14 + 40,遇到 V5 头就会把头部残留的字段当成像素读,图像顶部出现一条彩色噪点带——这个现象我见过不止一次,基本都是这个原因。
1.2 为什么 BMP 要“倒着存”和“按 4 字节对齐”
这两个设计是 BMP 最反直觉、也最容易出错的地方。
先说倒着存。biHeight为正数时,像素数组的第一行是图像的最下面一行,最后一行才是顶行。这叫 bottom-up DIB。原因要追到早期图形设备的内存布局:屏幕扫描线是从上往下刷的,但当时的显示缓冲区和打印输出习惯从底部开始组织,微软沿用了这个约定,并一直保留到 V3 头。后来为了支持视频解码这类天然 top-down 的场景,才允许把biHeight写成负数表示自上而下。所以你在代码里看到biHeight < 0,别当成错误,那是一种合法写法,只是老软件兼容性差一些。
再说 4 字节对齐。BMP 要求每一行的字节数必须是 4 的倍数,不够就在行尾补零。这条规定源于 32 位 CPU 的访存习惯:早期图形驱动直接用memcpy或者 DMA 把扫描线搬到显存,4 字节对齐能让每次搬运都落在字边界上,省掉跨字访问。代价就是文件里混进了填充字节,而这些填充字节不属于任何像素,解析时必须跳过。
公式推导一下:
每行实际字节数 row_stride = ceil(width × bitcount / 32) × 4 = ((width × bitcount + 31) // 32) × 4拿开头那个案例验证:宽度 1366、位深 24,1366 × 24 = 32784位,(32784 + 31) // 32 = 1025,1025 × 4 = 4100字节。而纯像素是1366 × 3 = 4098字节,所以每行尾部要补 2 个零字节。如果代码按 4098 去算行偏移,第 n 行就偏了2n字节,画面自然斜了。
反过来,像 1920、1280、800 这类宽度乘 3 之后正好是 4 的倍数(5760、3840、2400),完全没有填充,这也是很多人“本地测试一切正常、换个分辨率就翻车”的原因——测试图恰好不需要补齐。
1.3 位深与调色板:1/4/8/16/24/32 位各是什么世界
位深决定了每个像素占多少位,也决定了后面整套读取逻辑。
1 位是最省空间的,一个像素 1 bit,只能表示两种颜色,靠调色板给出具体是哪两种。常见于早期的图标、票据打印、二值化算法输出。4 位是 16 色,一个字节塞两个像素,高 4 位是左边的像素,低 4 位是右边。8 位是 256 色,一个字节一个像素,直接作为调色板索引,灰度图也常这么存。这三种都属于索引色,读出来的数值不是颜色,是调色板下标,必须查表才能还原 RGB。
16 位是高彩色,默认按 RGB555 解释(红绿蓝各 5 位,最高位空闲),如果压缩字段是BI_BITFIELDS,则按头里给的掩码解析,常见的是 RGB565(红 5、绿 6、蓝 5)。24 位是真彩色,每像素 3 字节,顺序是B、G、R——注意不是 RGB。32 位每像素 4 字节,顺序 B、G、R、A(或保留字节)。
这里有个必须点明的坑:24 位和 32 位的通道顺序是 BGR 而不是 RGB。原因是 BMP 诞生于小端时代,把颜色打包成一个 32 位整数时,低字节存蓝、高字节存红,内存里读出来就是 BGR 的字节流。你如果直接把这段 buffer 丢给按 RGB 排列的渲染接口,红蓝会对调——人脸变成蓝色,天空变成橙红。这不是 bug,是字节序留下的历史包袱。
2. 关键字段逐个拆:读懂那 54 个字节
2.1 文件头 14 字节:类型、大小与像素偏移
文件头一共 5 个字段,我把它们和常见的坑一起列出来。
| 偏移 | 字段 | 长度 | 说明与注意点 |
|---|---|---|---|
| 0 | bfType | 2 | 必须是0x4D42,内存里读出来是42 4D,即 ASCII 的BM |
| 2 | bfSize | 4 | 整个文件的字节数,部分软件写错,读取时不要盲信 |
| 6 | bfReserved1 | 2 | 保留,必须为 0 |
| 8 | bfReserved2 | 2 | 保留,必须为 0 |
| 10 | bfOffBits | 4 | 像素数据起始偏移,以它为准,不要自己算 |
bfType这一项值得单独说。你写 C 代码时如果定义uint16_t bfType,然后判断bfType == 'BM',在小端机器上其实是错的——'BM'作为多字符常量在编译器里的取值依赖实现,稳妥写法是判断bfType == 0x4D42,或者直接比较前两个字节是否为'B'和'M'。我见过有人为了这个“类型判断失败”查了一下午,最后发现是常量写法问题。
bfSize和bfOffBits的关系也要留意。理论上bfSize = bfOffBits + row_stride × height,但现实中bfSize被写错的情况很常见,有的软件写成 0,有的没算填充字节,有的把尾部附加数据漏了。所以读取时以bfOffBits加实际数据长度为准,bfSize只用来做交叉校验:如果bfSize和文件真实大小差得离谱,说明文件可能被截断或拼接了额外数据。
bfOffBits还有一个隐蔽用途:它和14 + biSize + 调色板长度之间可能存在间隙。某些加壳工具、水印工具会在这个间隙里塞数据。正规解析器应该直接seek(bfOffBits),而不是自己累加。
2.2 信息头 40 字节:宽高、位深与压缩方式
V3 信息头的 11 个字段里,真正影响解码的只有前六个,后面几个属于“写了也没人看,但必须写对”的元数据。
| 偏移(相对头部起点) | 字段 | 说明 |
|---|---|---|
| 0 | biSize | 信息头长度,40 表示 V3 |
| 4 | biWidth | 宽度,有符号 32 位 |
| 8 | biHeight | 高度,正数 bottom-up,负数 top-down |
| 12 | biPlanes | 恒为 1 |
| 14 | biBitCount | 1/4/8/16/24/32 |
| 16 | biCompression | 0=BI_RGB,1=RLE8,2=RLE4,3=BITFIELDS |
| 20 | biSizeImage | 像素区字节数,BI_RGB 时可为 0 |
| 24 | biXPelsPerMeter | 水平分辨率,像素/米 |
| 28 | biYPelsPerMeter | 垂直分辨率,像素/米 |
| 32 | biClrUsed | 调色板实际项数,0 表示 2^位深 |
| 36 | biClrImportant | 重要颜色数,0 表示全部重要 |
biWidth虽然是有符号的,但负宽度几乎没有实现支持,遇到就当损坏文件处理。biHeight的符号才是关键。biPlanes必须是 1,不是 1 的文件我至今没见过能正常打开的。
biSizeImage在实践中经常是 0,尤其是BI_RGB无压缩的情况下——因为像素区大小可以由row_stride × height完全推出来,写文件的人就偷懒了。你解析时必须准备好这个回退逻辑:size_image = biSizeImage or (row_stride * abs(height))。
biXPelsPerMeter和biYPelsPerMeter是很多人忽略的一项。它不是随便填的,和 DPI 有明确换算关系:DPI = ppm × 0.0254。比如 2835 ppm 约等于 72 DPI,3780 ppm 约等于 96 DPI。如果你做的工具需要按物理尺寸打印,或者做扫描件归档,这两个字段不能写 0,否则下游排版软件会按默认 72 DPI 处理,输出尺寸全错。
2.3 那些容易被忽略的字段:掩码、调色板与扩展头
biCompression = 3(BI_BITFIELDS)时,信息头后面还跟着三个 4 字节掩码,分别指定红、绿、蓝在 16 位或 32 位像素值里的位位置。比如 RGB565 的掩码是0xF800、0x07E0、0x001F,RGB555 是0x7C00、0x03E0、0x001F。这三个掩码的作用是让你能处理非标准排布,比如某些相机传感器把绿通道拆成两段。
V4 和 V5 头把掩码挪到了头内部,还额外增加了色彩空间类型、端点坐标、Gamma 值、ICC 配置文件的偏移和长度。V5 更进一步支持 ICC 配置直接嵌在文件里。这类文件不算罕见,尤其是从带色彩管理的设备导出的图。你如果只按 V3 解析,会在像素区前面多读一百多字节的“垃圾”,表现为图像顶部有一条彩色横条。
调色板的细节也值得说。位深 ≤ 8 时,调色板紧跟信息头,每项 4 字节,顺序是 B、G、R、保留(一般填 0)。项数由biClrUsed决定,为 0 时取2^biBitCount。这里有一个经典错误:调色板的 B 和 R 又反了。跟像素数据一样,调色板也是 BGRX 顺序。有人为了省事直接按 RGBX 读,结果整张图的红蓝互换,而像素区本身没问题——排查时容易误判成像素顺序错误,白折腾半天。
3. 手写一个 BMP 编解码器:从读字节到出图
3.1 读取流程与行补齐的计算
我习惯把读取分成四步:读文件头校验类型、读信息头拿尺寸和位深、按需读调色板、按 stride 逐行搬运像素。下面这段 Python 是能直接跑的最小实现,不依赖任何图像库。
import struct def read_bmp(path): with open(path, "rb") as f: data = f.read() if len(data) < 26 or data[0:2] != b"BM": raise ValueError("不是合法的 BMP 文件") bf_size, _, _, bf_off = struct.unpack_from("<IHHI", data, 2) header_size = struct.unpack_from("<I", data, 14)[0] if header_size == 12: # OS/2 CORE 头 w, h, planes, bpp = struct.unpack_from("<HHHH", data, 18) compression, clr_used = 0, 0 palette_off, palette_entry = 26, 3 top_down = False else: # V3 及以上 (w, h, planes, bpp, compression, size_img, xppm, yppm, clr_used, clr_imp) = struct.unpack_from("<iiHHIIiiII", data, 18) palette_off, palette_entry = 14 + header_size, 4 top_down = h < 0 h = abs(h) if w <= 0 or h == 0 or bpp not in (1, 4, 8, 16, 24, 32): raise ValueError("不支持的图像参数") # 调色板 palette = [] if bpp <= 8: n = clr_used if clr_used else (1 << bpp) for i in range(n): b, g, r = struct.unpack_from("<BBB", data, palette_off + i * palette_entry) palette.append((r, g, b)) stride = ((w * bpp + 31) // 32) * 4 # 关键:行字节数 buf = bytearray(w * h * 3) # 统一输出 RGB24 rows = range(h) if top_down else range(h - 1, -1, -1) # 统一成自上而下 for dst_y, src_y in enumerate(rows): base = bf_off + src_y * stride for x in range(w): if bpp == 24: p = base + x * 3 b, g, r = data[p], data[p + 1], data[p + 2] elif bpp == 32: p = base + x * 4 b, g, r = data[p], data[p + 1], data[p + 2] elif bpp == 8: r, g, b = palette[data[base + x]] elif bpp == 4: byte = data[base + x // 2] idx = (byte >> 4) & 0x0F if x % 2 == 0 else byte & 0x0F r, g, b = palette[idx] else: # 1 位 byte = data[base + x // 8] bit = (byte >> (7 - x % 8)) & 0x01 r, g, b = palette[bit] o = (dst_y * w + x) * 3 buf[o], buf[o + 1], buf[o + 2] = r, g, b return w, h, bytes(buf)这段代码里有几个决定成败的细节。stride用((w * bpp + 31) // 32) * 4而不是w * bpp // 8,这是唯一正确的写法。rows统一成自上而下的遍历顺序,top-down 和 bottom-up 的差异在这里被一次性抹平,业务层拿到的永远是正常方向的图像。1 位和 4 位的取位顺序是高位在前,即一行里最左边的像素在字节的最高位,这点和很多人的直觉相反。
这个版本是教学用的,逐像素处理,速度谈不上。真要用在工程里,24 位和 32 位应该走切片或者numpy的 strided 视图,速度能差出两个数量级。
3.2 写入流程:如何组装头部并正序/倒序落盘
写 BMP 和读是对称的,但有两处必须自己算对:bfSize和biSizeImage。
import struct def write_bmp(path, w, h, rgb_data, bpp=24): assert bpp in (24, 32) bytes_pp = bpp // 8 stride = ((w * bpp + 31) // 32) * 4 pad = stride - w * bytes_pp img_size = stride * h palette_size = 0 off_bits = 14 + 40 + palette_size file_size = off_bits + img_size fh = struct.pack("<2sIHHI", b"BM", file_size, 0, 0, off_bits) ih = struct.pack("<IiiHHIIiiII", 40, w, h, 1, bpp, 0, img_size, 2835, 2835, 0, 0) # 2835 ppm ≈ 72 DPI with open(path, "wb") as f: f.write(fh) f.write(ih) blank = b"\x00" * pad for y in range(h - 1, -1, -1): # 正高度 = 自下而上写入 row = rgb_data[y * w * 3:(y + 1) * w * 3] out = bytearray(w * bytes_pp) for x in range(w): r, g, b = row[x * 3], row[x * 3 + 1], row[x * 3 + 2] out[x * bytes_pp] = b out[x * bytes_pp + 1] = g out[x * bytes_pp + 2] = r if bytes_pp == 4: out[x * 4 + 3] = 255 f.write(out) f.write(blank)写文件时的关键决策是:头里写正高度还是负高度。写正数就得倒序写像素,写负数就正序写。倒序写看起来麻烦,但兼容性最好——三十年前的老软件、某些 PLC 的屏配置工具、一些单片机图形库,至今只认正高度。我自己的习惯是默认写正高度,除非目标场景明确只看新实现。
另一个容易翻车的是 32 位图的 Alpha 字节。填 255 还是填 0?这取决于用途。如果图是给界面框架当贴图用的,Alpha 有实际意义,必须填真实值;如果只是借用 32 位图的对齐便利(宽高天然 4 字节对齐,不用补 padding),那第 4 字节一般填 0,某些看图器会把 0 当全透明处理。这里没有统一标准,我一般看目标环境,不确定就填 255,因为多数看图器对 Alpha 通道直接忽略。
3.3 调色板图像与 RLE 压缩的处理
8 位调色板图像的写入,重点在调色板本身。常见做法是把 256 项全部写满,即使实际只用了几十种颜色——因为biClrUsed = 0的含义就是“有 256 项”,写不满会导致解析器越界读取。
灰度 BMP 是 8 位索引图的一个特例:调色板第 i 项写(i, i, i, 0),像素值直接就是灰度级。这种格式在医学影像、工业检测里用得不少,因为无损、结构简单、任何语言几十行就能读写。我做过一个缺陷检测的中间结果归档,最后就选了 8 位灰度 BMP——一张 2048×2048 的图 4 MB,比原图小,而且十年后随便找个工具都能打开,不依赖任何解码库。
RLE 压缩(biCompression为 1 或 2)现在基本见不到了,但遇到老资产还是得能解。RLE8 的编码规则是“计数值 + 颜色值”成对出现:03 1A表示连续 3 个索引 0x1A 的像素。特殊组合集中在计数值为 0 的时候:00 00表示行结束,00 01表示位图结束,00 02 xx yy表示跳过若干个像素(Delta),00 03..FF表示“接下来 N 个字节是字面量”,用于颜色变化密集的区域。
def decode_rle8(data, w, h): pixels = [[0] * w for _ in range(h)] x = y = 0 i = 0 while i < len(data) - 1: count, val = data[i], data[i + 1] i += 2 if count > 0: for _ in range(count): if x < w and y < h: pixels[y][x] = val x += 1 else: if val == 0: # 行结束 x, y = 0, y + 1 elif val == 1: # 位图结束 break elif val == 2: # Delta dx, dy = data[i], data[i + 1] i += 2 x, y = x + dx, y + dy else: # 绝对模式,val 个字面量 for k in range(val): if x < w and y < h: pixels[y][x] = data[i + k] x += 1 i += val if val % 2: i += 1 # 字面量必须按 2 字节对齐 return pixels那段“字面量必须按 2 字节对齐”是最容易被漏掉的规则。绝对模式的字节数如果是奇数,后面要补一个填充字节,不补的话后面整个解码流就错位了,表现为图像下半部分变成随机噪点。我第一次写这个的时候就在这里卡了整整一晚上。
3.4 用 C 写一遍:结构体对齐与大小端陷阱
C 语言直接操作 BMP 是很常见的需求,尤其是在没有标准库的嵌入式环境。这里的问题集中在结构体。
#include <stdint.h> #pragma pack(push, 1) typedef struct { uint16_t bfType; /* 必须为 0x4D42 */ uint32_t bfSize; uint16_t bfReserved1; uint16_t bfReserved2; uint32_t bfOffBits; } BMPFILEHEADER; typedef struct { uint32_t biSize; int32_t biWidth; int32_t biHeight; uint16_t biPlanes; uint16_t biBitCount; uint32_t biCompression; uint32_t biSizeImage; int32_t biXPelsPerMeter; int32_t biYPelsPerMeter; uint32_t biClrUsed; uint32_t biClrImportant; } BMPINFOHEADER; #pragma pack(pop) #define BMP_STRIDE(w, bpp) ((((w) * (bpp) + 31) / 32) * 4)#pragma pack(push, 1)绝对不能省。默认对齐下,bfType后面会被插入 2 字节填充,整个结构体从 14 字节膨胀到 16 字节;BMPINFOHEADER里的uint32_t后面也可能补字节,40 字节变成 44 字节。用这样的结构体直接fread会读出乱七八糟的值,还特别难排查——因为字段看起来“差不多对”,只是整体错位。
大小端的问题集中在bfType。写入时正确做法是逐字节写:
void write_header(FILE *fp, int32_t w, int32_t h, uint16_t bpp) { uint32_t stride = BMP_STRIDE(w, bpp); uint32_t img_size = stride * (uint32_t)h; BMPFILEHEADER fh = { 0x4D42, 14 + 40 + img_size, 0, 0, 14 + 40 }; BMPINFOHEADER ih = { 40, w, h, 1, bpp, 0, img_size, 2835, 2835, 0, 0 }; fwrite(&fh, sizeof(fh), 1, fp); fwrite(&ih, sizeof(ih), 1, fp); }0x4D42在小端机器上写出内存是42 4D,正是'B' 'M',这是对的。但如果你写成'B' | ('M' << 8),逻辑上等价,容易绕晕自己;写成0x424D则直接错,生成的文件所有看图器都不认。我建议加一行静态断言保护:_Static_assert(sizeof(BMPFILEHEADER) == 14, "header size");,编译期就能拦住对齐问题。
还有一点值得提醒:Windows 平台的内存 DIB(设备无关位图)和磁盘 BMP 文件相比,少了 14 字节文件头,其余结构一致。所以很多 API 拿到的是BITMAPINFO加一块像素指针,写盘时需要自己补BMPFILEHEADER。搞混这两者,生成的文件头会是垃圾数据。
4. BMP 和它的邻居们:格式选型到底怎么选
4.1 体积、无损、解析复杂度三维对比
选格式本质上是三个维度做权衡:文件体积、是否无损、解码复杂度。我把常见几种放在一起对比,方便按场景选。
| 格式 | 压缩方式 | 是否无损 | 解码复杂度 | 典型体积(2048×2048 彩色) |
|---|---|---|---|---|
| BMP 24 位 | 无 | 是 | 极低,几乎等于 memcpy | 约 12.6 MB |
| BMP 8 位灰度 | 无 | 是 | 极低 | 约 4.2 MB |
| PNG | DEFLATE + 预测滤波 | 是 | 中等,需 zlib | 约 2~5 MB |
| JPEG | DCT + 量化 | 否 | 高,需完整解码器 | 约 0.3~0.8 MB |
| TIFF | 多种可选 | 可配 | 中到高,变体多 | 视配置而定 |
体积差距是数量级的,一张 2048×2048 的 24 位 BMP 要 12 MB 出头,同样内容存成 PNG 可能只要 2 MB。那为什么还要用 BMP?因为解码成本。BMP 的读取就是一次头部解析加一次批量内存拷贝,在百兆主频的单片机上都能跑;PNG 需要引 zlib,解压加滤波重建,占用几十 KB 内存和可观 CPU 时间。在资源极度受限的屏显、或者每秒要出几十帧的中间结果导出场景,BMP 的“不压缩”反而是优点。
计算一下 2048×2048 的精确体积,顺便把公式用一遍:stride = (2048 × 24 + 31) // 32 × 4 = 6144,正好是 4 的倍数,无填充。img_size = 6144 × 2048 = 12,582,912字节,加 54 字节头,约 12.0 MiB。
4.2 什么时候必须用 BMP:嵌入式、算法中间态、屏幕直写
几类场景我会明确推荐 BMP。
裸机或 RTOS 上的屏显资源。一个 STM32 加一块 SPI 屏,Flash 里存图标,程序里读出来直接刷。BMP 不需要解码库,读头部拿宽高位深,fseek到像素区,逐行按 stride 刷到屏幕缓冲区就行。占用的 RAM 只要一个行缓冲。换成 PNG,光是解压算法就够吃了。
算法中间结果的落盘调试。做图像算法时,“中间那张图长什么样”是最有效的调试手段。用 BMP 存,格式简单到几乎没有出错空间,而且可以边算边存,随时打开看。我用过很多次:颜色空间转换对不对、卷积边界有没有越界、通道顺序错了没有,存一张 BMP 一看就清楚。
需要绝对无损、且要长期可读的归档。BMP 的规范三十年没变过,也不依赖任何外部字典或专利算法。我做过一个归档需求,要求“十年后换台机器还能打开”,最后就选了 8 位灰度 BMP,每个文件旁边配一份纯文本的元数据说明,简单到不会出问题。
需要给显存做零拷贝的场景。BMP 的像素区布局和 GPU 纹理、帧缓冲的线性布局高度一致,很多图形接口支持直接从内存指针上传,省掉一次解码。做屏幕录制、远程桌面传输时,最省事的做法就是直接抓屏幕缓冲区的原始位,套个 BMP 头写出去。
反过来,什么时候不该用?网络传输、需要分享给他人的图片、需要透明通道的素材、需要嵌入到文档里的图。这些场景体积和兼容性比解码成本重要得多,老老实实用 PNG。
5. 踩坑实录:BMP 处理中的典型故障与排查表
5.1 图像上下颠倒、斜条纹、花屏的根因
这三类故障几乎覆盖了 BMP 处理 90% 的问题,根因各不相同。
上下颠倒只有一个原因:忽略了biHeight的符号。正数表示 bottom-up,读出第一行其实是最底下一行。表现就是图像垂直镜像,人脸倒过来。解决办法就是我在 3.1 里写的,把行序统一翻转一次再交给业务层。要注意的是,这个错误在纯色测试图上完全看不出来,必须用有明显上下特征的图才能暴露——这是我建议测试图别用纯色块的原因。
斜条纹或者渐进式偏移,根因是行补齐没处理。表现是画面沿对角线方向越来越歪,越往下偏得越多。判断方法很简单:看偏移量是否随行号线性增长。开头那个 1366 宽的案例,每行错 2 字节,第 700 行的时候就偏了 1400 字节,肉眼非常明显。反向验证:把宽度改成 1366 附近、乘 3 后恰好是 4 的倍数(比如 1368)再造一张,如果不斜了,那基本确定是这个原因。
花屏、彩色噪点带、局部出现随机颜色块,通常有三种可能。一是信息头长度按 40 硬编码,遇到 V4/V5 头多读了一百多字节,出现规则横条。二是biClrUsed处理错,调色板没读够,索引越界。三是 RLE 解码时字面量补齐没处理,后半段整体错位。排查顺序建议从“错误是否呈现规则形状”入手:规则横条优先查头部长度,随机色块优先查调色板和压缩。
还有一种较少见的:图像只有一部分正常,其余部分全黑。这多半是bfOffBits和实际数据不匹配,或者biSizeImage为 0 时按错误的 stride 算出了过小的数据长度,读到中途就越界了。
5.2 常见问题速查表
| 现象 | 最可能原因 | 快速验证 | 处理方式 |
|---|---|---|---|
| 整体上下颠倒 | 高度为正但按 top-down 读了 | 用带上下特征的测试图 | 翻转行序 |
| 斜条纹、逐行偏移 | 未按 4 字节补齐算 stride | 换成宽度×位深为 4 倍数的图 | 用((w*bpp+31)//32)*4 |
| 红蓝互换 | 通道按 RGB 读了 BMP 的 BGR | 看人脸肤色是否偏蓝 | 交换 R/B |
| 顶部一条彩色横条 | 头部长度硬编码 40 | 打印biSize看是否大于 40 | 用biSize定位像素区 |
| 索引图颜色全错 | 调色板按 RGBX 读了 BGRX | 检查调色板前三字节 | 按 BGR 读调色板 |
| 8 位图读出来全黑 | biClrUsed=0时没读满 256 项 | 打印调色板长度 | 项数回退到1<<bpp |
| 32 位图全透明 | Alpha 字节为 0 被当真值 | 检查第 4 字节 | 忽略 Alpha 或填 255 |
| 文件尾多出几百字节 | 尾部附加了私有数据 | 对比bfSize和实际大小 | 按bfOffBits和数据长度读 |
| 写入后打不开 | bfType字节序写反 | 十六进制看前两字节是否为42 4D | 写0x4D42或逐字节写 |
这张表我基本上是按“看到现象想到什么”的顺序排的,实际排查时从现象倒推比从头顺推快得多。
5.3 几条压箱底的实操心得
第一条,先看十六进制再写代码。遇到打不开或者显示异常的文件,别急着改代码,先拿十六进制工具看前 16 个字节:前两位是42 4D说明类型对;第 3~6 字节是文件大小;第 11~14 字节是像素偏移。再看第 15~18 字节是biSize,如果不是 40 就说明是扩展头,解析逻辑要跟着变。这一步做完,八成问题的方向就定了。我见过太多人一上来就怀疑算法,其实文件头里就写着答案。
第二条,测试图要覆盖四种边界。宽度乘位深不是 4 倍数的(测 stride)、高度为正和为负的(测方向)、位深为 8 和 24 的(测调色板和直读)、以及宽高为奇数的(测 4 位和 1 位的半字节、位对齐)。这四张图跑通,代码基本就稳了。只用一张 1920×1080 的彩色图做测试,是在给自己埋雷。
第三条,写文件时留一个自校验。生成完文件后,用独立的实现读回来,逐像素比对原图。这个“独立实现”最好是系统自带看图器手工看,或者用另一套语言写个十行的读取脚本。同一套代码读自己写的文件是发现不了问题的,因为错误在读写两侧对称抵消了。我有一次就栽在这上面:写的时候行序和读的时候行序都错,自测完全正常,交付到第三方的软件里才发现全反了。
第四条,大图不要用逐像素循环。Python 里逐像素处理一张 4000×3000 的图要几十秒,用切片或numpy的重塑加转置,一秒钟都不到。以 24 位图为例,可以先按 stride 切出每行,去掉尾部填充,再把每行 reshape 成(width, 3),最后[:, :, ::-1]一次把 BGR 转成 RGB。这样写起来还更短,可读性也不差。
第五条,DPI 字段别填 0。这个前面提过,但值得再强调一次。填 0 之后,在一些排版软件里图像会按 72 DPI 处理,本来该打印成 A4 的扫描件,输出成了明信片大小。规矩的填法是 2835 或 3780,对应 72 和 96 DPI。这个小字段平时没人看,出问题时排查起来极其费劲,因为它不影响屏幕上显示,只影响物理尺寸计算。
第六条,关于 32 位图的一个经验判断:如果第四字节全是 0,多半只是“为了对齐而升到 32 位”,Alpha 没有真实意义,按不透明处理更安全;如果第四字节有明显的变化范围,那才是真正的 Alpha 通道。这个判断没法百分百准确,但比盲目信任要靠谱得多。我手上的项目里,遇到来源不明的 32 位 BMP,一律先按不透明渲染,需要透明效果再另做处理,避免出现整张图消失的尴尬。