技術記事

HotPDFでTesseract OCRから検索可能PDFをDelphiで作る

HotPDFは、スキャンされたPDFページを、Tesseractで検索可能なPDFへ変えます。ローカルにインストールされたTesseract実行ファイルをIHPDFOCREngineとして包むファクトリー、HPDFCreateTesseractOCREngineを通してです。そのエンジンをApplyLoadedOCRTextLayerへ渡すと、各ページを描画し、ページごとに1回Tesseractを走らせ、ワードレベルのTSV出力を解析し、要求された全ページの不可視Unicodeテキストレイヤーを、1つのトランザクションでコミットするか、まったくコミットしません

HotPDFのページごとのOCRパイプライン。設定されたDPIでページを描画し、専用のHotPDF-OCRディレクトリーにinput.bmpを保存し、tessedit_create_tsv付きでTesseractの子プロセスを起動し、12列のTSVを解析し、信頼度でワードをフィルターし、要求された全ページの不可視テキストレイヤーを、全部か無しかでコミットします
アダプターが置き換えるのは認識だけです。描画、解析、検証、オールオアナッシングのコミットは既存のテキストレイヤーパイプラインに留まるので、下流のコードは変わりません

このアダプターが存在する理由は、範囲です。組み込みのテンプレートマッチングOCRエンジンは意図的に狭く作られています。機械印字のASCII文字と数字、それだけです。アクセント付きの名前のある請求書、中国語の契約書、多言語のアーカイブには、学習済み言語モデルを持つ本物のレコグナイザーが必要で、Tesseractは明白な候補です。アプリケーションの隣に配置できるコマンドラインプログラムだからです。文書ライブラリーから外部プログラムを呼ぶのは簡単そうに聞こえます。違います。アダプターの面白いコードの大半は、プログラムが誤動作する、固まる、キャンセルされる、あるいは決して見るべきでないものを継承する、そのとき何が起きるかについてです

HotPDFはDelphiアプリケーションからTesseractをどう駆動するか

HotPDFはTesseractを、ページごとの隠し子プロセスとして走らせ、描画済みビットマップを与え、TSVファイルを読み返し、結果を、組み込みエンジンが使うのと同じIHPDFOCREngineの継ぎ目を通して公開します。下流では何も変わりません。座標のマッピング、回転の処理、Unicode検証、信頼度のフィルタリング、そしてアトミックなコミットは、あなたがすでに持っているテキストレイヤーのパイプラインです。ファクトリーはHPDFTesseractRecognitionユニットにあり、事前に検証します。実行ファイルが存在すること、tessdataディレクトリーが存在すること、タイムアウトが1から3,600,000ミリ秒の間であること、そして言語識別子はASCII文字、数字、_、+しか含めないこと。最後の検査が効いてくるのは、言語文字列がコマンドラインに載るからです。eng+chi_simはTesseractの正当な値ですが、引用符や空白を含むものは違います

uses
  SysUtils, HPDFTypes, HPDFDoc, HPDFTesseractRecognition;

procedure MakeSearchable(const SourceFile, TargetFile: string;
  Token: THPDFCancellationToken);
var
  Doc: THotPDF;
  Engine: IHPDFOCREngine;
  Options: THPDFOCRTextLayerOptions;
  Info: THPDFOCRTextLayerInfo;
begin
  // 実行ファイルの欠落、tessdataの欠落、
  // 不正な言語識別子、1..3600000 ms外のタイムアウトでEArgumentExceptionをraise
  Engine := HPDFCreateTesseractOCREngine(
    'C:\OCR\Tesseract\tesseract.exe',
    'C:\OCR\Tesseract\tessdata',
    'eng+chi_sim',      // '+'で結合された複数のモデル
    120000);            // ページごとの上限、既定は60000
  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
    Options.CancellationToken := Token;
    // 空のページリストは全ページを意味する。テキストのあるページは既定でスキップ
    if Doc.ApplyLoadedOCRTextLayer([], Engine, Options, Info) then
    begin
      Writeln(string(Info.EngineName), ': ', Info.AcceptedWordCount,
        ' words accepted, ', Info.DroppedWordCount, ' dropped');
      Doc.SaveLoadedDocument(TargetFile);
    end
    else
      case Info.Status of
        otlsCancelled:      Writeln('Cancelled, document unchanged');
        otlsEngineError:    Writeln('Engine: ', string(Info.Diagnostic));
        otlsBudgetExceeded: Writeln('Budget: ', string(Info.Diagnostic));
      else
        Writeln(string(Info.Diagnostic));
      end;
  finally
    Doc.Free;
  end;
end;

ページごとに、Recognizeはtempパスの下にHotPDF-OCR-{GUID}という名前の専用ディレクトリーを作り、描画済みビットマップをinput.bmpとして保存し、tesseract input.bmp output --tessdata-dir … -l … --dpi N --psm 3 -c tessedit_create_tsv=1を起動します。すべてのパス引数は、バックスラッシュと埋め込み引用符についてのWindowsコマンドラインのエスケープ規則で引用符付きにします。--dpiの値はTHPDFOCRTextLayerOptions.DPIからの描画DPIなので、Tesseractは画像のメタデータから解像度を推測する必要がありません。そして--psm 3は完全自動のページ分割を要求します。エンジンは自分をTesseract (local CLI)と報告し、これがInfo.EngineNameに入ります。Tesseractとその言語モデルはHotPDFに同梱されません。インストールはアプリケーションの仕事です

TSVパーサーがこれほど厳格なのはなぜか

HotPDFのTSVパーサーは、不正な行が1つでもあればページ全体を失敗させます。部分的に解析されたワードリストは、画像と静かに食い違うテキストレイヤーを作るからです。TesseractのTSV出力には固定の12列ヘッダーがあり、levelからtextまでです。HotPDFは最初の行を、省略可能なバイト順マークを剥いだうえで、その正確なヘッダーと比較します。以降のすべての行はきっかり12フィールドに分割されなければならず、分割は11番目のタブの後で止まるので、認識されたテキストの中のタブは、13列目を作る代わりに、ワードの一部のまま残ります。レベル5の行だけがワードです。レベル1から4はページ、ブロック、段落、行を記述し、スキップされます。テキストが空か純粋な空白であるレベル5の行もスキップされます。空白のワードにはボックスはあるが、位置づけたり検索したりするものがないからです。それ以外はすべて厳しく検査されます。整数のジオメトリ、不変のen-USフォーマットでパースされる信頼度、ドイツ語ロケールが93.5をゴミと読まないように、ビットマップの完全内側にあるボックス、そして0から100の間の信頼度です。1つの失敗がraiseし、エンジンはFalseを返し、ワード配列はクリアされます。リグレッションテストはまさにそのケースを含みます。有効なワード1つに続いて壊れた行がある場合、1ではなく0ワードでなければなりません

HotPDFでTesseractのTSV行が通る6つのゲート。正確な12列ヘッダー、きっかり12フィールド、レベル5のみ、非空白のテキスト、ビットマップの内側のボックス、そして不変にパースされた0から100の信頼度。1つの壊れた行が、ページ全体をゼロワードまで落とします
部分的に解析されたワードリストは画像と静かに食い違うので、パーサーは、すでに読んだワードを保持する代わりに、最初の不正な行でページ全体を拒否します
// HPDFLocalTSVRecognitionのレベル5ループから要約
if (Fields.Count <> 12) or not TryStrToInt(Fields[0], Level) then
  raise EConvertError.Create('Invalid Local OCR TSV row');
if Level <> 5 then Continue;                 // ページ/ブロック/段落/行の行
WordText := Fields[11];
if Trim(WordText) = '' then Continue;        // 空白のワードに位置はない
if not TryStrToInt(Fields[6], X) or not TryStrToInt(Fields[7], Y) or
  not TryStrToInt(Fields[8], W) or not TryStrToInt(Fields[9], H) or
  not TryStrToFloat(Fields[10], Confidence, Settings) then
  raise EConvertError.Create('Invalid Local OCR word geometry');
if (X < 0) or (Y < 0) or (W <= 0) or (H <= 0) or
  (Int64(X) + W > Request.Bitmap.Width) or
  (Int64(Y) + H > Request.Bitmap.Height) or
  not ((Confidence >= 0) and (Confidence <= 100)) then
  raise EConvertError.Create('Local OCR word is outside the image');
Words[Count].Confidence := Confidence / 100;  // パイプラインは0..1を期待する

最後の行は、予想していないかもしれない既定値と噛み合います。Tesseractの信頼度は0から100、パイプラインは0から1で動き、THPDFOCRTextLayerOptions.MinimumConfidenceの既定は0.5です。だから50未満のTesseractワードはInfo.DroppedWordCountに数えられ、ページへ届くことはありません。きれいな300 DPIスキャンでは、それは妥当な下限です。ノイズの多いファックスでは、ページの驚くほどの割合を落とし得ます。正しい対応は、閾値を下げる前に、落とした数を見ることです。低信頼度のワードこそ、間違っている可能性が最も高いものですから

Tesseractの子プロセスは何を継承するか

Tesseractの子プロセスがHotPDFから継承するのは、きっかり2つのハンドルです。標準入出力のNULハンドルと、標準エラーのファイルハンドル。この精密さこそが要点です。bInheritHandles = TrueのCreateProcessは標準ハンドルを子へ渡すやり方ですが、それだけでは、ホストプロセスのすべての継承可能なハンドルを渡します。アプリケーションの無関係なコードが開いたファイル、パイプ、イベントも含めて。子はそれらのオブジェクトを、終了するまで生かし続けます。だからTesseractがページを処理している間、ファイルはロックされたまま、パイプは終端を見ません。HotPDFはこの隙間を、拡張スタートアップレコードで塞ぎます。STARTUPINFOEX、PROC_THREAD_ATTRIBUTE_HANDLE_LISTを運ぶ属性リスト、そしてEXTENDED_STARTUPINFO_PRESENT作成フラグです。ハンドルリストがあれば、bInheritHandlesはそれでもTrueでなければなりませんが、境界を越えるのはリストされたハンドルだけです。同じ封じ込めの発想が、ワーカープロセスでのPDF画像コーデックの隔離を支えています。そちらでは子が信頼されないコードです。こちらでは子は信頼されますが、ホストは自分のハンドルテーブルの唯一の所有者ではありません

HotPDFにおけるTesseract子プロセスのハンドル継承。素のCreateProcessとbInheritHandlesは、継承可能なファイル、パイプ、イベントのハンドルすべてを子へ渡します。一方STARTUPINFOEXとPROC_THREAD_ATTRIBUTE_HANDLE_LISTは、集合を、stdinとstdoutのNULハンドルとstderrのファイルハンドルに限定します
属性リストがなければ、子は無関係なオブジェクトを終了まで生かし続け、ファイルをロックし、パイプを飢えさせます。あれば、境界を越えるのはリストされた2つのハンドルだけです
// 定数は名前で表示。ソースは数値を渡す
// 両ハンドルはbInheritHandle = Trueで作られる
InheritedHandles[0] := NullHandle;    // stdinとstdout
InheritedHandles[1] := ErrorHandle;   // 専用ディレクトリーのstderr.txt
InitializeProcThreadAttributeList(Startup.AttributeList, 1, 0, AttributeBytes);
UpdateProcThreadAttribute(Startup.AttributeList, 0,
  PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
  @InheritedHandles[0], SizeOf(InheritedHandles), nil, nil);
CreateProcess(PChar(Executable), PChar(Command), nil, nil,
  True,                                        // ハンドルリストに必要
  CREATE_NO_WINDOW or EXTENDED_STARTUPINFO_PRESENT,
  nil, PChar(DirectoryName), Startup.StartupInfo, ProcessInfo);

キャンセルされたOCR実行がエンジン失敗に見え得るのはなぜか

キャンセルされたOCR実行がエンジン失敗に見えるのは、IHPDFOCREngine.Recognizeが単一のBooleanを返し、Falseが「Tesseractが失敗した」と「ユーザーがCancelを押した」の両方を意味するからです。アダプターは、子が動いている間、キャンセルトークンとタイムアウトを25ミリ秒ごとにポーリングし、トークンが立ったらRecognizeの内側でraiseし、自分の例外を捕捉し、後片付けをして、診断付きのFalseを返します。パイプラインがそれをエンジンエラーとして扱うと、呼び出し側は、ユーザーが意図的に止めた仕事に対してotlsEngineErrorを見ることになります。だからApplyLoadedOCRTextLayerは、RecognizeがFalseを返すときは必ずトークンを先に確認し、トークンが立っていなかったときにだけ、結果をエンジン失敗へ変換します。この順序がマルチページの契約を守ります。認識、検証、budget計上、そして内容の構築は、グラフトランザクションが開く前に、要求されたすべてのページに対して走ります。だから50ページ中40ページでのキャンセルはotlsCancelledを報告し、最初の39ページを含む文書を無傷のままにします。あとで説明すべき、部分的に検索可能なファイルは存在しません。そして残りの失敗処理も、同じ有界スタイルに従います:

  • タイムアウトはRecognize呼び出しごとで、開始から測られるので、既定の60,000 msは文書全体ではなく各ページに適用されます
  • タイムアウトやキャンセル時点でまだ動いている子は終了され、最大5秒待ち合わされ、その専用ディレクトリーはfinallyブロックで削除されます
  • output.tsvは64 MiB、stderr.txtは1 MiBで頭打ちです。子が動いている間も、終了後も、確認されます
  • ワード数とUTF-16コードユニットは、残りのMaxWordsPerPage、MaxTotalWords、MaxTextCodeUnitsのbudgetでページごとに頭打ちになり、超過すると、ワードリストを切り詰める代わりに、実行は失敗します
  • 標準出力はNULへ行きます。Tesseractはoutput.tsvに書くからです。一方、標準エラーはファイルへ行き、ゼロでない終了コードは、エンジン自身の不満を最大4,096文字で伴って報告されます。たいてい、.traineddataファイルの欠落を知る最速の道です

認識されたワードが不可視テキストレイヤーになるまで

HotPDFはTesseractのワードを、テキストレンダリングモード3、ISO 32000-1 §9.3.6で定義される塗りも描画もしないモードで、不可視テキストとして書きます。だからページはスキャン画像を表示し続け、検索とコピーは認識されたワードの上で働きます。コンテンツストリームはBTを3 Trで開き、各ワードは、ベースラインにTm行列を受け取り、描画DPIでのピクセル単位のボックス高さから導出されたフォントサイズ、そしてグリフ列を測られたボックス幅へ引き伸ばすTz水平スケールを受け取ります。検索のハイライトが、画像の上を漂う代わりに、画像の中のワードに乗るのは、そのためです

TesseractのTSVにはボックスはあるがベースラインはありません。だからアダプターはすべてのワードをベースラインなしで報告し、パイプラインは、底辺の上、ボックス高さの5分の1のところにベースラインを推定します。テキストそのものは、Identity-Hエンコードと生成されたToUnicode CMapを持つ、共有の非埋め込みType0フォントを通ります。実行全体を通して、異なるUnicodeスカラーごとに1つのCIDです。中国語、アクセント付きラテン文字、補助面の文字が、コピーと検索を生き延びるのは、そのためです。この設計には、先に述べておく価値のある2つの限界があります。1つの実行が運べるのは最大65,535の異なるスカラーまで、そして非埋め込みフォントはISO 19005のフォント埋め込み要求を満たさないので、PDF/A出力は、別に埋め込まれた適合フォントを必要とします。結果の確認は簡単で、自動化する価値があります。保存し、再読み込みし、Delphiで読み込み済みPDFからテキストを抽出するの普通の読み込み済み文書テキスト経路を走らせる。ワードが期待どおりのページで返ってくれば、レイヤーは本物です

同じTSVプロトコル上のRapidOCRと他のエンジン

HotPDFは、RapidOCRのために、同じプロセスランナーとTSVパーサーをHPDFCreateRapidOCREngine(PythonExecutable, BridgeScript, ModelDirectory, TimeoutMilliseconds)経由で再利用します。簡体字中国語のスキャンには、こちらのほうが有用な選択です。コマンドラインは、Python実行ファイルの後にブリッジスクリプトのパスが挿入されることと、言語がchi_simに固定されることを除いて同一です。HotPDFはブリッジをtools/OCR/rapidocr_tsv.pyとして同梱します。これはrapidocrとonnxruntimeパッケージと3つのローカルONNXモデルを期待し、モデルの自動ダウンロードを無効化し、Tesseractの形をしたTSVを書きます。Delphi側に2つ目のパーサーは要りません。Info.EngineNameで報告されるエンジン名はRapidOCR (local ONNX)です。この形が一般的な処方箋を示唆します。Tesseractスタイルの引数リストを受け付け、12列のTSVを出す小さなスクリプトに包めるレコグナイザーなら、ハンドル隔離、タイムアウト、キャンセル、出力のbudget、オールオアナッシングのコミットを無料で受け継ぎます。アダプターはWindows専用で、1度に1ページを同期的に走らせ、レンダラーが出すものを超えて画像をデスキューしたり前処理したりしません。入る画像の品質が、出るものの上限を決めることに変わりはありません

TesseractとRapidOCRのアダプター、不可視テキストレイヤーのライター、それらに食わせるページレンダラー、そして結果を検証するテキスト抽出は、DelphiとC++Builder向けの同じネイティブVCLコンポーネントにすべて同梱されています。文書キャプチャやアーカイブアプリケーションにOCRを足すなら、HotPDF Delphi PDF componentがパイプラインを提供します。インストールが残るのはOCRエンジンそのものだけです