把點陣圖像素映射回頁面時,若走的是 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 修回來的正是這件事
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 為點陣圖高度:
| /Rotate | Page X | Page Y | 映射依賴的框邊 |
|---|---|---|---|
| 0 | Left + x / S | Bottom + (H - y) / S | Left, Bottom |
| 90 | Left + y / S | Bottom + x / S | Left, Bottom |
| 180 | Right - x / S | Bottom + y / S | Right, Bottom |
| 270 | Right - y / S | Top - x / S | Right, Top |
最後一欄解釋了這 bug 在生產環境裡為什麼看起來像隨機發作。只裁頁面頂部的 CropBox 動不到 Left 與 Bottom,正向頁面於是完美無瑕,只有帶 /Rotate 270 的頁面漂移。旋轉還會對調點陣圖的長寬:90 與 270 度時,點陣圖寫 (Top - Bottom) * S 像素寬、(Right - Left) * S 像素高
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) 點
把同一頁轉個方向,錯誤的方向也跟著變,因為牽涉的邊不同了。/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 頁面