輕量級圖片信息解析程序
簡介
平時的工作中我經常需要獲取圖片文件的一些基本信息(寬度、高度、通道數、色深)。因為項目依賴 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 外,其他格式無需自動內存釋放)。
后續考慮增加更多圖片格式的支持。項目代碼在 這里。
后續修改
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 等格式暫時不支持,后續考慮添加更多格式支持。
另外還是 3DMAX,它的資源包中有大量 tga 圖片,文件頭存儲的色深是 8 位,按照我查閱的資料,這樣的 tga 是灰度圖,其后續的 alpha bits 數值應該是 0,但是這些圖片的 alpha bits 都是 8,我的程序將這樣的 tga 判斷為非法圖片從而解析失敗。但是我看 PhotoShop 和我常用的 FastStoneImageViewer 都將它們解析為單通道灰度圖。這方面的資料可能還需繼續完善以支持后續的代碼修改。

浙公網安備 33010602011771號