技術文章

用 HotPDF 解碼 PDF 頁面中的旋轉 QR Code

HotPDF 解碼已載入 PDF 頁面裡旋轉過的 QR 符號,做法是在解碼器內部把取樣出的模組矩陣正規化過全部八個 D4 方位。對線性符號體系有用的外層旋轉重試,對 QR 行不通,而弄懂為什麼,能幫您省下追著一個看似壞掉、其實沒壞的解碼器跑一整天的時間

場景再普通不過。掃描的送貨單以 PDF 進來,每頁帶一個 QR 標籤,操作掃描器的人把整疊紙往進紙槽肯收的方向塞。有的標籤是正的,有的偏了四分之一圈,有幾張是倒的。您呼叫條碼解碼器,一半的頁面解析出來,另一半什麼都沒帶回來,連個錯誤都沒有

為什麼旋轉掃描遮罩永遠救不回旋轉的 QR?

因為 QR 的 finder 圖樣佈局是刻意不對稱的,而整張影像的旋轉只會保存這個不對稱,不會消除它。QR Code 把三個 finder 方塊放在左上、右上與左下角,右下角刻意留空(ISO/IEC 18004:2015 §6.3.3)。那個空角就是方位提示。把頁面點陣圖轉九十度,缺口只是搬到另一個角。平面上不存在任何非平凡的旋轉能把三角佈局映射回它自己,所以只接受標準方位的解碼器,會把每一次嘗試都依序拒收

這件事要緊,因為直覺的修法恰恰是錯的那個。自然的本能是把重試掛在外面:算繪頁面,把遮罩交給解碼器,失敗了就旋轉遮罩,再試 90、180、270 度。對 Code 39,這個策略完全正確,因為線性符號體系有起始與終止圖樣,只要條紋轉成水平,掃描器就找得到。對 QR,這是四次必然的失敗,外加一份「找不到」的報告

把 D4 群用在模組矩陣上

正規化的正確位置在取樣之後,作用在布林模組格上,而不是像素遮罩上。一旦解碼器把符號解析成 nn 的深淺模組矩陣,它就可以列舉正方形的二面體群:四個旋轉乘兩個鏡射,共八個候選方位。對每個候選檢查 finder 三角形,第一個讓三個 finder 落在左上、右上與左下位置的候選,就是真正的方位。從那裡開始,既有管線原樣運行,因為格式資訊位元、之字形資料放置與 Reed-Solomon 糾錯都假設拿到一個標準方位的矩陣,而現在真的拿到了

同一個 HotPDF QR 模組矩陣在 D4 群 0、90、180 與 270 度旋轉下的四個呈現:三個 finder 圖樣在各角之間遷徙,空角跟著一起移動,只有標準方位會把 finder 呈現在左上、右上與左下給解碼器
旋轉像素遮罩除不掉 QR finder 的不對稱,所以 HotPDF 在取樣後的模組矩陣上列舉 D4 方位,保留第一個 finder 落在左上、右上與左下的候選

兩個性質讓這件事很便宜。矩陣跟算繪出來的點陣圖相比非常小,所以八次轉置遠比八次頁面算繪便宜。而且矩陣是取樣器建出的乾淨布林陣列,途中任何變換都不可能引入從未被取樣過的值

版本偵測是一次整除搜尋,不是一次除法

模組數沒辦法靠「取樣寬度除以假設的模組尺寸」推導出來,弄錯這件事是高解析算繪下一個隱蔽的解碼失敗來源。版本 v 的 QR 符號每邊是 4v + 17 個模組,所以版本 1 是 21 個模組,版本 40 是 177。一個量起來 126 像素寬的遮罩,同樣符合「版本 1、每模組六像素」,也同樣符合好幾個模組更小的更高版本。線性除法會挑其中一個,而且通常挑錯

行得通的是對候選版本做整除搜尋。從版本 40 往下走到版本 1,留下模組數能整除取樣寬度、而且每個模組還能分到至少三個像素的候選,取存活版本中最小的那個。三像素的下限擋住了搜尋把一個粗糙符號讀成荒謬密度的解讀,最小版本規則則把剩下的歧義推向掃描器實際上會產出的那個讀法

HotPDF 對 126 像素取樣遮罩上 QR 符號的版本偵測走訪:從版本 40 往下到版本 1,逐一檢驗候選模組數 4v 加 17 是否整除、是否滿足三像素模組下限,最後由存活的最小版本勝出
QR 模組數來自對候選版本的整除搜尋,不是遮罩寬度除以假設模組尺寸,存活的最小版本負責消解歧義
var
  Pdf: THotPDF;
  Options: THPDFBarcodeDecodeOptions;
  Codes: THPDFDecodedBarcodes;
  Info: THPDFBarcodeDecodeInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('delivery-notes.pdf');
    Options := THPDFBarcodeDecodeOptions.Default;
    Options.DPI := 300;
    Options.RotationPolicy := bdrpFallback;
    Options.MinimumConfidence := 0.5;
    Options.MaxResults := 16;
    if Pdf.DecodeLoadedPageBarcodes(0, Options, Codes, Info) then
      for I := 0 to High(Codes) do
        if Codes[I].Symbology = bsyQRCode then
          Writeln(Codes[I].Text, '  at ',
            Format('%.0f', [Codes[I].OrientationDegrees]), ' degrees');
  finally
    Pdf.Free;
  end;
end;

THPDFBarcodeDecodeOptions.Default 回傳的是一個填好預設值的記錄,不是歸零的,這一點要緊,因為 DPI 設零或結果上限設零,都是一種看起來合法、實際上什麼都拿不回來的寫法。RotationPolicy 只管外層重試:bdrpNone 只算繪一次,bdrpFallback 在第一輪失敗後重試其他方位,bdrpAll 無條件把每個方位都算繪一遍。因為 QR 的正規化發生在解碼器內部,三種策略之下 QR 頁面都在第一次嘗試就解析出來。這個策略是為真正需要它的線性符號體系準備的

怎麼證明一個點陣圖變換沒有憑空發明像素?

兩邊都數一數墨水,要求總數相等。旋轉只是像素的排列,僅此而已,所以輸出裡非零格的數量必須等於輸入。當外層重試路徑裡的遮罩旋轉回報進去 4800 個置位格、出來 7439 個,光這一個比對就足以定罪那個變換,一行幾何程式碼都不必讀

肇因平凡,卻值得當成一條規則帶走。用 SetLength 配大小的動態陣列,當它是一個函式結果、走的又是 runtime 不會清空的路徑時,並不保證以歸零狀態送達,於是旋轉從未寫入的格子就帶著之前就在那裡的任意位元組。這些殘留位元組有些非零,而非零就是墨水。修法只有一行:在排列迴圈跑之前先 FillChar(Result[0], N, 0),它背後的紀律更廣:任何回傳遮罩或點陣圖緩衝區的函式,都該明確清空輸出,而不是依賴配置語意

比缺陷本身更有趣的,是它怎麼活過三個版本。QR 把方位處理搬進解碼器之後,QR 就完全不再行使外層遮罩旋轉,那條程式路徑剩下的唯一消費者是 Code 39。共用基礎建設向來就是這樣藏蟲的:一個功能的覆蓋讓某條路徑看起來有被測到,而真正依賴它的那個功能自己卻什麼測試都沒有。新功能不再使用的每一條路徑,都需要一個仍然在用它的測試

用頁面座標把結果讀回來

解碼器產出的每一個幾何值,都表達在嘗試用點陣圖的座標框架裡,而呼叫端要的是 PDF 使用者空間。這道轉換分兩段:先抵銷重試施加的四分之一圈,再抵銷把使用者空間映射到點陣圖的算繪變換。THPDFDecodedBarcode 裡抵達的是使用者空間中一個軸對齊的包圍盒,LeftBottomRightTop 遵循 Y 軸向上的 PDF 慣例,外加一個逆時針的 OrientationDegrees

HotPDF 條碼管線:從算繪出的頁面點陣圖取樣成布林模組矩陣,經 D4 正規化、整除式版本偵測與 Reed-Solomon 解碼,然後是兩段式座標轉換,抵銷重試的四分之一圈與算繪變換,最後由 THPDFDecodedBarcode 在使用者空間公布 Left、Bottom、Right、Top 與 OrientationDegrees
解碼器內部的 QR 正規化讓頁面第一次嘗試就解析出來,兩段式座標轉換則把嘗試點陣圖的結果變成使用者空間的軸對齊包圍盒

第二段轉換的方向弄反了,症狀很賊:文字解得完美無缺,但您為審閱覆疊畫的那個框,落在正確位置的鏡像上。任何要在解碼器之上建審閱介面的人,都該拿一個已知樣本做斷言,把符號刻意放在接近某個頁角的位置,讓 Y 軸翻轉一眼就看得出來。同樣的道理適用於任何跨越算繪邊界的座標,這也是為什麼在解碼器上動工之前,值得先讀懂在 Delphi 把 PDF 頁面算繪成點陣圖

內建解碼器會做什麼、不會做什麼

內建解碼器是一個有邊界、零相依的實作,對自己的極限很誠實,不會默默退化。它認得 Code 39 與 QR,在公布任何資料之前先驗證 BCH 保護的格式位元與遮罩圖樣,也不對受損符號嘗試錯誤復原。如果您的輸入是一張彎曲標籤在不均勻光線下的照片,那是另一個問題類別,需要專門的引擎

// 換上您自己的引擎:實作 IHPDFBarcodeDecoder,
// 傳給解碼器感知的那個多載。頁面算繪、預算、
// 座標映射與去重仍然由 HotPDF 負責
if not Pdf.DecodeLoadedPageBarcodes(PageIndex, MyDecoder, Options,
     Codes, Info) then
  case Info.Status of
    bdsBudgetExceeded:
      Log('raise MaxPixels or lower DPI: ' + string(Info.Diagnostic));
    bdsRenderError:
      Log('page did not render: ' + string(Info.Diagnostic));
    bdsDecoderError:
      Log(string(Info.DecoderName) + ' failed: ' + string(Info.Diagnostic));
  end;

THPDFBarcodeDecodeInfo 是生產管線真正賺到錢的地方。RotationAttemptCountDecoderCallCount 告訴您外層重試到底跑過沒有;ReceivedResultCount 對照 AcceptedResultCount,可以區分「解碼器什麼都沒找到」與「信心度門檻把它找到的東西全數退貨」;RenderedPixelsPeakWorkingBytes,是批次作業開始記憶體抖動時您該畫圖看的兩個數。空的結果集配上 bdsSucceeded,代表頁面真的沒有可讀符號,這與 bdsBudgetExceeded 是兩種完全不同的營運事實

預算欄位值得一次深思熟慮的決定,而不是留預設。MaxPixelsMaxWorkingBytes 存在的理由是 DPI 以平方倍數放大:A4 頁面從 300 DPI 走到 600 DPI,算繪成本與配置尖峰都翻四倍,而一個宣告了巨大頁面框的不可信任輸入,能把一次掃描作業變成一起記憶體耗盡事故。上限設在您最惡劣的合法文件所需要的程度,然後讓 bdsBudgetExceeded 把離群值導向一條更慢、但隔離的路徑

如果您的文件把機器可讀標籤與打算建索引的印刷文字混在一起,條碼解碼器跟 HotPDF 內建的範本比對 OCR一文的辨識引擎是天然一對,同一個故事的產生端則在用 HotPDF 把條碼畫進 PDF。兩者跑在同一套算繪與預算基礎建設上,已經為其中一個設好合理上限的管線,幾乎免費得到另一個

旋轉容錯屬於那種「正常時沒人看見、失效時人人暴怒」的功能,而它的工程教訓可以推廣到 QR 之外:正規化要盡量貼近語意表示,不要留在像素層——那裡的資料還帶著擷取過程的每一次意外。HotPDF 把這一切當成 HotPDF Delphi PDF component 的一部分出貨,與同一批 intake 管線通常需要的算繪、OCR 與頁面分析元件同在