ふと思い立って、一晩でコーディング。今回、ScanSnapでスキャンしたPDFをyomitoku(日本語特化OCR)+ ローカルLLMで全自動解析し、Google Sheetsをメインデータベースとした検索可能な名刺データベースを構築した。構成と実装のポイントをまとめる。
スキャンは ScanSnap 一択
Google Driveのスキャンアプリが良いという記事を見て試したけど、遅すぎ。1枚差し替えて撮影、の繰り返しだとかなり遅い。10枚ならいいけど、毎年1000枚の名刺が溜まっていて、手元には数千枚の名刺がある。餅は餅屋ってことで、ScanSnapを使ったら、あっという間でした。フィーダーに束でセットしてボタン1回。1分で数十枚が両面スキャンされPDF化される。1000枚超でも30分くらいで完了。
システム全体構成
処理パイプラインを整理するとこうなる。
元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に反映される。
