技術文章

HotPDF 中文與多語言 OCR:RapidOCR 模型與 CTC 字典

HotPDF 在 Delphi 裡做中文與多語言 OCR,靠的是它的原生 RapidOCR DLL 配接器:THPDFRapidOCRDLLOptions.ForLanguage 把 'zh-CN'、'zh-TW'、'ru' 或 'ar' 這類語言標籤映射到一套匹配的辨識模型與字元字典,THotPDF.ApplyLoadedOCRTextLayer 再把辨識出的行變成掃描 PDF 頁面上隱形、可搜尋的 Unicode 文字層

讓拉丁字母的示範跑起來是簡單的部分。有趣的故障從您切到繁體中文或俄文開始:輸出變成一串言之鑿鑿、格式工整的胡言亂語;或每一行悄悄丟掉最後一個字元;或阿拉伯文頁面回來時文字框順序不對。這些都不會自己丟例外。HotPDF v2.775.0 加入的語言預設,存在的主要目的就是補這些洞;下面四個陷阱值得弄懂,就算您永遠不碰原生程式碼——每一個都對應一種您可能追一天的症狀

ForLanguage 怎麼挑模型與字典?

THPDFRapidOCRDLLOptions.ForLanguage 把標籤解析成九個設定檔之一,回傳指向模型目錄下 <profile>/recognition.onnx 與 <profile>/dictionary.txt 的選項,同時保留共用的偵測器、選用的角度分類器,以及 THPDFRapidOCRDLLOptions.Default 的執行緒、像素與逾時預設。方法會把標籤轉小寫、底線換連字號、修掉前後空白,所以 'zh_TW'、'ZH-tw' 與 ' zh-tw ' 都落在同一個設定檔。別名是一份明確清單、不是前綴匹配:'zh-Hant-TW' 被接受是因為它名列其中,沒列進去的任意地區變體則在任何模型載入之前就丟 EArgumentException

HotPDF 對 THPDFRapidOCRDLLOptions 的 ForLanguage 設定檔解析示意圖:zh_TW、ZH-tw 與 zh-TW 這類標籤經正規化後對上九個明列的設定檔,每個設定檔釘死一組永遠同時設定的辨識模型與字典,沒列出的標籤在任何模型載入前丟 EArgumentException
一個標籤選定一組釘死的模型與字典配對;偵測器、分類器與預算維持共用,不認得的標籤快速失敗、什麼都不載
設定檔語言範例標籤釘死的模型
ch簡體中文與英文zh, zh-CN, zh-Hans, chi_simPP-OCRv4
chinese_cht繁體中文zh-TW, zh-HK, zh-Hant, chi_traPP-OCRv3
en英文en, en-US, en-GB, engPP-OCRv4
latin法文、德文、西班牙文、葡萄牙文、義大利文、荷蘭文、土耳其文fr, de, es-419, pt-BR, trPP-OCRv3
japan日文ja, ja-JP, jpnPP-OCRv4
korean韓文ko, ko-KR, korPP-OCRv4
cyrillic俄文、烏克蘭文、保加利亞文、白俄羅斯文ru, ru-RU, uk, bgPP-OCRv3
arabic阿拉伯文、波斯文、烏爾都文ar, ar-SA, fa, urPP-OCRv4
devanagari印地文、馬拉地文、尼泊爾文hi, mr, nePP-OCRv4

配接器本身什麼都不下載。檔案用隨附的輔助腳本備妥一次即可,例如 tools/Install-RapidOCRModels.ps1 -Destination C:/OCR/models -Language ch,chinese_cht,cyrillic(九個設定檔全要就用 -Language All),腳本會把共用的偵測器與分類器放在 Default 期望的根檔名上。之後,一份簡體中文掃描件幾行程式就變得可搜尋。引擎的管路就是 行程內 RapidOCR DLL 與其 ABI 邊界一文描述的同一個 IHPDFOCREngine 接縫,所以本文聚焦語言

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeChineseScanSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Models: THPDFRapidOCRDLLOptions;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // ch/recognition.onnx + ch/dictionary.txt,共用偵測器與分類器
  Models := THPDFRapidOCRDLLOptions.ForLanguage('zh-CN');
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Layer := THPDFOCRTextLayerOptions.Default;  // 300 DPI、MinimumConfidence 0.5
    // 空頁面清單代表每一頁;已有文字的頁面會跳過
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Layer, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' lines, ', Info.UniqueScalarCount, ' distinct characters');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

那段輸出有兩個細節值得一提。原生管線每個偵測到的文字行回傳一個結果、不是每個詞一個,所以這裡的 AcceptedWordCount 數的是行,MinimumConfidence 對比的也是整行的字元平均信心值:平均 0.45 的行整行丟棄。UniqueScalarCount 回報文字層為了塞進字型與 ToUnicode 表,映射了多少個相異 Unicode 純量——一個很好的 sanity check,確認送進來的真是 CJK 文字、而不是幾個拉丁後備字元。引擎介面要跨文件握著不放,因為模型初始化發生在工廠函式裡,是最貴的那一步

只換辨識模型為什麼會生出亂碼?

CTC 辨識模型從不輸出字元,只輸出類別索引,把索引 1,204 變成字形的唯一東西是字典。把 ch/recognition.onnx 換成 cyrillic/recognition.onnx、卻留著中文字典,模型會高高興興吐出合法的西里爾索引,舊字典把它們譯成隨機的漢字。結果看起來像文字、過得了 UTF-8 驗證,可搜尋到的東西恰好是零。這就是 ForLanguage 永遠一起設定 RecognitionModel 與 CharacterDictionary 的原因,也是手工組選項時絕不該只改其一的原因

最直覺的安全檢查——比對字典大小與模型輸出寬度——必要但不充分。兩份字典可以有相同的條目數、卻是不同的順序,順序差一位,每個字元就平移一個 code point。所以工廠函式初始化模型時分兩階段檢查。第一,輸出類別數必須等於字典條目數加二。第二,ONNX 檔若內嵌 character 中繼資料清單,每個字典條目都按順序跟它比對,不吻合就讓初始化以 EInvalidOperation 加原生診斷失敗,而不是晚點產出像模像樣的亂碼

「加二」來自類別版面。類別 0 是 CTC blank,類別 1 到 N 是按檔案順序排列的字典行,最後一個類別是空格。有些字典自己還帶一個空格條目,那一行必須原樣保留。Trim 的好意在這裡造成真傷害:它把單一空格條目變成空字串,表格隨之平移或損毀。唯一安全的正規化是移掉行尾的回車字元,所以以 CRLF 行尾存的字典載得進來;UTF-8 位元組順序記號、空行、含 tab 的條目則一律拒收。下面用 Pascal 素描這個版面;那是解說用的程式碼,不是 HotPDF 的 API

HotPDF 給 RapidOCR 字典的 CTC 類別表版面示意圖:類別 0 是 blank,類別 1 到 N 是按檔案順序排列的字典行、單獨的空格條目照樣保留,最後一個類別是空格,合計 N 加 2 個輸出類別,工廠函式連同 metadata 一起對著模型驗證
把類別索引變成字元的只有字典,所以它的大小、順序與空格條目,都在第一頁被辨識之前驗完
// 僅供解說:CTC 辨識器期望的類別表
uses
  SysUtils, IOUtils;

function BuildCTCClassTable(const FileName: string): TArray<string>;
var
  Text, Entry: string;
  Lines: TArray<string>;
  I, Last: Integer;
begin
  Text := TEncoding.UTF8.GetString(TFile.ReadAllBytes(FileName));
  if (Text <> '') and (Text[1] = #$FEFF) then
    raise EArgumentException.Create('Dictionary must be UTF-8 without a BOM');
  Lines := Text.Split([#10]);
  Last := High(Lines);
  if (Last >= 0) and (Lines[Last] = '') then
    Dec(Last);                                   // 檔尾的換行
  SetLength(Result, Last + 3);
  Result[0] := '';                               // 類別 0:CTC blank
  for I := 0 to Last do
  begin
    Entry := Lines[I];
    if (Entry <> '') and (Entry[Length(Entry)] = #13) then
      SetLength(Entry, Length(Entry) - 1);       // CRLF:只去掉 CR
    if (Entry = '') or (Pos(#9, Entry) > 0) then
      raise EArgumentException.Create('Invalid dictionary entry');
    Result[I + 1] := Entry;                      // 絕不 Trim:' ' 是一個類別
  end;
  Result[Last + 2] := ' ';                       // 最後一個類別:空格
  // Length(Result) 必須等於模型輸出類別數
end;

Greedy CTC 解碼到底在做什麼?

Greedy CTC 解碼在每個時間步挑分數最高的類別、把連續重複塌縮成一個字元、丟掉 blank 類別;正是 blank 讓真正的疊字得以存活。辨識模型把一行文字看成一段狹窄垂直切片的序列,對每個切片(即時間步)輸出每個類別的機率。含 AA中 的一行,argmax 序列可能是 A A blank A 中 space。前兩個 A 步塌縮成一個 A,blank 把它跟下一個 A 分開,結果是 AA中 ,連行尾空格都完好。沒有 blank 規則,book 與 bok 就無法區分

HotPDF 的 GreedyCTCDecode 逐步解說示意圖:六個時間步投票出 argmax 類別 A、A、blank、A、一個漢字與空格,連續重複塌縮,blank 重置重複防護、讓真正的疊字存活;三種邊界 bug 會悄悄丟掉詞間空格、最後一個字元或疊字
解碼器只有十幾行,每個邊界都要緊:涵蓋最後一個類別、涵蓋最後一個時間步,且只准 blank 分隔重複

解碼器只有十幾行,邊界特別容易寫錯,而且失敗無聲無息。內層 argmax 迴圈少跑一個類別,空格類別就永遠贏不了,每行回來都沒有詞間空格——英文與拉丁頁面的片語搜尋就此報廢。外層迴圈少跑一個時間步,每行的最後一個字元消失,短行可能就此少三分之一。重複防護若不被 blank 重置,ll 這類疊字或 谢谢 這類中文疊詞就塌成一個。HotPDF 的解碼器涵蓋最後一個類別與最後一個時間步、保留以 blank 分隔的重複,另外還拒收非有限值或落在 0 到 1 之外的分數,以及任何與字典不符的類別數。同樣的邏輯以 Pascal 素描如下

// 僅供解說:邊界正確的 greedy CTC 解碼。
// Scores 存 Steps * Classes 個機率,每個時間步一列
function GreedyCTCDecode(const Scores: array of Single;
  Steps, Classes: Integer; const Characters: array of string): string;
var
  Step, C, Best, Previous: Integer;
  BestScore: Single;
begin
  if (Classes < 3) or (Length(Characters) <> Classes) or
    (Length(Scores) <> Steps * Classes) then
    raise EArgumentException.Create('Model output does not match the dictionary');
  Result := '';
  Previous := 0;                            // 類別 0 是 CTC blank
  for Step := 0 to Steps - 1 do             // 涵蓋最後一個時間步
  begin
    Best := 0;
    BestScore := Scores[Step * Classes];
    for C := 1 to Classes - 1 do            // 涵蓋最後一個類別(空格)
      if Scores[Step * Classes + C] > BestScore then
      begin
        Best := C;
        BestScore := Scores[Step * Classes + C];
      end;
    if (Best <> 0) and (Best <> Previous) then
      Result := Result + Characters[Best];
    Previous := Best;                       // blank 會重置重複防護
  end;
end;

Greedy 解碼不是現有最準的 CTC 策略;帶語言模型的 beam search 能救回一些曖昧的切片。對 300 DPI 的印刷文件,greedy 結果通常就是模型能給的全部,解碼器也不是補模型短板的地方。舉例來說,拉丁 PP-OCRv3 模型在乾淨輸入上也可能把 ñ 讀成 n。HotPDF 不用後處理字元替換來遮醜,因為一張救了西班牙文的替換表會弄壞別的東西,而可搜尋層裡一個錯字,比誠實的漏認更糟

HotPDF 怎麼排序文字行,包括由右至左的阿拉伯文?

HotPDF 把偵測到的文字框由上而下排序,垂直方向重疊達較小框高一半以上的框併成同一列,每列由左至右排序——啟用 RightToLeft 時則由右至左;每行辨識出的文字內部永遠不會反轉。分組之所以要緊,是因為偵測器常把一條視覺上的行拆成好幾個框,例如被寬間隔隔開的標籤與值,純按頂部座標排序時,只要頂部差一兩個像素,它們就會跟鄰行交錯

阿拉伯文預設設 RightToLeft := True,指示 DLL 按每列框的右緣排序、從右邊界往內。效果就這麼多。模型為一行回傳的文字已經是 Unicode 邏輯順序——阿拉伯文讀者閱讀與輸入的順序——也正是 PDF 文字擷取與搜尋期望的順序。為了在偵錯器裡「看起來對」而機械式反轉字串,會弄壞搜尋、複製貼上與螢幕閱讀器。雙向顯示與字形塑形是檢視器的職責

一顆引擎服務一種語言設定檔。沒有自動的文字系統偵測,所以混用文字系統的文件,每種設定檔要一顆引擎、套用到使用它的那些頁面。ApplyLoadedOCRTextLayer 收明確的頁面清單、每次呼叫都是自己的全有或全無交易,所以做起來很直接

uses
  SysUtils, HPDFDoc, HPDFRapidOCRRecognition;

function CreateRapidEngine(const Tag: string): IHPDFOCREngine;
var
  Models: THPDFRapidOCRDLLOptions;
begin
  // 不認得的標籤會丟 EArgumentException,在任何模型載入之前
  Models := THPDFRapidOCRDLLOptions.ForLanguage(Tag);
  Models.MaxPixels := 33554432;          // 給 300 DPI 的 A3 頁面留餘裕
  Result := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
end;

procedure OCRMixedArchive(Doc: THotPDF);
var
  Chinese, Arabic: IHPDFOCREngine;
  Layer: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  Chinese := CreateRapidEngine('zh-TW');  // chinese_cht 設定檔
  Arabic := CreateRapidEngine('ar-SA');   // arabic 設定檔,RightToLeft = True
  Layer := THPDFOCRTextLayerOptions.Default;
  if not Doc.ApplyLoadedOCRTextLayer([0, 1, 2], Chinese, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
  if not Doc.ApplyLoadedOCRTextLayer([3], Arabic, Layer, Info) then
    raise Exception.Create(string(Info.Diagnostic));
end;

MaxPixels 那行不是白寫的。DLL 選項預設每次請求 16,777,216 像素,300 DPI 的 A4 與 US Letter 綽綽有餘,但 300 DPI 的 A3 頁面約 3508 × 4961 像素、差不多 1,740 萬,請求會因超過預算被拒。大格式請調高 MaxPixels(上限 67,108,864)或調低 THPDFOCRTextLayerOptions.DPI。由右至左排序用的是 ABI 版本 1 選用的 HPDFRapidOCRSetReadingDirection export;配接器只在設了 RightToLeft 時才要求它,所以舊 DLL 照樣服務由左至右的語言,阿拉伯文則在建立引擎時以 EArgumentException 失敗、指名缺了哪個 export

為什麼比較新的 OCR 模型載不進來?

HotPDF 的 RapidOCR DLL 靜態連結 ONNX Runtime 1.14,讀不了以 ONNX IR 版本 10 存的模型,而 PP-OCRv5 模型這類新匯出可能要求比這更新的 runtime;這種模型會在建立引擎時以原生診斷失敗。語言包之所以釘在特定的 PP-OCRv3 與 PP-OCRv4 辨識器與字典配對上、而不是追「最新」,這條限制正是原因;上面那張表混著兩個世代也是:每組釘死的配對都能在那個 runtime 下載入並通過驗證

安裝腳本強制執行這個配對。它清單裡的每個檔案都帶 SHA256 雜湊,既有檔案雜湊不同時終止安裝、而不是直接覆寫,每個下載先落暫存名、雜湊對上才就位。這防的是字典問題的安靜版:有人手工把一份較新的 recognition.onnx 丟進設定檔資料夾,類別數碰巧對上,然後什麼都不報錯——直到客戶回報「明明看得到的詞,搜尋卻找不到」。執行期配接器保持離線、絕不抓缺的模型。辨識器載入時也驗模型形狀,接受固定高度 32 或 48 像素或動態高度的 NCHW 輸入,動態高度以 48 執行

九個設定檔都蓋不到的文字系統,照樣可以把 RecognitionModel 與 CharacterDictionary 指到您自己的檔案。同樣的檢查照樣適用——這正是重點:配對錯了在初始化就失敗,而不是在客戶的檔案庫裡。兩種 RapidOCR 設定檔都不合身的頁面,可搜尋 PDF 的 Tesseract 配接器插進同一個 ApplyLoadedOCRTextLayer 呼叫;機器印刷的 ASCII 表單則交給 內建範本比對 OCR 引擎,它根本不需要模型

速查:多語言 RapidOCR 檢查清單

  • 用 THPDFRapidOCRDLLOptions.ForLanguage 建選項,把 EArgumentException 當成不支援的標籤,而不是執行期故障
  • RecognitionModel 與 CharacterDictionary 一起換、絕不只換一個;類別數相等證明不了字元順序相等
  • 字典保持無 BOM 的 UTF-8、絕不 Trim 條目,並預期模型有 N + 2 個類別:blank、N 個條目、空格
  • 自訂 CTC 解碼器必須涵蓋最後一個類別與最後一個時間步,並讓 blank 分隔重複
  • 每種語言設定檔一顆引擎,混用文字系統的文件傳明確的頁面清單
  • RightToLeft 只改框的順序;辨識出的文字維持 Unicode 邏輯順序
  • 模型用 Install-RapidOCRModels.ps1 安裝,SHA256 釘選才能守住模型與字典的配對;用 -SkipClassifier 安裝的話要設 UseAngleClassifier := False
  • 300 DPI 跑 A3 或更大頁面之前,把 MaxPixels 調到 16,777,216 預設之上

RapidOCR 語言預設、原生 DLL 配接器與 OCR 文字層管線,都是 HotPDF Delphi PDF Component 的一部分,支援 Delphi、C++Builder 與 Windows FPC/Lazarus,多語言設定檔自 v2.775.0 起