技術記事

HotPDFインプロセスRapidOCR:DelphiでネイティブDLL OCR

HotPDFは、インプロセスのRapidOCRによってスキャンPDFページを検索可能にします。そのためのHPDFCreateRapidOCRDLLOCREngineは、v2.774.0で追加されたファクトリーで、HotPDFRapidOCR.dllをロードし、ONNXの検出、角度分類、認識モデルをメモリに常駐させ、IHPDFOCREngineを返します。そのエンジンをTHotPDF.ApplyLoadedOCRTextLayerへ渡すと、各ページを描画し、Pythonも子プロセスもなしでCPU推論を回し、不可視のUnicodeテキストレイヤーをコミットします

動機はページあたりのコストです。先に登場したRapidOCRプロセスアダプターHPDFCreateRapidOCREngineは、Recognizeの呼び出しごとにPythonワーカーを起動し、そのワーカーは1ピクセルも読む前にランタイムをインポートしONNXモデルをロードします。500ページのアーカイブでは、この起動税が500回繰り返されますし、配備はDelphi実行ファイルの隣にPython環境を同梱することを意味します。ネイティブDLLならモデルはエンジンを作るときに一度ロードされるだけで、配備はDLL、モデルファイル、文字辞書へ縮みます。引き換えに手放すのは、ハングした認識器を殺す能力です。このアダプターのエンジニアリングの大半は、その現実と正直に付き合う話です

RapidOCR DLLでスキャンPDFを検索可能にするには

ネイティブRapidOCR DLLで検索可能PDFを作るのは、ファクトリー呼び出し1回と、どのHotPDF OCRエンジンでも同じApplyLoadedOCRTextLayer呼び出しです。ファクトリーはHPDFRapidOCRRecognitionユニットに住み、熱心に検証します。DLLとモデルディレクトリーが存在すること、すべてのモデルと辞書ファイルが解決すること、ABIバージョンが1であること、そして必須エクスポートがすべて揃っていることは、どれもモデルが初期化される前に確認されます。設定ミスはEArgumentExceptionを上げ、ロードに失敗したモデルはDLLが書いた診断テキストを運ぶEInvalidOperationを上げます

HPDFCreateRapidOCRDLLOCREngineに対するHotPDF RapidOCR DLLファクトリーの検証シーケンスを示す図。パスとモデルファイルが存在し、HPDFRapidOCRAbiVersionが1を返し、必須エクスポートが解決し、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、16,777,216ピクセルの入力上限、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が、C呼び出し規約以外にMSVCと共有するものを持たないからです。std::string、std::vector、C++例外には、1つのコンパイラと1つのランタイムライブラリに属するレイアウトとアンワインディングモデルがあります。そのどれか1つでも境界を越えさせると、失敗はクリーンなエラーではなく、壊れたスタックや、間違ったアロケーターに解放されたヒープブロックです

だからABIバージョン1は短いルールリストに従います。すべてのエクスポートはcdeclでint32_tのステータスを返し、1が成功、0が失敗です。失敗し得るすべての関数は、呼び出し側所有の診断バッファーとその容量(バイト)を取ります。DLLはNUL終端のUTF-8メッセージを収まるよう切り詰めて書き込み、アダプターは自分の4,096バイトバッファーの最終バイトにハード終端を置いてデコードします。各エクスポートの本体はcatch (const std::exception &)とcatch (...)の両方を持つtryで包まれるので、ONNX Runtimeのエラー、OpenCVのアサーション、無効な辞書は、ステータス0とテキストになって終わり、Pascalコードへ脱出する例外は決してありません

エクスポート役割アダプターが解決するタイミング
HPDFRapidOCRAbiVersion1を返す。他の値は拒否される最初に。他の何よりも先
HPDFRapidOCRCreate検出、オプションの分類、認識モデルと辞書をロードファクトリー内
HPDFRapidOCRRecognize1つのビットマップを回し、テキスト行ごとに1つのコールバックを出すファクトリー内
HPDFRapidOCRDestroyモデルインスタンスを解放するファクトリー内
HPDFRapidOCRSetReadingDirectionオプションの右から左の行順。v2.775.0で追加RightToLeftが設定されているときだけ

オプションのエクスポートが遅延解決なのは意図的です。それを持たないv2.774.0のDLLでも、左から右の要求には対応できます。DLLはLoadLibraryExで、DLL自身のフォルダーとデフォルトの安全ディレクトリーをカバーする検索フラグ付きでロードされるので、HotPDFRapidOCR.dllの隣に置かれたONNX RuntimeやOpenCVの依存が、PATHに触らずに見つかります。モデルと辞書のパスはUTF-8で渡され、DLLはワイド文字APIでファイルを開く前に、strictモードのMultiByteToWideCharで変換します。だから中国語やキリル文字のユーザー名の下にあるモデルディレクトリーは、バイトごとに引き延ばされてナンセンスになる代わりに、ちゃんと動きます

1つのルールはヘッダーでなくビルドに住んでいます。DLLはONNX RuntimeとOpenCVを静的リンクし、デフォルトのCMake設定は静的リリースCRT(/MT)を使います。/MD向けにコンパイルされた静的ライブラリが/MTのDLLに混ざると、良い場合でもリンクエラー、悪い場合には独立した2つのヒープが生まれるので、調達するライブラリはDLLが使うCRTモードと一致していなければなりません

TBitmapとテキスト行の間で何が起きるのか

HotPDFはDLLへ、描画済みページの独立したトップダウンBGRスナップショットを渡し、DLLは認識されたテキスト行ごとに1つのコールバックで戻します。テキストは借用UTF-8で、アダプターは戻る前にコピーしなければなりません

Delphi側のアダプターは、ページのビットマップをプライベートのTBitmapへ代入し、pf24bitを強制し、負のbiHeightでGetDIBitsにより行を読みます。これは4バイト境界にパディングされたトップダウンの行を返し、そのストライドは明示的に渡されます。FPCではCreateIntfImage経由で読みます。LCLのscanline書き込みはGDIハンドルを更新せずに生画像を変え得るからです。呼び出し側のビットマップが変更されることはありません。そしてピクセル予算(MaxPixels、デフォルト16,777,216、最大67,108,864まで設定可)と、ディメンションあたり32,767ピクセルの上限は、スナップショットバッファーの割り当て前に検査されます

ビットマップからテキストレイヤーまでのHotPDF RapidOCR DLLパイプラインを示す図。アダプターはページをトップダウンのpf24bit BGRでスナップショットし、DLLはパディング、検出、行への整列、クロップの認識を行い、借用UTF-8テキスト、ボックス、信頼度とともに行ごとに1つのコールバックを届け、アダプターはテキストレイヤーコミットの前に各行を検証します
ピクセルはスナップショットとしてABIを1度だけ渡り、行はコールバックで1行ずつ戻り、すべての検査を通るまで何も検索可能レイヤーへ届きません

DLLの内側では、スナップショットは白50ピクセルでパディングされ、テキスト領域は最大辺1,024ピクセルで検出され、ボックスは水平の行へ整列され、各クロップは認識の前に角度分類器で回転され得ます。各テキスト行は続いて、const char*、バイト数、元画像ピクセルでの整数ボックス、平均文字信頼度を受け取るコールバックを通ります。テキストポインターが有効なのはコールバックの間だけなので、アダプターは即座にコピーします。そして受けて良いものについては厳格です:

  • UTF-8はMB_ERR_INVALID_CHARS付きでデコードされます。不正なシーケンスは、検索可能レイヤーに置換文字を生む代わりに、ページを失敗させます
  • C0とC1の制御文字は拒否され、空白だけの行はスキップされます
  • ボックスはビットマップの内側になければならず、信頼度は0から1の有限値でなければなりません
  • テキストはリクエストのMaxTextCodeUnitsに対して数えられ、呼び出しあたり1,048,576 UTF-16ユニットのハード上限があり、補助平面の文字は2ユニットを消費します
  • コールバック内のPascal例外はそこで捕捉、保存され、0リターンに変換されます。DLLは停止して失敗を報告し、保存されたメッセージが診断になります

チューニングに関わる帰結が2つあります。第1に、出力の単位は語でなく行です。各行がMaxWordsスロットを1つ消費し、Info.AcceptedWordCountとInfo.DroppedWordCountは行を数え、検索ハイライトは行ボックスにまたがります。第2に、MinimumConfidence(デフォルト0.5)は行の平均文字信頼度と比較されるので、20のきれいな文字の中の1つの判読不能文字を含む行はたいてい生き残ります。DLLはベースラインを供給しないので、テキストレイヤーのパイプラインがボックスから推定します。空のページはゼロ行で成功し、失敗は部分結果をクリアするので、複数ページのコミットはオールオアナッシングを保ちます

モデルの所有権とスレッドセーフティ

各RapidOCR DLLエンジンは、自分の生存期間全体でちょうど1つのモデルインスタンスを所有し、そのエンジンへのRecognizeの呼び出しはクリティカルセクションで直列化されます。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セッションのintra-opとinter-opのスレッド数の両方を設定し、DLLはそれをアクティブプロセッサー数で頭打ちにします。1つのエンジンを共有する2つのスレッドは並列に走りません。2番目はロックを待ちます。その待ちには目隠しのEnterCriticalSectionではなく、アダプターが25 msごとにTryEnterCriticalSectionを呼び、試行の合間にキャンセルトークンとデッドラインを確認する作りになっているので、キューに入ったリクエストでもキャンセルやタイムアウトが可能です。真の並列性が必要なら、ワーカーごとにエンジンを1つ作り、各エンジンがメモリにモデルの自分のコピーを保持することを受け入れてください

テアダウンの順序はエンジンのデストラクターが固定します。HPDFRapidOCRDestroyがまずモデルインスタンスを解放し、それからFreeLibraryがDLLをアンロードします。ネイティブ側も、モデル初期化は同じくらい慎重です。検出器と分類器のセッションがすでに構築された後に認識モデルが失敗したときは、エラーを報告する前にそれらのセッションを解放し、辞書のクラス数は最初のページでなく初期化の間にモデル出力と照合されます

ネイティブOCRの呼び出しを推論の途中で殺せない理由

ネイティブRapidOCRの呼び出しが推論の途中で殺せないのは、それがあなたのスレッドで、あなたのプロセスの中で、割り込みを受け付けないONNX Runtimeセッションの途中で走るからです。だからHotPDFのDLLアダプターにおけるキャンセルは協調的です。DLLは検出の前後、分類の後、認識された各行の後に中断コールバックを呼び、コールバックが0を返した最初のチェックポイントで停止します。始まってしまった1つのONNX Runは先に終わります

代替手段は待つのより悪いです。TerminateThreadは、CRTのヒープロック、ONNX Runtimeのスレッドプール、OpenCVの状態を、それぞれその瞬間の状態のまま放置し、プロセスの残りを汚染します。呼び出しがまだ実行中のFreeLibraryは、スタックに載っているコードをアンロードします。どちらも安全にはできません。だからアダプターは決して試みません。したがってTimeoutMillisecondsのデッドラインは協調的デッドラインであり、期限切れはタイムアウト診断付きのエンジンエラーとして、キャンセルされたトークンはotlsCancelledとして表面化します:

// Tokenは呼び出し側が作り、UIスレッドと共有する。
// ユーザーがStopを押すとToken.Cancelを呼ぶのはUIスレッド
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アダプターのトレードオフを示す図。プロセスアダプターはページごとにワーカーを起動してモデルをロードするが、殺すことができ、クラッシュを封じ込めます。インプロセスのRapidOCR DLLはモデルを一度だけロードし、協調的チェックポイントでのみ停止し、アドレス空間を共有し、モデルと辞書付きのDLLとして配備されます
ワークロードごとに選んでください。1ページずつ処理するデスクトップアプリは温かいDLLの恩恵を受け、信頼できないスキャンを昼夜問わず取り込むサーバーは、プロセスの壁の代金を払うべきです
  • 起動コスト:TesseractとPythonのRapidOCRアダプターはページごとにプロセスを起動してモデルをロードします。DLLはエンジンごとに一度だけモデルをロードします
  • 停止:子プロセスは丸ごと終端でき、Pythonワーカーはクローズ時に殺すJob Objectの中で走るので、プロセスツリー全体が道連れになります。DLLはステージと行の境界でしか止められません
  • 障害封じ込め:tesseract.exeのクラッシュは1ページを失敗させます。DLLの内側のアクセス違反はあなたのプロセスを持ち去ります
  • 配備:プロセスアダプターにはインストール済みのプログラムかPython環境が要ります。DLLにはDLL自身、モデル、辞書が要り、アプリケーションのビットネスと一致させてください
  • メモリ:プロセスアダプターは子の終了ですべてを解放します。DLLエンジンは最後のインターフェース参照が解放されるまでモデルを常駐させます

1ページずつOCRする対話型デスクトップアプリケーションでは、たいていDLLの応答性が勝ちます。信頼できないスキャンを昼夜取り込むサーバーでは、プロセス境界が起動コストの見合う価値です

HotPDFRapidOCR.dllのビルドと配備

HotPDFRapidOCR.dllは、Native/RapidOCRのC++ソースから、MSVC、C++17、Windows SDK、CMake 3.20以降でビルドされます。ネイティブのネットワークソース、ONNX Runtime、OpenCVの各ディレクトリーに、Win32かWin64のプラットフォームを取るヘルパースクリプトを使います。両方を出荷するなら両方をビルドしてください。32ビットのDelphiアプリケーションは64ビットDLLをロードできませんし、調達する静的ライブラリはCRTモードだけでなくターゲットアーキテクチャにも一致しなければなりません

モデル側には独自の互換性限界があります。検出器はDBテキスト検出器です。認識器は、入力の高さが32か48に固定されたNCHWレイアウトのCTCモデルを受け付け、動的高さのモデルには48を使います。バンドルされた静的ONNX Runtimeは、より新しいIRバージョンで保存されたモデルをロードできず、最近のPP-OCRv5エクスポートは部分的にロードされる代わりに、診断付きで初期化に失敗します。辞書はBOMなしのUTF-8で、モデルの文字順序と正確に一致しなければならず、クラス数はモデル出力と一致しなければなりません。CRLFの行末は受け付けます。認識はオフラインです。DLLは欠けているモデルをダウンロードすることは決してありません

クイックリファレンス

  • ファクトリー:HPDFCreateRapidOCRDLLOCREngine(LibraryPath, ModelDirectory[, Options])はHPDFRapidOCRRecognitionにあり、v2.774.0からDelphi、C++Builder、WindowsのFPC/Lazarusビルドで利用可能
  • 返されたIHPDFOCREngineをページやドキュメントをまたいで生かしておく。解放するとモデルが破壊されDLLがアンロードされる
  • 1つのエンジンは1度に1つの認識しか走らせない。並列ワーカーには複数のエンジンを作り、モデルのコピーごとにメモリを予算化する
  • 出力はテキスト行ごとに1つのエントリーで、平均文字信頼度が付き、THPDFOCRTextLayerOptions.MinimumConfidenceでフィルターされる
  • キャンセルとTimeoutMillisecondsは協調的。進行中のONNXランは常に完了する
  • DLLのビットネスをアプリケーションに、静的ONNX RuntimeとOpenCVライブラリのCRTモードをDLLに一致させる
  • エンジンごとの言語プロファイルはTHPDFRapidOCRDLLOptions.ForLanguageで選ぶ(v2.775.0)。1つのエンジンが自分で言語を検出することはない

ネイティブRapidOCRアダプター、プロセスベースのOCRアダプター、それらに食わせるページレンダラー、不可視Unicodeテキストレイヤーのライターは、DelphiとC++Builder向けのネイティブVCL PDFコンポーネントであるHotPDFに、全部一緒に同梱されています。ドキュメントキャプチャやアーカイブのアプリケーションが、ターゲットマシンにPythonランタイムなしで検索可能出力を要するなら、HotPDF Delphi PDF componentが、配備はDLLとモデルだけという形でパイプライン全体を提供します