轻量级图片信息解析程序
简介
平时的工作中我经常需要获取图片文件的一些基本信息(宽度、高度、通道数、色深)。因为项目依赖 opencv,以前都是直接用的 opencv 来读入图片后获取这些信息的,opencv 读入图片是读取所有的数据,会影响效率和内存占用,后来改用 stb_image,但是发现它不支持 tif 格式的文件。来回在网上搜索了一些开源的图片解析工具都没有完全符合我的需求,遂打算自己写一个。
需求
程序的需求很简单:
1.只解析文件头中的几个简单信息,不读取像素数据;
2.不依赖任何三方库。
由于不需要解析像素数据,我就不用管诸如解压缩、调色板取色、哈夫曼解码等等复杂操作,实现应该会非常简单,所以不依赖三方库是完全可以做到的。
语言选择
这个功能不是项目必须的模块,没有开发时间的强制要求,大可以一边慢慢查资料一边写代码。
刚好突然想起来五年前转行时,我是跟着 《C Primer Plus》 这本书从写 C 语言代码开始学编程的,敲了几个月 C 代码最后学了一点点 C++,没成想找到工作后一直写 C++,于是我决定用 C 写一下这个功能,找回一下 C 的手感。
开发过程中的问题
我们常见的图片文件都是以二进制形式存储(查了一下资料有一些明文形式的图片格式,但它们不在我的关注范围之内),要解析这些文件必须事先知晓它们的存储布局,找到自己需要解析的数据位置进行解析,在这个过程中我碰到了二进制文件解析中常见的一些问题。
大小端字节序
二进制文件通常是把内存数据直接映射到文件中,所以处理器架构使用的字节序会直接决定文件的字节序。假如我们使用一种字节序的机器存储文件,而使用另一种字节序的机器读取,就必须对读取出来的字节序列进行字节序转换。由于字节序只有大端和小端两种,我们只需要判断当前文件的字节序是否与处理器字节序相同,若不一致就逆转一下数据的字节顺序:
代码
//判断一个 int 的低地址是否存储它的低位字节来确定处理器字节序
inline Endian check_endian(void)
{
int checker = 1;
if (*((char *)&checker) == 1)
return IHR_ENDIAN_LITTLE;
else
return IHR_ENDIAN_BIG;
}
//16位数据的字节序转换
inline void change_endian_16_bit(void *addr)
{
uint16_t u16 = *(uint16_t *)addr;
*(uint16_t *)addr = (u16 << 8 & 0xff00) | (u16 >> 8 & 0xff);
}
//32位数据的字节序转换
inline void change_endian_32_bit(void *addr)
{
uint32_t u32 = *(uint32_t *)addr;
*(uint32_t *)addr =
(u32 << 24 & 0xff000000) |
(u32 << 8 & 0xff0000) |
(u32 >> 8 & 0xff00) |
(u32 >> 24 & 0xff);
}
//64位数据的字节序转换
inline void change_endian_64_bit(void *addr)
{
uint64_t u64 = *(uint64_t *)addr;
*(uint64_t *)addr =
(u64 << 56 & 0xff00000000000000) |
(u64 << 48 & 0xff000000000000) |
(u64 << 40 & 0xff0000000000) |
(u64 << 32 & 0xff00000000) |
(u64 >> 32 & 0xff000000) |
(u64 >> 40 & 0xff0000) |
(u64 >> 48 & 0xff00) |
(u64 >> 56 & 0xff);
}
上述是我自己写的字节序操作代码,听说编译器有内置的高效的字节序操作接口,查了一下不同的编译器名称不一样。因为以这个功能模块的调用次数和来说,不太可能成为性能瓶颈,还是等到以后若有需求再替换吧。
对于图片文件,数据的字节序都是明确规定的,当它们与当前处理器的字节序不一致时就需要对多字节数据进行字节反转。如 bmp 统一使用小端字节序,jpeg、png 统一使用大端字节序,而对于 tif 格式文件,它的字节序是在文件头内指定的,需要根据解析的信息来确定。
字节对齐
C 和 C++ 程序员经常会精心安排结构体的数据成员顺序以消除不必要的 padding 从而节省内存占用,特别是那些需要创建大量实例的结构体,不同的成员组织方式可能带来巨大的内存消耗差异。
在图片解析时,当我们看到某个数据段包含一系列的数据成员时,自然会想到创建一个与之对应的结构体,然后将文件内的数据读取到结构体的内存中,后续可以方便地引用。
有些时候这种方式会导致错误发生,二进制文件为了节省容量,通常不会像内存一样在数据之间插入 padding,而是紧密存储数据的。如果一组数据在内存中和在文件中的布局不统一,直接读取数据到结构体会造成数据解析错位。
举个例子,big tif(tif 格式的大文件扩展格式) 的 DirectoryEntry 规定为 20 字节:
代码
{
uint16_t tag; //offset: 0
uint16_t data_type; //offset: 2
uint64_t count; //offset: 4
uint64_t content; //offset: 12
}
与之对应的结构体由于内存对齐会占用 24 字节:
代码
struct directory_entry
{
uint16_t tag; //offset: 0
uint16_t data_type; //offset: 2
//4 byte padding //offset: 4
uint64_t count; //offset: 8
uint64_t content; //offset: 16
};
我们可以让编译器将结构体紧密 pack 而不插入 padding,但是不同的编译器这个命令写法是不一样的,并且听说禁用内存对齐的数据结构会影响程序运行时效率(虽然对于这个功能模块来说这些性能影响可能并不明显),我并不打算使用这种紧密 pack 的结构体。
那么解析时要么完全不使用结构体读取,要么还是用普通结构体但是以 padding 位置划分后多次读取(上述例子中,先读取 tag 和 data_type 的4字节,再读取 count 和 content 的16字节)。
最终我选择了不使用结构体的方式,而是读取整块数据后使用指针偏移来进行解析。
资料的获取
图片存储格式的知识我以前是毫不了解,所以写这个程序时免不了查阅大量的资料。如果是以前,我大概率会查看各种博客了解一下大致情况然后找到官方文档,参照文档中的明确定义编写代码。
最近两年以来,AI 逐渐取代了搜索引擎,成为了我获取知识的主要途径。特别是那种本来就表达不明白的问题,我可以从含糊的概念开始,不断从 AI 的回答中修正和深入挖掘,这个过程舒适且高效,传统的搜索引擎检索方式很难实现这样的体验。
但是完全信任 AI 得到的结论我认为也是不可取的,所以每次搜索一些专业领域的知识,我都会要求 AI 提供官方文档依据或者它得出结论的信息来源,我会跟进去浏览一下,确认信息可靠再采用,毕竟搜索过程相对 AI 时代之前节省了不少时间,最后花些时间核实也不会让我变得效率低下。正是这个核实环节,让我多次发现 AI 擅长使用令人信服的展现方式展示错误的知识:一个完全错误或者真假混杂的结论,AI 能够以非常确信的口吻回答出来,甚至辅以图表详细说明。有时候当我打开它提供的信息来源时,发现只不过是一篇某野鸡网站上的连语句都没理顺的文章,AI 将这样的垃圾堂而皇之地包装得像是在权威文章上摘抄下来的段落一样呈现给我。
我很喜欢的 kurzgesagt 组织 最近发布的一个 视频。对 AI 时代互联网的未来,他们表达了诸多担忧,通过大量的调查取证和数据分析,他们发现越来越多由 AI 创建的难辨真伪的知识正在快速涌入人类的互联网知识库,互联网信任危机正在不断加剧。
作为普通人,这些宏大的叙事总是没有日常生活的柴米油盐更让我们关注,但它们最终肯定会影响到我们生活的细枝末节,希望最终都能往好的方向发展。
遗留问题
代码里面的解析逻辑都是现学现卖,难免疏漏,而且测试覆盖率比较低,肯定会有一些 bug。比如使用调色板的图片计算色深的逻辑没有仔细研究,可能存在问题;多页 tif 文件,手头弄不到测试数据,是否写的有问题是未知的。
还有一些已知问题,是由于比较懒只考虑普遍情况。比如 jpeg 图片只读取第一个 SOF0 字段来获取信息,听说移动端的 jpeg 图片首个 SOF0 可能存储的是缩略图信息;还有就是如果文件存储的信息出现前后不一致时,直接视为解析错误。
由于 tif 文件分普通格式和 big tif 格式,两种格式流程基本一致,但是细节有区别(主要是解析时使用的数据类型不同),考虑过用宏来生成两份代码,但是需要写几百行的宏,比较丑陋,就直接写了两份重复度极高的代码,如果是用 C++ 编写,可以只写一份模板代码,减少一些重复。
这里吐槽一下 tif 格式,我想它应该是那些设计数据库的人设计的,文件内部的数据存储形式极其灵活,只要你愿意,可以把任何类型的数据塞到一个 tif 文件内。解析程序必须在它的 IFD(Image File Directory) 中遍历,取出每个 IFD 内的 DE(Directory Entry),根据 DE 的 tag 获取解析数据类型,而后再根据数据大小决定是在 DE 内部读取还是根据 DE 的偏移值跳转到文件的另一个位置读取。这仅仅是我解析 tif 文件头时需要的操作,如果要写一个完全的解析器,复杂度会更高。stb_image 的作者 Sean Barrett 就曾多次提到为了维持解析器的轻量简洁,不会增加对更多图片格式的支持(虽然未专门提及,但是 tif 的复杂程度肯定和他的意愿相悖),幸好我不用写这样的一个解析器。
最终代码
目前代码支持解析 jpeg、bmp、tif、png,除 tif 格式组织形式麻烦一点外,其他几个格式只需要极少量的解析代码,最后添加了一层简单的 C++ 封装用于自动内存管理(其实除了多页 tif 外,其他格式无需自动内存释放)。
后续考虑增加更多图片格式的支持。项目代码在 这里。
后续修改
2025.10.31
代码上传后我抽空测试了一下程序,之前提到测试数据覆盖率不足,我这几天想到一个好办法,直接遍历我电脑一个磁盘分区内的所有支持的图片文件喂给我的程序。经过一轮测试下来,确实发现了非常多无法支持的图片文件,并且之前未触及的代码分支也完整覆盖了(比如多页 tif),于是我断断续续修改了代码,解决了一部分问题:
空文件解析 如果文件为空(大小为0字节),程序解析失败但是没有关闭文件,在文件数量巨大的情况下,这个问题就浮现出来了。累积太多未关闭的文件会触发操作系统限制,在 windows 系统上,当我尝试继续打开文件时,会发出 "Too many opened files." 错误信息,致使后续的文件打开操作全部失败。
tif 文件 前面已经抱怨过 tif 的复杂性了,由于一些对文档的误解(或者是编写代码时的疏忽),tif 解析模块测试通过率比较低,甚至发生了崩溃(空指针忘记赋值后使用)。之前说过没有用宏来统一 normal tif 和 big tif 的解析代码,其恶果已经迅速显现:查到一个 bug 后,我需要同时修改两份代码的对应位置,麻烦又容易出错(“重复代码是万恶之源” 在我这里又一次应验)。其实不用宏我也是有一些考量的,主要是宏生成的代码无法直接调试,但是一想到同步修改代码在未来可能带来的痛苦局面,我毅然用一套宏替换了两块重复度极高的代码。调试的话,如果是 gcc 或者 clang, 可以用 -E 输出一份宏展开后的源码文件,复制对应的宏展开代码替换宏,VS2022(17.5 之后版本)有个原位展开宏的功能。宏展开后的代码往往没有适当的换行,我们再调用一下格式化工具就可以得到便于调试的源码,然后在其上进行调试就行了。
前文说的 jpg 文件在 SOF 段解析时偷懒了,在这次测试中也发现不少因此而失败的 jpg 图片,使用二进制查看器检查它们的文件头发现大部分都没有 SOF0 段,图片信息存放在 SOF2 段内,这个问题留到后面有有时间查清资料再解决。
另外我还发现大量挂羊头卖狗肉的图片,比如 3DMAX 软件资源包中的很多贴图,后缀是 png 实际却是 psd,后缀是 bmp 实际是 ico 的。由于这个程序其实是不管文件后缀而是通过文件头信息判断图片类型,而 psd、ico 等格式暂时不支持,后续考虑添加更多格式支持。
tga 支持 查了一下资料我发现 tga 解析很简单,就实现了一下(之前的代码 tga 格式只留了一个占位,没有实现)。测试时发现 3DMAX 的资源包中很多 tga 图片,文件头存储的色深是 8 位,按照我查阅的资料,这样的 tga 是灰度图,其后续的 alpha bits 数值应该是 0,但是这些图片的 alpha bits 都是 8,我的程序将这样的 tga 判断为非法图片从而解析失败。但是我看 PhotoShop 和我常用的 FastStoneImageViewer 都将它们解析为单通道灰度图。这方面的资料可能还需继续完善以支持后续的代码修改。
2025.11.03
jpeg 和 tga 解析修改 jpeg 格式查阅了更多资料,得知缩略图的 SOF 段只可能嵌套在 APP 段内部,而首个顶层 SOF 段存储的信息必定是主图像信息,所以新的实现中解析了首个顶层 SOF0-15(不包括4) 段的信息,目前从测试情况来看,没有发现解析错误的文件了。前面提到的像素位宽为8却有8位 alpha 的 tga 图片虽然没有找到官方文档,但是确实看到一个老旧的网站上提到纹理图片的颜色通道和透明度通道拆分到独立文件进行存储的情况,所以这种 tga 文件我就理解为某个其他图片的透明通道文件,为它添加了特殊处理逻辑,使之能够正常解析。

浙公网安备 33010602011771号