技術文章

在 Delphi 中合併近似圖片以縮小 PDF

PDF Library for Delphi 可透過 DeduplicateSimilarImages,合併 PDF 中看起來相同、但位元組並不完全一致的圖片。精確去重複只會合併字典與編碼負載完全相符的圖片;這項功能則比對圖片實際看起來的樣子,使用感知雜湊搭配兩項像素誤差門檻,並把每一處參照全部重新導向同一個標準物件

最能從中受益的檔案,通常是那些因為意外而變大的檔案。例如一批掃描檔,每一頁都帶有同一個標誌,但各自被不同掃描器重新編碼過;例如一份由多個來源組合而成的報告,每個來源都嵌入了同一張圖表,只是 JPEG 品質略有不同;又例如一份縮圖被兩套不同工具重新產生過的型錄。每種情況下,檔案裡都存在同一張圖片的許多份副本,而精確去重複對這些副本一律無能為力

「夠相似」究竟該怎麼定義

兩張圖片要被合併,必須同時滿足三項門檻,每一項各自捕捉一種不同的差異。感知雜湊距離限制的是結構性差異:一個 64 位元的差異雜湊值,是從解碼後圖片的 64x64 取樣區塊計算而來,允許的漢明距離範圍是 0 到 7。平均色版誤差限制的是整體漂移,這正是重新編碼會產生的效應。單一色版最大誤差限制的是局部性差異,這正是小幅編輯會產生的效應

要求三項條件同時成立,正是讓這次合併站得住腳的關鍵。一張只在某個角落多了浮水印的圖片,平均誤差低、但最大誤差高,因此會被最大誤差這道關卡擋下。一張被重新壓縮過的圖片,則是各處誤差都很小,因此能通過檢查。這正是大小最佳化工具需要區分出來的差別

三道門檻共同把關近似圖片的合併:雜湊距離、平均色版誤差,以及單一色版最大誤差
三道關卡都使用解碼後的像素——一對帶浮水印的圖片會在最大誤差關卡被擋下,一對重新編碼過的圖片則能順利通過
uses
  PDFlibrary;

var
  Lib: TPDFlib;
  Stats: TPDFlibImageSimilarityDedupStats;
  Merged: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('scanned-batch.pdf', '');

    // 雜湊距離 2、平均誤差 2、單一色版誤差 4
    // 選項可要求試跑模式,讓內容不受任何修改
    Merged := Lib.DeduplicateSimilarImages(2, 2, 4, 0, Stats);
    if Merged < 0 then
      raise Exception.Create('deduplication failed');

    Log(Format('%d scanned, %d merged, %d refs repointed, %d bytes duplicated',
      [Stats.ImagesScanned, Stats.ImagesDeduplicated,
       Stats.ReferencesRepointed, Stats.DuplicatePayloadBytes]));
    Log(Format('worst accepted: hash %d, mean %.2f, channel %d',
      [Stats.MaxAcceptedHashDistance, Stats.MaxAcceptedMeanError,
       Stats.MaxAcceptedChannelError]));
    Lib.SaveToFile('scanned-batch-optimised.pdf');
  finally
    Lib.Free;
  end;
end;

語意相同優先於視覺相似

在比對任何像素之前,候選圖片必須先在語意上一致。函式庫會從完整的圖片字典建立一個穩定簽章,排除 /Length、/Filter 與 /DecodeParms,因此尺寸、色彩空間、位元深度、遮罩、/Decode 陣列、內插方式、選用內容成員身分與中繼資料,全部都必須相符

這樣的先後順序,能防止一整類錯誤合併。兩張圖片在 64x64 取樣下可能看起來完全相同,但在頁面上的表現卻不同:一張 /Interpolate 為真、另一張不是,縮放時的平滑處理方式就會不同;而屬於某個選用內容群組的圖片,會在該圖層被隱藏時消失。跨越這些邊界進行合併,會得到一份更小、但是錯誤的文件

只有間接的 Image XObject 會被掃描。內嵌影像是另一種結構,經濟性也不同,而無法解碼的圖片則會被保守地略過並計數,而不是憑信任進行合併——沒有像素證據,就不合併

搜尋如何維持低成本

把每張圖片與其他每張圖片逐一比對,是二次方複雜度,在一份含有數千張圖片的文件上是無法接受的。因此雜湊值會被切成八個 8 位元的區段,每個區段各自建立一份有序索引

剩下的工作交給鴿籠原理完成:若兩個雜湊值最多相差 7 個位元,那麼它們在八個區段中必定至少有一段完全一致。因此逐區段查找候選項,就能在不進行窮舉配對掃描的情況下,找出每一個符合條件的相符項,而公開的上限值 7,正是因為這項保證只在該距離範圍內成立才訂出來的

64 位元差異雜湊值被切成八個 8 位元區段,鴿籠原理保證即使翻轉七個位元,仍必有一個區段維持相符
七個位元在八個區段中翻轉,必定會留下一個區段完好無缺,這讓一次二次方掃描變成八次有序查找

候選項與區段命中結果,都依物件編號與世代編號做確定性排序,物件編號最小的那一個會成為標準物件。這讓輸出結果具備可重現性:對同一份輸入執行兩次最佳化,得到的標準物件選擇會完全相同,這在結果需要被比對或簽署時十分重要

試跑與誠實的報告

由於被合併的圖片本來就不完全相同,這在某種意義上是一種有損最佳化,因此明智的做法是三思而後行。試跑選項會回報哪些圖片會被合併、能回收多少負載大小,卻不實際修改物件圖,這讓批次作業能記錄下這個效果,也讓人可以一次核准門檻設定,而不必逐一檔案核准

報告中有兩點誠實揭露值得一提。可回收的數字,估算的只是重複串流的編碼負載,並未計入字典、間接物件與交互參照的額外開銷,因此實際的檔案大小縮減幅度,會略大於報告數字,而不是更小。此外報告也會揭露實際被接受的最大誤差與比對工作量,讓你能看出這次合併與你設定的門檻究竟有多接近

固定使用 64x64 取樣網格是一種刻意採用的近似做法,它有一項後果值得明白說清楚:即使把門檻設為零,小於一個取樣格的差異仍可能不被察覺。掃描頁面中一個更動過的單字,可能無法在這種取樣下存活下來。當需要位元組層級的證明時,請改用精確去重複路徑——它只合併真正完全相同的資源,完全不做任何視覺判斷

在縮減檔案大小的流程中該擺在哪裡

依風險高低排列各個處理步驟。先執行精確資源去重複,因為它無損且幾乎零成本。接著是字型子集化,同樣無損,且通常是文字密集文件中效果最大的單一手段,涵蓋於PDF 檔案大小最佳化與字型子集化一文。若目標媒介允許,接著是圖片重新壓縮或降低取樣。感知去重複應該排在這些步驟之後,因為它是唯一一個結果取決於主觀判斷的步驟

建議的處理流程順序:從無損的精確去重複與字型子集化開始,接著視情況進行圖片重新壓縮、感知去重複,最後進行驗證
每一步都會增加一些風險,因此無損的收益排在最前面,需要主觀判斷的合併排在最後,再接一次渲染比對檢查

門檻值應依文件類別設定,而不是全站統一。對於每一頁都帶有相同信頭的掃描批次,雜湊距離設為 2 到 3、平均誤差約為 2,就能可靠地合併信頭。對於圖片本身承載資料的文件——圖表、地圖、醫學影像——則應使用零或接近零的門檻,或乾脆略過這個步驟,因為兩張只差一個繪製點的圖表,無論在 64x64 下看起來多相似,本質上仍是兩張不同的圖表

最佳化之後,務必驗證。渲染最佳化前後的一批頁面樣本並互相比對,並確認頁數與圖片數量的變化符合預期。要檢視一份文件實際包含哪些內容,可參考依類別稽核 PDF 檔案大小一文,這也是釐清圖片究竟是不是問題根源最快的方法

感知去重複、精確資源去重複與大小稽核,在 Delphi、C++Builder 與 Free Pascal 中都運作於同一個物件圖;完整功能清單請見PDF Library for Delphi 頁面