2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

1000枚の名刺をAI OCRで自動データ化した——yomitoku + ローカルLLM + Google Sheets構成

2
Posted at

ふと思い立って、一晩でコーディング。今回、ScanSnapでスキャンしたPDFをyomitoku(日本語特化OCR)+ ローカルLLMで全自動解析し、Google Sheetsをメインデータベースとした検索可能な名刺データベースを構築した。構成と実装のポイントをまとめる。

スキャンは ScanSnap 一択

Google Driveのスキャンアプリが良いという記事を見て試したけど、遅すぎ。1枚差し替えて撮影、の繰り返しだとかなり遅い。10枚ならいいけど、毎年1000枚の名刺が溜まっていて、手元には数千枚の名刺がある。餅は餅屋ってことで、ScanSnapを使ったら、あっという間でした。フィーダーに束でセットしてボタン1回。1分で数十枚が両面スキャンされPDF化される。1000枚超でも30分くらいで完了。

システム全体構成

img_system_architecture.png

処理パイプラインを整理するとこうなる。

元PDF(複数ページ)
  ↓ pypdf で1ページ=1名刺に分割
  ↓ pdf2image で画像に変換
  ↓ yomitoku で OCR(日本語特化)
  ↓ ローカルLLM(LM Studio / Ollama)でフィールド抽出
  ↓ Google Sheets に保存(メインDB)
     ├── GAS Webアプリ(別端末から閲覧・編集)
     └── Flask + index.html(ローカル処理UI)

PDF分割: 1ページ=1名刺に切り出す

ScanSnapは名刺を束でフィードするので、出力PDFは「1ファイル=N枚分のNページ」になっている。OCRにかける前に、まず1ページずつ個別PDFに分割する。

from pypdf import PdfReader, PdfWriter

def split_pdf_pages(pdf_path: Path, output_dir: Path) -> list:
    """PDFを1ページずつ個別PDFに分割して保存する。"""
    output_dir.mkdir(parents=True, exist_ok=True)
    safe_stem = re.sub(r'[^\w\-_.]', '', re.sub(r'[\s::/\\]+', '_', pdf_path.stem))
    reader = PdfReader(str(pdf_path))
    n = len(reader.pages)
    digits = len(str(n))

    page_files = []
    for i, page in enumerate(reader.pages, 1):
        writer = PdfWriter()
        writer.add_page(page)
        out_path = output_dir / f'{safe_stem}_p{i:0{digits}d}.pdf'
        with open(out_path, 'wb') as f:
            writer.write(f)
        page_files.append(out_path)
    return page_files

分割先は split_cards/ ディレクトリに集め、以降の処理はこのディレクトリ内のPDFを1枚ずつ処理する。ページ番号はゼロパディングして、ファイル名ソート順と処理順を一致させている。

向き補正: ocrmypdfで前処理する

ScanSnapの両面スキャンでも、ごく稀に天地が逆になったPDFが混ざる。また、横向き名刺は90度回転した状態でスキャンされることがある。OCRの前にこれを補正しておかないと、yomitokuの認識精度が大幅に落ちる。

ocrmypdf の --rotate-pages オプションを使うと、Tesseractの OSD(Orientation and Script Detection)が向きを推定して自動回転してくれる。

import subprocess

def make_searchable(input_pdf: Path, output_pdf: Path, lang: str = 'jpn+eng') -> bool:
    """ocrmypdf で向き補正 + 検索可能PDFを生成する(アーカイブ用)。"""
    cmd = [
        'ocrmypdf',
        '--language', lang,
        '--rotate-pages',
        '--rotate-pages-threshold', '2',   # 信頼度スコア2以上で回転を適用
        '--output-type', 'pdf',
        '--jobs', '1',
        '--quiet',
        str(input_pdf), str(output_pdf),
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    # returncode=6 は「すでに検索可能なPDF」を意味するため正常扱い
    return result.returncode in (0, 6)

--rotate-pages-threshold は低すぎると誤回転が起きるため 2 を基準にした。向き補正後のPDFを pdf2image で画像に変換してからyomitokuに渡す、というのが前処理の流れだ。

分割済みPDF
  ↓ ocrmypdf --rotate-pages(向き補正 + 検索可能化)
  ↓ pdf2image(DPI 200、RGB→BGR変換)
  ↓ yomitoku OCR

ocrmypdfが未インストールの環境ではこのステップをスキップし、補正なしで続行するフォールバックも実装している。

OCR: yomitokuを選んだ理由

日本語名刺のOCRは普通のTesseractでは厳しい。社名の旧字体、縦書き氏名、小さなメールアドレスが混在するからだ。

yomitokuはTransformerベースの日本語特化OCRで、縦書き・横書きを自動認識する。CPUで動作するliteモードもある。

from yomitoku import OCR

engine = OCR(configs={'lite': True}, device='cpu', visualize=False)
img_array = np.array(image.convert('RGB'))[:, :, ::-1].copy()  # PIL → BGR
results, _ = engine(img_array)

ハマりポイント: 内部のTransformerモデルがPythonのデフォルト再帰上限(1000)を超えてクラッシュした。起動前に設定が必要でした。

import sys
sys.setrecursionlimit(5000)

またエンジンの初期化コストが大きいため、シングルトンとしてモジュールレベルでキャッシュする。

_yomitoku_engine = None

def _get_yomitoku_engine():
    global _yomitoku_engine
    if _yomitoku_engine is not None:
        return _yomitoku_engine
    sys.setrecursionlimit(max(sys.getrecursionlimit(), 5000))
    try:
        _yomitoku_engine = OCR(configs={'lite': True}, device='cpu', visualize=False)
    except TypeError:
        _yomitoku_engine = OCR(device='cpu', visualize=False)
    return _yomitoku_engine

縦書き名刺の落とし穴: テキストブロックのソート順

yomitokuはOCRそのものは縦横どちらも認識するが、結果をLLMに渡す前のテキストブロックのソート順が問題になる。

  • 横書き: 上→下・左→右 → (y昇順, x昇順) でソート
  • 縦書き: 右列→左列・列内は上→下 → (x降順, y昇順) でソート

ソート順が崩れた状態でLLMに渡すと、氏名や役職の誤認識が多発する。

def ocr_with_yomitoku(image, direction: str = 'horizontal') -> tuple:
    engine = _get_yomitoku_engine()
    img_array = np.array(image.convert('RGB'))[:, :, ::-1].copy()
    results, _ = engine(img_array)

    text_blocks = _extract_blocks(results)  # JSON経由でブロック取得

    if direction == 'vertical':
        # 縦書き: 右列優先(x降順)、列内は上から(y昇順)
        text_blocks.sort(key=lambda b: (
            -(b['box'][0] if b['box'] else 0),
             (b['box'][1] if b['box'] else 0),
        ))
    else:
        # 横書き: 上から下(y昇順)、左から右(x昇順)
        text_blocks.sort(key=lambda b: (
            b['box'][1] if b['box'] else 0,
            b['box'][0] if b['box'] else 0,
        ))

    raw_text = '\n'.join(b['text'] for b in text_blocks if b['text'])
    return raw_text, text_blocks

最終的には、修正画面でUIからワンクリックで横書き・縦書きを切り替えて再OCRできるようにした。

フィールド抽出: ローカルLLMに構造化させる

OCRで得たテキストを「氏名」「会社名」「部署・役職」「電話番号」「メールアドレス」に分解する。正規表現では名刺の書式の多様さに太刀打ちできないため、LLMに任せた。

LM Studio(OpenAI互換API)とOllamaの両方に対応し、どちらも起動していなければヒューリスティックにフォールバックする。

LM_STUDIO_URL = 'http://localhost:1234'
OLLAMA_URL    = 'http://localhost:11434'

def _detect_llm_backend():
    # LM Studio を優先して確認
    try:
        res = urllib.request.urlopen(f'{LM_STUDIO_URL}/v1/models', timeout=2)
        models = json.loads(res.read())['data']
        if models:
            return 'lmstudio', models[0]['id']
    except Exception:
        pass
    # Ollama を確認
    try:
        res = urllib.request.urlopen(f'{OLLAMA_URL}/api/tags', timeout=2)
        models = json.loads(res.read()).get('models', [])
        if models:
            return 'ollama', models[0]['name']
    except Exception:
        pass
    return None, None

プロンプトにはフォントサイズが大きいブロック(氏名候補)を 【大文字】 として明示し、LLMが氏名を誤認識しにくくしている。

def _build_prompt(raw_text, text_blocks, source_pdf, page, card_pdf):
    # フォントサイズ上位20%のブロックを大文字マーカー付きで出力
    sizes = [b.get('font_size', 0) for b in text_blocks if b.get('font_size')]
    threshold = sorted(sizes)[int(len(sizes)*0.8)] if sizes else 0

    marked_lines = []
    for b in text_blocks:
        line = b['text']
        if b.get('font_size', 0) >= threshold and threshold > 0:
            line = f'【大文字】{line}'
        marked_lines.append(line)

    return f"""以下は日本の名刺1枚からOCRで読み取ったテキストです。
【大文字】は大きなフォントサイズのテキスト(氏名に使われることが多い)を示します。

{chr(10).join(marked_lines)}

次のJSON形式で情報を抽出してください:
{{"name":"","company":"","department":"","email":"","phone":""}}"""

Google SheetsをメインDBにする

スマホから名刺データにアクセスするため、どこかにデータを公開しないといけないが、わざわざサーバに上げるまでもないので、Google Drive(PDF置き場) + SpreadSheet(データベース) + GAS(UI)という構成にしてある。

gspreadでOAuth2認証し、全件書き込み・行単位更新・行削除の3操作を実装した。

COLUMNS = [
    'id', 'name', 'company', 'department', 'email', 'phone',
    'phones_json', 'source_pdf', 'page', 'card_pdf', 'added_at', 'raw_text',
    'drive_url',
]

def sync_cards(cards: list, base_dir: Path) -> bool:
    gc = gspread.oauth(
        credentials_filename=str(base_dir / 'credentials.json'),
        authorized_user_filename=str(base_dir / 'token.json'),
        scopes=['https://www.googleapis.com/auth/spreadsheets',
                'https://www.googleapis.com/auth/drive'],
    )
    sh = gc.open_by_key(get_spreadsheet_id(base_dir))
    ws = sh.worksheet('Cards')
    rows = [COLUMNS] + [_card_to_row(c) for c in cards]
    ws.clear()
    ws.append_rows(rows, value_input_option='RAW')
    return True

def patch_card_in_sheet(card_id: str, fields: dict, base_dir: Path) -> bool:
    """1行だけ更新(全件同期より高速)"""
    gc = _get_client(base_dir)
    ws = gc.open_by_key(get_spreadsheet_id(base_dir)).worksheet('Cards')
    headers = ws.row_values(1)
    id_values = ws.col_values(headers.index('id') + 1)
    row_num = id_values.index(card_id) + 1  # 見つからなければ ValueError

    for field, val in fields.items():
        if field in headers:
            ws.update_cell(row_num, headers.index(field) + 1, val)
    return True

認証のハマりポイント: gspread 6.xではgc.authやgc.credentials属性が廃止されている。Drive APIに必要なCredentialsオブジェクトはtoken.jsonから直接読み込む。

def _build_drive_service(base_dir: Path):
    from googleapiclient.discovery import build
    from google.oauth2.credentials import Credentials
    from google.auth.transport.requests import Request

    token_data = json.loads((base_dir / 'token.json').read_text())
    creds = Credentials(
        token=token_data.get('token'),
        refresh_token=token_data.get('refresh_token'),
        token_uri=token_data.get('token_uri', 'https://oauth2.googleapis.com/token'),
        client_id=token_data.get('client_id'),
        client_secret=token_data.get('client_secret'),
        scopes=token_data.get('scopes'),
    )
    if creds.expired and creds.refresh_token:
        creds.refresh(Request())
        (base_dir / 'token.json').write_text(creds.to_json())
    return build('drive', 'v3', credentials=creds, cache_discovery=False)

また、token.jsonのスコープが古い(driveスコープなし)場合はDrive APIが使えないため、一度削除して再認証が必要だ。

GASでWebビューアを作る

PDF➝データ化は、コスト削減のためLM Stuio(gpt-oss-20b)に投げたくて、ローカルサーバーで実行して、Google Sheetsに書き込む。GAS側からはGoogle Sheetsの内容を読み取って可視化するという構成。

// Code.gs
function doGet(e) {
  return HtmlService.createHtmlOutputFromFile('Page')
    .setTitle('名刺データベース')
    .setSandboxMode(HtmlService.SandboxMode.IFRAME);
}

function getCards() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Cards');
  const data = sheet.getDataRange().getValues();
  const headers = data[0].map(h => String(h).trim());
  return data.slice(1)
    .filter(row => String(row[headers.indexOf('id')]).trim() !== '')
    .map(row => {
      const card = {};
      headers.forEach((h, i) => { card[h] = String(row[i] ?? ''); });
      try { card.phones = JSON.parse(card.phones_json || '[]'); }
      catch { card.phones = []; }
      return card;
    });
}

function updateCard(id, fields) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Cards');
  const data = sheet.getDataRange().getValues();
  const headers = data[0].map(h => String(h).trim());
  const idCol = headers.indexOf('id');
  const rowIdx = data.findIndex((row, i) => i > 0 && String(row[idCol]) === id);
  if (rowIdx < 0) return { success: false, error: 'ID not found' };

  Object.entries(fields).forEach(([key, val]) => {
    const col = headers.indexOf(key);
    if (col >= 0) sheet.getRange(rowIdx + 1, col + 1).setValue(val);
  });
  return { success: true };
}

フロントエンド(Page.html)はGoogle Apps ScriptのIFRAMEサンドボックスで動き、google.script.runでバックエンドを呼ぶ。

// Page.html(フロント)
function saveCard() {
  const id = document.getElementById('edit-id').value;
  const fields = {
    name:       document.getElementById('edit-name').value,
    company:    document.getElementById('edit-company').value,
    department: document.getElementById('edit-dept').value,
    phone:      document.getElementById('edit-phone').value,
    email:      document.getElementById('edit-email').value,
  };
  google.script.run
    .withSuccessHandler(result => {
      if (result.success) { closeModal(); showStatus('保存しました', 'success'); }
      else showStatus('エラー: ' + result.error, 'error');
    })
    .updateCard(id, fields);
}

ローカルFlaskとSheetsの双方向同期

ローカルで名刺を編集した場合もSheetsに反映させる。Flaskの各エンドポイントでDBに書いた後、バックグラウンドスレッドでpatch_card_in_sheet()を呼ぶ。

import threading

def _sync_patch_to_sheets(card_id: str, fields: dict):
    """ブロッキングせずにバックグラウンドでSheets更新"""
    def _run():
        try:
            from sheets_sync import patch_card_in_sheet
            patch_card_in_sheet(card_id, fields, BASE_DIR)
        except Exception as e:
            print(f'[app] Sheets同期エラー: {e}')
    threading.Thread(target=_run, daemon=True).start()

@app.route('/api/cards/<card_id>', methods=['PATCH'])
def update_card(card_id):
    data = request.get_json()
    # cards.json を更新
    cards = load_cards()
    card = next((c for c in cards if c['id'] == card_id), None)
    if not card: abort(404)
    card.update(data)
    save_cards(cards)
    # Sheets にも反映(非同期)
    _invalidate_sheets_cache()
    _sync_patch_to_sheets(card_id, data)
    return jsonify({'status': 'updated'})

Sheetsをデータベースとして読んでいるので、ローカルの/api/cards GETはSheetsから読んでキャッシュする。

_sheets_cache: list = None
_sheets_cache_at: float = 0.0
_SHEETS_CACHE_TTL: float = 60.0  # 60秒キャッシュ

def _get_cards_cached() -> list:
    import time
    now = time.time()
    if _sheets_cache is not None and (now - _sheets_cache_at) < _SHEETS_CACHE_TTL:
        return list(_sheets_cache)
    try:
        from sheets_sync import fetch_cards
        cards = fetch_cards(BASE_DIR)
        if cards is not None:
            globals().update(_sheets_cache=cards, _sheets_cache_at=time.time())
            return list(cards)
    except Exception as e:
        print(f'[app] Sheets読み込みエラー(cards.jsonにフォールバック): {e}')
    # フォールバック
    return json.loads(DB_PATH.read_text()) if DB_PATH.exists() else []

まとめ

工程 技術 ポイント
スキャン ScanSnap 大量処理は専用スキャナー一択
PDF分割 pypdf 1ページ=1名刺
OCR yomitoku 再帰上限・シングルトンキャッシュに注意
縦書き対応 ソート順切り替え (x降順, y昇順) で列単位読み取り
フィールド抽出 LM Studio / Ollama フォントサイズヒントをプロンプトに埋め込む
DB Google Sheets gspread 6.x はtoken.jsonから直接Credentials生成
ビューア GAS Webアプリ HTTP/HTTPS混在問題はSheetsをDBにして回避
ローカルUI Flask + index.html バックグラウンドスレッドで非同期Sheets同期

OCRとLLMはすべてローカルで完結しており、クラウドAPIへのデータ送信はない。名刺は個人情報の塊なので、この点は重要だと判断した。

誤認識があった場合は画面から「再OCR(横)」「再OCR(縦)」「再抽出」の3ボタンで個別に修正でき、修正内容は即座にSheetsに反映される。


2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?