轻量级图片信息解析程序

简介

平时的工作中我经常需要获取图片文件的一些基本信息(宽度、高度、通道数、色深)。因为项目依赖 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_ENDIAN;
	else
		return IHR_ENDIAN_BIG_ENDIAN;
}

//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 外,其他格式无需自动内存释放)。

后续考虑增加更多图片格式的支持。项目代码在 这里