动态

详情 返回 返回

輕量級圖片信息解析程序 - 动态 详情

簡介

平時的工作中我經常需要獲取圖片文件的一些基本信息(寬度、高度、通道數、色深)。因為項目依賴 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 都將它們解析為單通道灰度圖。這方面的資料可能還需繼續完善以支持後續的代碼修改。

user avatar hlinleanring 头像 ZhongQianwen 头像 donnytab 头像 youqingyouyidedalianmao 头像 hedzr 头像 artificer 头像 fanqiemao 头像 ishare 头像 qiyuxuanangdelvdou 头像 aixiaodewulongcha_ehoerm 头像 thinkerdjx 头像 feixianghelanren 头像
点赞 20 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.