はじめに
2024年にはマイナンバーカードの読み取りがスマートフォンのNFCで可能になり、2025年にはiPhoneへの運転免許証搭載も始まりました。正直なところ、免許証の画像をOCRで読み取るというアプローチには「今更感」があります。
とはいえ、実務ではまだ免許証の画像からテキストを抽出したい場面は残っています。NFC対応していないカード、過去に撮影した画像の処理、NFC読み取り機材がない環境など、OCRに頼らざるを得ないケースはゼロではありません。
今回、業務でそういった要件に当たったこともあり、ブラウザだけで完結する免許証OCRのデモコードを作成しました。画像データは一切サーバーに送信せず、すべての推論処理をブラウザ内のWebAssemblyで実行します。
デモページを用意しているので、手元の免許証画像で試すことができます。
ソースコードは GitHub で公開しています。
どんな場面で使えるか
- 本人確認(eKYC)の入力支援 — 免許証の氏名・住所・番号を自動入力し、手入力ミスを削減
- 顧客登録の効率化 — 受付窓口で免許証を撮影→即座にフォームへ反映
- 行政手続きのデジタル化 — 各種申請書への転記作業を自動化
- オフライン環境での利用 — 初回のモデルダウンロード後はネットワーク不要で動作
ブラウザ完結なので、機密性の高い環境でも導入しやすいのが大きなメリットです。
技術スタック
| カテゴリ | 技術 |
|---|---|
| フレームワーク | Astro (Static) |
| 言語 | TypeScript |
| 推論ランタイム | ONNX Runtime Web (WebAssembly) |
| 検出モデル | DEIM — 国立国会図書館(NDL)学習済み |
| 認識モデル | PARSeq — NDL学習済み |
| モデルキャッシュ | IndexedDB (idb-keyval) |
モデルの合計サイズは約77MBです。初回アクセス時にダウンロードされ、IndexedDBにキャッシュされるため2回目以降は即座に利用できます。
OCRパイプラインの全体像
処理の流れは以下の通りです。
画像入力
↓
透視変換補正(4点ホモグラフィー)
↓
DEIM 検出(文書レイアウト解析)
↓
レイアウト解析(検出結果→要素ツリー構築)
↓
読み順決定(XY-Cut アルゴリズム)
↓
PARSeq 認識(行画像→テキスト)
↓
テンプレートゾーン分類(座標ベースでフィールド割当)
↓
構造化データ出力
各ステップを順番に解説していきます。
Step 1: 透視変換補正
スマートフォンで撮影した免許証は、角度や歪みによって台形に写ることがほとんどです。このまま OCR にかけると精度が大幅に落ちるため、まず4点ホモグラフィーで正面から見た長方形に補正します。
UIでは免許証の四隅にハンドルを配置し、ユーザーがドラッグで位置を調整できるようにしています。
export function computeHomography(src: Point[], dst: Point[]): number[] {
// 4組の対応点から8x9の拡大行列を構築し、
// ガウスの消去法で3x3のホモグラフィ行列を求める
const A: number[][] = [];
for (let i = 0; i < 4; i++) {
const { x, y } = src[i];
const { x: X, y: Y } = dst[i];
A.push([x, y, 1, 0, 0, 0, -X * x, -X * y, X]);
A.push([0, 0, 0, x, y, 1, -Y * x, -Y * y, Y]);
}
const h = solveLinearSystem(A);
return [...h, 1];
}
変換先の座標(長方形)と元画像の四隅の対応から射影変換行列を計算し、逆写像+バイリニア補間で歪みのない画像を生成します。ライブラリに依存せず、Canvas API の ImageData だけで実装しています。
Step 2: DEIM による文書レイアウト検出
補正後の画像に対して、DEIM(Document Element Inspection Model)で文字領域を検出します。このモデルは国立国会図書館(NDL)が学習したもので、文書の構造要素(LINE、BODY、CAPTION など17クラス)を高精度に検出できます。
export class DEIMDetector {
private session: ort.InferenceSession | null = null;
async detect(imageData: ImageData): Promise<Detection[]> {
// 1. 画像を正方形にパディングし、800x800にリサイズ
const padded = padAndResize(imageData, this.inputH);
// 2. ImageNet正規化(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])
const normalized = normalizeImageNet(padded.data, this.inputH, this.inputW);
// 3. HWC → CHW に並べ替え
const chw = hwcToChw(normalized, this.inputH, this.inputW, 3);
// 4. ONNX Runtimeで推論
const imagesTensor = new ort.Tensor("float32", chw, [1, 3, 800, 800]);
const results = await this.session.run(feeds);
// 5. 後処理:信頼度閾値でフィルタリングし、元画像のスケールに戻す
return this.postprocess(results, paddedSize);
}
}
前処理のポイントは、入力画像を正方形にパディングしてからリサイズすることです。免許証のようなアスペクト比が固定の画像でも、モデルの入力サイズ(800x800)に合わせる必要があります。
後処理では、クラスIDが BigInt64Array で返ってくることに注意が必要です。DEIM の出力は1-indexed なので、クラスリストと照合する際に1を引いています。
// BigInt64Arrayからクラスインデックスを取得(1-indexed → 0-indexed)
const classIndex = Number(classIdsRaw[i]) - 1;
Step 3: 読み順の決定
検出されたバウンディングボックスは順序が保証されていません。人間が読む順序(上から下、左から右)に並べ替える必要があります。
ここでは XY-Cut アルゴリズムを使っています。画像を水平・垂直に再帰的に分割し、各ブロック内の要素を読み順に並べます。
// 検出結果を要素ツリーに変換
const page = detectionsToPage(imgW, imgH, "input.jpg", detections);
const root = createElement("OCRDATASET", {}, [page]);
// XY-Cutアルゴリズムで読み順を決定
evalPage(root, true);
// LINE要素を読み順で収集
const lines = findAll(page, "LINE");
Step 4: PARSeq によるテキスト認識
読み順に並んだ各行画像を、PARSeq(Permuted Autoregressive Sequence)モデルで認識します。NDL が7,141文字の日本語文字セットで学習したモデルを使用しており、漢字・ひらがな・カタカナ・数字・記号を幅広く認識できます。
export class PARSeqRecognizer {
async read(lineImage: ImageData): Promise<string> {
// 1. 縦書きの場合は回転、16x768にリサイズ
const resized = resizeForParseq(lineImage, this.inputW, this.inputH, true);
// 2. BGR反転 + [-1, 1]正規化
const normalized = normalizeBgr(resized.data, this.inputH, this.inputW);
// 3. HWC → CHW
const chw = hwcToChw(normalized, this.inputH, this.inputW, 3);
// 4. 推論
const inputTensor = new ort.Tensor("float32", chw, [1, 3, 16, 768]);
const results = await this.session.run({ [inputName]: inputTensor });
// 5. argmaxでデコード、ストップトークン(index 0)で打ち切り
const indices = argmaxAxis2(data, seqLen, vocabSize);
let result = "";
for (let s = 0; s < seqLen; s++) {
const idx = indices[s];
if (idx === 0) break; // ストップトークン
if (idx - 1 >= 0 && idx - 1 < CHARSET_TRAIN.length) {
result += CHARSET_TRAIN[idx - 1];
}
}
return result;
}
}
PARSeq の前処理で注意すべき点が2つあります。
1つ目は、チャンネル順序が BGR であることです。通常の画像は RGB ですが、このモデルは BGR を期待します。前処理で R と B を入れ替えています。
export function normalizeBgr(data: Uint8ClampedArray, h: number, w: number): Float32Array {
const out = new Float32Array(h * w * 3);
for (let i = 0; i < h * w; i++) {
const base = i * 4;
out[i * 3 + 0] = 2.0 * (data[base + 2] / 255) - 1.0; // B
out[i * 3 + 1] = 2.0 * (data[base + 1] / 255) - 1.0; // G
out[i * 3 + 2] = 2.0 * (data[base + 0] / 255) - 1.0; // R
}
return out;
}
2つ目は、デコード時に index 0 がストップトークン であることです。出力の各タイムステップで argmax を取り、0 が出たらそこで認識結果を打ち切ります。文字セットへのマッピングは index - 1 で行います。
Step 5: テンプレートゾーンによるフィールド分類
OCR で得られたテキストと座標を、免許証のレイアウトに基づいて各フィールドに振り分けます。日本の運転免許証は国家公安委員会規則でレイアウトが標準化されているため、正規化座標で固定のゾーンを定義できます。
┌─────────────────────────────────────────┐
│ [氏名] [生年月日] [photo] │
│ [住所] [photo] │
│ [交付日] [有効期限] [photo] │
│ [条件等] │
│ [免許種類] │
│ [免許証番号] │
└─────────────────────────────────────────┘
各ゾーンは正規化座標(0〜1)で定義しています。
export const LICENSE_ZONES: LicenseZone[] = [
{ field: "name", label: "氏名", x0: 0.00, y0: 0.00, x1: 0.55, y1: 0.12 },
{ field: "birthDate", label: "生年月日", x0: 0.45, y0: 0.00, x1: 0.80, y1: 0.12 },
{ field: "address", label: "住所", x0: 0.00, y0: 0.12, x1: 0.70, y1: 0.26 },
{ field: "issueDate", label: "交付", x0: 0.00, y0: 0.26, x1: 0.70, y1: 0.33 },
{ field: "expiryDate", label: "有効期限", x0: 0.00, y0: 0.33, x1: 0.70, y1: 0.40 },
{ field: "conditions", label: "条件等", x0: 0.00, y0: 0.40, x1: 0.70, y1: 0.56 },
{ field: "licenseNumber", label: "免許証番号", x0: 0.00, y0: 0.60, x1: 0.70, y1: 0.78 },
];
分類ロジックは2段階です。まずテキストボックスの中心座標がゾーン内に入るかを判定し、入らない場合は最も近いゾーンに0.04以内のマージンで割り当てます。
function findZone(cx: number, cy: number): LicenseZone | null {
// 第1パス:厳密な包含判定
for (const zone of LICENSE_ZONES) {
if (cx >= zone.x0 && cx <= zone.x1 && cy >= zone.y0 && cy <= zone.y1) {
return zone;
}
}
// 第2パス:最近傍ゾーン(マージン0.04以内)
let bestZone: LicenseZone | null = null;
let bestDist = 0.04;
for (const zone of LICENSE_ZONES) {
const dx = Math.max(0, zone.x0 - cx, cx - zone.x1);
const dy = Math.max(0, zone.y0 - cy, cy - zone.y1);
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < bestDist) {
bestDist = dist;
bestZone = zone;
}
}
return bestZone;
}
ゾーンに分類されたテキストには後処理も施します。「氏名」「住所」などのラベル文字列を除去し、全角数字を半角に変換し、和暦の日付パターンを正規化します。
Web Worker による非同期処理
ONNX Runtime の推論はCPU負荷が高いため、Web Worker で実行してメインスレッドをブロックしないようにしています。
async function runOcr(imageBlob: Blob, presetId: string): Promise<void> {
await initModels(presetId);
const imageData = await decodeImage(imageBlob);
// 検出
const detections = await detector!.detect(imageData);
post({ type: "detect-done", numDetections: detections.length });
// 読み順決定
const page = detectionsToPage(imgW, imgH, "input.jpg", detections);
evalPage(createElement("OCRDATASET", {}, [page]), true);
// 認識(進捗をメインスレッドに通知)
const lines = findAll(page, "LINE");
for (let i = 0; i < lines.length; i++) {
const lineImg = cropImageData(imageData, x, y, w, h);
const text = await recognizer!.read(lineImg);
if ((i + 1) % 5 === 0 || i === lines.length - 1) {
post({ type: "recognize-progress", current: i + 1, total: lines.length });
}
}
post({ type: "result", lines: resultLines, imgW, imgH });
}
Worker からメインスレッドへの進捗通知は型安全な discriminated union で定義しています。
export type WorkerResponse =
| { type: "init-progress"; model: string; loaded: number; total: number }
| { type: "init-done" }
| { type: "detect-done"; numDetections: number }
| { type: "recognize-progress"; current: number; total: number }
| { type: "result"; lines: { text: string; x: number; y: number; w: number; h: number; score: number }[]; imgW: number; imgH: number }
| { type: "error"; message: string };
モデルのキャッシュ戦略
77MBのモデルを毎回ダウンロードするのは現実的ではありません。IndexedDB にキャッシュすることで、2回目以降はネットワークアクセスなしで起動できます。
export async function fetchModel(
url: string,
cacheKey: string,
onProgress?: ProgressCallback,
): Promise<ArrayBuffer> {
// キャッシュヒットすればそのまま返す
const cached = await get<ArrayBuffer>(cacheKey);
if (cached) {
onProgress?.(cached.byteLength, cached.byteLength);
return cached;
}
// ReadableStream で進捗付きダウンロード
const response = await fetch(url);
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
onProgress?.(loaded, contentLength);
}
const arrayBuffer = buffer.buffer as ArrayBuffer;
await set(cacheKey, arrayBuffer); // IndexedDBに保存
return arrayBuffer;
}
fetch の Response.body を ReadableStream として読み取ることで、ダウンロード進捗をリアルタイムにUIへ反映しています。
COOP/COEP ヘッダーについて
ONNX Runtime Web は SharedArrayBuffer を使用するため、以下の HTTP ヘッダーが必須です。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Astro の設定で開発サーバーに自動付与しています。
export default defineConfig({
server: {
port: 7575,
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
vite: {
optimizeDeps: { exclude: ["onnxruntime-web"] },
worker: { format: "es" },
},
});
本番環境では、Web サーバー(Nginx、Cloudflare Pages など)側でこれらのヘッダーを設定する必要があります。
プロジェクト構成
src/
├── pages/
│ └── index.astro # メインページ
├── app.ts # UIロジック・イベント処理
├── settings/
│ ├── presets.ts # モデルURL・入力サイズ定義
│ ├── vocab.ts # 7,141文字の文字セット
│ └── categories.ts # 17クラス定義
├── inference/
│ ├── detector.ts # DEIM 検出エンジン
│ ├── recognizer.ts # PARSeq 認識エンジン
│ ├── image-ops.ts # 画像デコード・リサイズ・切り出し
│ ├── tensor-ops.ts # テンソル変換・正規化
│ └── homography.ts # 透視変換
├── layout/
│ ├── detection-builder.ts # 検出結果→要素ツリー変換
│ ├── zone-defs.ts # 免許証テンプレートゾーン定義
│ └── field-extractor.ts # ゾーンベースのフィールド抽出
├── sequencer/ # XY-Cutアルゴリズム一式
├── cache/
│ └── model-store.ts # IndexedDB キャッシュ
└── pipeline/
└── ocr.worker.ts # Web Worker
セットアップ
git clone https://github.com/nogataka/license-ocr-demo.git
cd license-ocr-demo
npm install
npm run dev # http://localhost:7575 で起動
開発を通じて得た知見
モデル選定で苦労した話
当初は PaddleOCR(PP-OCRv3 / v5)の認識モデルを検討しましたが、日本語の免許証テキストに対する精度が不十分でした。特に PP-OCRv5 は統合多言語モデルのため、日本語テキストに対して中国語の文字を出力する問題がありました。
最終的に NDL が学習した PARSeq モデルに落ち着きました。7,141文字の日本語文字セットで学習されており、政府系文書の認識精度が高いです。
検出精度がすべてを左右する
認識モデルの精度以上に、検出モデルの crop 精度が最終結果を大きく左右します。PaddleOCR の DB++ 検出ではバウンディングボックスが数ピクセルずれるだけで認識が崩壊する現象がありました。DEIM はボックスの精度が安定しており、この問題を解消できました。
テンプレートゾーンのキャリブレーション
ゾーンの座標は実際の検出結果のログから調整しました。開発コンソールに各テキストボックスの relY(正規化Y座標)を出力し、それを元にゾーンの境界を決定しています。マージンの値(0.04)も、条件等のテキストが有効期限ゾーンに混入しないようチューニングした結果です。
ライセンス
学習済みモデルは国立国会図書館が CC BY 4.0 で公開しています。アーキテクチャ(DEIM / PARSeq)は Apache License 2.0、ONNX Runtime Web は MIT ライセンスです。
おわりに
ブラウザだけで免許証OCRが動く時代になりました。ONNX Runtime Web と Web Worker を組み合わせることで、サーバーレスでも実用的な精度の OCR パイプラインを構築できます。
モデルの精度やテンプレートゾーンの調整など改善の余地はまだありますが、プライバシーを重視する場面での OCR ソリューションとして十分に使えるレベルだと考えています。
ソースコードは GitHub で公開しています。
