技術記事

HotPDF Tesseract DLL OCR:DelphiからC APIを呼ぶ

HotPDFは、HPDFCreateTesseractDLLOCREngineを通して、あなたのDelphiプロセスの内側でTesseractを走らせます。これはv2.772.0で追加されたファクトリーで、Tesseract 5互換のDLLを動的にロードし、そのC API(TessBaseAPIInit2、TessBaseAPIRecognize、結果イテレーター)を駆動し、IHPDFOCREngineを返します。THotPDF.ApplyLoadedOCRTextLayerはそのエンジンで、スキャンPDFページへ不可視で検索可能なUnicodeテキストレイヤーを追加します

同じ認識器は、BMPを書いてTSVをパースする外部tesseract.exeアダプター経由ですでに届いていました。その経路は動きます。ただしページごとに、プロセス起動、一時ビットマップファイル、そしてベースラインもページセグメンテーションの制御もないテキスト形式の代金を払います。DLLを呼ぶ方式は3つすべてを取り除きます。同時にプロセスの壁も取り除きます。つまり、PascalバインディングがC構造体、Cブーリアン、Cで確保された文字列の真上に載るということです。このアダプターについて知る価値の大半は、そのバインディングが静かに間違い得る場所です

HotPDFでTesseractをDelphiからインプロセスで走らせるには

HotPDFでTesseractをインプロセスで走らせるのは、HPDFTesseractRecognitionユニットのファクトリー呼び出し1回と、どのHotPDF OCRエンジンでも同じApplyLoadedOCRTextLayer呼び出しです。ファクトリーは熱心に検証します。DLLファイルとtessdataディレクトリーが存在すること、言語識別子はASCIIの英字、数字、_、+しか含めないこと、chi_sim+engのような組み合わせのすべてのモデルに対応する.traineddataファイルがあること、そして必須エクスポート21個すべてが解決することは、エンジンが返される前に確認されます。設定ミスはEArgumentExceptionを上げ、ロードに失敗したDLLは、Windowsエラーコードと、アーキテクチャと依存を確認せよというヒント付きのEOSErrorを上げます

uses
  SysUtils, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // Win64アプリには64ビットDLLが必要。依存DLLはその隣へ置く
  Engine := HPDFCreateTesseractDLLOCREngine('C:\OCR\Win64\libtesseract-5.dll',
    'C:\OCR\tessdata', 'chi_sim+eng');   // THPDFTesseractOptions.Defaultを使う
  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,
      ' words accepted, ', Info.DroppedWordCount, ' dropped');
    Doc.SaveLoadedDocument(TargetFile);
  finally
    Doc.Free;
  end;
end;

THPDFTesseractOptions.Defaultは、PageSegModeをtpsAutoへ、EngineModeをtemDefaultへ、TimeoutMillisecondsを60,000へ、MaxPixelsを16,777,216へ設定します。ピクセル予算は見た目より効いてきます。デフォルトの300 DPIでのUS Letterページは2,550×3,300ピクセル、約840万で、収まります。同じページの600 DPIは5,100×6,600、約3,370万で、アダプターはTesseractが1ピクセルも見る前に拒否します。MaxPixelsを上げる(上限は67,108,864)か、DPIをそのままにしてください。各辺も32,767ピクセルで頭打ちです

DLLは、DLL自身のフォルダーとデフォルトの安全ディレクトリーをカバーする検索フラグ付きのLoadLibraryExでロードされるので、Tesseractが依存する画像ライブラリは、PATHやカレントディレクトリーに触れずに隣に置けます。HotPDFはOCRのランタイムもモデルもバンドルもダウンロードもしません。両方ともあなたが調達します

tesseract.exeアダプターと比べて何が変わるのか

DLLアダプターは、プロセス分離を、豊かな出力と低いページあたりオーバーヘッドと引き換えにします。どちらのアダプターも同じテキストレイヤーのパイプラインへ差し込まれるので、座標マッピング、信頼度フィルター、オールオアナッシングのコミットは同一です。違うのは、ピクセルがどう入り、単語がどう出てくるかです

観点tesseract.exeアダプターTesseract DLLアダプター
ファクトリーHPDFCreateTesseractOCREngineHPDFCreateTesseractDLLOCREngine
入力ピクセルプライベートな一時ディレクトリーのBMPファイルメモリ内の8ビットグレースケールバッファー
出力単語語単位のTSV、64 MiBで頭打ち結果イテレーター、語ごとのUTF-8
ベースライン利用不可TessPageIteratorBaselineから素通し
ページセグメンテーションとエンジンモード自動セグメンテーションのみTHPDFTesseractPageSegMode、THPDFTesseractEngineMode
タイムアウトハード:子プロセスを終端する協調的:Tesseractが気づく必要がある
クラッシュとメモリの分離別プロセスなし。あなたのアドレス空間を共有する

消えないコストが1つあります。Recognizeの呼び出しごとに専用のAPIインスタンスを作りTessBaseAPIInit2を呼ぶため、言語モデルはエンジンごとに一度ではなくページごとに初期化されます。OSのファイルキャッシュが再ロードを和らげますが、大きな多言語モデルセットでは、依然としてページあたりの支配的な固定コストで、認識デッドラインにも算入されます。インプロセスのRapidOCR DLLエンジンは逆の設計を取り、ONNXモデルをエンジンの生存期間中メモリに常駐させます。境界の問題(C ABI、借用バッファー、中断不能なネイティブ処理)は同じ仲間ですが

DelphiがTesseractのモニター構造体をコピーできない理由

DelphiがTesseractのプログレスモニターを安全にミラーできないのは、ETEXT_DESCがバージョン依存の内部フィールドを含むからです。手で写したレコードは、いくつかのビルドで、キャンセルコールバックとデッドラインを間違ったオフセットに置きます。そのとき大音量で失敗するものは何もありません。Tesseractは単に、今や別の何かが入っているフィールドからあなたのコールバックポインターを読むか、デッドラインを最初から見ません

だからHotPDFはモニターをオペークポインターとして扱い、エクスポートされた関数経由でしか触れません。TessMonitorCreate、TessMonitorSetCancelThis、TessMonitorSetCancelFunc、TessMonitorSetDeadlineMSecs、TessMonitorDeleteです。別の目的で自分でC APIをバインドするなら、同じパターンが当てはまります。下のスケッチはHotPDF APIではなくあなた自身のバインディングコードで、HotPDFが内部で使う宣言をミラーしたものです

HotPDF Tesseract DLLのモニター扱いを示す図。バージョン依存のETEXT_DESCレコードをコピーするとキャンセルコールバックとデッドラインが間違ったオフセットに置かれて静かに失敗しますが、HotPDFはモニターをオペークとして扱い、TessMonitorCreate、TessMonitorSetCancelThis、TessMonitorSetCancelFunc、TessMonitorSetDeadlineMSecsを駆動し、cdeclコールバックを例外フリーに保ちます
オペークポインターと5つのエクスポートで契約はすべてです。コールバックはフラグと時計を読むだけの1バイトBooleanのままにします
type
  // C: typedef bool (*TessCancelFunc)(void *cancel_this, int words);
  TTessCancelFunc = function(CancelThis: Pointer; Words: Integer): Boolean; cdecl;
  TTessMonitorCreate = function: Pointer; cdecl;   // ETEXT_DESC*、決して参照外ししない
  TTessMonitorDelete = procedure(Monitor: Pointer); cdecl;
  TTessMonitorSetCancelFunc = procedure(Monitor: Pointer; Func: TTessCancelFunc); cdecl;
  TTessMonitorSetCancelThis = procedure(Monitor, CancelThis: Pointer); cdecl;
  TTessMonitorSetDeadlineMSecs = procedure(Monitor: Pointer; MSecs: Integer); cdecl;
  TTessBaseAPIRecognize = function(Handle, Monitor: Pointer): Integer; cdecl;

  TOCRJob = record
    CancelRequested: Boolean;
    DeadlineTick: UInt64;
  end;
  POCRJob = ^TOCRJob;

function ShouldCancel(CancelThis: Pointer; Words: Integer): Boolean; cdecl;
begin
  // Tesseractのスタック上で走る:フラグと時計を読むだけ、決してraiseしない
  Result := (CancelThis = nil) or POCRJob(CancelThis)^.CancelRequested or
    (GetTickCount64 >= POCRJob(CancelThis)^.DeadlineTick);
end;

// 使用例。関数ポインターはGetProcAddressで解決済み:
//   Monitor := MonitorCreate();
//   try
//     MonitorSetCancelThis(Monitor, @Job);
//     MonitorSetCancelFunc(Monitor, ShouldCancel);
//     MonitorSetDeadlineMSecs(Monitor, RemainingMs);
//     RC := BaseAPIRecognize(API, Monitor);
//   finally
//     MonitorDelete(Monitor);
//   end;

このスケッチの2つの細部は意図的です。コールバックはBooleanを返します。DelphiでもFree Pascalでも1バイトで、TessCancelFuncのC boolと一致します。4バイトのWindows BOOLやDelphiのLongBoolは、入れ替え可能に見えてそうではありません。片側が1バイト書き、もう片側が4バイト読むとき、戻りレジスタの上位バイトはたまたま残っていた何かで、falseがtrueとして届き得ます。同じヘッダーはさらに面倒も抱えます。TessPageIteratorBoundingBoxのような関数はintを返し、HotPDFはそれをIntegerと宣言するからです。API全体に1つの規約を仮定するのでなく、戻り値ごとにCの型を読んでください

2つ目の細部は、コールバックが決してraiseしないことです。TesseractのC++フレームを通ってアンワインドするDelphi例外は未定義動作なので、HotPDFのコールバックはキャンセルトークンと単調なGetTickCount64の値を読むだけです。アダプターはTessBaseAPIRecognizeが返った後に結果をキャンセルかタイムアウトの診断へ変え、その検査はネイティブの戻りコードにかかわらず行います

Delphi側が所有するネイティブポインターはどれか

HotPDFのTesseract DLLアダプターは、リクエストごとに3つのネイティブオブジェクトを所有します。APIインスタンス、モニター、結果イテレーターです。それ以外はすべて借用します。Recognizeの呼び出しごとに専用のセットを作り、finallyブロックで順に解放します。TessResultIteratorDelete、次にTessMonitorDelete、次にTessBaseAPIDeleteです。エンジンインターフェースの解放がライブラリをアンロードします

Recognize呼び出しごとのHotPDF Tesseract DLLのオブジェクト所有を示す図。結果イテレーター、モニター、APIインスタンスは所有され、finally内でその順に解放されます。TessResultIteratorGetPageIteratorからのページイテレーターは決して解放してはいけない借用ビューで、GetUTF8Textの文字列はコピーされTessDeleteText経由で返されます
所有は3オブジェクト、それ以外は借用です。固定の順で解放し、ページイテレーターを二重解放せず、アロケーターを混ぜない
  • TessResultIteratorGetPageIteratorは、新しいオブジェクトでなく、結果イテレーターへの借用ビューを返します。HotPDFはTessPageIteratorBoundingBoxとTessPageIteratorBaselineに使うだけで、決して解放しません。別途削除すると同じメモリを2回解放することになります
  • TessResultIteratorGetUTF8Textは、DLL自身のランタイムが確保した文字列を返します。HotPDFはそれをコピーし、finallyブロックでTessDeleteTextを通して返します。PascalのFreeMemは間違ったヒープで解放します
  • 単語テキストはstrictなUTF-8検証でデコードされ、変換前に長さ検査されます。制御文字を持つ単語、不正なUTF-8、画像の外のボックス、反転した矩形、0〜100の外の信頼度は、静かに修正される代わりにリクエストを失敗させます
  • リクエストあたりのテキスト合計は1,048,576 UTF-16コードユニットで頭打ちで、単語数はApplyLoadedOCRTextLayerから渡されるリクエスト予算に収まらなければなりません

信頼度は0〜100で届き、0〜1へスケールされるので、THPDFOCRTextLayerOptions.MinimumConfidenceはすべてのエンジンで同じ意味になります。Tesseractがベースラインを報告するときは両端が素通しされ、そうでなければテキストレイヤーのパイプラインが幾何推定へフォールバックします。TSV入力とまったく同じです

DLLに届く前にenumを検証する理由

HotPDFは、範囲検査の前にPageSegModeとEngineModeの生の序数をIntegerへコピーします。コンパイラは、enum変数が常に宣言値を保持すると仮定してOrd(X) > Ord(High(T))を定数falseへ折り畳み得るからです。序数は飾りではありません。THPDFTesseractPageSegModeはTesseractのページセグメンテーション番号0から13に、THPDFTesseractEngineModeはエンジンモード番号0から3に従い、どちらも素の整数としてDLLへ渡ります。FillCharで作られたオプションレコード、ストリームから埋められたもの、あるいはC++Builderからキャスト整数で渡されたものは、200のようなバイトを運び得ます。コピーされた序数の検証は、それをネイティブコード内の未定義モードでなく、ファクトリー時点のEArgumentExceptionへ変えます。ファクトリーはさらに、単語を生まないtpsOSDOnlyとtpsAutoOnlyを拒否し、tpsAutoOSDとtpsSparseTextOSDにはosd.traineddataを要求します

認識タイムアウトが実際に保証するものとは

Tesseract DLLのタイムアウトは協調的です。HotPDFは自分の作業を止め、Tesseractにも止まるよう頼めますが、ネイティブコードを強制的に返らせることはできません。時計はRecognizeが始まったときに動き出すので、ビットマップ変換とモデル初期化は認識と同じ予算を消費します。HotPDFは、グレースケール変換の間と、結果をイテレートしている単語の合間に、経過時間とキャンセルトークンを確認し、TessBaseAPIRecognizeを呼ぶ前に残りミリ秒をTessMonitorSetDeadlineMSecsへ渡します

隙間はネイティブ呼び出しの内側にあります。Tesseractのモニターが参照されるのは単語認識の間であって、TessBaseAPIInit2やページレイアウト解析の間ではありません。だから遅いモデルロードや病的なレイアウトは、タイムアウトが報告される前にデッドラインを走り抜けられます。ピクセルと出力の予算も、ネイティブライブラリ自身のメモリ使用を制限しません。殺せるワーカーが必要なら、プロセスアダプターを使ってください。それが正直なトレードオフであって、欠けている機能ではありません

HotPDF Tesseract DLLの協調的タイムアウトの解剖を示す図。時計はRecognizeの開始で動き出し、グレースケール変換、TessBaseAPIInit2、レイアウト解析を覆いますが、モニターが参照されるのは単語認識の間だけなので、HotPDFがotlsEngineErrorやotlsCancelledを報告する前にモデルロードやレイアウトが走り過ぎられます
ここのデッドラインは要求であって保証ではありません。初期化とレイアウト解析は長く走り得ますし、本当に殺せるワーカーにはプロセスアダプターが要ります

ページセグメンテーションは、DLLアダプターが厄介な入力で本領を発揮する場所です。散在するフィールドを持つフォーム、ラベル、スキャンテーブルは、存在しないカラムや段落を組み立てようとする自動セグメンテーションより、tpsSparseTextの方がたいていよく認識されます

procedure OCRFormPages(Doc: THotPDF; const Pages: array of Integer);
var
  Engine: IHPDFOCREngine;
  TessOptions: THPDFTesseractOptions;
  LayerOptions: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  TessOptions := THPDFTesseractOptions.Default;
  TessOptions.PageSegMode := tpsSparseText;  // 散在フィールド、カラム組み立てなし
  TessOptions.EngineMode := temLSTMOnly;     // tessdata内のLSTMモデルが必要
  TessOptions.TimeoutMilliseconds := 20000;  // モデル初期化を含む
  Engine := HPDFCreateTesseractDLLOCREngine('C:\OCR\Win64\libtesseract-5.dll',
    'C:\OCR\tessdata', 'eng+deu', TessOptions);

  LayerOptions := THPDFOCRTextLayerOptions.Default;
  LayerOptions.MinimumConfidence := 0.6;
  if not Doc.ApplyLoadedOCRTextLayer(Pages, Engine, LayerOptions, Info) then
    case Info.Status of
      otlsCancelled:
        Writeln('OCR cancelled, document unchanged');
      otlsEngineError:
        Writeln('Tesseract failed or timed out: ', string(Info.Diagnostic));
    else
      Writeln(string(Info.Diagnostic));
    end;
end;

タイムアウトは診断Tesseract DLL OCR timed out付きのotlsEngineErrorとして表面化し、キャンセルされたトークンはotlsCancelledとして表面化します。どちらの場合もApplyLoadedOCRTextLayerは、コミットトランザクションを始める前に、選択された全ページを認識し終えているので、50ページ中40ページでの失敗は、ロード済みドキュメントをまったくそのままにします。tpsSingleLine、tpsSingleBlock、tpsSparseTextはセグメンテーションだけを変えることに注意してください。傾いたスキャンをまっすぐにするものはどれもありません

Free PascalとLazarus:古いピクセルと消えた中国語

2つのTesseractファクトリーは、FPC固有の2つの修正の後、v2.772.1以降、WindowsのFree PascalとLazarusのWin32とWin64ビルドで動きます。まずターゲットアーキテクチャ向けにLazarusパッケージを再ビルドしてください。移植の全体はFree PascalとLazarus Win64上のHotPDFで扱います

1つ目の修正はピクセルに関するものです。scanline経由で書かれたLCLのTBitmapは、Windowsのビットマップハンドルを更新せずに生画像を変え得るので、そのハンドルへのGetDIBitsは古いピクセルを返します。症状は困惑ものでした。ビットマップへ直接描かれたテキストは認識されるのに、HotPDFのPDFレンダラーが描画したページは空の単語リストになりました。FPCでは、アダプターは現在、CreateIntfImage経由でフォーマットを考慮したスナップショットを読みます。これは生画像のピクセル形式と行順を尊重します。Delphiビルドは、プライベートな24ビットコピー上でGetDIBits経路を保ちます。どちらのビルドも呼び出し側のビットマップを変更しません

2つ目の修正はtesseract.exeアダプター側に属します。FPCのTStringListはANSI文字列を保存するので、デコード済みのUTF-8 TSVテキストをLines.Textへ代入すると、システムANSIコードページが表現できなかった中国語や補助平面の文字が黙って落ちていました。FPC経路は現在、TSVをUTF-8バイトとして保持し、BOMをバイトレベルで剥がし、各単語を個別にUnicodeStringへデコードします。DLLアダプターはこの問題を最初から抱えていません。イテレーターから直接各単語をデコードするからです

クイックリファレンス

  • ファクトリー:HPDFCreateTesseractDLLOCREngine(LibraryPath, TessDataDirectory, Language[, Options])はHPDFTesseractRecognitionにあり、v2.772.0で追加、FPC対応はv2.772.1
  • デフォルト:tpsAuto、temDefault、60,000 ms、16,777,216ピクセル。タイムアウト範囲1〜3,600,000 ms、ピクセル上限67,108,864
  • DLLのビットネスをアプリケーションに一致させ、依存DLLはTesseract DLLの隣に置く
  • モニターはオペークとして扱う。ETEXT_DESCをPascalレコードへコピーしない
  • キャンセルコールバックは1バイトのBoolean戻り値でcdeclと宣言し、例外を脱出させない
  • イテレーターのテキストはTessDeleteTextで解放する。結果イテレーターから得たページイテレーターは決して解放しない
  • デッドラインは協調的だと期待する。モデル初期化とレイアウト解析は走り過ぎ得る
  • ハード終端やクラッシュ分離が必要なときは、tesseract.exeアダプターを使う

Tesseract DLLアダプター、プロセスアダプター、組み込みOCRエンジンは、Delphi、C++Builder、Free Pascal向けのHotPDF Delphi PDFコンポーネントにすべて同梱されています。エディションとダウンロードは、HotPDF製品ページを参照してください