22、序列化与图像处理:BMP、PNG等图像格式的序列化原理

图像格式的序列化,说白了就是把像素数据按照特定规则打包成文件。我做了这么多年嵌入式开发,跟图像格式打过不少交道。从最简单的BMP到复杂的PNG,每种格式都有自己的脾气。今天咱们就聊聊这些格式背后的序列化原理。

BMP格式:最朴素的序列化方式

BMP是Windows下的标准图像格式,也是我最早接触的图像格式。它的序列化方式特别直白——就是文件头+信息头+像素数据。嗯,这里要注意,BMP的像素数据是从下往上存的,也就是左下角第一个像素。

核心要点:BMP的序列化就是按固定结构把数据写进文件。没有压缩,没有编码,纯粹的内存转储。

我曾经在一个工业相机项目里用过BMP格式。当时需要快速保存图像做调试,BMP的简单结构帮了大忙。来看看它的文件结构:

// BMP文件头结构
typedef struct {
    uint16_t bfType;      // 固定为0x4D42 ("BM")
    uint32_t bfSize;      // 文件大小
    uint16_t bfReserved1; // 保留
    uint16_t bfReserved2; // 保留
    uint32_t bfOffBits;   // 像素数据偏移
} __attribute__((packed)) BMP_FILEHEADER;

// BMP信息头结构
typedef struct {
    uint32_t biSize;          // 结构体大小
    int32_t  biWidth;         // 图像宽度
    int32_t  biHeight;        // 图像高度(正数表示从下往上)
    uint16_t biPlanes;        // 颜色平面数,固定为1
    uint16_t biBitCount;      // 每像素位数(24表示RGB)
    uint32_t biCompression;   // 压缩方式(0表示不压缩)
    uint32_t biSizeImage;     // 像素数据大小
    int32_t  biXPelsPerMeter; // 水平分辨率
    int32_t  biYPelsPerMeter; // 垂直分辨率
    uint32_t biClrUsed;       // 使用的颜色数
    uint32_t biClrImportant;  // 重要颜色数
} __attribute__((packed)) BMP_INFOHEADER;

序列化BMP时,我习惯这样处理:

// BMP序列化函数
int bmp_serialize(const char* filename, uint8_t* pixels, 
                  int width, int height, int bpp) {
    BMP_FILEHEADER fh;
    BMP_INFOHEADER ih;
    
    // 计算行对齐(每行必须是4字节对齐)
    int row_size = ((width * bpp / 8 + 3) / 4) * 4;
    int data_size = row_size * height;
    
    // 填充文件头
    fh.bfType = 0x4D42;
    fh.bfSize = sizeof(fh) + sizeof(ih) + data_size;
    fh.bfOffBits = sizeof(fh) + sizeof(ih);
    
    // 填充信息头
    ih.biSize = sizeof(ih);
    ih.biWidth = width;
    ih.biHeight = height;
    ih.biBitCount = bpp;
    ih.biCompression = 0;
    ih.biSizeImage = data_size;
    
    // 写入文件
    FILE* fp = fopen(filename, "wb");
    fwrite(&fh, 1, sizeof(fh), fp);
    fwrite(&ih, 1, sizeof(ih), fp);
    
    // 写入像素数据(注意BGR顺序)
    for (int y = height - 1; y >= 0; y--) {
        for (int x = 0; x < width; x++) {
            int idx = (y * width + x) * 3;
            uint8_t bgr[3] = {pixels[idx+2], pixels[idx+1], pixels[idx]};
            fwrite(bgr, 1, 3, fp);
        }
        // 补齐对齐字节
        uint8_t pad = 0;
        fwrite(&pad, 1, row_size - width * 3, fp);
    }
    
    fclose(fp);
    return 0;
}

避坑指南:我曾经在BMP序列化时忘记处理行对齐,结果图像在Windows上显示错位。BMP要求每行数据必须是4字节对齐,不足的用0填充。这个细节很容易被忽略。

PNG格式:带压缩的序列化

PNG比BMP复杂多了。它用了Deflate压缩算法,还支持透明通道。我个人觉得PNG的序列化设计得很巧妙——用chunk(数据块)的方式组织数据,每个chunk都有类型标识和CRC校验。

为什么会这样设计?因为PNG要考虑网络传输的可靠性。每个chunk独立校验,就算某个chunk损坏,其他chunk还能正常解析。

// PNG chunk结构
typedef struct {
    uint32_t length;      // 数据长度
    char     type[4];     // chunk类型(如IHDR、IDAT、IEND)
    uint8_t* data;        // chunk数据
    uint32_t crc;         // CRC32校验
} PNG_CHUNK;

// PNG关键chunk类型
// IHDR: 图像头信息
// PLTE: 调色板
// IDAT: 图像数据(压缩后)
// IEND: 图像结束标记

PNG的序列化流程是这样的:

  1. 写入PNG文件签名(8字节固定值)
  2. 写入IHDR chunk(图像尺寸、位深、颜色类型等)
  3. 如果有调色板,写入PLTE chunk
  4. 将像素数据经过过滤、压缩后,写入一个或多个IDAT chunk
  5. 写入IEND chunk结束

我记得第一次实现PNG序列化时,被过滤步骤搞晕了。PNG有5种过滤方式,每种都针对不同的图像特征优化。我建议初学者先实现最简单的None过滤(不过滤),等跑通了再优化。

// PNG过滤函数(None模式)
void png_filter_none(uint8_t* row, uint8_t* filtered, int width, int bpp) {
    filtered[0] = 0; // 过滤类型:None
    memcpy(filtered + 1, row, width * bpp / 8);
}

// PNG过滤函数(Sub模式)
void png_filter_sub(uint8_t* row, uint8_t* filtered, int width, int bpp) {
    int bytes_per_pixel = bpp / 8;
    filtered[0] = 1; // 过滤类型:Sub
    
    for (int i = 0; i < width * bytes_per_pixel; i++) {
        if (i < bytes_per_pixel) {
            filtered[i + 1] = row[i];
        } else {
            filtered[i + 1] = row[i] - row[i - bytes_per_pixel];
        }
    }
}

注意:PNG的CRC校验范围是chunk type + chunk data,不包括length字段。这个细节我踩过坑,写出来的PNG文件用标准库打开就报CRC错误。

图像格式序列化的核心差异

特性 BMP PNG
压缩方式 无压缩 Deflate压缩
颜色顺序 BGR RGB
透明通道 不支持 支持(RGBA)
数据组织 连续像素数据 chunk结构
校验方式 CRC32
序列化复杂度

实战:从BMP到PNG的转换

在实际项目中,我经常需要把BMP转成PNG。BMP的序列化简单,适合快速保存;PNG的压缩率高,适合存储和传输。来看看转换的核心逻辑:

// BMP转PNG的核心流程
int bmp_to_png(const char* bmp_file, const char* png_file) {
    // 1. 读取BMP文件
    BMP_FILEHEADER fh;
    BMP_INFOHEADER ih;
    uint8_t* pixels = read_bmp(bmp_file, &fh, &ih);
    
    // 2. 转换为RGB顺序(BMP是BGR)
    int pixel_count = ih.biWidth * ih.biHeight;
    for (int i = 0; i < pixel_count; i++) {
        uint8_t tmp = pixels[i * 3];
        pixels[i * 3] = pixels[i * 3 + 2];
        pixels[i * 3 + 2] = tmp;
    }
    
    // 3. 创建PNG写入器
    png_structp png_ptr = png_create_write_struct(
        PNG_LIBPNG_VER_STRING, NULL, NULL, NULL);
    png_infop info_ptr = png_create_info_struct(png_ptr);
    
    // 4. 设置PNG头信息
    png_set_IHDR(png_ptr, info_ptr, 
                 ih.biWidth, ih.biHeight,
                 8, PNG_COLOR_TYPE_RGB,
                 PNG_INTERLACE_NONE,
                 PNG_COMPRESSION_TYPE_DEFAULT,
                 PNG_FILTER_TYPE_DEFAULT);
    
    // 5. 写入像素数据
    png_write_info(png_ptr, info_ptr);
    for (int y = 0; y < ih.biHeight; y++) {
        png_write_row(png_ptr, 
                      pixels + y * ih.biWidth * 3);
    }
    png_write_end(png_ptr, NULL);
    
    // 6. 清理资源
    png_destroy_write_struct(&png_ptr, &info_ptr);
    free(pixels);
    
    return 0;
}

个人经验:在嵌入式系统里做图像格式转换,内存管理是个大问题。BMP转PNG时,我习惯用流式处理——读一行BMP数据,过滤压缩一行,再写入PNG。这样内存占用只有几KB,而不是整个图像的大小。

图像序列化的性能考量

你想想看,在嵌入式设备上处理图像,性能瓶颈往往在内存拷贝和压缩计算上。我做过一个项目,用STM32F4采集摄像头数据,需要实时保存为PNG。当时遇到两个问题:

  • Deflate压缩太慢,一帧640x480的图像要压缩好几秒
  • 内存不够,没法一次性缓存整帧图像

解决方案是:

  1. 用BMP格式做临时存储,速度快
  2. 后台线程慢慢转成PNG
  3. 用行缓冲方式处理,内存占用控制在4KB以内

说白了,没有最好的格式,只有最适合场景的格式。BMP适合快速读写,PNG适合存储传输。理解它们的序列化原理,你就能根据实际需求做选择。

图像格式序列化核心流程 原始像素数据 RGB/BGR数组 格式选择 BMP / PNG BMP序列化 文件头 + 信息头 BGR像素数据(行对齐) PNG序列化 签名 + IHDR + IDAT + IEND 过滤 → Deflate压缩 → CRC校验 output.bmp output.png BMP:简单快速,适合调试和临时存储 PNG:压缩率高,适合存储和传输

图像序列化说到底就是数据格式转换。BMP像是个老实人,有什么说什么;PNG则像个精明的商人,总想着怎么压缩打包。理解它们的原理,你就能在项目中游刃有余。

总结:图像格式的序列化核心在于数据组织方式和压缩策略。BMP用固定结构,简单可靠;PNG用chunk结构,灵活可扩展。实际开发中,我建议根据应用场景选择:调试阶段用BMP,产品阶段用PNG。


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