23、C++项目实战:图片处理(BMP格式)
说到图片处理,很多人第一反应是 OpenCV、Python 这些。但说实话,真正理解一张图片在内存里长什么样,还得自己动手解析一次 BMP 格式。我当年刚入行时,就是靠手写 BMP 读写器,才彻底搞懂了像素、颜色通道、文件头这些概念。
今天咱们就一起实现一个轻量级的 BMP 图片处理库。不依赖任何第三方库,纯 C++ 标准库搞定。你想想看,这多酷?
BMP 文件结构:一张图在硬盘里怎么躺
BMP 格式其实挺老实的。它不像 JPEG 那样藏着掖着,而是把数据摊开给你看。整个文件分三块:
- 文件头(BITMAPFILEHEADER):14 字节,告诉你这是 BMP 文件,以及像素数据从哪开始
- 信息头(BITMAPINFOHEADER):40 字节,描述宽、高、色深、压缩方式等
- 像素数据:按行排列的 RGB 或 BGRA 值
嗯,这里要注意:BMP 的像素数据是 从下往上 存的。也就是说,文件里第一行像素对应的是图片的最下面一行。我第一次写 BMP 读取时没注意这个,结果图片上下颠倒了,调试了半天才发现。
核心要点:BMP 的像素行是倒序存储的。读取时要么翻转行顺序,要么在显示时从最后一行开始读。
定义数据结构:用结构体对齐文件布局
我个人习惯用 #pragma pack(1) 来强制结构体按 1 字节对齐。为什么?因为 BMP 文件头就是紧凑排列的,编译器默认的对齐方式会插入填充字节,导致结构体大小和文件实际布局对不上。
#pragma pack(1)
struct BMPFileHeader {
uint16_t bfType; // 固定为 0x4D42,即 "BM"
uint32_t bfSize; // 整个文件大小
uint16_t bfReserved1; // 保留,必须为 0
uint16_t bfReserved2; // 保留,必须为 0
uint32_t bfOffBits; // 从文件头到像素数据的偏移量
};
struct BMPInfoHeader {
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; // 重要颜色数
};
#pragma pack()
小技巧:用 static_assert 在编译期检查结构体大小。比如 static_assert(sizeof(BMPFileHeader) == 14);,这样一旦对齐出问题,编译就会报错,而不是运行时崩溃。
读取 BMP:从文件到内存像素矩阵
读取 BMP 的核心步骤其实就三步:
- 读文件头和信息头,验证文件合法性
- 跳到像素数据起始位置
- 按行读取像素,注意行对齐和上下翻转
这里有个坑:BMP 的每行像素数据必须是 4 字节对齐 的。如果一行像素的字节数不是 4 的倍数,文件里会补零。我曾经在解析一个 24 位色深的 1x1 像素图片时,就因为没处理行对齐,读出来的数据全是乱的。
class BMPImage {
public:
bool load(const std::string& filename) {
std::ifstream file(filename, std::ios::binary);
if (!file) return false;
// 读文件头
BMPFileHeader fileHeader;
file.read(reinterpret_cast<char*>(&fileHeader), sizeof(fileHeader));
if (fileHeader.bfType != 0x4D42) return false; // 不是 BMP
// 读信息头
BMPInfoHeader infoHeader;
file.read(reinterpret_cast<char*>(&infoHeader), sizeof(infoHeader));
width_ = infoHeader.biWidth;
height_ = std::abs(infoHeader.biHeight);
channels_ = infoHeader.biBitCount / 8;
// 计算每行实际字节数(含对齐)
int rowSize = ((width_ * channels_ + 3) / 4) * 4;
pixels_.resize(height_, std::vector<uint8_t>(width_ * channels_));
// 跳到像素数据起始位置
file.seekg(fileHeader.bfOffBits, std::ios::beg);
// 逐行读取(BMP 存储顺序是倒的)
std::vector<uint8_t> row(rowSize);
for (int y = height_ - 1; y >= 0; --y) {
file.read(reinterpret_cast<char*>(row.data()), rowSize);
std::memcpy(pixels_[y].data(), row.data(), width_ * channels_);
}
return true;
}
private:
int width_, height_, channels_;
std::vector<std::vector<uint8_t>> pixels_;
};
写入 BMP:把内存数据存回文件
写入比读取简单一些,因为我们可以控制一切。关键点就两个:
- 计算正确的文件大小和偏移量
- 写入时把像素行顺序颠倒回来
bool save(const std::string& filename) {
std::ofstream file(filename, std::ios::binary);
if (!file) return false;
int rowSize = ((width_ * channels_ + 3) / 4) * 4;
int pixelDataSize = rowSize * height_;
int fileSize = sizeof(BMPFileHeader) + sizeof(BMPInfoHeader) + pixelDataSize;
// 写文件头
BMPFileHeader fileHeader = {0x4D42, (uint32_t)fileSize, 0, 0,
sizeof(BMPFileHeader) + sizeof(BMPInfoHeader)};
file.write(reinterpret_cast<char*>(&fileHeader), sizeof(fileHeader));
// 写信息头
BMPInfoHeader infoHeader = {sizeof(BMPInfoHeader), width_, height_, 1,
(uint16_t)(channels_ * 8), 0, (uint32_t)pixelDataSize,
2835, 2835, 0, 0};
file.write(reinterpret_cast<char*>(&infoHeader), sizeof(infoHeader));
// 写像素数据(倒序写入)
std::vector<uint8_t> row(rowSize, 0);
for (int y = height_ - 1; y >= 0; --y) {
std::memcpy(row.data(), pixels_[y].data(), width_ * channels_);
file.write(reinterpret_cast<char*>(row.data()), rowSize);
}
return true;
}
实战:实现灰度化和反色效果
有了读写能力,图片处理就变得很直接了。说白了就是遍历每个像素,修改 RGB 值。
灰度化:用心理学公式 Gray = 0.299*R + 0.587*G + 0.114*B。注意 BMP 里像素顺序是 BGR,不是 RGB。
void toGrayscale() {
for (auto& row : pixels_) {
for (size_t i = 0; i < row.size(); i += channels_) {
uint8_t b = row[i];
uint8_t g = row[i + 1];
uint8_t r = row[i + 2];
uint8_t gray = static_cast<uint8_t>(0.299f * r + 0.587f * g + 0.114f * b);
row[i] = row[i + 1] = row[i + 2] = gray;
}
}
}
反色:每个通道取 255 减去原值。这个效果很酷,而且代码简单到令人发指。
void invert() {
for (auto& row : pixels_) {
for (auto& pixel : row) {
pixel = 255 - pixel;
}
}
}
注意:如果图片是 32 位色深(含 Alpha 通道),反色时通常只处理 RGB,不处理 Alpha。否则透明区域会变得不透明,效果很奇怪。
知识体系总览
下面这张图概括了 BMP 图片处理的核心流程。我建议你把它当作路线图,按顺序实现每个模块。
避坑指南:我踩过的那些坑
做这个项目时,我遇到过几个特别容易翻车的地方,分享给你:
- 文件头校验不严格:有些 BMP 变种会在文件头里塞额外信息。我曾经只检查了
bfType,结果读到一个压缩过的 BMP,解析出来的像素全是花的。建议至少检查biCompression是否为 0。 - 负高度值:BMP 的
biHeight可以是负数,表示像素数据是正序存储的(从上往下)。我一开始没处理这个,导致某些图片读出来是镜像的。 - 调色板模式:8 位色深的 BMP 带调色板,需要额外读取颜色表。这个项目里我们只处理 24/32 位,但如果你遇到 8 位图,记得加上调色板解析逻辑。
调试小技巧:用十六进制编辑器打开一个已知正确的 BMP 文件,对照结构体字段逐个字节验证。我当年就是这么干的,比自己瞎猜快多了。
扩展思考:还能做什么?
这个 BMP 处理库虽然基础,但它是很多高级图像处理的起点。你可以在此基础上实现:
- 图像缩放(最近邻插值或双线性插值)
- 卷积滤波(模糊、锐化、边缘检测)
- 直方图均衡化
- 格式转换(BMP 转 PPM、TGA 等简单格式)
我个人觉得,手写一个 BMP 读写器是每个 C++ 开发者都应该做一遍的练习。它让你真正理解「图片」在计算机里是什么,而不是永远停留在调用库函数的层面。
好了,代码都在上面了。打开你的 IDE,新建一个项目,从 load 函数开始写起吧。遇到问题别慌,对照着文件结构一步步调试,你会发现其实没那么复杂。