Canvas で OCR 前処理:Sauvola 二値化とコントラスト伸長で認識精度を上げる

概要

今回はCanvas で画像を前処理して、Tesseract.js の OCR 認識精度を上げる方法について紹介していきます。

スマホで撮った写真をそのまま Tesseract.js に渡しても、日本語の認識結果はけっこうボロボロになりがちです。
原因はシンプルで、OCR エンジンは「白い紙に黒い文字」みたいな、くっきりした画像が得意なんですよね。

でも写真って、色はついてるし、影もあるし、光の当たり方にムラもある。OCR から見ると「情報が多すぎて読みにくい」状態になっています。

なので、OCR に渡す前にひと手間加えて、写真を「白黒のくっきり画像」に変換してから渡すというのが今回のテーマです。手順はこんな感じ。

<4 段階の前処理>
  1. グレースケール化 … 色を捨てて白黒に
  2. コントラスト伸長 … 明るさのメリハリを強くする
  3. 二値化 … 完全な「白 or 黒」だけの画像にする
  4. 明暗の反転(必要なら) … 黒背景の画像を白背景に直す

全部ブラウザの Canvas API だけで完結するので、サーバーも追加ライブラリも不要です。

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

目次

前提知識:OCR・Tesseract.js・前処理ってなに?

本題に入る前に、用語をサッと整理しておきます。既に知ってる方は飛ばして OK です。

<用語まとめ>
  • OCR(Optical Character Recognition) … 画像から文字を読み取って、コピペできるテキストに変換する技術。スマホの「カメラで文字をコピー」機能の裏側もこれ。
  • Tesseract(テッセラクト) … OCR のオープンソース定番エンジン。Google が開発を引き継いでいます。
  • Tesseract.js … Tesseract をブラウザ / Node.js で動かせるようにした JavaScript 版。WebAssembly で動きます。
  • 前処理 … OCR に渡す前に画像を加工して、エンジンが読みやすい形に整えること。料理で言う「下ごしらえ」です。

Tesseract.js の入門的な使い方は前回の記事 Tesseract.jsでブラウザだけの日本語OCRを実装する方法 にまとめてあるので、あわせて読んでみてください。

なぜ前処理で OCR 精度が上がるのか

OCR エンジンは、画像を見て「ここが文字、ここは背景」と仕分けをしながら読んでいます。
このとき「白い背景に黒い文字」という前提で仕分けの基準を作っているので、カラー写真だと色の境目が曖昧で、背景のシミや影まで「文字かも?」と拾ってしまうんですね。

百聞は一見にしかずなので、架空の名刺画像を 3 段階で処理した例を見てみます。
左から、元画像・コントラスト伸長後・Sauvola 二値化後、の順です。

前処理の比較(元画像 → コントラスト伸長後 → Sauvola 二値化後)
OCR 前処理の before after 比較

一番右、完全な白黒まで変換した状態が一番くっきりして見えますよね。
OCR に渡すときは、この右端のような「白背景に黒文字だけ」の状態を目指すのがコツです。

処理の全体像

流れはこんな感じです。冒頭でも触れた 4 段階に、最初の「リサイズ」を足した 5 ステップ構成です。

<前処理手順>
  1. リサイズ … サイズを揃える(重すぎ / 小さすぎを避ける)
  2. グレースケール化 … 色をなくす
  3. コントラスト伸長 … 明暗をはっきりさせる
  4. 二値化 … 白と黒の 2 色だけにする
  5. 明暗の反転(必要なら) … 黒地に白文字なら白黒を入れ替える

各ステップは 1 つずつ独立した関数に切り分けるのがおすすめです。
後で「このステップだけスキップしたい」「このステップだけ差し替えたい」というときに、気軽にいじれるようになります。

以降、この手順を上から順番に実装していきます。

ステップ 1:リサイズ

まず画像のサイズを整えます。

<なぜリサイズが必要なのか>
  • 画像が 大きすぎる(例:スマホで撮った 4000px とか)→ OCR の処理が重くて遅い
  • 画像が 小さすぎる(例:200px の低解像度)→ 文字がつぶれて読めない

経験上、短辺(縦横の短い方)を 900px くらいにそろえると、精度と速度のバランスが取りやすいです。

resize.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 画像を「短辺 target px」のサイズにリサイズして返す
export function resizeToShortEdge(
source: HTMLImageElement | HTMLCanvasElement,
target = 900
): HTMLCanvasElement {
const w = source.width;
const h = source.height;
// 目標サイズに対する倍率を計算(ただし 1 以上=拡大はしない)
const scale = Math.min(1, target / Math.min(w, h));
const canvas = document.createElement("canvas");
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);
const ctx = canvas.getContext("2d")!;
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
return canvas;
}

ポイントは Math.min(1, ...) の部分で、元画像が小さい場合は無理に拡大しないようにしています。
低解像度の画像を拡大すると、引き伸ばしたシールみたいにボヤけるだけで、逆効果になるんですよね。

ステップ 2:グレースケール化

次は色を捨てて白黒にします。
OCR にとって色情報は邪魔なだけなので、ここで一気に「明るさ(= 輝度)」の情報だけに圧縮します。

<なぜ単純平均 (R+G+B)/3 じゃダメなのか>

人間の目は色ごとに感度が違っていて、緑が一番明るく、赤がそこそこ、青は暗く見えます。
そこで輝度計算では、その感度差に合わせた係数(緑 0.587、赤 0.299、青 0.114)を使うのが定番です。
この係数は BT.601 というテレビ放送の規格に由来しています。

grayscale.ts

1
2
3
4
5
6
7
8
9
10
11
12
// Canvas 上のピクセルを「色 → 明るさだけ」に変換する
export function toGrayscale(canvas: HTMLCanvasElement): void {
const ctx = canvas.getContext("2d")!;
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const d = img.data; // [R, G, B, A, R, G, B, A, ...] の並び
for (let i = 0; i < d.length; i += 4) {
// 輝度 y を計算して、R・G・B 全てに同じ値を入れる
const y = 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
d[i] = d[i + 1] = d[i + 2] = y;
}
ctx.putImageData(img, 0, 0);
}

d[R, G, B, A, R, G, B, A, ...] というふうに、1 ピクセルあたり 4 つの数字(赤・緑・青・透明度)が並んだ配列です。i += 4 で 1 ピクセルずつ進めて処理しているわけですね。

この時点ではまだ全体的にぼんやりしているので、次のコントラスト伸長で締めます。

ステップ 3:コントラスト伸長

次は明るさのメリハリをつける処理です。
カメラで撮った写真って、ほとんどのピクセルが「グレーっぽい中間色」に寄っていることが多いんですよね。
この中途半端な状態を、思い切って「白と黒の幅いっぱい」に引き伸ばしてあげます。

<イメージ>
  • 加工前:明るさ 50〜200 の間にピクセルが集まっていて、グレーっぽい
  • 加工後:明るさ 0〜255 まで引き伸ばして、黒は黒、白は白にくっきり

具体的には「暗い側 2% のピクセルは全部黒にする」「明るい側 2% のピクセルは全部白にする」「残った中間を 0〜255 に引き伸ばす」という処理です。

<なぜ 2% を切り捨てるのか>

単純に「画像内の最小値を黒、最大値を白」にすると、ゴミ 1 ピクセル(反射や黒点)に全体が引きずられて効かなくなるんですよね。
なので上下 2% ずつは「外れ値」として無視する、というのが定番のテクニックです。

<パーセンタイルとは>
  • 統計用語で「下からちょうど X% の位置にある値」のこと
  • 身近な例:学校のテストで「上位 2%」と言ったら、100 人中上から 2 番目の点数のこと
  • ここでも同じで、画素を暗い順に並べて、下から 2% の位置にある明るさを「黒と見なす基準」にする、という意味

実装はこんな感じです。

stretchContrast.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
// 画像全体の明暗を 0〜255 の範囲いっぱいに引き伸ばす
export function stretchContrast(canvas: HTMLCanvasElement): void {
const ctx = canvas.getContext("2d")!;
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const d = img.data;

// 1. ヒストグラム(明るさごとのピクセル数)を数える
const histogram = new Uint32Array(256);
for (let i = 0; i < d.length; i += 4) histogram[d[i]]++;

// 2. 「下から 2%」「下から 98%」にあたる明るさを探す
const total = canvas.width * canvas.height;
const loTarget = total * 0.02;
const hiTarget = total * 0.98;

let lo = 0, hi = 255, acc = 0;
for (let v = 0; v < 256; v++) { acc += histogram[v]; if (acc >= loTarget) { lo = v; break; } }
acc = 0;
for (let v = 0; v < 256; v++) { acc += histogram[v]; if (acc >= hiTarget) { hi = v; break; } }

// 3. 見つけた lo〜hi の範囲を、0〜255 に引き伸ばす
const range = Math.max(1, hi - lo);
for (let i = 0; i < d.length; i += 4) {
const v = Math.max(0, Math.min(255, ((d[i] - lo) * 255) / range));
d[i] = d[i + 1] = d[i + 2] = v;
}
ctx.putImageData(img, 0, 0);
}
<ヒストグラムとは>

明るさごとに「そのピクセルが何個あるか」を数え上げた棒グラフのこと。
コード中の histogram[v] は「明るさ v のピクセル数」を表しています。
写真加工アプリで見る「波形」も、この仲間です。

これで画像がずいぶんくっきりしました。
次はいよいよ、白と黒の 2 色だけに絞り込む「二値化」です。

ステップ 4:二値化(Sauvola vs Otsu)

いよいよ本題の「白と黒だけの画像に変換する」処理です。
ここが OCR 精度を一番左右するポイントで、何を基準に「このピクセルは白/黒」と決めるかがキモになります。

代表的な二値化アルゴリズムは 2 つあります。

<二値化アルゴリズムの使い分け>
  • Otsu 法(大津の二値化):画像全体で 1 つの明るさを決めて、それより明るければ白・暗ければ黒。均一に照らされたスキャン書類のような画像に強い。
  • Sauvola 法:ピクセルごとに「周りのエリアの平均」を見て基準を変える。照明ムラ(片側だけ暗い、中央だけ明るい、など)に強い。

わかりやすく例えると、Otsu は「クラス全員の平均点で合否を決める」、Sauvola は「席の近い人との比較で合否を決める」みたいな違いです。
写真が混じる用途だと、まず Sauvola をデフォルトにしておけば大抵うまくいきます

Sauvola 法の実装

Sauvola 法は、ピクセルごとに「周囲(窓)の平均と、明るさのばらつき具合」を見て、白/黒の基準を決めます。
数式にするとこうなります。

  • 基準値 = 平均 × (1 + k × (ばらつき / R - 1))
<記号の意味>
  • 平均(mean) … ピクセルの周り(例:11×11 の範囲)の明るさの平均
  • ばらつき(sd) … その範囲内で明るさがどれくらい散らばっているか(標準偏差)
  • k … 0.2〜0.5 くらいのパラメータ。値が大きいほど「黒寄りに倒す」強さが増す
  • R … ばらつきの基準値。グレースケール画像では 128 が定番
<補足:標準偏差とは>

「データがどれくらいバラついてるか」を表す数字です。
例えば文字の上ならピクセルは黒と白が入り混じるのでバラつき大、無地の背景ならバラつき小。
Sauvola は「バラついてる=文字がある」と判断して基準をうまく調整してくれます。

素直に実装すると、ピクセルごとに周囲をぐるっと見るので、画像サイズの 2 乗に比例して重くなります。
そこで積分画像(integral image)という高速化テクを使います。

<積分画像とは>

画像の「左上の隅から、そのピクセルまでの合計値」をあらかじめ計算しておいたテーブルのこと。
これがあると、どんな矩形範囲の合計でも足し算 4 回で出せるようになります。
細かい理屈が分からなくても、「この定型コードを入れると数十倍速くなるおまじない」 として捉えて OK です。

sauvola.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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Sauvola 法による二値化(白 or 黒だけの画像に変換)
export function binarizeSauvola(
canvas: HTMLCanvasElement,
opts: { windowSize?: number; k?: number; R?: number } = {}
): void {
const window = opts.windowSize ?? autoWindow(canvas); // 参照範囲の広さ
const k = opts.k ?? 0.34; // 基準の厳しさ
const R = opts.R ?? 128; // 正規化定数
const ctx = canvas.getContext("2d")!;
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const w = canvas.width, h = canvas.height;
const d = img.data;

// グレースケール値だけを抜き出した 1 次元配列を作る(R 成分のみでOK)
const gray = new Uint8ClampedArray(w * h);
for (let i = 0, j = 0; i < d.length; i += 4, j++) gray[j] = d[i];

// 積分画像を作る(合計と、2 乗和)。これで任意矩形の平均・分散が O(1) で取れる
const sum = new Float64Array((w + 1) * (h + 1));
const sumSq = new Float64Array((w + 1) * (h + 1));
for (let y = 1; y <= h; y++) {
for (let x = 1; x <= w; x++) {
const v = gray[(y - 1) * w + (x - 1)];
const idx = y * (w + 1) + x;
sum[idx] = v + sum[idx - 1] + sum[idx - (w + 1)] - sum[idx - (w + 2)];
sumSq[idx] = v * v + sumSq[idx - 1] + sumSq[idx - (w + 1)] - sumSq[idx - (w + 2)];
}
}

// 各ピクセルを走査して「窓内の平均・標準偏差」から閾値を決める
const r = Math.floor(window / 2);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const x1 = Math.max(0, x - r), y1 = Math.max(0, y - r);
const x2 = Math.min(w - 1, x + r), y2 = Math.min(h - 1, y + r);
const area = (x2 - x1 + 1) * (y2 - y1 + 1);
// 矩形内の合計 = D - B - C + A(積分画像の定番テク)
const A = sum[y1 * (w + 1) + x1];
const B = sum[y1 * (w + 1) + x2 + 1];
const C = sum[(y2 + 1) * (w + 1) + x1];
const D = sum[(y2 + 1) * (w + 1) + x2 + 1];
const mean = (D - B - C + A) / area;
const A2 = sumSq[y1 * (w + 1) + x1];
const B2 = sumSq[y1 * (w + 1) + x2 + 1];
const C2 = sumSq[(y2 + 1) * (w + 1) + x1];
const D2 = sumSq[(y2 + 1) * (w + 1) + x2 + 1];
const variance = (D2 - B2 - C2 + A2) / area - mean * mean;
const sd = Math.sqrt(Math.max(0, variance));
// Sauvola の閾値式
const t = mean * (1 + k * (sd / R - 1));
// 閾値より明るいなら白、暗いなら黒
const v = gray[y * w + x] > t ? 255 : 0;
const i = (y * w + x) * 4;
d[i] = d[i + 1] = d[i + 2] = v;
}
}
ctx.putImageData(img, 0, 0);
}

// 画像サイズに応じて「周囲を見る窓の大きさ」を自動決定する
function autoWindow(canvas: HTMLCanvasElement): number {
const size = Math.round(Math.min(canvas.width, canvas.height) / 30);
const odd = size % 2 === 0 ? size + 1 : size; // 奇数にそろえる
return Math.max(15, Math.min(61, odd)); // 15〜61 の範囲にクランプ
}

窓サイズ(周囲を見る範囲の広さ)は経験上、画像の短辺の 30 分の 1 くらいが落ち着きが良いです。

<窓サイズを外すとどうなるか>
  • 小さすぎる → 1 文字の中でもムラが出て、文字がブツブツに欠ける
  • 大きすぎる → 照明ムラに弱くなる(Otsu 法に近づく)

Otsu 法との使い分け

ヒストグラム(ステップ 3 で出てきた、明るさごとのピクセル数のグラフ)がきれいに 2 つの山に分かれている画像、つまり「背景と文字がもとからくっきり分かれているスキャン書類」なら、Otsu 法の方が高速でシンプルです。

ただし写真が混ざる用途では Sauvola 1 本で十分なので、まずは Sauvola だけ実装して、速度が問題になってから Otsu に切り替える、くらいで大丈夫です。

ステップ 5:明暗の自動反転

最後のステップです。ここはオマケ的な処理なのでサクッといきます。

お店のメニューや看板みたいに「黒地に白文字」のデザインって、世の中に割と多いんですよね。
でも OCR は「白背景に黒文字」前提なので、黒地のままだと精度が落ちます。

そこで、二値化した後の画像を見て「黒のピクセルが多すぎたら白黒をひっくり返す」という処理を入れておきます。

maybeInvert.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 黒ピクセルが多い画像(黒地に白文字)なら、白黒を反転する
export function maybeInvert(canvas: HTMLCanvasElement, blackRatio = 0.6): void {
const ctx = canvas.getContext("2d")!;
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const d = img.data;

// 黒ピクセル(明るさ 128 未満)の数を数える
let black = 0;
for (let i = 0; i < d.length; i += 4) if (d[i] < 128) black++;

// 全体に対する黒の割合が blackRatio 未満なら「反転不要」として終了
const total = canvas.width * canvas.height;
if (black / total < blackRatio) return;

// 黒の方が多ければ、白黒を全部ひっくり返す
for (let i = 0; i < d.length; i += 4) {
d[i] = 255 - d[i];
d[i + 1] = 255 - d[i + 1];
d[i + 2] = 255 - d[i + 2];
}
ctx.putImageData(img, 0, 0);
}

ロジックはシンプルで、「画像内の黒ピクセルが 60% を超えたら、きっと黒地に白文字だろう」と判断して反転します。
閾値 60% はちょっと緩めの設定です。日本語は漢字が多くて「文字のピクセル数」が英語より多めになりがちなので、少し余裕を持たせてあります。

全部つなげる

ここまで作った 5 つの関数を、上から順に呼ぶだけのエントリーポイントを用意します。
これで OCR 側からは 1 行で「下ごしらえ済みの画像」を取得できるようになります。

preprocess.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { resizeToShortEdge } from "./resize";
import { toGrayscale } from "./grayscale";
import { stretchContrast } from "./stretchContrast";
import { binarizeSauvola } from "./sauvola";
import { maybeInvert } from "./maybeInvert";

// 画像を OCR が読みやすい「白黒くっきり画像」に整えて返す
export function preprocessForOcr(source: HTMLImageElement): HTMLCanvasElement {
const canvas = resizeToShortEdge(source, 900); // 1. サイズをそろえる
toGrayscale(canvas); // 2. 白黒(グレースケール)にする
stretchContrast(canvas); // 3. 明暗をはっきりさせる
binarizeSauvola(canvas); // 4. 完全な白 or 黒にする
maybeInvert(canvas); // 5. 黒地なら反転する
return canvas;
}

出来上がった Canvas をそのまま Tesseract.js に渡せば OK です。
呼び出し側のイメージはこんな感じ。

useOcr.ts

1
2
3
4
5
6
7
8
import Tesseract from "tesseract.js";
import { preprocessForOcr } from "./preprocess";

async function runOcr(imageElement: HTMLImageElement) {
const prepared = preprocessForOcr(imageElement);
const { data } = await Tesseract.recognize(prepared, "jpn");
console.log(data.text);
}

前処理を入れる前と入れた後で、どれくらい精度が変わるか、手元の画像で試してみてください。体感できるレベルで変わるはずです。

つまずきポイント Q&A

前処理を組むときによくハマるポイントを、Q&A 形式でまとめておきます。

<Q1. 文字がブツブツに欠けて読み取れない>

Sauvola の窓サイズが小さすぎている可能性が高いです。
autoWindow の下限(15)を 2125 に上げて様子を見てください。

<Q2. 背景のノイズまで文字として拾われる>

今度は窓サイズが大きすぎか、k が緩すぎかもしれません。
k0.340.5 にすると、基準が厳しくなって背景ノイズを拾いにくくなります。

<Q3. 白黒反転がおかしくなる(白地なのに反転される、逆も)>

maybeInvertblackRatio を調整してください。
文字量の多いドキュメントなら 0.65〜0.7 まで上げると安定します。

<Q4. 処理が遅すぎてブラウザが固まる>

リサイズの短辺を 900600 に下げる、または Web Worker の中で動かすのが定番の対処法です。
OCR 本体もそこそこ重い処理なので、前処理と OCR を両方 Worker 化するのがおすすめです。

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

<Canvas 前処理のメリット>
  • ブラウザ標準機能のみで完結、追加ライブラリなし
  • Tesseract.js の認識率を手軽に底上げできる
  • 各ステップが独立しているので、切り替えや無効化がしやすい
<デメリットと対策>
  • Sauvola はそのままだと処理が重い → 積分画像で高速化
  • 反転判定は画像によってブレる → 怪しければ手動切り替えボタンも用意する
  • 白黒を強くしすぎると細い線が消える → k や窓サイズを画像別に微調整

締め

OCR の精度改善というと「もっと強いエンジンに変える」発想になりがちですが、実は入力画像を整えるだけでも体感できるレベルで精度が上がるんですよね。

今回紹介した Canvas 5 ステップの前処理なら、Tesseract.js でも十分実用レベルの精度が出せます。コピペで動く規模なので、気軽にパイプラインに組み込んでみてください。

次回は、OCR で読み取ったテキストに誤認識が混じっても検索にヒットさせる工夫、Fuse.js の動的しきい値ファジー検索について書いていきます。

以上お疲れさまでした。