简介:这是一份面向C语言开发者和图像处理初学者的实用源码资源,解决PNG、JPEG图片转换为BMP格式以及放大、缩小、旋转等常见处理需求。资源包共243个文件,大小14.15MB,主要包含C/C++源文件、头文件、libjpeg等库依赖、工程配置文件以及大量png/jpg/bmp示例图片,还附有可直接运行的exe和经过编译的obj文件,便于对照学习与调试。围绕解码、编码和位图操作,源码涉及PNG块结构、JPEG的DCT与量化、BMP文件头与行填充、插值缩放及旋转矩阵等知识点,能帮助读者理解图像格式底层原理并掌握用C语言操作像素数据的方法。已有2647人学习下载,适合想深入图像处理或需要完成类似格式转换功能的开发者参考。
1. 图片格式转换的最后一公里:用C语言手写PNG/JPEG转BMP转换器
某些老系统、嵌入式屏幕驱动、图像处理课的大作业,只认BMP这种最直白的格式。图片格式转换在Python里用Pillow确实一行搞定,但在C语言里,你得亲手把PNG的IDAT块解压成像素,把JPEG的DCT系数还原成RGB,再按BMP的字节布局一点一点填进去。这个项目就是用libpng和libjpeg做解码、自己构造BMP文件的原生转换器,输入PNG或JPEG,输出24位BMP。适合想搞懂图片编码底层原理的C开发者,也适合在没有Python、没有GUI的Linux机器上批量转格式的维护场景。
2. 整体架构与库选型:libpng加libjpeg的双解码管线
2.1 转换管线:从输入文件到BMP像素的全流程
整体流程并不复杂,只有四步:读文件、判断格式、调用对应解码器、写BMP。复杂的地方在每一步的细节里,下面把完整链路展开。
主流程用魔数判断而不是扩展名,因为扩展名可以随便改,魔数是文件第一个字节的真实指纹。PNG前8字节固定为89 50 4E 47 0D 0A 1A 0A,JPEG前2字节固定为FF D8。
int convert_image(const char *input, const char *output) { FILE *fp = fopen(input, "rb"); if (!fp) { perror("fopen"); return -1; } unsigned char magic[4]; if (fread(magic, 1, 4, fp) != 4) { fclose(fp); return -1; } fseek(fp, 0, SEEK_SET); /* 回到文件头,让解码器从头读 */ if (magic[0] == 0x89 && magic[1] == 0x50) { /* PNG 魔数 89 50 4E 47 */ return png_to_bmp(fp, output); } else if (magic[0] == 0xFF && magic[1] == 0xD8) { /* JPEG 魔数 FF D8 */ return jpeg_to_bmp(fp, output); } else { fprintf(stderr, "不支持的格式\n"); fclose(fp); return -1; } }这里两个解码函数都接收已经打开的文件指针,解码器内部负责读取并关闭自己的资源。fseek回文件头是关键一步,忘了的话解码器会从第4个字节开始读,直接导致解码失败——这是新手最容易踩的坑之一。参数说明:input和output都是普通路径字符串,返回值 0 表示成功,-1 表示失败。magic数组读4个字节足够区分这两种格式,JPEG只要前两字节即可判定。
2.2 为什么选libpng和libjpeg,而不是手写解码器
PNG不是“未压缩格式”,它不是把像素原样放进去,而是先做行间差分滤波,再用DEFLATE压缩算法压缩整块数据。libpng底层依赖zlib完成解压,同时处理了Adam7隔行扫描、调色板索引、16位深转8位、伽马校正这些繁琐分支。JPEG更是如此,一张照片背后是DCT变换、量化、哈夫曼编码和ZigZag扫描,手写一个能解各种编码表的解码器,复杂度不亚于写一个小型项目。
选用libpng和libjpeg的另一个理由是接口稳定:前者用png_struct/png_info两个句柄贯穿全部API,后者用jpeg_decompress_struct管理解码状态,文档和示例代码都多,遇到问题容易搜到方案。相比之下,BMP没有压缩,文件结构固定,适合自己构造,这也是本项目输出端不依赖任何库的原因。
编译时通过pkg-config获取这两个库的编译参数和链接参数,可以兼容不同发行版的安装路径,也让Makefile里的规则更简洁。链接顺序直接用pkg-config --libs的结果即可,日常用GCC很少踩链接顺序的坑。
2.3 源码目录与文件职责
项目把解码、写入、主流程三件事拆成了独立模块,目录结构如下:
convert-tool/ ├── main.c # 入口:解析命令行参数、调用转换主流程 ├── convert.c # 格式分发主流程,魔数判断 ├── convert.h ├── bmp_writer.c # BMP文件头构造与像素写入 ├── bmp_writer.h ├── png_decoder.c # 封装libpng,输出24位RGB ├── png_decoder.h ├── jpeg_decoder.c # 封装libjpeg,输出24位RGB ├── jpeg_decoder.h └── Makefile # 使用pkg-config自动检测依赖main.c只负责拿到输入输出路径,交给convert_image;convert.c根据魔数分发;两个decoder各自负责把源格式变成“宽、高、RGB像素数组”;bmp_writer只认这三个参数,不关心像素来自PNG还是JPEG。这条单向依赖很干净:改PNG的滤波逻辑不影响BMP写入,后期加GIF解码只需新写一个gif_decoder.c,再加一个魔数分支。设计里刻意让decoder输出“统一RGB24”,这样BMP写入端永远不用处理RGBA、16位深、调色板这类差异,把格式差异收敛在解码器内部。
3. 核心模块拆解:BMP构造、PNG解码与JPEG解码的完整链路
3.1 BMP那点事:文件头、信息头和24位像素布局
BMP文件的结构分三块:14字节的文件头、40字节的信息头、然后是像素数据。用#pragma pack(push, 1)压紧结构体布局,防止成员因对齐产生空洞,导致fwrite后字节偏移错乱。
#pragma pack(push, 1) typedef struct { uint16_t bfType; /* 'BM',小端写入为0x4D42 */ uint32_t bfSize; /* 文件总大小字节数 */ uint16_t bfReserved1; /* 保留字段,0 */ uint16_t bfReserved2; /* 保留字段,0 */ uint32_t bfOffBits; /* 像素数据偏移,这里恒为54 */ } BMPFileHeader; typedef struct { uint32_t biSize; /* 40,信息头自身大小 */ int32_t biWidth; /* 像素宽度 */ int32_t biHeight; /* 高度,正数=自下而上存储 */ uint16_t biPlanes; /* 固定为1 */ uint16_t biBitCount; /* 24(本项目输出) */ uint32_t biCompression; /* 0=BI_RGB,不压缩 */ uint32_t biSizeImage; /* 像素数据字节数 */ int32_t biXPelsPerMeter; /* 水平分辨率,可填0 */ int32_t biYPelsPerMeter; /* 垂直分辨率,可填0 */ uint32_t biClrUsed; /* 0表示使用全部颜色 */ uint32_t biClrImportant; /* 0表示所有颜色重要 */ } BMPInfoHeader; #pragma pack(pop)bfType的写法要注意:小端机器上把'BM'两个字符按uint16_t读出是0x4D42,直接赋值即可。结构体字段逐个填写,别漏掉biSizeImage,有些图像查看器根据它判断像素区长度,填0可能导致文件被截断显示。GCC环境也可以用__attribute__((packed))达到同样效果,#pragma pack在MSVC和GCC下都兼容,跨平台更省心。
像素写入是BMP最容易出问题的地方,三个细节决定成败:第一是BGR顺序而不是RGB,第二是每行字节数必须对齐到4的倍数,第三是行顺序与biHeight的正负有关。项目采用biHeight为正数、自下而上写数据,对应代码里从最后一行往上循环。
int bmp_write_pixels(FILE *fp, const unsigned char *rgb24, int width, int height) { int row_size = ((width * 3 + 3) / 4) * 4; /* 4字节对齐 */ int padding = row_size - width * 3; for (int y = height - 1; y >= 0; y--) { const unsigned char *row = rgb24 + y * width * 3; for (int x = 0; x < width; x++) { fputc(row[x * 3 + 2], fp); /* 先写B */ fputc(row[x * 3 + 1], fp); /* 再写G */ fputc(row[x * 3 + 0], fp); /* 最后写R */ } for (int i = 0; i < padding; i++) { fputc(0, fp); /* 行尾补零对齐 */ } } return 0; }row_size的计算公式((width * 3 + 3) / 4) * 4是把“每像素3字节”的行大小向上取整到4的倍数,width * 3正好是4的倍数时padding为0,否则行尾补0。fputc逐字节写确实比fwrite整块写慢,但在这个工具里解码耗时占大头,BMP写入的开销可以接受;想优化可以改用fwrite写一行缓冲区。参数rgb24是解码器输出的一维数组,width和height决定循环边界,三者必须一致,否则越界读是早晚的事。
3.2 PNG解码:从IHDR到IDAT拿RGBA像素
libpng的用法是固定套路:创建png_struct和png_info,设置错误跳转,初始化I/O,读信息,做格式变换,读像素。
int png_to_bmp(FILE *fp, const char *output) { png_structp png_ptr = png_create_read_struct( PNG_LIBPNG_VER_STRING, NULL, NULL, NULL); png_infop info_ptr = png_create_info_struct(png_ptr); if (!png_ptr || !info_ptr) { return -1; } /* libpng 出错会 longjmp 回 setjmp 现场 */ if (setjmp(png_jmpbuf(png_ptr))) { png_destroy_read_struct(&png_ptr, &info_ptr, NULL); return -1; } png_init_io(png_ptr, fp); png_read_info(png_ptr, info_ptr); int width = png_get_image_width(png_ptr, info_ptr); int height = png_get_image_height(png_ptr, info_ptr); int color_type = png_get_color_type(png_ptr, info_ptr); int bit_depth = png_get_bit_depth(png_ptr, info_ptr); /* 把各种颜色类型统一成 RGBA8,简化后续逻辑 */ if (bit_depth == 16) png_set_strip_16(png_ptr); /* 16位深降为8位 */ if (color_type == PNG_COLOR_TYPE_PALETTE) png_set_palette_to_rgb(png_ptr); /* 调色板索引转RGB */ if (color_type == PNG_COLOR_TYPE_GRAY && bit_depth < 8) png_set_expand_gray_1_2_4_to_8(png_ptr); if (png_get_valid(png_ptr, info_ptr, PNG_INFO_tRNS)) png_set_tRNS_to_alpha(png_ptr); /* 透明色块转Alpha */ png_set_filler(png_ptr, 0xFF, PNG_FILLER_AFTER); /* 补Alpha */ png_read_update_info(png_ptr, info_ptr); png_bytep *rows = malloc(sizeof(png_bytep) * height); size_t rowbytes = png_get_rowbytes(png_ptr, info_ptr); for (int y = 0; y < height; y++) { rows[y] = malloc(rowbytes); } png_read_image(png_ptr, rows); /* 到这里 rows[y] 是 RGBA8,每行 rowbytes 字节 */ /* 转成RGB24后调用bmp_writer,注意跳过Alpha字节 */ return 0; }setjmp那句很重要,libpng内部所有错误都通过longjmp抛回这里,如果没设置,解码失败时程序会直接abort。png_set_filler给没有Alpha通道的RGB图每像素补一个0xFF字节,让所有输入统一成RGBA8——注意它不会给本来就带Alpha的图重复追加,libpng内部会判断颜色类型。png_get_rowbytes必须放在png_read_update_info之后调用,返回的是转换后的行字节数,而不是原始文件的,顺序写反会拿到错误的行宽。
3.3 JPEG解码:YUV到RGB的隐式转换
libjpeg的解码流程和libpng类似但更“结构化”:建压缩信息结构、建错误处理、打开输入、读头、设置输出颜色空间、开始解压、逐行读扫描线。
int jpeg_to_bmp(FILE *fp, const char *output) { struct jpeg_decompress_struct cinfo; struct jpeg_error_mgr jerr; cinfo.err = jpeg_std_error(&jerr); jpeg_create_decompress(&cinfo); jpeg_stdio_src(&cinfo, fp); jpeg_read_header(&cinfo, TRUE); cinfo.out_color_space = JCS_RGB; /* 强制输出RGB三通道 */ jpeg_start_decompress(&cinfo); int width = cinfo.output_width; int height = cinfo.output_height; int channels = cinfo.output_components; unsigned char *rgb24 = malloc(width * height * channels); unsigned char *dst = rgb24; JSAMPARRAY buffer = (*cinfo.mem->alloc_sarray)( (j_common_ptr)&cinfo, JPOOL_IMAGE, width * channels, 1); while (cinfo.output_scanline < height) { jpeg_read_scanlines(&cinfo, buffer, 1); memcpy(dst, buffer[0], width * channels); dst += width * channels; } jpeg_finish_decompress(&cinfo); jpeg_destroy_decompress(&cinfo); /* 到这里 rgb24 是按行连续排列的RGB数据 */ return 0; }cinfo.out_color_space = JCS_RGB这行不要省。JPEG内部本来就是YUV,libjpeg默认会按源文件的色彩空间输出,如果源文件是灰度JPEG,output_components会变成1,后面按3通道memcpy就会越界读。强制JCS_RGB让libjpeg负责把YUV转成RGB,我们拿到的永远是三通道数据。JPEG的行没有对齐要求,每一行正好是width * channels字节,这跟BMP形成鲜明对比。buffer是每次读一行的扫描线缓冲,循环直到output_scanline达到高度。
3.4 内存管理与统一清理路径
两个解码器的像素缓冲区都靠malloc分配,分配失败时,按工具型程序的做法直接报错退出。这里有个细节:如果中途失败,png_ptr和jpeg_decompress都要走各自的销毁函数,而不是直接free结构体,因为它们内部还引用了zlib和libjpeg的全局状态。
static void *xmalloc(size_t size) { void *p = malloc(size); if (!p) { fprintf(stderr, "内存不足\n"); exit(1); } return p; }把malloc包一层的好处是所有失败路径共用一个出口,不用在每段代码里写两遍错误分支。注意这里exit(1)是明确“程序该停了”,如果以后把逻辑改成长驻服务的库函数,更应该返回错误码而不是退出进程。项目定位是命令行工具,exit是合理的选择。像素数据解析完到写BMP之间没有二次拷贝,解码器直接把缓冲区指针传给bmp_write_pixels,写完后统一free,内存峰值约等于一张位图的大小。
4. 编译运行与避坑记录:三条命令跑通,五个高频翻车点
4.1 依赖安装:libpng和libjpeg的编译期注意事项
Debian/Ubuntu系的安装命令是:
sudo apt install libpng-dev libjpeg-dev build-essentialmacOS上则是:
brew install libpng libjpeg常见的坑是某些发行版把库拆成运行时包和开发包,运行时只有.so,编译却需要头文件,少装-dev包会直接报“找不到 png.h”或“未定义引用”,跟你项目代码本身一点关系都没有。所以编译之前先确认头文件存在,比如直接查/usr/include/png.h和/usr/include/jpeglib.h,没有就补装。
4.2 编译命令与命令行参数说明
Makefile里用pkg-config自动注入编译和链接参数:
CC ?= gcc CFLAGS ?= -Wall -O2 CFLAGS += $(shell pkg-config --cflags libpng libjpeg) LDLIBS += $(shell pkg-config --libs libpng libjpeg) TARGET = convert OBJS = main.o convert.o bmp_writer.o png_decoder.o jpeg_decoder.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $@ $(OBJS) $(LDLIBS) %.o: %.c convert.h bmp_writer.h png_decoder.h jpeg_decoder.h $(CC) $(CFLAGS) -c $< clean: rm -f $(OBJS) $(TARGET)生成二进制后,运行方式很直接:
make ./convert demo.png demo.bmp ./convert photo.jpg out.bmp命令行参数就两个位置参数,含义如下:
| 参数 | 含义 | 示例 |
|---|---|---|
| input | 源图片路径,PNG或JPEG | demo.png |
| output | 输出BMP路径 | out.bmp |
入口main.c里检查参数个数并转发,逻辑很短:
int main(int argc, char **argv) { if (argc != 3) { fprintf(stderr, "用法: convert <input.png|jpg> <output.bmp>\n"); return 1; } return convert_image(argv[1], argv[2]); }4.3 高频避坑记录:五个真实踩过的坑
坑一:BMP图片上下颠倒。现象是图片查看器里图像是倒的。原因是BMP规定biHeight为正时像素自下而上存储,按解码顺序从上往下写就颠倒了。解决方法是写像素时从height - 1行向上循环到第0行。
坑二:画面出现七彩斜纹。现象是图像完全错位,像被切成一条条色带。原因是BMP每行字节数必须对齐到4的倍数,没补padding时下一行数据从错误位置开始。解决方法是row_size = ((width * 3 + 3) / 4) * 4算好每行实际字节数,行尾补零。
坑三:红蓝互换。现象是皮肤变成蓝色、天空变成橙色。原因是BMP像素存储是BGR字节序,解码出来是RGB。解决方法是写像素时交换第一个字节和第三个字节,也就是fputc(row[x*3+2])先写B。
坑四:透明PNG转BMP后透明区一团黑。现象是原本透明的地方变成黑色块。原因是24位BMP没有Alpha通道,RGBA的A被简单丢弃后,透明区域的RGB值往往是任意值,常见为0即黑色。解决方法是输出32位BMP保留Alpha通道,或者先把透明像素混合到白色背景上再写。
坑五:灰色JPEG转换时报段错误。现象是程序崩溃或图像扭曲。原因是灰度JPEG的output_components是1,下游按3通道memcpy导致越界。解决方法是cinfo.out_color_space = JCS_RGB强制输出三通道,让libjpeg自己做灰度到RGB的扩展。
4.4 用像素对比验证转换结果
对PNG转BMP来说,转换是无损的,可以用一个小脚本验证像素是否完全一致。临时写Python脚本快速对比两个图像的RMSE:
# 临时验证脚本:计算两个图像文件的RMSE(像素均方根误差) import sys from PIL import Image import math a = Image.open(sys.argv[1]).convert("RGB") b = Image.open(sys.argv[2]).convert("RGB") if a.size != b.size: print("尺寸不一致") sys.exit(1) pa, pb = a.load(), b.load() total = 0 for y in range(a.height): for x in range(a.width): ra, ga, ba = pa[x, y] rb, gb, bb = pb[x, y] total += (ra - rb) ** 2 + (ga - gb) ** 2 + (ba - bb) ** 2 rmse = math.sqrt(total / (a.width * a.height * 3)) print(f"RMSE={rmse:.3f}")说明:源PNG转BMP是无损操作,RMSE应该接近0;JPEG转BMP的RMSE很小但不为0,因为JPEG本身是有损编码,解码结果跟编码前不完全一致。这个脚本只用来验证转换器没犯低级错误——比如BGR写反或者行对齐写错,RMSE会明显跳高到几十上百。
5. 二次开发三板斧:批量转换、图像缩放与性能优化
5.1 加一个批量转换
日常使用中单文件转换不够用,最常见的改造是遍历目录里所有图片。用dirent.h遍历即可:
#include <dirent.h> void batch_convert(const char *dir) { DIR *d = opendir(dir); struct dirent *ent; while ((ent = readdir(d)) != NULL) { const char *name = ent->d_name; if (ends_with(name, ".png") || ends_with(name, ".jpg") || ends_with(name, ".jpeg")) { char out_path[512]; snprintf(out_path, sizeof(out_path), "%s.bmp", name); convert_image(name, out_path); } } closedir(d); }这段代码靠扩展名过滤,对大小写敏感,.PNG不会被匹配,想要健壮就把名字转成小写再比较。
5.2 加图像缩放
BMP是裸像素,缩放特别直观。最近邻采样一个循环搞定:
void scale_nearest(const unsigned char *src, int sw, int sh, unsigned char *dst, int dw, int dh) { for (int y = 0; y < dh; y++) { int sy = (y * sh) / dh; for (int x = 0; x < dw; x++) { int sx = (x * sw) / dw; memcpy(dst + (y * dw + x) * 3, src + (sy * sw + sx) * 3, 3); } } }最近邻快但是锯齿明显,双线性插值效果更好但要多算周围四个像素的加权平均,做缩略图的话最近邻足够。
5.3 性能优化的方向
现在每次转换开两个文件、解码一遍、再写一遍。批量场景下瓶颈在I/O。常见做法是把小尺寸图片整个读进内存再解码,减少fread系统调用次数;另一个方向是用线程池并行处理多张图片,libpng和libjpeg的解码上下文互相独立,天然线程安全,只要注意像素数据分配和写回文件名的冲突就行。
这块BMP转换让我养成一个习惯:凡是碰到带“格式”二字的协议,先翻规范里关于字节序、对齐、行序的原文,再动手写代码。那次红蓝颠倒排查了半个小时的经历至今难忘。这份源码包里的Makefile和四个模块文件都是完整的,拿到后make一条命令就能跑,适合直接作为自己项目的参考。希望这几个经验能帮到你。
本文还有配套的精品资源,点击获取