技術文章

HotPDF OCR 文字層偏移:穿越 CropBox 的座標映射

把點陣圖像素映射回頁面時,若走的是 MediaBox、而不是渲染器實際點陣化的那個框——裁到 MediaBox 之內的 CropBox(ISO 32000-1 §14.11.2)——OCR 文字層、條碼邊界與人臉遮蔽框就會在裁切過的 PDF 頁面上漂移。HotPDF 在 v2.770.153 為 ApplyLoadedOCRTextLayer 修掉這問題,v2.770.154 再修 DecodeLoadedPageBarcodes 與 DetectLoadedRedactionFindings

送進來的 bug 回報通常長這樣。一份掃描的合約檔案庫跑完 OCR,輸出可以搜尋,但條款編號的搜尋命中,亮在印出編號的左下方半英吋處。批次裡多數檔案都正常,壞掉的那些全部出自同一台掃描站——它會寫一個 /CropBox 來裁掉平台邊緣。就這麼一個細節,把 OCR 引擎看到的畫面與文字層被放進去的框架分了家;同樣的不匹配也移動條碼邊界,以及更要命的人臉遮蔽框

OCR 文字層為什麼會漂離掃描出來的字?

文字層會漂,是因為管線的兩半對「點陣圖蓋住哪個矩形」意見不合。v2.766.64 起,HotPDF 讓渲染、SVG 匯出、檢視與列印都改為尊重 CropBox:頁面經裁到 MediaBox 之內的 CropBox 顯示——ISO 32000-1 §14.11.2 正是這麼規定的——並新增 GetLoadedPageVisibleBox 回傳這個可視框。辨識功能卻繼續用 GetLoadedPageBox(PageIndex, pbMediaBox, ...) 建裝置到頁面的轉換。點陣圖蓋的是可視框,轉換仍假設 MediaBox,每個辨識出的位置因此偏移了兩框之間的差距

受影響的版本窗口因此很精確。ApplyLoadedOCRTextLayer 從 v2.766.64 到 v2.770.152 放錯文字位置。整頁的 DecodeLoadedPageBarcodes 與 DetectLoadedRedactionFindings 裡的人臉偵測,多錯了一個建置、到 v2.770.153。v2.766.64 之前,渲染器畫的是整個 MediaBox,映射與點陣一致——代價是辨識了檢視器從來不顯示的內容。每個功能的修正同時動了三件事:轉換本身、像素預算估計,以及請求記錄裡交給自訂引擎的頁面框

有幾種情況從頭到尾沒被波及:

  • 沒有 /CropBox、或 CropBox 等於 MediaBox 的頁面,修正前後映射完全相同
  • 設了 HasRegion 的 DecodeLoadedPageBarcodes 只渲染您傳的區域、也經同一個區域映射,所以明確區域解碼始終正確;區域是否在頁面內的檢查則仍然用 MediaBox
  • 樣式比對的遮蔽發現(電子郵件、卡號之類)來自 user space 的文字擷取、不是點陣圖,所以只有人臉偵測的發現會動

三種座標框架,各對應哪些 HotPDF API

HotPDF 裡碰辨識的程式碼要面對三種框架,多數映射 bug 都是把它們其中兩種混著用出來的

  • 點陣圖像素:左上角原點、Y 向下增長,單位是請求 DPI 下的像素。THPDFOCRWord.Left、Top、Right 與 Bottom 在這個框架裡,選用的 baseline 點、自訂 IHPDFBarcodeDecoder 回傳的結果、自訂 IHPDFFaceDetector 給出的框也是
  • 已載入頁面的 PDF user space:左下角原點、Y 向上增長,單位是 points,Bottom < Top。GetLoadedPageBox 與 GetLoadedPageVisibleBox 以這個框架回傳 Left、Bottom、Right、Top;THPDFOCRRequest 的 PageLeft、PageBottom、PageRight 與 PageTop 欄位、THPDFDecodedBarcode 的邊界、THPDFRedactionFinding 的矩形也是
  • HotPDF 頁面繪圖座標:您用來建立新頁面的 API(文字輸出、形狀、條碼、連結、表單欄位)用左上角原點、Y 向下增長。那個框架屬於文件產生,跟上面的已載入文件 API 毫無關係,所以絕不要把已載入頁面的 user space 矩形原樣餵進去

OCR 文字記錄刻意以像素為本:引擎只回報它在影像裡看到的東西,轉換歸 ApplyLoadedOCRTextLayer 管。這種分工只有在轉換用對框時才成立——v2.770.153 修回來的正是這件事

HotPDF 的辨識座標框架示意圖:THPDFOCRWord 框與自訂解碼器使用的左上角原點點陣圖像素、GetLoadedPageBox 與 GetLoadedPageVisibleBox 回傳的左下角原點 PDF user space,以及左上角原點的頁面繪圖 API——後者絕不能原樣收下已載入頁面的矩形
引擎回報像素,因為那是它看到的東西;映射是 HotPDF 的事;把兩種框架混用,正是層與遮蔽框漂移的由來

OCR、條碼與人臉背後的裝置到頁面轉換

HotPDF 用一個仿射矩陣把點陣圖像素映射到頁面,矩陣由五個輸入構成:旋轉、比例 DPI / 72、點陣圖高度,以及渲染框的 Left、Bottom、Right 與 Top。OCR、條碼解碼與人臉偵測共用同一個例程,所以一個框輸入錯了,三個功能以同樣的方式一起壞。未旋轉頁面的頁面到裝置矩陣 [A B C D E F] 是:

  • A = Scale、D = -Scale,其中 Scale = DPI / 72;負的 D 把 user space(Y 向上)翻轉成點陣圖空間(Y 向下)
  • B = C = 0,未旋轉的頁面沒有剪變、軸也沒有互換
  • E = -Left * Scale,把框的左緣移到像素第 0 欄
  • F = BitmapHeight + Bottom * Scale,把框的下緣映射到 y = BitmapHeight——點陣圖的下緣——於是上緣落在第 0 列

像素經該矩陣的逆矩陣映射回頁面。Request.PageRotation 帶著正規化為 0、90、180 或 270 的頁面 /Rotate(不是 90 倍數的值一律當 0),渲染器按 ISO 32000-1 §7.7.3.3 的要求順時針轉頁面。旋轉之下軸會互換、釘在點陣圖原點上的框邊換成另一對。寫成反向公式——S = DPI / 72、x 與 y 為像素、H 為點陣圖高度:

/RotatePage XPage Y映射依賴的框邊
0Left + x / SBottom + (H - y) / SLeft, Bottom
90Left + y / SBottom + x / SLeft, Bottom
180Right - x / SBottom + y / SRight, Bottom
270Right - y / STop - x / SRight, Top

最後一欄解釋了這 bug 在生產環境裡為什麼看起來像隨機發作。只裁頁面頂部的 CropBox 動不到 Left 與 Bottom,正向頁面於是完美無瑕,只有帶 /Rotate 270 的頁面漂移。旋轉還會對調點陣圖的長寬:90 與 270 度時,點陣圖寫 (Top - Bottom) * S 像素寬、(Right - Left) * S 像素高

HotPDF 對頁面旋轉的反向映射表示意圖:/Rotate 0 與 90 時轉換釘住渲染框的 Left 與 Bottom 邊,180 釘 Right 與 Bottom,270 釘 Right 與 Top——混合方向的文件裡,裁切頁面因此每種方向漂往不同一邊
頁面帶著不同 /Rotate 值時,同樣半英吋的裁切看起來像三個不同的 bug,因為每種方向釘住的是不同一對框邊

MediaBox [0 0 612 792] 配 CropBox [36 36 576 756] 會出什麼錯?

四邊各裁半英吋時,未旋轉頁面的文字層若用了 MediaBox,正好落在掃描文字左邊 36 點、下方 36 點的位置。拿 US Letter 頁面來說,CropBox 每邊裁 36 點(0.5 英吋)。可視框是 540 × 720 點,所以在預設 OCR 解析度 300 DPI 下,比例是 300 / 72 ≈ 4.1667,點陣圖 2250 × 3000 像素

假設引擎回報一個詞,像素框 Left 450、Top 600、Right 900、Bottom 660,無 baseline。HotPDF 便把 baseline 放在底邊上方詞高的 20% 處,即像素第 648 列,然後映射起點 (450, 648):

  • 經可視框:x = 36 + 450 / 4.1667 = 144.0、y = 36 + (3000 - 648) / 4.1667 = 600.48,正是這個詞印出來的位置
  • 經 MediaBox:x = 0 + 108.0 = 108.0、y = 0 + 564.48 = 564.48,整整偏移 (-36, -36) 點
HotPDF 在 US Letter 頁面上的 CropBox 漂移解剖圖(MediaBox 0 0 612 792、CropBox 36 36 576 756):渲染器以 300 DPI 點陣化可視框,把詞的像素 450 經 GetLoadedPageVisibleBox 映射得 144.0 與 600.48,MediaBox 轉換卻落在 108.0 與 564.48
點陣圖蓋的是 CropBox,所以任何以 MediaBox 建出來的轉換,都把每個辨識出的詞整整推移一個裁切邊距

把同一頁轉個方向,錯誤的方向也跟著變,因為牽涉的邊不同了。/Rotate 180 時 X 項用 Right,612 取代 576 把層往右推 36 點,Bottom 仍把它往下拉 36 點。/Rotate 270 時 Right 與 Top 都偏大,層於是右移 36 點、又上移 36 點。方向混雜的文件可以同時出現三個方向的漂移——這是此 bug 的可靠指紋。自己手寫、從框推導比例的程式碼,例如 Bitmap.Width / (Right - Left),還會在偏移之上把每個座標拉伸 612 / 540,約 13%

您的哪些 PDF 文件受影響?

只要有一頁的可視框與 MediaBox 不同,這份 PDF 文件就暴露在風險中,而 HotPDF 幾行程式就能告訴您答案。每一頁都比對 GetLoadedPageBox 的 pbMediaBox 與 GetLoadedPageVisibleBox,順便印出 GetLoadedPageRotation,就能照上面的表預測漂移方向。THPDFPageBoundary 另有 pbCropBox、pbBleedBox、pbTrimBox 與 pbArtBox,但 GetLoadedPageBox(pbCropBox) 在沒有 crop box 時後備到 MediaBox、而且不裁剪,所以要比對的對象是可視框

uses
  System.SysUtils, HPDFDoc;

procedure ReportCroppedPages(const FileName: string);
var
  Pdf: THotPDF;
  I: Integer;
  ML, MB, MR, MT, VL, VB, VR, VT, Tmp: Single;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile(FileName) < 1 then
      raise Exception.Create('Cannot load ' + FileName);
    for I := 0 to Pdf.LoadedPageCount - 1 do
    begin
      if not Pdf.GetLoadedPageBox(I, pbMediaBox, ML, MB, MR, MT) then
        Continue;
      // 存放的陣列可能以任意順序列出四個角
      if MR < ML then begin Tmp := ML; ML := MR; MR := Tmp; end;
      if MT < MB then begin Tmp := MB; MB := MT; MT := Tmp; end;
      // 已正規化並裁到 MediaBox 之內
      if not Pdf.GetLoadedPageVisibleBox(I, VL, VB, VR, VT) then
        Continue;
      if (Abs(VL - ML) > 0.01) or (Abs(VB - MB) > 0.01) or
         (Abs(VR - MR) > 0.01) or (Abs(VT - MT) > 0.01) then
        Writeln(Format('Page %d  MediaBox [%g %g %g %g]  visible [%g %g %g %g]  /Rotate %d',
          [I + 1, ML, MB, MR, MT, VL, VB, VR, VT,
           Pdf.GetLoadedPageRotation(I)]));
    end;
  finally
    Pdf.Free;
  end;
end;

像這樣的腳本要留意 GetLoadedPageVisibleBox 的兩個細節。函式失敗時不動它的 out 參數,所以呼叫前先預設一個頁面尺寸是安全寫法。而畸形 CropBox 與 MediaBox 完全不相交時,函式回傳的是 MediaBox、不是空矩形。報告列出頁面、而您部署的建置在 OCR 部分舊於 v2.770.153、或條碼與人臉部分舊於 v2.770.154 的話,升級後請對那些頁面重跑辨識。受影響建置提交的 OCR 層會留在存出的檔案裡,而預設的 SkipPagesWithText 選項會在第二輪跳過那些頁面——除非您關掉它,或先把舊層移除

自訂 IHPDFOCREngine 該怎麼把像素映射回 PDF 空間?

自訂 IHPDFOCREngine 應該以點陣圖像素回傳詞框、把映射交給 HotPDF;只有為了自己的判斷才轉 user space,而且用的要是請求裡的框,絕不是 MediaBox。從 v2.770.153 起,請求的 PageLeft、PageBottom、PageRight 與 PageTop 描述的是渲染的可視框,與 Request.Bitmap 恰好吻合。下面的輔助函式是函式庫轉換的逆運算,包括正向頁面使用實際點陣圖高度這一點,因此與 HotPDF 逐像素一致

uses
  System.SysUtils, System.Math, Vcl.Graphics, HPDFDoc;

// 點陣圖像素(左上角原點、Y 向下)轉 PDF user space
// (左下角原點、Y 向上),經點陣圖渲染時所用的框
procedure HotPixelToPage(Rotation, DPI, BitmapHeight: Integer;
  Left, Bottom, Right, Top: Single; X, Y: Double;
  out PageX, PageY: Double);
var
  S: Double;
begin
  S := DPI / 72.0;
  case Rotation of
    90:  begin PageX := Left + Y / S;  PageY := Bottom + X / S; end;
    180: begin PageX := Right - X / S; PageY := Bottom + Y / S; end;
    270: begin PageX := Right - Y / S; PageY := Top - X / S; end;
  else
    PageX := Left + X / S;
    PageY := Bottom + (BitmapHeight - Y) / S;
  end;
end;

引擎裡會需要 user space 的現實理由通常是區域規則:信頭您永遠不想變得可搜尋的發票,或會把辨識器搞糊塗的印章區。下面這個引擎以 TInterfacedObject 撰寫、生命週期交給參照計數,按詞的中心落在頁面何處過濾,再把倖存者以像素座標原樣回傳。RunRecognizer 代表您自己的辨識器呼叫

type
  TZoneFilterOCREngine = class(TInterfacedObject, IHPDFOCREngine)
  private
    FSkipLeft, FSkipBottom, FSkipRight, FSkipTop: Single;  // user space
    function RunRecognizer(Bitmap: TBitmap; MaxWords: Integer;
      out Words: THPDFOCRWords): boolean;  // 您的辨識器,像素框
  public
    constructor Create(SkipLeft, SkipBottom, SkipRight, SkipTop: Single);
    function GetName: AnsiString;
    function Recognize(const Request: THPDFOCRRequest;
      out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
  end;

function TZoneFilterOCREngine.Recognize(const Request: THPDFOCRRequest;
  out Words: THPDFOCRWords; out Diagnostic: AnsiString): boolean;
var
  Raw: THPDFOCRWords;
  I, Count: Integer;
  CX, CY: Double;
begin
  Diagnostic := '';
  SetLength(Words, 0);
  if not RunRecognizer(Request.Bitmap, Request.MaxWords, Raw) then
  begin
    Diagnostic := 'recognizer failed';
    Exit(False);
  end;
  SetLength(Words, Length(Raw));
  Count := 0;
  for I := 0 to High(Raw) do
  begin
    HotPixelToPage(Request.PageRotation, Request.DPI,
      Request.Bitmap.Height, Request.PageLeft, Request.PageBottom,
      Request.PageRight, Request.PageTop,
      (Raw[I].Left + Raw[I].Right) / 2, (Raw[I].Top + Raw[I].Bottom) / 2,
      CX, CY);
    if (CX >= FSkipLeft) and (CX <= FSkipRight) and
       (CY >= FSkipBottom) and (CY <= FSkipTop) then
      Continue;
    Words[Count] := Raw[I];  // 仍是像素:映射由 HotPDF 自己做
    Inc(Count);
  end;
  SetLength(Words, Count);
  Result := True;
end;

把引擎交給 ApplyLoadedOCRTextLayer(PageIndices, Engine, Options, Info),跟其他引擎一樣。函式庫在信任回傳結果之前會先驗:框跑出點陣圖、Right <= Left 或 Bottom <= Top、或 Confidence 落在 0..1 之外或低於 MinimumConfidence 的詞會被丟棄、計入 Info.DroppedWordCount。回傳超過 MaxWordsPerPage 個詞、或讓累計總數衝過 MaxTotalWords,整個呼叫以預算錯誤失敗,所以引擎裡要守 Request.MaxWords。回傳前不要把詞框轉成 user space;HotPDF 會把那些 point 值當像素,層就會朝點陣圖原點塌縮

映射您自己的偵測器輸出

同一個輔助函式也服務以 RenderLoadedPageToBitmap 為底的自製管線——它跟辨識功能一樣渲染可視框、套 /Rotate。用 GetLoadedPageVisibleBox 讀框,照 HotPDF 的方式正規化旋轉,再映射每個像素框的兩個對角。Y 軸會翻轉,90 與 270 度時軸還會互換,所以映射出來的角沒有固定順序;取映射點的最小與最大即可——HotPDF 建條碼邊界用的也是這招

const
  DPI = 200;
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  VL, VB, VR, VT: Single;
  Rotation: Integer;
  PxL, PxT, PxR, PxB, X1, Y1, X2, Y2: Double;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('scanned-ids.pdf');
    if not Pdf.GetLoadedPageVisibleBox(0, VL, VB, VR, VT) then Exit;
    Rotation := Pdf.GetLoadedPageRotation(0) mod 360;
    if Rotation < 0 then Inc(Rotation, 360);
    if (Rotation <> 90) and (Rotation <> 180) and (Rotation <> 270) then
      Rotation := 0;
    Bmp := Pdf.RenderLoadedPageToBitmap(0, DPI);
    if Bmp = nil then Exit;
    try
      MyDetector(Bmp, PxL, PxT, PxR, PxB);  // 您的程式碼,像素框
      HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
        PxL, PxT, X1, Y1);
      HotPixelToPage(Rotation, DPI, Bmp.Height, VL, VB, VR, VT,
        PxR, PxB, X2, Y2);
      Writeln(Format('User-space box [%.2f %.2f %.2f %.2f]',
        [Min(X1, X2), Min(Y1, Y2), Max(X1, X2), Max(Y1, Y2)]));
    finally
      Bmp.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

旋轉行為在 攤平頁面旋轉而不弄壞頁面框一文裡有更深的討論,消費同一套轉換的條碼解碼管線則見 從 PDF 頁面解碼旋轉的 QR code。您的引擎若是包著外部辨識器,可搜尋 PDF 的 Tesseract OCR 配接器展示了同一介面的行程隔離與取消那一面

速查:CropBox 安全的座標映射

  • 渲染器點陣化的是可視框——裁到 MediaBox 之內的 CropBox(ISO 32000-1 §14.11.2);每次像素到頁面的映射都得用那個框,以 GetLoadedPageVisibleBox 讀取
  • HotPDF v2.770.153 修正 ApplyLoadedOCRTextLayer;v2.770.154 修正整頁 DecodeLoadedPageBarcodes 與 DetectLoadedRedactionFindings 的人臉發現;v2.766.64 起到那些版本之間的建置都受影響
  • THPDFOCRWord 的框是左上角原點的點陣圖像素;GetLoadedPageBox 與 GetLoadedPageVisibleBox 回傳左下角原點的 PDF user space、Bottom < Top
  • 比例是 DPI / 72;從 DPI 推導,絕不用頁面框去除點陣圖寬度
  • /Rotate 決定哪些邊要緊:0 與 90 度看 Left 與 Bottom,180 度看 Right 與 Bottom,270 度看 Right 與 Top
  • OCR 詞以像素回傳、映射交給 HotPDF;只有自己的過濾邏輯才需要轉換
  • 受影響建置處理過的裁切頁面要重跑 OCR,並記得 SkipPagesWithText 會跳過已帶舊層的頁面

這裡用到的辨識功能、頁面框查詢與已載入文件渲染,都隨 Delphi 與 C++Builder 的 HotPDF 元件出貨;授權、試用下載與完整功能清單見 HotPDF Delphi PDF component 頁面