实战:图像处理(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画出来,方便你理解:
流程看起来简单,但每一步都有细节。我建议你先把读取和写入封装成独立函数,调试时先确保「读进来再写出去」图像不变,然后再加灰度化和滤波逻辑。
代码骨架:主函数长这样
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就行,别用纯色图测试滤波,看不出效果。我一般用一张带噪点的灰度渐变图,一眼就能看出滤波有没有生效。