技術文章

HotPDF 行程內 RapidOCR:Delphi 原生 DLL OCR

HotPDF 用行程內 RapidOCR 讓掃描 PDF 頁面變得可搜尋,靠的是 v2.774.0 新增的工廠函式 HPDFCreateRapidOCRDLLOCREngine:它載入 HotPDFRapidOCR.dll,讓 ONNX 的偵測、角度分類與辨識模型常駐記憶體,回傳一個 IHPDFOCREngine。把這個引擎交給 THotPDF.ApplyLoadedOCRTextLayer,它就渲染每一頁、不用 Python 也不開子行程地跑 CPU 推論,然後寫入一層隱形的 Unicode 文字層

動機是每頁成本。早先出貨的 RapidOCR 行程配接器 HPDFCreateRapidOCREngine,每次 Recognize 都要起一個 Python worker,而那個 worker 在讀到第一個像素之前,得先匯入執行環境、載入 ONNX 模型。500 頁的檔案庫,這筆啟動稅就交 500 次,部署也變成要在 Delphi 執行檔旁邊擺一套 Python 環境。原生 DLL 在您建立引擎時把模型載一次,部署縮成 DLL、模型檔與一份字元字典。換來的代價是放棄「把卡死的辨識器殺掉」的能力,而這個配接器的工程工夫,大半都花在誠實地與這件事共處

怎麼用 RapidOCR DLL 把掃描 PDF 變成可搜尋?

用原生 RapidOCR DLL 做出可搜尋 PDF,只要一次工廠呼叫,加上每種 HotPDF OCR 引擎都在用的那個 ApplyLoadedOCRTextLayer。工廠函式住在 HPDFRapidOCRRecognition 單元裡,而且驗證從嚴、寧早勿晚:DLL 與模型目錄必須存在、每個模型與字典檔必須解析得到、ABI 版本必須是 1、所有必要的 export 必須齊備,然後才輪到任何模型初始化。設定錯誤丟 EArgumentException;模型載入失敗丟 EInvalidOperation,帶著 DLL 寫下的診斷文字

HotPDF 對 HPDFCreateRapidOCRDLLOCREngine 的 RapidOCR DLL 工廠驗證順序示意圖:路徑與模型檔必須存在、HPDFRapidOCRAbiVersion 必須回傳 1、必要的 export 必須解析得到、HPDFRapidOCRCreate 必須初始化模型,EArgumentException 或 EInvalidOperation 在任何辨識開跑前就早早丟出,後者帶著原生診斷文字
驗證刻意從早:設定問題在任何模型初始化之前就丟例外,爛路徑或爛 ABI 永遠碰不到辨識期限
uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFRapidOCRRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // 模型在這裡載入,不在任何辨識期限的壓力之下。
  // THPDFRapidOCRDLLOptions.Default 裡的相對模型名稱
  // 以模型目錄為基準解析。
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models');
  Doc := THotPDF.Create(nil);
  try
    Doc.AutoLaunch := False;
    if Doc.LoadFromFile(SourceFile) < 1 then
      raise Exception.Create('Cannot load ' + SourceFile);
    Options := THPDFOCRTextLayerOptions.Default;  // 300 DPI、MinimumConfidence 0.5
    // 空頁面清單代表每一頁;已有文字的頁面會跳過
    if not Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
      raise Exception.Create(string(Info.Diagnostic));
    Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
      ' lines accepted, ', Info.DroppedWordCount, ' dropped');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

THPDFRapidOCRDLLOptions.Default 指名 ch_PP-OCRv3_det_infer.onnx、ch_PP-OCRv3_rec_infer.onnx、ch_ppocr_mobile_v2.0_cls_infer.onnx 與 ppocr_keys_v1.txt,單一 CPU 執行緒、1,600 萬像素輸入上限、60,000 ms 辨識期限。從 v2.775.0 起,THPDFRapidOCRDLLOptions.ForLanguage 可以換進繁體中文、俄文、日文、阿拉伯文等其他語言設定檔的辨識模型與字典;模型與字典為什麼必須一起換,見 HotPDF 裡的 RapidOCR 多語言模型與 CTC 字典。引擎在 Info.EngineName 裡自報家門為 RapidOCR (native DLL),跟 外接 Tesseract OCR 行程配接器、內建範本比對 OCR 引擎並列時,日誌不會含糊

為什麼這個 C ABI 只講 int32_t 與 UTF-8 位元組?

HotPDFRapidOCR.dll 的 ABI 只用固定寬度整數、裸指標與明確的位元組長度,因為 Delphi、C++Builder 與 Free Pascal 跟 MSVC 之間,除了 C 呼叫慣例之外什麼都不共用。std::string、std::vector 或 C++ 例外的記憶體版面與堆疊展開模型,屬於某一個編譯器與某一個執行期程式庫。讓任何一個越過邊界,下場就是堆疊損壞、或堆積區塊被錯的配置器釋放——而不是乾淨的錯誤

所以 ABI 版本 1 遵守一份很短的清單。每個 export 都是 cdecl、回傳 int32_t 狀態碼,1 是成功、0 是失敗。每個可能失敗的函式都收一個歸呼叫方所有的診斷緩衝區與其位元組容量;DLL 寫入 NUL 結尾的 UTF-8 訊息、超長就截斷塞滿,配接器解碼時在自己的 4,096 位元組緩衝區最後一個位元組硬放終結符。每個 export 本體都包在 try 裡,同時掛 catch (const std::exception &) 與 catch (...),於是 ONNX Runtime 錯誤、OpenCV 斷言或壞字典都變成狀態 0 加文字,絕不會有例外逃進 Pascal 程式碼

Export角色配接器何時解析它
HPDFRapidOCRAbiVersion回傳 1;其他任何值都拒收最先,先於一切
HPDFRapidOCRCreate載入偵測、選用的分類、辨識模型與字典在工廠函式裡
HPDFRapidOCRRecognize跑一張點陣圖,每行文字發一次回呼在工廠函式裡
HPDFRapidOCRDestroy釋放模型實例在工廠函式裡
HPDFRapidOCRSetReadingDirection選用的由右至左列序,v2.775.0 新增只有設了 RightToLeft 時

那個選用 export 刻意延後解析:缺了它的 v2.774.0 DLL 照樣服務由左至右的請求。DLL 以 LoadLibraryEx 載入,搜尋旗標涵蓋 DLL 自身資料夾加預設安全目錄,所以擺在 HotPDFRapidOCR.dll 旁的 ONNX Runtime 或 OpenCV 相依不必動 PATH 就找得到。模型與字典路徑以 UTF-8 傳遞,DLL 在經寬字元 API 開檔之前,先用嚴格模式的 MultiByteToWideChar 轉換,所以中文或西里爾使用者名稱底下的模型目錄照樣可用,而不是被逐位元組拉寬成一串亂碼

有一條規則活在建置裡、不在標頭檔裡。DLL 靜態連結 ONNX Runtime 與 OpenCV,預設的 CMake 設定用靜態 release CRT(/MT)。以 /MD 編譯的靜態程式庫混進 /MT 的 DLL,輕則連結錯誤、重則兩座互不相干的堆積,所以備妥的程式庫必須匹配 DLL 用的那種 CRT 模式

從 TBitmap 到一行文字之間發生了什麼?

HotPDF 把渲染頁面的獨立副本——top-down BGR 快照——交給 DLL;DLL 對每行辨識出的文字回呼一次,帶著借用來的 UTF-8 文字,配接器必須在回呼返回前複製走

在 Delphi 上,配接器把頁面點陣圖指派給私有的 TBitmap、強制 pf24bit、用負的 biHeight 走 GetDIBits 讀列——這樣得到的是 top-down、補齊四位元組對齊的列;stride 明確傳遞。在 FPC 上則經 CreateIntfImage 讀,因為 LCL 的 scanline 寫入可能更新了原始影像卻沒重整 GDI handle。呼叫方的點陣圖絕不被修改,而像素預算(MaxPixels,預設 16,777,216、可設定到 67,108,864)與每維 32,767 像素上限,都在快照緩衝區配置之前檢查

HotPDF 的 RapidOCR DLL 從點陣圖到文字層的管線示意圖:配接器以 top-down pf24bit BGR 快照頁面,DLL 補邊、偵測、排序並辨識裁切區,每行回呼一次、帶借用的 UTF-8 文字與框與信心值,配接器逐行驗證後才提交文字層
像素以快照形式跨 ABI 一次,文字行一次回呼一行地回來,每一關都過了,東西才進得了可搜尋層

DLL 內部,快照先補上 50 個白色像素,文字區域以最長邊 1,024 像素偵測,框被排成水平列,每個裁切區在辨識前可選擇性地由角度分類器旋轉。每一行文字隨後經過一次回呼,回呼收到 const char*、位元組數、以原始影像像素計的整數框,以及字元平均信心值。文字指標只在回呼期間有效,所以配接器立刻複製,而且驗收標準很嚴:

  • UTF-8 以 MB_ERR_INVALID_CHARS 解碼;畸形序列讓該頁失敗,而不是在可搜尋層裡生出替換字元
  • C0 與 C1 控制字元一律拒收,純空白行直接跳過
  • 框必須落在點陣圖之內,信心值必須是 0 到 1 之間的有限值
  • 文字對著請求的 MaxTextCodeUnits 計數,每次呼叫硬上限 1,048,576 個 UTF-16 單位,增補平面字元一個算兩個單位
  • 回呼裡丟出的任何 Pascal 例外都會在原地被接住、存起來、轉成回傳 0,DLL 於是停下來回報失敗;存下的訊息隨後成為診斷文字

對調校而言有兩個後果要緊。第一,輸出單位是「行」、不是「詞」:每行吃掉一個 MaxWords 名額,Info.AcceptedWordCount 與 Info.DroppedWordCount 數的是行,搜尋高亮跨的是行的框。第二,MinimumConfidence(預設 0.5)對比的是行的字元平均信心值,所以二十個乾淨字元裡夾一個認不得的,那行通常還活著。DLL 不提供 baseline,文字層管線自己從框裡估一個。空頁面以零行成功,任何失敗都清掉部分結果,多頁提交因此維持全有或全無

模型所有權與執行緒安全

每個 RapidOCR DLL 引擎終其一生恰好擁有一個模型實例,對該引擎的 Recognize 呼叫由 critical section 串行化。握住 IHPDFOCREngine 介面,模型才保持熱著,所以批次工作的正確姿勢是引擎建一次、跨文件重複用

procedure OcrBatch(const Files: TStrings; const OutputDir: string);
var
  Models: THPDFRapidOCRDLLOptions;
  Engine: IHPDFOCREngine;
  Doc: THotPDF;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
  I: Integer;
begin
  Models := THPDFRapidOCRDLLOptions.Default;
  Models.UseAngleClassifier := False;    // 正向掃描:不載入分類器模型
  Models.Threads := 4;                   // 1..64,上限為邏輯處理器數
  Models.TimeoutMilliseconds := 120000;  // 每次 Recognize 呼叫,合作式
  Engine := HPDFCreateRapidOCRDLLOCREngine(
    'C:\OCR\Win64\HotPDFRapidOCR.dll', 'C:\OCR\models', Models);
  Options := THPDFOCRTextLayerOptions.Default;
  for I := 0 to Files.Count - 1 do
  begin
    Doc := THotPDF.Create(nil);
    try
      Doc.AutoLaunch := False;
      if (Doc.LoadFromFile(Files[I]) > 0) and
        Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
        Doc.SaveLoadedDocument(IncludeTrailingPathDelimiter(OutputDir) +
          ExtractFileName(Files[I]))
      else
        Writeln(Files[I], ': ', string(Info.Diagnostic));
    finally
      Doc.Free;
    end;
  end;
end;  // 最後一個參照釋放:模型銷毀,接著 DLL 卸載

Threads 同時設定每個 ONNX session 的 intra-op 與 inter-op 執行緒數,DLL 會把它夾到作用中處理器數為止。兩個執行緒共用一個引擎並不平行執行;第二個等鎖。那個等待不是閉著眼的 EnterCriticalSection:配接器每 25 ms 呼叫一次 TryEnterCriticalSection,每次嘗試之間檢查取消權杖與期限,所以排隊中的請求照樣可以取消或逾時。真要平行,就每個 worker 一顆引擎,並接受每顆引擎都在記憶體裡自持一份模型

拆除順序由引擎解構子定死:HPDFRapidOCRDestroy 先釋放模型實例,FreeLibrary 隨後卸載 DLL。原生那一側的模型初始化同樣細心:偵測器與分類器 session 都建好之後辨識模型才失敗的話,那些 session 會先釋放再回報錯誤;字典的類別數在初始化時就對著模型輸出檢查,而不是拖到第一頁

原生 OCR 呼叫為什麼無法在推論中途被殺掉?

原生 RapidOCR 呼叫無法在推論中途被殺,因為它跑在您的執行緒上、您的行程裡、一個不接受中斷的 ONNX Runtime session 中間。HotPDF 的 DLL 配接器於是把取消做成合作式:DLL 在偵測前後、分類之後、每行辨識之後呼叫一次中止回呼,在回呼回傳 0 的第一個檢查點停手。已經開跑的那一次 ONNX Run 會先跑完

替代方案比等更糟。TerminateThread 會把 CRT 堆積鎖、ONNX Runtime 的執行緒池與任何 OpenCV 狀態留在它們當下碰巧所在的狀態,毒化行程的其餘部分。呼叫還在執行時 FreeLibrary,等於卸載還在堆疊上的程式碼。兩者都做不安全,所以配接器從不嘗試。TimeoutMilliseconds 裡的期限因此是合作式期限:逾時以引擎錯誤、帶逾時診斷的姿態浮現,取消的權杖則浮現為 otlsCancelled:

// 權杖由呼叫方建立,與 UI 執行緒共用,
// 使用者按下 Stop 時由 UI 執行緒呼叫 Token.Cancel
Options := THPDFOCRTextLayerOptions.Default;
Options.CancellationToken := Token;
if not Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
  case Info.Status of
    otlsCancelled:
      // 在下一個階段或行邊界返回;文件不變
      Writeln('Cancelled');
    otlsEngineError:
      // 含合作式期限逾期與原生診斷
      Writeln('Engine: ', string(Info.Diagnostic));
    otlsBudgetExceeded:
      Writeln('Budget: ', string(Info.Diagnostic));
  else
    Writeln(string(Info.Diagnostic));
  end;

這就是 HotPDF 行程配接器與行程內 DLL 之間的核心取捨,哪一邊都沒有全勝:

HotPDF 的 OCR 配接器取捨示意圖:行程配接器每頁起 worker、載模型,但殺得掉、也關得住崩潰;行程內的 RapidOCR DLL 模型只載一次、只在合作式檢查點停手、共享位址空間,部署時就是 DLL 加模型與字典
按工作負載挑:一次一頁的桌面應用受益於熱著的 DLL,吞不可信掃描的伺服器該為行程這道牆付錢
  • 啟動成本:Tesseract 與 Python RapidOCR 配接器每頁起一個行程、載一次模型;DLL 每顆引擎只載一次
  • 停手:子行程可以直接終止,Python worker 還跑在關閉即殺的 Job Object 裡、整棵行程樹一起走;DLL 只能在階段與行邊界停
  • 故障隔離:tesseract.exe 崩潰只賠一頁;DLL 裡的存取違規把您的整個行程帶走
  • 部署:行程配接器要一套裝好的程式或 Python 環境;DLL 只要它自己、它的模型與字典,且位元數要跟應用程式匹配
  • 記憶體:行程配接器在子行程退出時全部歸零;DLL 引擎的模型常駐到最後一個介面參照釋放

一次 OCR 一頁的互動式桌面應用,DLL 的回應速度通常贏。全天候吞不可信掃描的伺服器,行程邊界值得它的啟動成本

建置與部署 HotPDFRapidOCR.dll

HotPDFRapidOCR.dll 用 MSVC、C++17、一個 Windows SDK 與 CMake 3.20 以上,從 Native/RapidOCR 的 C++ 原始碼建置,輔助腳本吃原生網路來源、ONNX Runtime 與 OpenCV 目錄,外加 Win32 或 Win64 平台。兩種都要出貨就兩種都建,因為 32 位元的 Delphi 應用載不動 64 位元 DLL,而備妥的靜態程式庫也得同時對上目標架構與 CRT 模式

模型那一側有自己的相容性界線。偵測器是 DB 文字偵測器;辨識器收 NCHW 版面、固定輸入高度 32 或 48 的 CTC 模型,動態高度的模型用 48。綁在一起出貨的靜態 ONNX Runtime 載不動以更新 IR 版本存的模型,所以新近的 PP-OCRv5 匯出會帶著診斷文字初始化失敗,而不是載一半。字典必須是無 BOM 的 UTF-8、字元順序與模型完全一致,類別數必須匹配模型輸出;CRLF 行尾可以接受。辨識是離線的:DLL 絕不會下載缺的模型

速查

  • 工廠函式:HPDFRapidOCRRecognition 裡的 HPDFCreateRapidOCRDLLOCREngine(LibraryPath, ModelDirectory[, Options]),v2.774.0 起,Delphi、C++Builder 與 Windows FPC/Lazarus 建置皆可用
  • 讓回傳的 IHPDFOCREngine 跨頁、跨文件保持存活;釋放它就銷毀模型、卸載 DLL
  • 一顆引擎一次跑一個辨識;要平行 worker 就建多顆引擎,並為每份模型副本編列記憶體
  • 輸出每行文字一個條目、帶字元平均信心值,經 THPDFOCRTextLayerOptions.MinimumConfidence 過濾
  • 取消與 TimeoutMilliseconds 都是合作式;進行中的 ONNX run 一定跑完
  • DLL 位元數對上應用程式,靜態 ONNX Runtime 與 OpenCV 程式庫的 CRT 模式對上 DLL
  • 每顆引擎用 THPDFRapidOCRDLLOptions.ForLanguage(v2.775.0)選一種語言設定檔;引擎自己不會偵測語言

原生 RapidOCR 配接器、以行程為本的 OCR 配接器、餵它們的頁面渲染器,以及寫隱形 Unicode 文字層的寫入器,全部一起隨 HotPDF——Delphi 與 C++Builder 的原生 VCL PDF 元件——出貨。您的文件擷取或歸檔應用需要在目標機器上不裝 Python 執行環境就能產出可搜尋結果的話,HotPDF Delphi PDF component 提供整條管線,剩下要部署的只有 DLL 與它的模型