实战:图像处理(BMP读写+灰度化+滤波)

图像处理,听起来很高大上对吧?其实说白了,就是操作像素矩阵。我最早接触图像编程是在大学做机器人比赛的时候,摄像头拍回来的图要转成灰度才能做边缘检测。那时候用的还是OpenCV,后来自己手写了一遍BMP读写,才算真正理解了图像在内存里长什么样。

这一章,我们就从零开始,手撸一个简单的图像处理工具。目标很明确:读取BMP文件 → 灰度化 → 均值滤波 → 保存结果。不依赖任何第三方库,纯C++标准库搞定。

BMP文件格式,你得知道这些

BMP是Windows上最基础的位图格式。它的结构其实挺直白的,就三部分:

  • 文件头(BITMAPFILEHEADER):14字节,包含文件类型、大小、像素数据偏移量。
  • 信息头(BITMAPINFOHEADER):40字节,包含宽、高、位深度、压缩方式等。
  • 像素数据:从下往上、从左到右存储的BGR三通道数据(24位图)。

嗯,这里要注意:BMP的像素数据是倒着存的——第一行其实是图像的最后一行。我第一次写读取函数时没注意这个,结果图像上下颠倒了,调试了半天才发现。

核心知识点:BMP文件头结构体定义(Windows环境下可直接使用#pragma pack(1)对齐,跨平台建议手动解析字节)。

#pragma pack(1)  // 按1字节对齐,避免结构体填充
typedef struct {
    uint16_t bfType;      // 固定为'BM',即0x4D42
    uint32_t bfSize;      // 文件总大小
    uint16_t bfReserved1;
    uint16_t bfReserved2;
    uint32_t bfOffBits;   // 像素数据偏移量
} BITMAPFILEHEADER;

typedef struct {
    uint32_t biSize;          // 本结构体大小,40
    int32_t  biWidth;         // 图像宽度(像素)
    int32_t  biHeight;        // 图像高度(像素)
    uint16_t biPlanes;        // 固定为1
    uint16_t biBitCount;      // 位深度,24或32
    uint32_t biCompression;   // 压缩方式,0表示不压缩
    uint32_t biSizeImage;     // 像素数据大小
    int32_t  biXPelsPerMeter;
    int32_t  biYPelsPerMeter;
    uint32_t biClrUsed;
    uint32_t biClrImportant;
} BITMAPINFOHEADER;
#pragma pack()

我的习惯:读取BMP时,不要直接memcpy整个文件头结构体。不同编译器对pack的处理不一样,我建议逐字节读取并赋值,虽然代码长一点,但跨平台更稳。

灰度化:从彩色到黑白

彩色图像每个像素有B、G、R三个分量(注意顺序是BGR不是RGB)。灰度化就是把这仨值合并成一个亮度值。最常用的公式是:

Gray = 0.299 * R + 0.587 * G + 0.114 * B

为什么权重不一样?因为人眼对绿色最敏感,对蓝色最不敏感。我在项目中试过直接用平均值(R+G+B)/3,结果图像看起来灰蒙蒙的,细节丢失严重。后来老老实实用了加权公式。

uint8_t rgbToGray(uint8_t b, uint8_t g, uint8_t r) {
    return static_cast<uint8_t>(0.299f * r + 0.587f * g + 0.114f * b);
}

灰度化后的图像,每个像素只占1字节,所以输出文件要改成8位BMP(带调色板)。调色板就是256个RGBQUAD,每个颜色索引对应一个灰度值。

我曾经踩过的坑:8位BMP的每行像素数据必须4字节对齐。如果宽度不是4的倍数,需要在每行末尾补0。忘记补0的话,图像会歪掉,而且有些看图软件直接打不开。

均值滤波:最简单的去噪

均值滤波,说白了就是「周围像素取平均」。用一个3x3的窗口滑过整张图,每个像素的新值等于它周围9个像素的平均值。效果是图像变模糊了,但噪点也被抹平了。

你想想看,为什么是3x3而不是5x5?窗口越大,模糊越严重,计算量也越大。3x3是性价比最高的选择。

void meanFilter3x3(const uint8_t* src, uint8_t* dst, int width, int height) {
    for (int y = 1; y < height - 1; ++y) {
        for (int x = 1; x < width - 1; ++x) {
            int sum = 0;
            for (int dy = -1; dy <= 1; ++dy) {
                for (int dx = -1; dx <= 1; ++dx) {
                    sum += src[(y + dy) * width + (x + dx)];
                }
            }
            dst[y * width + x] = sum / 9;
        }
    }
    // 边界像素不做处理,直接复制
    // 实际项目中可以镜像或补0
}

注意:滤波时要用两个缓冲区——一个读一个写。如果原地修改,后面的像素会读到已经滤波过的值,结果就错了。我见过有人犯这个错,图像出现奇怪的条纹。

完整流程:从文件到文件

我们把上面三块串起来,写一个完整的处理流程。核心逻辑用SVG画出来,方便你理解:

输入BMP文件 24位彩色 解析文件头 读取像素数据 灰度化 BGR → Gray 均值滤波 3x3窗口 输出BMP文件 8位灰度图 图像处理流程 输入24位BMP → 灰度化 → 滤波 → 输出8位BMP

流程看起来简单,但每一步都有细节。我建议你先把读取和写入封装成独立函数,调试时先确保「读进来再写出去」图像不变,然后再加灰度化和滤波逻辑。

代码骨架:主函数长这样

int main() {
    // 1. 读取BMP
    int width, height;
    uint8_t* bgrData = readBMP24("input.bmp", &width, &height);
    if (!bgrData) { /* 错误处理 */ }

    // 2. 灰度化
    uint8_t* grayData = new uint8_t[width * height];
    for (int i = 0; i < width * height; ++i) {
        int offset = i * 3;
        grayData[i] = rgbToGray(bgrData[offset], bgrData[offset+1], bgrData[offset+2]);
    }

    // 3. 滤波
    uint8_t* filteredData = new uint8_t[width * height];
    meanFilter3x3(grayData, filteredData, width, height);

    // 4. 保存为8位BMP
    writeBMP8("output.bmp", filteredData, width, height);

    // 5. 清理
    delete[] bgrData;
    delete[] grayData;
    delete[] filteredData;
    return 0;
}

我的建议:写BMP读写函数时,一定要做边界检查。比如文件大小是否小于文件头长度、位深度是否支持等。我曾经在项目里遇到一个损坏的BMP文件,直接导致程序崩溃,后来加了校验才稳定下来。

性能优化小贴士

优化点 做法 效果
避免浮点运算 用整数近似:Gray = (77*R + 150*G + 29*B) >> 8 速度提升约30%
循环展开 一次处理4个像素,利用SIMD思想 大图效果明显
减少内存分配 复用缓冲区,避免频繁new/delete 减少碎片和开销
边界处理 单独写边界循环,内部用连续内存访问 缓存友好

说实话,对于一张1920x1080的图,纯C++单线程处理也就几十毫秒。除非你要做视频流实时处理,否则不用太纠结性能。先把功能做对,再考虑优化。

总结一下

这一章我们做了三件事:BMP读写、灰度化、均值滤波。看起来简单,但这是图像处理的基石。你想想看,后面要做的边缘检测、直方图均衡、形态学操作,都是在这个基础上叠加的。

嗯,最后提醒一句:写图像处理代码,一定要准备测试图。随便找一张照片转成BMP就行,别用纯色图测试滤波,看不出效果。我一般用一张带噪点的灰度渐变图,一眼就能看出滤波有没有生效。


公众号:蓝海资料掘金营,微信deep3321