HotPDF 提供 THPDFBuiltInOCREngine,這是完全以 Object Pascal 撰寫的有界樣板比對 OCR 引擎:它用 Otsu 閾值法二值化渲染頁面,將字形擷取為連通元件,再根據灰階覆蓋率與快取的多字型樣板比較每個字形,因此 Delphi 應用程式不需要外部 OCR 相依性就能建立可搜尋文字層。這個引擎在 v2.731.0 中不得不從頭重建,原因不在比對器,而在像素
舊引擎通過了測試。它能在合成點陣圖上辨識大寫 ASCII,在 Win32 上也持續運作了數月。後來同一份程式碼在 Win64 下執行,卻完全沒有輸出:沒有單字,除了「found no high-contrast foreground」之外沒有診斷,也沒有崩潰。最後發現,像素讀取路徑中有兩個彼此獨立的錯誤,它們此前剛好互相抵消;拆開這兩個錯誤,正好說明了 OCR 程式碼為何常常靜默失敗而不是大聲報錯
舊 OCR 引擎為什麼只是碰巧能工作
舊引擎之所以能工作,是因為樣板點陣圖和目標點陣圖以相同方式翻轉,所以像素讀取器中的垂直倒置對比對器不可見。TBitmap.ScanLine 回傳的行順序與其餘影像路徑所假定的正 biHeight DIB 慣例相反。把 M 倒置後再與同樣倒置的樣板比較,L1 差異與正確比較完全相同。每個字形都能匹配,但沒有任何一處是正確的
這種對稱性正是這類錯誤代價高昂的原因。只修正目標讀取而不修樣板,辨識就會坍縮成雜訊;先修正樣板也會從另一個方向得到同樣結果。這裡不存在漸進修復路徑。因此重建工作用 GetDIBits 完整替換了讀取過程,並明確宣告 BITMAPINFOHEADER;按照契約,正的 biHeight 表示自底向上的行,然後在複製到灰階緩衝區時有意只翻轉一次
第二個錯誤只有 Win64 暴露出來。傳給 GetDIBits 的 HDC 不能是點陣圖自己的記憶體 DC,因為點陣圖已經選入其中,而 Windows 將這種用法定義為無效。傳入 Bitmap.Canvas.Handle 在 Win32 程序中被容忍,在 Win64 測試程序中則始終失敗。修復方案是從 GetDC(0) 取得暫時的螢幕 DC,並在 finally 區塊中釋放它;它與任何點陣圖都沒有關係
procedure BitmapToGray(Bitmap: TBitmap; out Gray: TBytes);
var
Work: TBitmap;
Info: TBitmapInfo;
Buffer: TBytes;
DC: HDC;
P: PByte;
Stride, X, Y: Integer;
begin
Work := TBitmap.Create;
try
Work.Assign(Bitmap);
Work.PixelFormat := pf24bit;
Stride := ((Work.Width * 24 + 31) div 32) * 4;
SetLength(Buffer, Stride * Work.Height);
FillChar(Info, SizeOf(Info), 0);
Info.bmiHeader.biSize := SizeOf(BITMAPINFOHEADER);
Info.bmiHeader.biWidth := Work.Width;
Info.bmiHeader.biHeight := Work.Height; // 正值 => 自底向上的行
Info.bmiHeader.biPlanes := 1;
Info.bmiHeader.biBitCount := 24;
Info.bmiHeader.biCompression := BI_RGB;
DC := GetDC(0); // 絕不使用 Work.Canvas.Handle:Work 已選入其中
if DC = 0 then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
try
if GetDIBits(DC, Work.Handle, 0, Work.Height,
@Buffer[0], Info, DIB_RGB_COLORS) <> Work.Height then
raise EInvalidOperation.Create('Recognition bitmap pixels could not be read');
finally
ReleaseDC(0, DC);
end;
SetLength(Gray, Work.Width * Work.Height);
for Y := 0 to Work.Height - 1 do
begin
P := @Buffer[(Work.Height - 1 - Y) * Stride]; // 只在這裡有意翻轉一次
for X := 0 to Work.Width - 1 do
Gray[Y * Work.Width + X] :=
(Integer(P[X * 3]) * 29 + Integer(P[X * 3 + 1]) * 150 +
Integer(P[X * 3 + 2]) * 77) shr 8;
end;
finally
Work.Free;
end;
end;
二值化和連通元件:從灰階像素到字形框
HotPDF 首先使用 Otsu 方法進行二值化,只有在 Otsu 不適用時才回退到區域視窗閾值。全域路徑要求直方圖真正呈雙峰分布:引擎計算類間變異數最大值,並額外要求灰階範圍至少跨越 64 個級別,之後才信任結果。褪色的掃描件、帶漸層背景的頁面,或幾乎全是墨跡的點陣圖都會無法通過這個測試。回退路徑會將每個像素與 31×31 視窗的平均值比較,並減去 6 個灰階級別的偏移;它使用執行中的欄總和,因此滑動視窗的複雜度仍然與像素數量呈線性關係
字形擷取是在產生的遮罩上進行 8 連通元件標記,並明確使用堆疊而不是遞迴,因為整頁遮罩很容易在深度泛洪填充時耗盡 Delphi 執行緒堆疊。標記階段會執行兩個過濾器:小於 9 個像素的元件作為斑點雜訊丟棄,同時橫向和縱向都超過影像五分之三的元件作為邊框或線條丟棄,而不是作為字形。第二遍會合併垂直堆疊的框,只要它們的水平重疊至少達到較窄框的四分之一,這樣 i 或 j 的點就能重新與直桿合在一起。所有這些處理都作用於光柵,而光柵來自Delphi 中把已載入 PDF 頁面渲染為點陣圖所描述的同一個渲染器;這在實務上很重要:OCR 品質的上限由渲染品質決定,預設的 300 DPI 文字層是有意的取捨,而不是最大值
大寫 I 和小寫 l 為什麼無法僅憑形狀判定
在 Arial 中,大寫 I 和小寫 l 會點陣化成像素完全相同的直條,因此任何形狀特徵都無法將二者分開,大小寫只能從完全不同的地方推斷。引擎採用行層級高度分群。它先按垂直重疊把字形框分組為文字行,再分析每一行的帽高和基線眾數,並把行內高度拆成短群和高群。處於短群的直條是 l,同一個直條處於高群時就是 I
這種拆分最直觀的實作是固定比例閾值,但它並不可行。Arial 的 x 高與帽高比例約為 0.72,正好落在大家最先會嘗試的 0.70 和 0.75 之間。常數向任一方向移動百分之一,整個語料庫的大小寫就會翻轉。HotPDF 改用一維 k=2 變異數最小化拆分:將候選高度排序,嘗試每個切分點,保留群內離差平方和最小的切分點。於是閾值成為頁面屬性,而不是原始碼中的常數
// ClusterHeights 已按升序排列,尋找變異數最小的 k=2 切分
BestSplit := 1;
BestVariance := 1E18;
for I := 1 to ClusterCount - 1 do
begin
SumA := 0;
for J := 0 to I - 1 do SumA := SumA + ClusterHeights[J];
SumB := 0;
for J := I to ClusterCount - 1 do SumB := SumB + ClusterHeights[J];
MeanA := SumA / I;
MeanB := SumB / (ClusterCount - I);
Variance := 0;
for J := 0 to I - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanA);
for J := I to ClusterCount - 1 do
Variance := Variance + Sqr(ClusterHeights[J] - MeanB);
if Variance < BestVariance then
begin
BestVariance := Variance;
BestSplit := I;
end;
end;
// 只有兩個群組平均值的比例決定哪個是短高度帶
if SmallMean / TallMean <= 0.80 then
SmallGroup := ggSmall // 真正的 x 高帶:小寫形狀
else
SmallGroup := ggTall; // 只有一個高度帶:全部是帽高
Line.LowercaseContext := (SmallGroup = ggSmall);
只有一個高度帶的行不包含任何內部證據。全大寫標題和全小寫說明單獨看起來完全一樣。對於這些行,HotPDF 會將行的中位高度與頁面層級中位 x 高比較,頁面層級中位 x 高來自確實完成拆分的行:比例不超過 1.10 時將該行標記為小寫上下文,不低於 1.18 時標記為大寫上下文,介於兩者之間則不作限制。匹配階段隨後會向符合上下文的候選增加 0.03 的小型大小寫偏好加分,只會推動平手結果,不會覆蓋明確的形狀差異
為什麼 12×18 樣板網格會混淆 c 和 o
樣板網格從 12×18 個單元擴大到 16×24 個,是因為在較低解析度下,c 和 o 的灰階覆蓋率間隔跌到 0.007 以下,遠低於引擎的歧義閾值。每個字形框會被重新取樣到網格中,覆蓋值為 0 到 255,而不是二值樣板,因此一個三分之一被墨跡覆蓋的單元讀作大約 85,而不會被捨入成黑或白。在 12×18 網格中,c 的開口側只略寬於一個單元欄,抗鋸齒平均會把間隙沖掉。在 16×24 網格中,重新取樣後間隙得以保留,大多數容易混淆的字元對又回到了安全距離
評分是兩個覆蓋網格之間的正規化 L1 距離,加上 0.30 倍的寬高比差異對數和 0.16 倍的墨跡密度差異;另外還有一個硬式預過濾器,會跳過寬高比相差超過 2.6 倍的樣板。樣板會針對五種系統字型(Arial、Times New Roman、Courier New、Tahoma 和 Segoe UI)的 62 字元字母表在每個程序中點陣化一次,緩存在臨界區之後,供後續每次呼叫重用
最後一個常數很有意思。當亞軍字元的分數與勝者相差不超過 0.018 時,HotPDF 會把字形信心度限制為 0.5,低於 0.55 的接受門檻,於是該字形根本不會輸出。這是有意的失敗關閉策略,而不是調參痕跡:一個會猜測的有界引擎會產生與影像不一致的可搜尋層,而文字層中的錯誤單字比缺失單字更糟,因為稽核掃描件的人看不見它
不使用固定間距閾值拆分單字
HotPDF 根據每行字形間距的分布推導單字空格閾值,而不是使用平均字形寬度的固定倍數。經典啟發式「間距大於平均前進寬度的 0.75 就是空格」在一行混合數字和窄字母時立即失效,因為平均前進寬度不再代表任何真實量。引擎會對該行間距排序,並尋找連續排序值之間最大的跳躍;如果存在明顯邊界,它就是詞內群和詞間群的分界。三個保護條件可以阻止雜訊觸發:跳躍至少要達到平均字形寬度的 0.22,切分點以上的第一個間距至少要達到其 0.32,切分點以下的最後一個間距不得超過其 0.65。如果任一保護條件失敗,閾值保持為 MaxInt,整行成為一個單字。最後一個保護條件能阻止一個異常寬的字距對把單字拆成兩半,這種錯誤比把兩個單字合併更有害,因為合併後的詞仍然包含順序正確的字元,子字串搜尋仍能找到它
在掃描影像上寫入不可見文字層
ApplyLoadedOCRTextLayer 使用文字渲染模式 3,也就是 ISO 32000-1 第 9.3.6 節定義的既不填滿也不描邊模式,將辨識出的單字繪製成位於原始掃描影像之上的可搜尋層。內容串流以 BT 開始,接著是 3 Tr;每個單字都使用由回報的基線、按要求 DPI 從像素換算出的帽高,以及將合成字形字串拉伸到測量單字寬度的水平縮放共同建立文字矩陣定位。結果可以複製和搜尋,但不會繪製任何內容
有一個不需要引擎的多載,會替呼叫方實例化內建辨識器,這也是大多數呼叫內建路徑時應該使用的多載。辨識、Unicode 驗證、預算核算和內容建構都會在寫入時複製交易開啟之前完成,因此取消、預算超限或引擎失敗都不會改變物件圖和版本號。單字會經過兩次過濾:引擎先丟棄低於自身 0.55 字形信心度門檻的內容,然後 THPDFOCRTextLayerOptions.MinimumConfidence(預設值 0.5)再丟棄低於呼叫方門檻的整個單字
var
Doc: THotPDF;
Options: THPDFOCRTextLayerOptions;
Info: THPDFOCRTextLayerInfo;
begin
Doc := THotPDF.Create(nil);
try
Doc.AutoLaunch := False;
if Doc.LoadFromFile('scan.pdf') < 1 then
Exit;
Options := THPDFOCRTextLayerOptions.Default; // DPI 300,MinimumConfidence 0.5
Options.SkipPagesWithText := True; // 保留原生文字頁面不變
Options.UseOptionalContentGroup := True;
Options.OptionalContentGroupName := 'OCR Text Layer';
// 無引擎多載:HotPDF 提供內建的有界辨識器
if Doc.ApplyLoadedOCRTextLayer([0], Options, Info) then
begin
Writeln(Info.AcceptedWordCount, ' words accepted by ',
string(Info.EngineName));
Doc.SaveLoadedDocument('scan-searchable.pdf');
end
else
Writeln('No text layer written: ', string(Info.Diagnostic));
finally
Doc.Free;
end;
end;
有一項限制值得直接說清楚,而不是等到之後才發現。不可見層使用共用的合成未嵌入 Type0 字型,這足以讓所有檢視器進行搜尋和複製,卻不符合 ISO 19005 的字型嵌入要求。如果輸出必須是 PDF/A,呼叫方就必須另外嵌入符合要求的字型。OCR 文字層還只攜帶幾何資訊,不攜帶結構,因此閱讀順序完全來自字形位置;如果需要從已經帶有真實文字的頁面取得邏輯順序,由標籤樹驅動的結構順序文字擷取是另一種工具,用來解決另一類問題
內建引擎的邊界
內建引擎的範圍有意保持狹窄,了解邊界才能讓它真正有用。它面向高對比度、機器列印、接近五種樣板字型的 ASCII 文字,超出範圍時會回傳無單字,而不是猜測。具體邊界如下
- 影像最大為 4096×4096 和 4,194,304 個像素,辨識期限為 2000 ms,並透過
THPDFCancellationToken協作式取消 - 包含 ASCII 字母和數字的 62 字元字母表;不支援標點、重音字元或 CJK
- 只支援軸對齊文字,使用渲染器已經正規化的頁面旋轉;不會對傾斜掃描件去傾斜
- 有歧義的字形對保持未解析,因此頁面可能回傳部分單字,或回傳診斷「found no unambiguous ASCII words」
當這個範圍太小時,接縫就是 IHPDFOCREngine。針對自己的引擎實作 Recognize,將其交給三參數的 ApplyLoadedOCRTextLayer 多載,後續所有功能(座標映射、旋轉處理、Unicode 驗證、預算和原子提交)都會保持不變。點陣圖在同步呼叫期間由呼叫方借用,不得保留。要確認文字層正確落盤,可以重新載入儲存的檔案,然後執行Delphi 中從已載入 PDF 擷取文字所描述的普通文字路徑;如果能取回這些單字,文字層就是真實存在的
內建樣板比對 OCR、不可見文字層、為它們提供點陣圖的頁面渲染器,以及用於驗證結果的已載入文件文字擷取,都屬於同一個原生 VCL 元件,不需要外部 OCR 執行階段,也不需要隨應用程式部署 DLL。如果你在 Delphi 或 C++Builder 中建構文件收集、歸檔或掃描 PDF 搜尋,HotPDF Delphi PDF 元件可以用一個相依性提供完整流程