Tesseract.jsでブラウザだけの日本語OCRを実装する方法(PSM二段階戦略)

概要

今回はTesseract.js を使って、ブラウザ側だけで日本語 OCR を動かす実装方法について紹介していきます。

名刺やレシートをカメラで撮って文字起こしするような機能、サーバを持たずに全部ブラウザで完結できたら嬉しいですよね(^^

Tesseract.js なら WASM で OCR が動くので、Cloudflare Pages や GitHub Pages の無料枠だけで OCR 付きの Web アプリが作れます

ただ、そのままサンプル通りに使うと日本語の認識精度がなかなか出なかったり、初回ロードが重くて UI が固まったりして、正直実用化は難しいところです。
ここでは、実際に使えるレベルまで持っていくための 2 つの工夫、worker の使い回しPSM 二段階推論を中心にまとめます。

それではやっていきましょう!

目次

そもそも Tesseract.js とは

Tesseract.js は、Google がメンテしている OCR エンジン Tesseract を、ブラウザ(と Node.js)で動かせるようにポートした JavaScript ライブラリです。

特徴はこんな感じです。

<Tesseract.js の特徴>
  • サーバ不要、ブラウザの WASM で完結する
  • 日本語 (jpn) と英語 (eng) を含む 100 種類以上の言語モデルに対応
  • 言語モデル(tessdata)はネット経由で初回だけ DL し、以降はキャッシュ
  • ライセンスは Apache 2.0 で商用利用も OK

事前に用意するのは npm i tesseract.js だけなので導入はとても簡単です!!

とりあえず動かす最小コード

まずはシンプルに動かしてみます。
tesseract.js v5 を想定しています。

minimalOcr.ts

1
2
3
4
5
6
7
8
import Tesseract from "tesseract.js";

export async function recognizeSimple(file: File): Promise<string> {
const { data } = await Tesseract.recognize(file, "jpn+eng", {
logger: (m) => console.log(m),
});
return data.text;
}

この書き方でも動くのですが、実運用では色々と困ります

<素のコードで困る点>
  • 呼び出すたびに worker が作り直されて、毎回 10 秒単位で待たされる
  • PSM(Page Segmentation Mode)がデフォルト固定で、縦書きや密集レイアウトに弱い
  • 進捗イベントは出るけど、0〜100% の単一の数値には整形されていない

ここから先はこの 3 つの問題を 1 個ずつ潰していきます。

工夫 1:worker を singleton で使い回す

Tesseract.js の createWorker は、WASM と言語データを読み込むのでとても重いです。
呼び出しごとに作り直すと、毎回 5〜10 秒のロスになります

素直な解決策は、Promise をキャッシュして singleton にすることです。

ocrWorker.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { createWorker, type Worker, PSM, OEM } from "tesseract.js";

let workerPromise: Promise<Worker> | null = null;

export function getOcrWorker(): Promise<Worker> {
if (workerPromise) return workerPromise;
workerPromise = (async () => {
const worker = await createWorker(["jpn", "eng"], OEM.DEFAULT, {
langPath: "https://tessdata.projectnaptha.com/4.0.0",
logger: () => {},
});
await worker.setParameters({
tessedit_pageseg_mode: PSM.AUTO,
preserve_interword_spaces: "1",
user_defined_dpi: "300",
});
return worker;
})();
return workerPromise;
}

ポイントは以下です。

<実装上のコツ>
  • 初回呼び出しで生成、2 回目以降は同じ Promise を即返す
  • createWorker の第 1 引数に配列で ["jpn","eng"] を渡すと、数字・英字混じりの日本語に強くなる
  • langPathtessdata.projectnaptha.com の公式 CDN を指定しておけば jpn モデル(約 10MB)を取りに行ってくれる
  • preserve_interword_spaces: "1" はレシートのように空白が意味を持つレイアウトで効く

初回 DL は 10MB 超いきます。UI 側では必ずプログレス表示を用意しておきましょう

工夫 2:PSM 二段階で拾いこぼしを減らす

PSM(Page Segmentation Mode)は、Tesseract が画像をどう解釈するかのヒントです。
よく使う値はこれくらいです。

<主な PSM 値>
  • PSM.AUTO (3) :行方向も含めて自動判定。汎用。
  • PSM.SINGLE_BLOCK (6) :画像全体を 1 ブロックとして扱う。密集したテキスト向け。
  • PSM.SINGLE_BLOCK_VERT_TEXT (5) :縦書き 1 ブロック。
  • PSM.SPARSE_TEXT (11) :ばらつきのある文字群。看板・メニューなど。

どれか 1 つに固定すると、レイアウトがずれた時に一気に取れなくなります。
そこで、2 パス走らせて結果を連結する戦略が安定します。

ocrRecognize.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { PSM, type Worker } from "tesseract.js";
import { getOcrWorker } from "./ocrWorker";

type RecognizeOptions = {
vertical?: boolean;
onProgress?: (ratio: number) => void; // 0..1
};

export async function recognize(
input: File | HTMLCanvasElement | ImageData,
opts: RecognizeOptions = {}
): Promise<string> {
const worker: Worker = await getOcrWorker();

const secondMode = opts.vertical
? PSM.SINGLE_BLOCK_VERT_TEXT
: PSM.SINGLE_BLOCK;

const text1 = await runOnce(worker, input, PSM.AUTO, (r) =>
opts.onProgress?.(r * 0.5)
);
const text2 = await runOnce(worker, input, secondMode, (r) =>
opts.onProgress?.(0.5 + r * 0.5)
);

return `${text1}\n---\n${text2}`;
}

async function runOnce(
worker: Worker,
input: File | HTMLCanvasElement | ImageData,
psm: PSM,
progress: (r: number) => void
): Promise<string> {
await worker.setParameters({ tessedit_pageseg_mode: psm });
const { data } = await worker.recognize(input, {}, { logger: (m) => {
if (m.status === "recognizing text" && typeof m.progress === "number") {
progress(m.progress);
}
}});
return data.text;
}

PSM.AUTO だけだと「画像が密集した 1 ブロック」と見なされずに分割判定で壊れることがあり、
PSM.SINGLE_BLOCK だけだと逆に全体のレイアウトを無視して縦と横の文字が混ざって読まれます。
両方の出力を --- で区切って連結し、後段のトークン分割で重複をまとめるのが、日本語の実データに対して一番安定しました。

工夫 3:進捗率を 0〜100% に正規化する

Tesseract.js の logger は status: "recognizing text" + progress: 0..1 を出すんですが、二段階推論にするとそのままでは 0→1→0→1 と 2 回進みます
UI に出すには、前後半でそれぞれ 0〜0.5 / 0.5〜1.0 にスケールしてあげるとスムーズに見えます。

上のコードで opts.onProgress?.(r * 0.5)0.5 + r * 0.5 のように分割しているのがその処理です。

UI 側はこんな感じで受けるとシンプルです。

OcrProgress.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState } from "react";
import { recognize } from "./ocrRecognize";

export function OcrProgress({ file }: { file: File }) {
const [ratio, setRatio] = useState(0);
const [text, setText] = useState("");

async function start() {
setRatio(0);
const result = await recognize(file, { onProgress: setRatio });
setText(result);
}

return (
<div>
<button onClick={start}>解析する</button>
<progress value={ratio} max={1} />
<span>{Math.round(ratio * 100)}%</span>
<pre>{text}</pre>
</div>
);
}

<progress> タグの value にそのまま突っ込めるので、進捗の途中停止感が消えて体感が良くなります

worker を使い終わったときの後処理

長時間使わないセッションで抱え込むとメモリを食うので、必要に応じて終了もできるようにしておきます。

ocrWorker.ts

1
2
3
4
5
6
7
// 追記
export async function terminateOcrWorker() {
if (!workerPromise) return;
const worker = await workerPromise;
await worker.terminate();
workerPromise = null;
}

SPA なら画面遷移時、MPA ならタブ非アクティブを検知して呼ぶくらいで十分です。
頻繁に terminate しすぎると初回の DL 相当の再ロードが走るので、やりすぎ注意ですね。

メリット・デメリットまとめ

<Tesseract.js OCR のメリット>
  • サーバ不要で完全無料で使える
  • 画像がネット上に出ないので、プライバシー的に強い
  • Apache 2.0 で商用利用も可能
<デメリットと対策>
  • 初回 DL が 10MB 超 → プログレス表示で UX を吸収
  • 日本語は前処理しないと精度が厳しい → 二値化と軽いリサイズで大きく改善
  • worker 生成が重い → singleton 化でごまかす

締め

Tesseract.js を「動かすだけ」と「使い物になるレベル」の間には、worker の使い回しと PSM 二段階推論という地味なテクニックが挟まっています。
この 2 つと進捗の正規化を押さえれば、ブラウザだけで実用的な日本語 OCR の入り口には立てると思います。

この時点では精度はまだ甘々で商用利用はできませんが、きっとうまく使えば商用利用も検討できるはず。。。だと思って頑張ります!

次回はさらに精度を上げるために、ここに流し込む画像を Canvas で前処理して認識精度を上げる方法について書いていきます。

以上
無料でOCRを試せるとはいい時代!!
それではお疲れさまでした。