技術記事

HotPDFで中国語・多言語OCR:DelphiのRapidOCR言語プリセット

HotPDFは、ネイティブRapidOCR DLLアダプターを通して、Delphiで中国語・多言語OCRを実行します。THPDFRapidOCRDLLOptions.ForLanguageが、'zh-CN'、'zh-TW'、'ru'、'ar'のような言語タグを、一致する認識モデルと文字辞書へマップし、THotPDF.ApplyLoadedOCRTextLayerが、認識された行を、スキャンPDFページ上の不可視で検索可能なUnicodeテキストレイヤーへ変えます

ラテン文字のデモを動かすのは易しい方です。面白い失敗は、繁体字中国語やロシア語へ切り替えたとたんに出力が自信満々の、形の整ったナンセンスに変わるとき、全行が黙って最後の1文字を失うとき、アラビア語のページがテキストボックスを間違った順で返してくるときに始まります。どれも単独では例外を上げません。HotPDF v2.775.0で追加された言語プリセットは、主としてその隙間を塞ぐために存在します。そして下の4つの罠は、ネイティブコードに触れない人こそ理解する価値があります。それぞれが、知らなければ1日追い回されかねない症状の説明になるからです

ForLanguageはモデルと辞書をどう選ぶのか

THPDFRapidOCRDLLOptions.ForLanguageはタグを9つのプロファイルのどれかへ解決し、モデルディレクトリーの下の<profile>/recognition.onnxと<profile>/dictionary.txtを指すオプションを返します。共有の検出器、オプションの角度分類器、そしてTHPDFRapidOCRDLLOptions.Defaultのスレッド、ピクセル、タイムアウトのデフォルトは保持されます。メソッドはタグを小文字化し、アンダースコアをハイフンへ変え、前後の空白を刈り込むので、'zh_TW'、'ZH-tw'、' zh-tw 'はどれも同じプロファイルに着地します。エイリアスは明示的なリストであって、プレフィックスマッチではありません。'zh-Hant-TW'はリストにあるから受け付けられ、リストにない恣意的な地域変種は、どのモデルもロードされる前にEArgumentExceptionを上げます

THPDFRapidOCRDLLOptionsに対するHotPDF ForLanguageのプロファイル解決を示す図。zh_TW、ZH-tw、zh-TWのようなタグは正規化され、9つのリスト済みプロファイルと照合され、各プロファイルは常に一緒に設定される認識モデルと辞書を固定します。リスト外のタグは、どのモデルもロードされる前にEArgumentExceptionを上げます
1つのタグが1組の固定されたモデル・辞書ペアを選びます。検出器、分類器、予算は共有のままで、不明なタグは何もロードせずに早く失敗します
プロファイル言語タグの例固定されるモデル
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(9プロファイル全部なら-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;

この出力には注記に値する細部が2つあります。ネイティブパイプラインは検出されたテキスト行ごとに1つの結果を返すのであって、語ごとではないため、ここでのAcceptedWordCountは行を数え、MinimumConfidenceは行全体の平均文字信頼度と比較されます。平均0.45の行は丸ごと破棄されます。UniqueScalarCountは、テキストレイヤーがフォントとToUnicodeテーブルへマップしなければならなかった異なるUnicodeスカラーの数を報告します。少数のラテン文字フォールバックではなく、CJKテキストが実際に届いたことを確かめる、便利なサニティチェックです。エンジンのインターフェースはドキュメントをまたいで生かしてください。モデルの初期化はファクトリーで起きる、高価なステップです

認識モデルだけを切り替えるとゴミが出る理由

CTC認識モデルが出力するのは文字ではなくクラスタインデックスだけで、インデックス1,204をグリフに変えるのは辞書ただ一つです。ch/recognition.onnxをcyrillic/recognition.onnxへ替えつつ中国語辞書を保持すると、モデルは有効なキリル文字インデックスを喜んで出力し、旧辞書がそれをランダムな漢字へ翻訳します。結果はテキストに見え、UTF-8検証を通過し、そして検索しても何も見つかりません。ForLanguageが常にRecognitionModelとCharacterDictionaryを一緒に設定するのはこのためですし、手組みのオプションが片方だけ変えるべきでないのも同じ理由です

自明な安全検査である、辞書サイズとモデル出力幅の比較は、必要ではあっても十分ではありません。2つの辞書は、順序の違う同じエントリー数を持ち得ます。そして順序の1つずれは、すべての文字を1つのコードポイントぶんずらします。だからHotPDFは、ファクトリーがモデルを初期化するとき、2段階で検査します。第1に、出力クラス数が辞書エントリー数+2に等しいこと。第2に、ONNXファイルがcharacterメタデータリストを埋め込んでいるとき、辞書エントリーがそれと順に比較され、不一致は、後でそれらしいゴミを生む代わりに、EInvalidOperationとネイティブ診断で初期化を失敗させます

「+2」はクラスタレイアウトから来ます。クラス0はCTCブランク、クラス1からNはファイル順の辞書行、最終クラスはスペースです。辞書によっては独自のスペースエントリーを運ぶものもあり、その行はあるがままで保持されなければなりません。善意のTrimが本当に痛い傷を負わせるのはここです。スペース1つのエントリーを空文字列に変え、テーブルをずらしたり壊したりします。安全な正規化は末尾のキャリッジリターンの除去だけで、CRLFの行末で保存された辞書は正しくロードされます。その一方で、UTF-8のバイトオーダーマーク、空行、タブを含むエントリーは拒否されます。下のスケッチはPascalでのレイアウトです。説明用のコードであって、HotPDFのAPIではありません

RapidOCR辞書に対するHotPDFのCTCクラステーブルレイアウトを示す図。クラス0はブランク、クラス1からNはファイル順の辞書行で、単独のスペースエントリーは保持され、最終クラスはスペースです。モデルの出力クラス数はN+2となり、ファクトリーがメタデータ込みで検証します
クラスタインデックスを文字へ変えるのは辞書だけなので、そのサイズ、順序、スペースエントリーは、1ページも認識される前に検証されます
// 例示のみ: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ブランク
  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禁止:' 'も1クラス
  end;
  Result[Last + 2] := ' ';                       // 最終クラス:スペース
  // Length(Result)はモデル出力のクラス数と等しくなければならない
end;

グリーディーCTCデコードは実際に何をするのか

グリーディーCTCデコードは、すべてのタイムステップで最高スコアのクラスを選び、連続するリピートを1文字へ潰し、ブランククラスを落とします。ブランクがあるからこそ、本当に重複した文字が生き延びられるのです。認識モデルはテキスト行を、細い縦スライスの列として見ます。そして各スライス、つまりタイムステップごとに、すべてのクラスの確率を出力します。AA中を含む行は、argmax列としてA A blank A 中 spaceを生むかもしれません。最初の2つのAステップを潰すと1つのAになり、ブランクが次のAと隔てて、結果は末尾スペースが無事のままAA中 になります。ブランクのルールがなければ、bookとbokは区別不能です

HotPDF GreedyCTCDecodeのウォークスルーを示す図。6つのタイムステップがargmaxクラスA、A、blank、A、漢字、スペースに投票し、連続リピートは潰れ、ブランクがリピートガードをリセットするので本当に重複した文字は生き延び、3つの境界バグは単語間隔、最後の文字、重複文字を黙って落とします
デコーダーは12行ほどで、すべての境界が効きます。最後のクラスを含める、最後のステップを含める、リピートを隔てるのはブランクだけにする

デコーダーは12行ほどしかないので、境界を間違えるのは簡単で、失敗は無音です。内側のargmaxループが1クラス手前で止まると、スペースクラスは決して勝てず、すべての行が単語間隔なしで返ってきます。英語やラテン文字ページのフレーズ検索を壊します。外側のループが1タイムステップ手前で止まると、全行の最後の文字が消え、短い行ではテキストの3分の1になり得ます。そしてリピートガードがブランクでリセットされないと、llのような重複文字や、谢谢のような中国語の畳語が1つへ潰れます。HotPDFのデコーダーは、最後のクラスと最後のタイムステップを含め、ブランクで隔てられたリピートを保持し、加えて、有限でないスコアや0から1の外側のスコア、辞書と一致しないクラス数を拒否します。同じロジックのPascal例がこれです

// 例示のみ:正しい境界を持つグリーディーCTCデコーディング
// ScoresはSteps * Classes個の確率を持ち、タイムステップごとに1行
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ブランク
  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;                       // ブランクがリピートガードをリセットする
  end;
end;

グリーディーデコードは、利用できる中で最も正確なCTC戦略ではありません。言語モデル付きのビームサーチなら、いくつかの曖昧なスライスを直せます。300 DPIの印刷ドキュメントでは、グリーディーの結果はたいていモデルが提供できるものであり、デコーダーはモデルの弱さを補償する場所ではありません。たとえばラテン文字のPP-OCRv3モデルは、きれいな入力でもñをnと読み得ます。HotPDFはそれを後処理の文字置換で覆い隠しません。スペイン語を直す置換テーブルは何か別のものを壊しますし、検索可能レイヤーの中の間違った文字は、正直な取りこぼしより悪いからです

HotPDFは右から左のアラビア語を含むテキスト行をどう並べるのか

HotPDFは検出されたテキストボックスを上から下へソートし、小さい方のボックス高さの半分以上縦に重なるボックスを1つの行へグループ化し、各行を左から右へ、RightToLeftが有効なら右から左へ並べます。認識された行の内側の文字が逆順にされることは決してありません。グループ化が効くのは、検出器が1つの視覚行を複数のボックスへ分けがちだからです。たとえば広い隙間で隔てられたラベルと値です。純粋な上端座標ソートだと、上端が1〜2ピクセル違うだけで、隣の行とインターリーブされます

アラビア語プリセットはRightToLeft := Trueを設定します。これはDLLへ、各行のボックスを右端で並べるよう、右マージンから内側へ指示します。効果はそれだけです。モデルが行に対して返すテキストは、すでにUnicode論理順、つまりアラビア語の読み手が読み、タイプする順になっています。そしてそれは、PDFテキスト抽出と検索が期待する順でもあります。デバッガーで「正しく見える」ように文字列を機械的に逆順にすると、検索、コピー&ペースト、スクリーンリーダーが壊れます。双方向の表示とグリフのシェーピングはビュアーの仕事です

1つのエンジンが1つの言語プロファイルを担います。自動の文字体系検出はないので、文字体系を混ぜるドキュメントは、プロファイルごとに1つのエンジンを、それを使うページへ適用することになります。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ピクセル、約1740万で、リクエストは予算超過として拒否されます。大きなフォーマットではMaxPixelsを上げる(上限は67,108,864)か、THPDFOCRTextLayerOptions.DPIを下げてください。右から左の並べ替えは、ABIバージョン1のオプションエクスポートHPDFRapidOCRSetReadingDirectionを使います。アダプターはRightToLeftが設定されたときだけそれを要求するので、古いDLLでも左から右の言語は対応し続け、アラビア語では、欠けているエクスポートの名前を述べるEArgumentExceptionでエンジン生成に失敗します

新しいOCRモデルがロードに失敗する理由

HotPDFのRapidOCR DLLは静的なONNX Runtime 1.14へリンクしていて、これはONNX IRバージョン10で保存されたモデルを読めません。そしてPP-OCRv5モデルのような新しいエクスポートは、それより新しいランタイムを要求し得ます。その種のモデルは、ネイティブ診断とともにエンジン生成で失敗します。言語パックが「最新」でなく特定のPP-OCRv3とPP-OCRv4の認識器・辞書ペアに固定されているのはこの制約のためですし、上の表が2世代を混ぜるのも同じ理由です。固定されたペアはどれも、そのランタイムの下でロードし検証できるものです

インストーラーはペアリングを強制します。マニフェストのすべてのファイルはSHA256ハッシュを運び、ハッシュの違う既存ファイルは上書きされずにインストールを止め、各ダウンロードは一時的な名前の下へ着地し、ハッシュが一致した後にだけ所定の位置へ移動します。これは、辞書問題の静かな版からの保護です。誰かが手で新しいrecognition.onnxをプロファイルフォルダーへ放り込み、クラス数がたまたま一致し、そして顧客が「見えるのに検索で見つからない単語がある」と報告するまで何も失敗しない。実行時、アダプターはオフラインのままで、欠けたモデルを取得しに行くことは決してありません。認識器はさらに、ロード時にモデル形状を検証し、高さが32か48ピクセルに固定された、あるいは動的なNCHW入力を受け付けます。動的な高さは48で走らせます

9プロファイルのどれも覆わない文字体系が必要なら、RecognitionModelとCharacterDictionaryを自分のファイルへ向けることはまだできます。同じ検査が適用されます。それが狙いです。食い違ったペアは、顧客のアーカイブでなく初期化で失敗します。どちらのRapidOCRプロファイルも合わないページには、検索可能PDFのためのTesseractアダプターが同じApplyLoadedOCRTextLayer呼び出しへ差し込みますし、機械印字のASCIIフォームには、組み込みテンプレートマッチングOCRエンジンがモデルを一切必要としません

クイックリファレンス:多言語RapidOCRチェックリスト

  • オプションはTHPDFRapidOCRDLLOptions.ForLanguageで作り、EArgumentExceptionは未対応タグとして扱う。ランタイムの故障ではない
  • RecognitionModelとCharacterDictionaryは一緒に変える。片方だけは決して。等しいクラス数は、等しい文字順序の証明にならない
  • 辞書はBOMなしUTF-8のまま保ち、エントリーをtrimしない、そしてモデルはN+2クラスを持つと期待する。ブランク、Nエントリー、スペース
  • カスタムCTCデコーダーは、最後のクラスと最後のタイムステップをカバーし、ブランクで隔てられたリピートを保持しなければならない
  • 言語プロファイルごとに1つのエンジンを使い、文字体系混在のドキュメントには明示的なページリストを渡す
  • RightToLeftはボックスの順序だけを変える。認識されたテキストはUnicode論理順のまま
  • モデルはInstall-RapidOCRModels.ps1でインストールし、SHA256ピンにモデルと辞書のペアリングを守らせる。-SkipClassifierでインストールしたならUseAngleClassifier := Falseを設定する
  • 300 DPIでA3以上のページを回す前に、デフォルトの16,777,216を超えるMaxPixelsを設定する

RapidOCR言語プリセット、ネイティブDLLアダプター、OCRテキストレイヤーのパイプラインは、Delphi、C++Builder、WindowsのFPC/Lazarus向けHotPDF Delphi PDF Componentの一部で、多言語プロファイルはv2.775.0から入っています