OllamaのローカルLLMで便利なOCRシステムを構築する方法


最近、機密情報をセキュアに扱えるローカルLLMが流行っています。
QwenGemmaシリーズのテキスト処理性能が高く、ローカルLLM界隈に広まっていますが、これらのモデルはマルチモーダル対応であることが忘れられがちです。

今回は、qwen3.5:9bを用いて、画像中の文字列を高速・高精度に取得できるOCRシステムを構築し、その実力を発揮させたいと思います。

※潤沢なVRAM・GPU資源をお持ちの場合は、qwen3.6:27bをお勧めします。逆に、スペックに自信のない方やスピードを求めたい方はqwen3.5:4b, qwen3.5:2b, qwen3.5:0.8bをご使用ください。

準備

Ollamaのインストール

まず、以下からPCにOllamaをインストールします。Windows, macOS, Linuxに対応しています。

https://ollama.com/download

Ollamaは頻繁にアップデートされるため、常に最新のものを使うのが良いです。特に、新しいアーキテクチャのモデルを使う場合は、それに対応したバージョンを使う必要があります。
ただし、Ollamaは最新アーキテクチャへの対応が遅めなので、上級者はvLLMllama.cpp等を使うことをお勧めします。

インストール完了後、コマンドプロンプトで下記コマンドを実行し、qwen3.5:9bをダウンロードします。

ollama pull qwen3.5:9b

ここまで実行すると、下記のコマンドでLLMとチャットできるようになります。

ollama run qwen3.5:9b --think=false

Ctrl+Dで終了します。
--think=falseは、LLMに深い思考を行わせず、すぐに回答させるためのオプションです。
9bなどの小さめのLLMは、思考が無限ループしてしまったり、時間をかけて思考した割に回答精度があまり上がらないため、falseにすることを推奨します。

Pythonのインストール

Python公式サイトやMicrosoft Storeから、Pythonをインストールします。
ちなみに、私はWSL環境のpyenv+virtualenvで、Python 3.13.13をインストールしました。

設計

今回は、以下のような処理を行うシステムを作ります。

  1. フォルダに置いてあるプリントの写真を自動で読み取る
  2. プリントに書かれている氏名を自動抽出する
    ただし、氏名の候補の中から1つだけを選択する方式
  3. 写真のファイル名を[氏名].[元の拡張子]にする

ミソは、氏名をLLMに「選択させる」部分です。これを実現するために、制約付きデコーディングを利用します。ここでは

{"name": "[氏名の候補]"}

という形式での出力を強制することで、出力をJSONとしてパースできることを保証し、かつ候補以外の氏名が出力されることを防ぎます。

実装


from ollama import Client
import json
import os
from pathlib import Path

MODEL = 'qwen3.5:9b'

# カスタムホスト(IPとポート)を指定してクライアントを初期化
client = Client(host='http://127.0.0.1:11434')

def get_name_candidates():
    return ['有馬 馬', '有馬 かな','山田 太郎', '山田 花子', 
            '佐藤 翔太', '鈴木 悠真', '高橋 健太','田中 美咲',
            '伊藤 彩乃', '渡辺 拓海','山本 結衣', '中村 大輝',
            '小林 陽菜', '加藤 莉子']

def extract_name_from_image(image_path):
    """画像から名前を抽出する"""
    # JSONスキーマの定義
    json_schema = {
        'type': 'object',
        'properties': {
            'name': {
                'type': 'string',
                'enum': get_name_candidates()
            },
        },
        'required': ['name']
    }

    try:
        # 画像ファイルをバイナリで読み込み
        with open(image_path, 'rb') as f:
            image_data = f.read()

        # generate APIを呼び出し
        response = client.generate(
            model=MODEL,
            prompt='画像から名前を抽出してください。',
            images=[image_data],  # 画像を渡す
            format=json_schema,   # JSONスキーマで制約をかける
            stream=False,
            think=False,
        )

        # 出力された文字列をJSONオブジェクトとして読み込む
        output_data = json.loads(response['response'])
        name = output_data.get('name')
        return name

    except Exception as e:
        print(f'エラーが発生しました ({image_path}): {e}')
        return None

def process_images_in_folder(folder_path):
    """フォルダ内の全画像ファイルを処理して改名する"""
    pictures_dir = Path(folder_path)
    
    # 画像ファイルの拡張子
    image_extensions = {'.png', '.jpg', '.jpeg'}
    
    # フォルダ内の全画像ファイルを取得
    image_files = [f for f in pictures_dir.iterdir() 
                   if f.is_file() and f.suffix.lower() in image_extensions]
    
    if not image_files:
        print(f'{folder_path}に画像ファイルが見つかりません。')
        return
    
    print(f'見つかった画像ファイル数: {len(image_files)}')
    
    for image_file in image_files:
        print(f'\n処理中: {image_file.name}')
        
        # 画像から名前を抽出
        name = extract_name_from_image(str(image_file))
        
        if name:
            # 拡張子を保持して新しいファイル名を作成
            new_filename = f'{name}{image_file.suffix}'
            new_filepath = image_file.parent / new_filename
            
            # ファイルを改名
            image_file.rename(new_filepath)
            print(f'改名完了: {image_file.name} → {new_filename}')
        else:
            print(f'名前を抽出できませんでした: {image_file.name}')

if __name__ == '__main__':
    pictures_folder = './pictures'
    process_images_in_folder(pictures_folder)

picturesフォルダに格納されている写真を処理していくコードです。
もし別のPCにOllamaをインストールしている場合は、 http://127.0.0.1:11434 の部分をご自身の環境に合わせて書き換えてください。

json_schemaをclient.generateに渡すことで、制約付きデコーディングを実現しています。詳細はOllama公式サイトJSON Schemaサイトを参照するか、ChatGPTに聞いてみてください。氏名の候補を与えず、純粋なOCRを行いたい場合は、 'enum': get_name_candidates() の行を削除してください。

get_name_candidates関数には、文字列の配列を埋め込んでいます。本番運用では、外部のCSVファイル等を取り込む実装にすると良いかと思います。

モデルはqwen3.5:9bを指定していますが、お好みで変更してください。

プロンプトは「画像から名前を抽出してください。」としています。実際の用途に合わせて変更してください。

実行するためには、Ollama Python Libraryのインストールが必要です。下記のコマンドでインストールしてください。

pip install ollama

使用例

上記コードをtest.pyとして保存しておきます。

また、test.pyの隣にpictureフォルダを作成し、その中に以下のような画像を置いておきます。

「有馬 馬」さん、「鈴木 悠真」さん、「佐藤 翔太」さんのプリントです。それぞれ、
alibaba.png、suzuki.png、sato.pngとして保存します。

そして、下記コマンドを実行します。

python3 test.py

出力は以下のようになります。

見つかった画像ファイル数: 3

処理中: suzuki.png
改名完了: suzuki.png → 鈴木 悠真.png

処理中: sato.png
改名完了: sato.png → 佐藤 翔太.png

処理中: alibaba.png
改名完了: alibaba.png → 有馬 馬.png

エクスプローラー上も、ファイル名が変わっていました。
これで、写真の中から氏名を正しく読み取り、ファイル名に設定できたことを確認できました。

ちなみに今回のタスクは、氏名の候補を与える場合では、qwen3.5:0.8bという非常に小さいモデルでも完遂できることを確認しています。
候補を与えない場合では、qwen3.5:2b以上で完遂できました。

まとめ

ローカルLLMで見過ごされがちなマルチモーダルAIの力を、OCRという形で発揮させることができました。また、制約付きデコーディングにより、簡単なタスクであれば小さなモデルでも実用レベルまで力を引き上げられることが分かりました。

AIの利用料金の値上げラッシュが始まりつつあります。
簡単なタスクやデータを外部の送信してはならないタスクを中心に、ローカルLLMを積極的に活用していきたいですね。

この記事への感想を教えてください
  • 内容が十分
  • 内容が足りなかったが役立った
  • 内容が足りず役立たなかった
  • 求めている記事ではなかった
last

フォローする