技術文章

HotPDF 的影像降取樣核心與列印抖動

HotPDF 透過 ImageDownsampleKernel 屬性暴露三種影像降取樣核心,另透過 RenderOutputDither 提供一道獨立的 Floyd-Steinberg 處理。前者決定照片被縮小以滿足檔案大小預算之後的長相,後者決定頁面被降到黑白之後的長相。兩者預設都不開,而且都做成 opt-in,理由相同:它們是真的花時間

把人逼到這裡的壓力大家都熟。60 MB 的掃描合約要出一個拒收 10 MB 以上附件的郵件閘道,或者一批帳單要送到一台傳真式的單色設備,它把每一個灰色像素都判成紙白或碳粉黑。兩個問題都是重取樣問題,也都有一個快但難看的答案,和一個慢但對味的答案

三種核心實際上差在哪裡

THPDFResampleKernel 有三個值,它們在速度與品質曲線上站在真正不同的點。rkHalftone 委交給歷史悠久的 GDI StretchBlt 路徑加 HALFTONE 模式,名字雖然叫半調,做的其實是雙線性等級的濾波:快,對線稿與螢幕截圖夠用,而縮過的照片上那種粗硬邊緣一眼就認得出來。rkBicubic 跑可分離的 Catmull-Rom 核心,rkLanczos3 跑支撐範圍三瓣的可分離視窗化 sinc

兩個可分離核心都以純 Pascal 跑兩個 pass,先水平再垂直,每個目標像素 6 到 12 個 taps。這大約比 GDI 路徑慢一個數量級,也正是 rkHalftone 留在預設位置的原因。對每晚數千頁的批次,差別是排程決策,不是偏好;對一份有使用者在等的單一文件,Lanczos3 幾乎免費,而且看得出來比較好

HotPDF 三種降取樣核心的權重曲線:rkHalftone 委交支撐範圍 1 的雙線性級 GDI HALFTONE 路徑,rkBicubic 跑支撐範圍 2 的可分離 Catmull-Rom 三次曲線,rkLanczos3 跑支撐範圍 3 的視窗化 sinc,大約用一個數量級的速度換肉眼可見的更好照片
三個核心值在速度與品質曲線上站在真正不同的點:雙線性級 GDI 路徑、Catmull-Rom 三次曲線、三瓣視窗化 sinc,而可分離核心會把權重正規化,不讓任何東西振鈴越過黑或白

有兩個實作性質值得知道,因為它們決定輸出能做什麼、不能做什麼。邊界用邊緣複製夾限,不是繞回、也不是淡出;權重按每個目標像素正規化。這兩件事加起來,代表結果永遠不會振鈴到黑以下或白以上,所以經典的 Lanczos 硬邊緣越界光暈,不會以裁切偽影的形式出現在編碼後的影像裡

var
  Pdf: THotPDF;
  Info: THPDFLoadedResourceOptimizationInfo;
  Changed: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('scanned-contract.pdf');
    Pdf.ImageDownsampleKernel := rkLanczos3;   // 在呼叫之前設定
    Changed := Pdf.DownsampleLoadedImages(150, 82, 4096, Info);
    if Changed > 0 then
    begin
      Writeln('resampled images: ', Info.DownsampledImageCount);
      Writeln('kept calibrated : ', Info.PreservedCalibratedImageCount);
      Pdf.SaveToFile('scanned-contract-150dpi.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

MinimumSavingsBytes 參數(上例的 4096)是讓這個操作誠實的防衛。把一張已經壓得很有效率的影像重新編碼,可能產出比原始更大的串流,一個盲目替換每張影像的降取樣器,偶爾會把它受託縮小的檔案反而養大。這個門檻說的是:只有省下的位元組數至少達標,才提交替換。PreservedCalibratedImageCount 回報的是另一個保守決定:因為帶著重取樣會破壞的校準色彩空間而原樣保留的影像

為什麼錯的多項式係數這麼難發現?

因為壞掉的內插核心不會當掉、也不會丟例外,它只是產出一張微妙地不對、而且沒人能歸因的影像。Catmull-Rom 核心是分段三次曲線,它的外側分支寫成巢狀 Horner 形式是 ((-0.5t + 2.5)t - 4)t + 2。把中間那個係數寫成 -5 而不是 -4,函式照樣求值,照樣回傳範圍合理的數字,照樣產出影像

傷害顯現為 W(1) 求出 -1,而它必須是 0。負權重一路累積,總和在零被截斷,看得見的症狀是一條左端發黑的漸層,以及一個失去中間調的階梯邊緣。整個失敗裡沒有任何東西指向多項式。幾秒內抓到它的檢查是算術,不是視覺:內插核心必須滿足 W(0) = 1 與 W(±1) = W(±2) = 0,錯過這三個點的核心就有係數錯誤,沒有例外。把這三個值放進 unit test 斷言,這一整類手誤缺陷就絕跡了

HotPDF bicubic Catmull-Rom 外側分支的函數圖,顯示錯的係數為什麼藏得住:把巢狀 Horner 形式裡的 -4 手誤寫成 -5,函式照樣求值,卻讓 W(1) 停在 -1、W(2) 停在 -2,而這兩處必須為零;斷言 W(0) 等於 1 加上兩個零約束,幾秒就抓到
壞掉的核心從不當機,只是回傳看起來合理的數字,所以肉眼抓不到係數手誤;W(0) = 1 加上正負一與正負二處為零,是三行的 unit test

Floyd-Steinberg 抖動,以及它在管線裡的位置

抖動這道處理與重取樣是不同的問題,住在管線裡不同的位置。RenderOutputDither 在頁面合成之後套用 Floyd-Steinberg 誤差擴散,對單色列印預覽或傳真式匯出而言,這是唯一說得通的位置:這個操作要處理的是把一張完成的點陣圖降到每像素一個位元,跟個別影像進場時怎麼被縮放無關

演算法本身很短。亮度以五成門檻判定,量化誤差用經典的 7/16、3/16、5/16 與 1/16 權重擴散給四個鄰居:右、左下、下、右下。輸出像素在每個通道都是 0 或 255。天真做法給您的則是另一回事:沒有擴散的硬門檻會把照片變成剪影,把承載內容的每一階中間調全部丟掉

HotPDF Floyd-Steinberg 抖動在算繪管線中的位置:RenderOutputDither 在頁面合成之後對完成的 24 位元點陣圖執行,以五成門檻判定亮度,用 7/16、3/16、5/16 與 1/16 權重把每個量化誤差擴散到右側與下方,途中經過一個必須累加的列緩衝區,產出一個位元的單色輸出
抖動屬於合成之後,因為它把完成的點陣圖降到一個位元,跟影像怎麼被縮放無關;擴散權重總和為一,列緩衝區必須累加而不是覆寫
// 給單色預覽裝置的算繪期抖動
Pdf.RenderOutputDither := True;

// 或者把同一道處理套在您已經擁有的 bitmap 上。bitmap 必須
// 是 pf24bit;函式寧可回傳 False 也不亂猜
if not HPDFFloydSteinbergDitherBitmap(Preview) then
  raise Exception.Create('dither expects a 24-bit bitmap');

// 在文件管線之外重取樣時的直接核心存取
Small := HPDFResizeBitmapKernel(Large, 640, 480, rkLanczos3);
try
  Small.SaveToFile('thumb.bmp');
finally
  Small.Free;
end;

誤差擴散裡有一個實作細節,人人都會被咬一次。逐列之間的誤差緩衝區必須累加。下一列的每個像素會收到目前列三個不同像素的貢獻——3/16、5/16 與 1/16 三個 taps——如果程式碼用指派而不是相加,每次寫入都丟掉前一筆貢獻,只有最後一個 tap 活下來。影像看起來還是有抖動,這正是它難被察覺的原因,但紋理是錯的,色調重現會漂移。抓它的測試是定量的:抖動一塊均勻的中灰,要求內部覆蓋率落在四到六成之間

縮小檔案的管線該用哪種組合?

核心要配影像的實際身分,抖動則當成設備問題,不是壓縮問題。必須擠進大小預算的照片掃描,150 或 200 DPI 的 rkLanczos3 保住人眼會注意的細節,同時把像素數砍到四分之一以下。螢幕截圖、圖表與線稿,rkHalftone 真的夠用而且快得多,因為那些影像幾乎沒有需要保存的色調漸層。無法逐一檢視影像的混合批次,rkBicubic 是合理的中間值:比雙線性好,taps 大約是 Lanczos3 的一半

降取樣是幾根槓桿裡的一根,而且不總是最大那根。雙值掃描通常對 Delphi 原生 JBIG2 bilevel 壓縮一文的編碼器反應好得多,那裡的收穫來自符號字典,不是像素數。動手決定之前,先知道檔案裡到底裝了什麼會有幫助,這正是擷取影像與其 decode filters 的用途:一份影像物件與既有壓縮的盤點,會告訴您重取樣還有沒有油水

如果您要建的是展示結果的預覽介面,把 PDF 頁面算繪成點陣圖一文記錄的同一條算繪路徑,就是 RenderOutputDither 生效的地方,抖動過的預覽與抖動過的輸出因此出自同一條程式路徑,而不是兩套漸行漸遠的實作

這兩個功能背後的大原則是:品質設定應該明確、而且可逆。HotPDF 把歷史行為留在預設,讓既有應用程式升級時不會在輸出或耗時上遇到意外變化,然後把更好看、更慢的路徑放在一次屬性指派的距離之外。兩者都是 HotPDF Delphi PDF component 的一部分,與它們所依託的資源最佳化及算繪機制同在