【ローカルLLM】GLM-OCRで日本語環境を作ったら「中国語の壁」にぶつかった話と、その解決策

最近、ローカルで動くVision-Language Model(VLM)が熱いですね。 今回は、Ollamaで手軽に動かせるOCR特化モデル「GLM-OCR」を使って、画像から日本語のドキュメントをMarkdown形式で抽出するシステムを構築しました。

しかし、そこで待ち受けていたのは「認識は完璧なのに、漢字の一部が中国語(簡体字)になってしまう」という問題でした。本記事では、この問題を解決するために実装した「OpenCCを使った特製コンバータ」と、「過剰変換を防ぐロジック」について解説します。

1. なぜ GLM-OCR なのか?

OCR(光学文字認識)タスクにおいて、最近注目されているのが GLM-OCR です。このモデルの最大の特徴は、単なるテキスト抽出ではなく、レイアウトを維持したMarkdown形式で出力してくれる点です。

表組み(Table)や見出し構造を理解して出力してくれるため、書類のデータ化には最適です。

1.1. プロンプトエンジニアリングの工夫

GLM-OCRを使いこなす上で重要だったのが、プロンプトの調整です。検証の結果、以下のような特性があることが分かりました。

表の構造認識や再現性は非常に高いものの、表以外のテキスト部分が無視されてしまう傾向がありました。

テキストは拾いますが、表組みがMarkdownのテーブルとして形成されず、単なるテキストの羅列になってしまいました。

そこで今回は、表組みの再現性とテキストの網羅性を両立させるために、以下のような複合的なプロンプトを採用しました。これにより、Markdownのテーブル構造を維持しつつ、日本語の文章も正しく抽出できるようになりました。

Table Recognition:
Text Recognition:

Output is in Japanese (Japanese common kanji/old style), without converting to simplified Chinese.

Return only Markdown.

Markdown table format:
- Use pipes and a header separator line.
- Example format:
| Column 1 | Column 2 | Column 3 |
|---|---|---|
| Data 1 | Data 2 | Data 3 |

OCR Japanese. Preserve headings and lists in Markdown. Do not translate.

2. ぶつかった壁:「经済」「登录」問題

早速、Ollama経由で日本のWebサイトやチラシの画像を読ませてみました。すると、高精度で文字を認識します。しかし、出力されたテキストをよく見ると……。

読めなくはないですが、明らかに簡体字(Simplified Chinese)が混ざっています。 GLM-OCRは中国で開発されたモデルであり、学習データに中国語が多いため、「形が似ている漢字は、簡体字として出力される」というバイアス(癖)があるようです。

これでは、日本語のビジネス文書としては使えません。

3. 解決策:OpenCCによる多段変換

この問題を解決するために、OpenCC(Open Chinese Convert)というオープンソースの変換ライブラリのデータを使用することにしました。

日本語の漢字(新字体)にするために、以下の2段階の変換を行います。

  1. 簡体字 繁体字s2t: Simplified to Traditional)
  1. 繁体字 日本語新字体t2jp: Traditional to Japanese)

3.1. ライブラリ導入時の課題と「自前実装」への道

最初はPythonの既存ラッパーライブラリを使おうとしましたが、環境によっては設定ファイルが読み込めなかったり、パスの解決でエラーが出たりと、導入や挙動に不安定な部分がありました。

そこで今回は、OpenCCの辞書データ(テキストファイル)を直接Pythonで読み込み、オンメモリで変換処理を行うクラスを自作することにしました。 これにより、外部ライブラリの仕様に依存せず、「どの辞書を」「どういう順番で」適用するかを完全にコントロールできるようになります。

4. さらなる課題:「占い」が「佔い」になる過剰変換

単純に辞書を適用しただけでは、新たな問題が発生しました。 元々正しい日本語だった「占い(うらない)」が、変換プロセスによって「佔い」という繁体字の異体字に書き換わってしまったのです。

4.1. 解決ロジック:Protected List(保護リスト)

これを防ぐために、「変換しようとしている文字が、すでに日本の常用漢字である場合はスキップする」というロジックを追加しました。

  1. JPVariants.txt(日本漢字辞書)の「変換後の文字(右側)」をすべてリストアップし、「保護リスト」としてメモリに持つ。
  2. 簡体字変換を行う際、その文字が「保護リスト」に含まれていれば、辞書にマッチしても変換せずにスルーする。

このロジックにより、「簡体字は直す」が「正しい日本漢字は壊さない」という挙動を実現しました。


5. 開発環境の構築手順

実際にこのシステムをWindows環境(PowerShell)で構築する手順を紹介します。

5.1. 前提条件

5.2. Ollamaモデルの準備

ターミナルを開き、GLM-OCRモデルをダウンロードします。

ollama pull glm-ocr:latest

5.3. プロジェクトと仮想環境の作成

作業用ディレクトリを作成し、Pythonの仮想環境(venv)を構築します。

# フォルダ作成と移動
mkdir OCR_Project
cd OCR_Project

# 仮想環境(.venv)の作成 (Pythonのパスは環境に合わせて変更してください)
& "C:\Python312\python.exe" -m venv .venv

# 仮想環境の有効化
.\.venv\Scripts\Activate.ps1

5.4. ライブラリのインストール

必要なライブラリは ollama だけです(変換ロジックは標準ライブラリで書くため)。

pip install ollama

5.5. 辞書データの配置(重要)

ここがポイントです。変換に必要な辞書データ(.txt)は、OpenCCの公式リポジトリから取得します。 今回の実装はライブラリ経由ではなく、辞書データを直接読み込んで変換を行うため、以下の手順でファイルを配置してください。

  1. プロジェクトフォルダ内に my_dict というフォルダを作成します。
  2. OpenCC GitHubリポジトリ にアクセスします。
  3. 以下の3つのテキストファイルをダウンロードし、my_dict フォルダの中に保存してください。

配置後のフォルダ構成:

OCR_Project/
  ├── my_dict/
  │    ├── STPhrases.txt
  │    ├── STCharacters.txt
  │    └── JPVariants.txt
  ├── main.py  (後述のコード)
  └── .venv/

6. 実装コード (main.py)

以下が、過剰変換防止ロジックを組み込んだ完成版コードです。これを main.py として保存します。

import sys
import os
import ollama

class DirectDictionaryConverter:
    def __init__(self):
        self.s2t_phrases = {}
        self.s2t_chars = {}
        self.t2jp_chars = {}
        self.protected_chars = set() # 過剰変換防止用の保護リスト

        # 辞書の読み込み処理
        current_dir = os.path.dirname(os.path.abspath(__file__))
        dict_dir = os.path.join(current_dir, "my_dict")

        self.load_dict(os.path.join(dict_dir, "STPhrases.txt"), self.s2t_phrases)
        self.load_dict(os.path.join(dict_dir, "STCharacters.txt"), self.s2t_chars)
        # 日本漢字辞書を読み込みつつ、保護リストを作成
        self.load_dict(os.path.join(dict_dir, "JPVariants.txt"), self.t2jp_chars, extract_protected=True)

    def load_dict(self, path, target_dict, extract_protected=False):
        """辞書ファイルを読み込む"""
        if not os.path.exists(path):
            print(f"[WARN] 辞書が見つかりません: {path}")
            return
        with open(path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"): continue
                parts = line.split("\t")
                if len(parts) >= 2:
                    key = parts[0]
                    value = parts[1].split(" ")[0]
                    target_dict[key] = value
                    if extract_protected:
                        self.protected_chars.add(value)

    def convert(self, text):
        """簡体字 -> 繁体字 -> 日本漢字 の変換パイプライン"""
        # 1. 簡体字 -> 繁体字 (フレーズ優先 + 保護ロジック)
        converted = []
        i = 0
        length = len(text)
        while i < length:
            # フレーズ辞書マッチング(最大一致法)
            matched = False
            for width in range(5, 0, -1):
                if i + width > length: continue
                chunk = text[i:i+width]
                if chunk in self.s2t_phrases:
                    converted.append(self.s2t_phrases[chunk])
                    i += width
                    matched = True
                    break

            if matched: continue

            # 文字単位処理
            char = text[i]
            # ★ここがポイント:すでに日本漢字なら変換しない
            if char in self.protected_chars:
                converted.append(char)
            elif char in self.s2t_chars:
                converted.append(self.s2t_chars[char])
            else:
                converted.append(char)
            i += 1

        t_text = "".join(converted)

        # 2. 繁体字 -> 日本漢字
        jp_text = "".join([self.t2jp_chars.get(char, char) for char in t_text])
        return jp_text

# --- 実行クラス ---
class MarkdownOcrConverter:
    def __init__(self, model_name):
        self.model_name = model_name
        self.converter = DirectDictionaryConverter()

    def run(self, image_path):
        # 表組みとテキストの両立を目指したプロンプト
        prompt = """Table Recognition:
Text Recognition:

Output is in Japanese (Japanese common kanji/old style), without converting to simplified Chinese.

Return only Markdown.

Markdown table format:
- Use pipes and a header separator line.
- Example format:
| Column 1 | Column 2 | Column 3 |
|---|---|---|
| Data 1 | Data 2 | Data 3 |

OCR Japanese. Preserve headings and lists in Markdown. Do not translate."""

        response = ollama.chat(
            model=self.model_name,
            messages=[{'role': 'user', 'content': prompt, 'images': [image_path]}]
        )
        raw_output = response['message']['content']
        # 変換実行
        return self.converter.convert(raw_output)

if __name__ == "__main__":
    converter = MarkdownOcrConverter("GLM-OCR:latest")
    # 画像パスを指定して実行
    if len(sys.argv) > 1:
        print(converter.run(sys.argv[1]))
    else:
        print("Usage: python main.py <image_path>")
デバッグ出力版
import sys
import time
import os
import re
import ollama

class DirectDictionaryConverter:
    def __init__(self):
        self.s2t_phrases = {} # 簡体字 -> 繁体字 (単語)
        self.s2t_chars = {}   # 簡体字 -> 繁体字 (文字)
        self.t2jp_chars = {}  # 繁体字 -> 日本漢字 (文字)
        self.protected_chars = set() # ★追加: 変換してはいけない日本漢字リスト

        # 辞書フォルダのパス
        current_dir = os.path.dirname(os.path.abspath(__file__))
        dict_dir = os.path.join(current_dir, "my_dict")

        print("[-] 辞書データを読み込んでいます...")
        self.load_dict(os.path.join(dict_dir, "STPhrases.txt"), self.s2t_phrases)
        self.load_dict(os.path.join(dict_dir, "STCharacters.txt"), self.s2t_chars)

        # JP辞書読み込み時に、変換後の文字(日本漢字)を保護リストに追加
        self.load_dict(os.path.join(dict_dir, "JPVariants.txt"), self.t2jp_chars, extract_protected=True)

        # ★さらに手動で保護したい文字があればここで追加(念のため「占」などを明示)
        manual_protect = "占缺" 
        for char in manual_protect:
            self.protected_chars.add(char)

        print(f"    - フレーズ辞書: {len(self.s2t_phrases)}語")
        print(f"    - 文字辞書(S2T): {len(self.s2t_chars)}字")
        print(f"    - 文字辞書(JP): {len(self.t2jp_chars)}字")
        print(f"    - 保護対象の文字: {len(self.protected_chars)}字 (日本漢字)")

    def load_dict(self, path, target_dict, extract_protected=False):
        """辞書ファイルを読み込む"""
        if not os.path.exists(path):
            print(f"[WARN] 辞書が見つかりません: {path}")
            return

        try:
            with open(path, "r", encoding="utf-8") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#"):
                        continue
                    parts = line.split("\t")
                    if len(parts) >= 2:
                        key = parts[0]
                        values = parts[1].split(" ")
                        value = values[0] # 最初の候補を採用
                        target_dict[key] = value

                        # ★変換後の文字を保護リストに登録
                        if extract_protected:
                            self.protected_chars.add(value)

        except Exception as e:
            print(f"[ERROR] 辞書読み込みエラー {path}: {e}")

    def convert_s2t(self, text):
        """簡体字 -> 繁体字 (過剰変換防止ロジック付き)"""
        converted = []
        i = 0
        length = len(text)

        while i < length:
            # 1. フレーズ辞書 (単語単位の変換) を最優先
            matched = False
            for width in range(5, 0, -1):
                if i + width > length:
                    continue

                chunk = text[i:i+width]
                if chunk in self.s2t_phrases:
                    converted.append(self.s2t_phrases[chunk])
                    i += width
                    matched = True
                    break

            if matched:
                continue

            # 2. フレーズにヒットしなかった場合、文字単位の処理
            char = text[i]

            # ★ここが重要: すでに日本の漢字(保護リスト)にあるなら変換しない!
            # これにより「占」などが「佔」に誤変換されるのを防ぎます
            if char in self.protected_chars:
                converted.append(char)
            # 簡体字辞書にあるなら変換する (例: 经 -> 經)
            elif char in self.s2t_chars:
                converted.append(self.s2t_chars[char])
            # 辞書になければそのまま
            else:
                converted.append(char)

            i += 1

        return "".join(converted)

    def convert_t2jp(self, text):
        """繁体字 -> 日本新字体"""
        return "".join([self.t2jp_chars.get(char, char) for char in text])

    def convert(self, text):
        # Step 1: 簡体字 -> 繁体字 (過剰変換ガード付き)
        t_text = self.convert_s2t(text)
        # Step 2: 繁体字 -> 日本漢字
        jp_text = self.convert_t2jp(t_text)
        return jp_text

class MarkdownOcrConverter:
    def __init__(self, model_name):
        self.model_name = model_name
        self.converter = DirectDictionaryConverter()
        print(f"[-] 初期化完了。使用モデル: {self.model_name}")

    def clean_markdown(self, raw_text):
        cleaned = re.sub(r'```(?:markdown)?\n?(.*?)```', r'\1', raw_text, flags=re.DOTALL)
        return cleaned.strip()

    def run(self, image_path):
        print(f"[-] 画像ファイル '{image_path}' を読み込んでいます...")

        prompt = """Table Recognition:
Text Recognition:

Output is in Japanese (Japanese common kanji/old style), without converting to simplified Chinese.

Return only Markdown.

Markdown table format:
- Use pipes and a header separator line.
- Example format:
| Column 1 | Column 2 | Column 3 |
|---|---|---|
| Data 1 | Data 2 | Data 3 |

OCR Japanese. Preserve headings and lists in Markdown. Do not translate."""

        try:
            print(f"[-] Ollama ({self.model_name}) で解析中...")
            start_time = time.time()

            response = ollama.chat(
                model=self.model_name,
                messages=[{'role': 'user', 'content': prompt, 'images': [image_path]}]
            )

            duration = time.time() - start_time
            print(f"[-] 解析完了! (所要時間: {duration:.1f}秒)")

            if 'message' not in response:
                return None

            raw_output = response['message']['content']
            text_to_convert = self.clean_markdown(raw_output)

            print("\n" + "="*20 + " 【変換前のテキスト (OCR原文)】 " + "="*20)
            print(text_to_convert)
            print("="*60 + "\n")

            print("[-] 日本語漢字へ変換中 (過剰変換防止ON)...")

            final_text = self.converter.convert(text_to_convert)

            return final_text

        except Exception as e:
            print(f"\n[ERROR] エラーが発生しました: {e}")
            return None

if __name__ == "__main__":
    TARGET_MODEL = "GLM-OCR:latest"

    if len(sys.argv) > 1:
        img_path = sys.argv[1]
        converter = MarkdownOcrConverter(model_name=TARGET_MODEL)
        result = converter.run(img_path)

        if result:
            print("\n" + "="*40)
            print("【変換結果】")
            print("="*40)
            print(result)
            print("="*40)
    else:
        print(f"使用法: python {sys.argv[0]} <画像ファイルのパス>")

7. 変換コマンド(main.py)の使い方

作成したOCR&変換ツールは、コマンドライン(ターミナルまたはPowerShell)から実行します。

7.1. 基本的な実行方法

画像ファイルのパスを引数として渡すと、標準出力(画面)にMarkdown形式の変換結果が表示されます。

python main.py <画像ファイルのパス>

実行例:

python main.py documents/invoice.png

7.2. 実行前の確認事項(前提条件)

コマンドを実行する前に、以下の準備が整っているか確認してください。

  1. Ollamaの起動: バックグラウンドでOllamaが動作していること。
  2. モデルの準備: glm-ocr:latest がダウンロードされていること。
  1. 辞書の配置: スクリプトと同じ階層の my_dict フォルダに、OpenCCの辞書ファイル(3つ)が正しく配置されていること。

8. まとめ

結果として、以下のような変換が自動で行えるようになりました。

原文 (OCR出力) 変換後 判定
经済 (簡体字) 経済 ✅ 成功
登录 (簡体字) 登録 ✅ 成功
每日 (簡体字) 毎日 ✅ 成功
占い (日本漢字) 占い ✅ 維持 (過剰変換防止)

ローカルLLMを使ったOCRは、APIコストがかからず、プライバシー面でも安心です。「漢字の壁」さえ超えれば、非常に強力なツールになります。ぜひ試してみてください!

9. FAQ

Windows以外の環境(Mac/Linux)でも動きますか?
はい、動作します。OllamaとPythonが動作する環境であれば、macOSやLinuxでも同様の手順で構築可能です。ただし、ファイルパスの区切り文字などは os.path.join を使用しているためコードの変更は基本的に不要ですが、PowerShellコマンドの部分は各OSのシェル(Bash/Zshなど)に合わせて読み替えてください。
GPUがないPCでも実行できますか?
実行可能ですが、処理速度は遅くなります。OllamaはCPU実行もサポートしていますが、GLM-OCRのようなVLM(Vision Language Model)は計算負荷が高いため、画像1枚の解析に数十秒〜数分かかる場合があります。NVIDIA製GPUがある環境での実行を強く推奨します。
プロンプトにある「Table Recognition」と「Text Recognition」を併記するのはなぜですか?
検証の結果、Table Recognition だけだと表以外の本文が無視されやすく、Text Recognition だけだと表が崩れてテキストの羅列になる傾向があったためです。両方を併記することで、表の構造維持と本文の網羅性を両立させています。
縦書き(縦組み)の日本語画像にも対応していますか?
はい、対応しています。ただし、横書きよりも認識率は低い印象です。
「保護リスト」に含まれていない文字が誤変換された場合はどうすればいいですか?
my_dict/JPVariants.txt にその文字が含まれているか確認してください。もし含まれているのに変換されてしまう場合は、コード内の protected_chars セットに手動でその文字を追加(add())するか、独自の保護リストファイルを作成して読み込ませることで回避できます。
辞書ファイル(.txt)の中身を編集してもいいですか?
い、可能です。OpenCCの辞書はタブ区切りのテキストファイルなので、メモ帳などで編集できます。特定の単語(例:社内用語や特殊な固有名詞)がうまく変換されない場合は、STPhrases.txt(簡体字→繁体字)や JPVariants.txt(繁体字→日本新字体)に行を追加してカスタマイズしてください。
OpenCCのPythonライブラリ(pip install opencc)を使わないのはなぜですか?
ライブラリ版は環境によって設定ファイル(JSON)のパス解決がうまくいかないトラブルや、辞書の適用順序を細かく制御できない(保護リストロジックを挟めない)という課題があったためです。今回は完全なコントロールを得るために、辞書ファイルを直接読み込むオンメモリ実装を採用しました。
画像認識の結果(OCR)自体が間違っている場合、このスクリプトで直せますか?
いいえ。本スクリプトは「正しい文字コードへの変換(簡体字→日本漢字)」を行うものであり、Ollamaが画像を読み間違えた(例:「災害」を「炎害」と読んだ)場合は修正できません。その場合は、より高性能なモデルを使用するか、OCR後のテキストを別のLLM(GPT-4など)に校正させる処理が必要です。