🔎

yomitokuで作る無料の日本語OCR Webアプリ【Flask + TypeScript】

に公開

はじめに

昨今、AI が急速に普及している中で、改めて「OCR(光学文字認識)」という技術に注目してみました。

OCR の API サービスは多数存在していますが、どれも従量課金制で、利用量が増えるとコストが膨らんでいきます。Google Cloud Vision API や Amazon Textract などは高精度ですが、個人開発や小規模なプロジェクトでは原価が気になるところです。

「日本語 OCR を自分で構築できれば、コストを気にせず色々なことに使えるのではないか?」

OSS の OCR ライブラリとしては、Tesseract や EasyOCR などが有名ですが、日本語の認識精度や文書構造の理解という点では課題がありました。そんな中、日本語に特化した「yomitoku」というライブラリを見つけたのですが、実際の使い勝手や実装方法について詳しく解説した記事があまり見当たりませんでした。

そこで今回は、yomitoku を実際に使ってみて、その使い勝手を確かめながら、無料で動作する OCR Web アプリケーションを構築してみました。

この記事で紹介すること

  • yomitoku を使った日本語 OCR アプリの構築方法
  • Flask(Python)と TypeScript(Vite)を組み合わせた実装
  • yomitoku のデータ構造(paragraphs vs words)の理解
  • Docker Compose での開発環境構築

技術スタック

今回構築したアプリケーションの技術スタックは以下の通りです:

バックエンド

  • Python 3.11
  • Flask 3.1.2 - Web フレームワーク API
  • yomitoku 0.9.4 - 日本語 OCR エンジン
  • Pillow - 画像処理
  • OpenCV - 画像フォーマット変換
  • Gunicorn - 本番環境用 WSGI サーバー

フロントエンド

  • TypeScript - 型安全な開発
  • Vite - 高速ビルドツール&開発サーバー
  • Vanilla JS - シンプルなランタイム

インフラ

  • Docker Compose - 開発環境

yomitoku とは

yomitokuは、日本語文書に特化した OSS の OCR ライブラリです。

yomitoku の特徴

  1. 日本語に最適化

    • 日本語の文書レイアウトを理解
    • 縦書き・横書き両対応
  2. ドキュメント構造の理解

    • 段落(paragraphs)レベルでの認識
    • 単語(words)レベルでの認識
    • テーブル構造の認識
  3. 豊富な出力形式

    • JSON、Markdown、HTML、CSV 形式に対応
    • 位置情報(bounding box)付き
  4. オフライン動作

    • 完全にローカルで動作
    • 外部 API への通信不要
    • データのプライバシーを保護

アプリケーション構成

ディレクトリ構造

python-ocr-jp/
├── src/
│   ├── backend/          # Pythonバックエンド
│   │   ├── app.py       # Flaskアプリケーション
│   │   └── utils/
│   │       └── ocr_processor.py  # OCR処理ロジック
│   └── frontend/         # TypeScriptフロントエンド
│       ├── main.ts      # メインロジック
│       ├── style.css    # スタイル
│       └── index.html   # 開発用HTML
├── templates/            # Jinja2テンプレート(本番用)
│   └── index.html
├── static/              # ビルド後の静的ファイル(自動生成)
├── requirements.txt      # Python依存関係
├── package.json         # Node.js依存関係
├── vite.config.js       # Viteビルド設定
├── compose.yml          # Docker Compose設定
└── Dockerfile           # Dockerイメージ定義

アーキテクチャ

┌─────────────────────────────────────────────┐
│         ユーザー(ブラウザ)                    │
└─────────────────┬───────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────────┐
│   フロントエンド(TypeScript + Vite)           │
│   - ドラッグ&ドロップUI                         │
│   - 結果表示(Paragraphs / Words)             │
└─────────────────┬───────────────────────────┘
                  │ POST /api/ocr
                  ▼
┌─────────────────────────────────────────────┐
│        バックエンド(Flask)                    │
│   - ファイルアップロード処理                     │
│   - OCR結果の整形・返却                         │
└─────────────────┬───────────────────────────┘
                  │
                  ▼
┌─────────────────────────────────────────────┐
│         yomitoku(OCRエンジン)                │
│   - 画像解析                                   │
│   - テキスト抽出                               │
│   - レイアウト認識                             │
└─────────────────────────────────────────────┘

yomitoku のデータ構造を理解する

yomitoku の特徴的なポイントは、階層的なデータ構造を返すことです。

1. DocumentAnalyzerSchema の構造

yomitoku は以下のような階層でデータを返します:

DocumentAnalyzerSchema
├── paragraphs: List[ParagraphSchema]  # 段落レベル
├── words: List[WordSchema]            # 単語レベル
├── figures: List[FigureSchema]        # 図表
└── tables: List[TableSchema]          # テーブル

2. Paragraphs とは

paragraphsは、文書の意味的なまとまりを表します。

  • 複数の単語をまとめた段落単位
  • レイアウトを考慮した論理的なグルーピング
  • 文書構造を保ったまま抽出

例:

氏名 日 本 花 子 昭和61年 5月 1日生

これが 1 つの paragraph として認識されます。

3. Words とは

wordsは、個別の単語を表します。

  • OCR で認識した最小単位
  • より細かい粒度での抽出
  • 位置情報が正確

例:

"氏名", "架空", "菜舞絵", "住所", "東京都", ...

各単語が個別に認識されます。

4. どちらを使うべきか?

この 2 つのデータ構造は用途によって使い分けが必要です:

用途 使用データ 理由
文書全体の内容理解 paragraphs 文脈を保ったまま抽出できる
キーワード検索 words 細かい粒度で検索可能
レイアウト保持 paragraphs 段落構造が保たれる
データ抽出 words 個別要素へのアクセスが容易

5. 実装での工夫

今回のアプリケーションでは、両方を同時に表示することで、yomitoku がどのように文書を認識しているかを可視化しています。

# paragraphsとwordsの両方を返す
extracted_data = {
    "success": True,
    "paragraphs": {
        "text": paragraph_text,
        "blocks": paragraph_blocks,
        "count": len(paragraph_blocks)
    },
    "words": {
        "text": word_text,
        "blocks": word_blocks,
        "count": len(word_blocks)
    },
    "raw_data": raw_data
}

実装のポイント

1. yomitoku の初期化

yomitoku は初回実行時にモデルをダウンロードするため、アプリケーション起動時に初期化しています:

# src/backend/app.py
from utils.ocr_processor import initialize_analyzer

# yomitokuを起動時に初期化
print("Starting yomitoku initialization...")
initialize_analyzer()
print("yomitoku initialization completed!")

これにより、初回リクエストの待ち時間を削減できます。

2. 画像フォーマットの変換

yomitoku は OpenCV の BGR 形式を期待するため、PIL Image から NumPy 配列、そして BGR へと変換が必要です:

# PIL ImageをNumPy配列に変換
image = Image.open(image_path)
if image.mode != 'RGB':
    image = image.convert('RGB')

image_array = np.array(image)

# OpenCV形式(BGR)に変換
import cv2
image_array = cv2.cvtColor(image_array, cv2.COLOR_RGB2BGR)

# yomitokuでOCR処理
analyzer = yomitoku.DocumentAnalyzer(device="cpu", visualize=False)
results, ocr_vis, layout_vis = analyzer(image_array)

3. Paragraphs と Words の両方を取得

paragraphs だけでは情報が不足する場合があるため、words も同時に取得します:

# paragraphsを取得
paragraph_blocks = []
if hasattr(result, 'paragraphs') and result.paragraphs:
    for para in result.paragraphs:
        content = para.contents if hasattr(para, 'contents') else para.content
        paragraph_blocks.append({
            "content": str(content),
            "bbox": getattr(para, 'box', None)
        })

# wordsも常に取得
word_blocks = []
if hasattr(result, 'words') and result.words:
    for word in result.words:
        content = word.contents if hasattr(word, 'contents') else word.content
        word_blocks.append({
            "content": str(content),
            "bbox": getattr(word, 'box', None)
        })

4. フロントエンドでの表示

フロントエンドでは、Paragraphs と Words を別々のセクションで表示します:

// Paragraphsセクション
const paragraphLines = data.paragraphs.blocks
  .map((block) => `<p class="text-line">${escapeHtml(block.content)}</p>`)
  .join("");

// Wordsセクション
const wordLines = data.words.blocks
  .map((block) => `<p class="text-line">${escapeHtml(block.content)}</p>`)
  .join("");

resultContent.innerHTML = `
  <h2>OCR結果</h2>
  <h3>Paragraphs (${data.paragraphs.count}個)</h3>
  <div class="text-lines">${paragraphLines}</div>
  <hr>
  <h3>Words (${data.words.count}個)</h3>
  <div class="text-lines">${wordLines}</div>
`;

5. Docker Compose でホットリロード

開発体験を向上させるため、Python と TypeScript の両方でホットリロードを有効化しています:

# compose.yml
services:
  web:
    volumes:
      - ./src/backend:/app/src/backend
    command: python src/backend/app.py # Flask debug mode

  vite:
    volumes:
      - ./src/frontend:/app/src/frontend
    command: npm run dev -- --host 0.0.0.0 # Vite dev server

EasyOCR との比較

yomitoku を選んだ理由をより明確にするため、実際に同じ画像(運転免許証のサンプル)を EasyOCR と yomitoku で処理した結果を比較してみました。

EasyOCR の結果

EasyOCR では以下のような結果になりました:

0    氏名上日_本一花_子    (信頼度: 0.1939)
1    昭和61年              (信頼度: 0.9969)
2    5月                   (信頼度: 0.8933)
...
9    阿四ちつ碑)即1ケ爾    (信頼度: 0.0)
13   菜件芽                (信頼度: 0.0537)
26   公ま委員会            (信頼度: 0.2221)

EasyOCR の課題:

  • 誤認識が多い: "氏名上日_本一花_子"、"阿四ちつ碑)即1ケ爾" など、意味不明な文字列が多数
  • 信頼度が低い: 多くの認識結果で信頼度スコアが 0.5 以下
  • 文書構造の理解がない: 単純に文字列を列挙するだけで、段落や意味的なまとまりが考慮されていない
  • ノイズが多い: "_" や不要なスペースなど、後処理が必要

yomitoku の結果

一方、yomitoku では以下のように正確に認識されました:

Paragraphs(段落レベル):

氏名 日本 花子 生年月日 昭和61年5月1日生

Words(単語レベル):

氏名, 日本, 花子, 生年月日, 昭和61年, 5月, 1日生, ...

yomitoku の優位性:

  • 高精度な認識: 日本語の文字を正確に認識
  • 文書構造の理解: 段落単位で意味的なまとまりを保持
  • クリーンな出力: ノイズが少なく、後処理が不要
  • 柔軟なデータ構造: paragraphs と words の 2 つの粒度で取得可能

精度の違いが生まれる理由

今回のテストでは yomitoku の方がより良い結果が得られましたが、これは 日本語文書に特化したモデルを使用していることが影響していると考えられます:

  1. 日本語の文字認識に最適化

    • ひらがな、カタカナ、漢字を正確に識別
    • 文字の形状や画数を考慮した認識
  2. レイアウト解析エンジン

    • 文書の構造を理解
    • 段落、表、図などを適切に分類
  3. 日本語データセットでの学習

    • 日本語文書を大量に学習
    • 日本特有の文書フォーマットに対応

このように、日本語 OCR を行う場合、yomitoku は有力な選択肢の一つと言えるのではないでしょうか。

動作確認

1. アプリケーションのトップ画面

シンプルな UI で、画像をドラッグ&ドロップまたはクリックでアップロードできます。

2. OCR 処理中

yomitoku が OCR 処理を実行中です。10〜15 秒程度かかります。

3. OCR 結果の表示

Paragraphs セクション:

  • 段落単位でまとまった文章が表示されます
  • 文書の論理構造を保ったまま抽出されています

Words セクション:

  • 個別の単語が表示されます
  • より細かい粒度で認識結果を確認できます

4. 詳細情報(元データ)

詳細情報セクションを開くと、yomitoku が返した生のデータを JSON 形式で確認できます。位置情報(bounding box)なども含まれています。

パフォーマンス最適化

1. yomitoku の初期化を起動時に実行

# アプリケーション起動時に初期化
initialize_analyzer()

これにより、初回リクエストの待ち時間を削減できます。
初期化は1分くらいかかるので、リクエストごとにすると地獄です。

2. Analyzer インスタンスの再利用

状態管理の問題を避けるため、リクエストごとに新しいインスタンスを作成していますが、将来的にはプーリングなどで最適化の余地があります。

3. 処理後の一時ファイル削除

# OCR処理後にファイルを削除
os.remove(filepath)

ストレージを圧迫しないように、処理後は即座に削除しています。

セキュリティ上の考慮点

1. ファイルタイプの検証

ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp'}

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

2. ファイルサイズ制限

MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE

3. secure_filename()の使用

from werkzeug.utils import secure_filename

filename = secure_filename(file.filename)

パストラバーサル攻撃を防ぐため、ファイル名をサニタイズしています。

4. 一時ファイルの適切な削除

try:
    result = process_image(filepath)
    os.remove(filepath)
except Exception as e:
    if os.path.exists(filepath):
        os.remove(filepath)

エラー時も必ず削除することで、ディスク容量の浪費を防ぎます。

トラブルシューティング

1. yomitoku のインポートエラー

症状:

ModuleNotFoundError: No module named 'yomitoku'

解決方法:

pip install yomitoku==0.9.4

2. OpenGL ライブラリのエラー(Docker 環境)

症状:

ImportError: libGL.so.1: cannot open shared object file

解決方法:
Dockerfile に以下を追加:

RUN apt-get update && apt-get install -y \
    libgl1 \
    libglib2.0-0 \
    libfontconfig1

3. 'shape' AttributeError

症状:

AttributeError: 'PIL.Image.Image' object has no attribute 'shape'

解決方法:
PIL Image を NumPy 配列に変換:

import numpy as np
image_array = np.array(image)

4. paragraphs が少ない

症状:
画像によっては paragraphs が 1 つしか検出されない

解決方法:
words も同時に取得して表示することで、より詳細な情報を確認できます。

まとめ

今回、yomitoku を使った日本語 OCR Web アプリケーションを構築しました。

得られた知見

  1. yomitoku は日本語 OCR として非常に優秀

    • 高精度な文字認識
    • 文書構造の理解
    • 完全無料で使える
  2. paragraphs と words の使い分けが重要

    • 用途に応じて適切なデータ構造を選択
    • 両方を取得しておくと柔軟に対応可能
  3. Docker で環境構築すると楽

    • 依存関係の管理が容易
    • ローカル開発環境を簡単にセットアップ可能

最後に

これまで Tesseract や EasyOCR などのライブラリで日本語 OCR を試した際、どうしてもノイズが入ったり、認識精度に悩まされることがありました。しかし今回 yomitoku を使ってみて、日本語文書であればある程度実用的に使えそうな手応えを感じました。

もちろん完璧ではありませんし、画像の品質や文書の種類によっては精度が落ちることもあるでしょう。それでも、コストをかけずにローカルで動作する日本語 OCR の選択肢として、yomitoku は十分に検討する価値があると思います。

今回作ったモノですが、OCRをキッカケにより便利なウェブアプリとして作っていけると思うので、何かアイデアが浮かんだら発展させていこうかなと思っています。

参考リンク

Discussion