斜めから撮った書類の写真を、真上から撮ったような長方形に戻す「台形補正」を、ブラウザだけで実装した話です。OpenCV.js を読み込めば warpPerspective 一発ですが、ライブラリ本体が数MBあります。書類スキャン用途に必要な部分だけなら、依存なしのJavaScript 60行ほどで書けます。
※ 私たちはブラウザで動く書類スキャナー LensUp を作っていて、この記事はその台形補正を作ったときのメモです。
射影変換(ホモグラフィ)
写真の中で台形にゆがんだ紙の四隅と、補正後の長方形の四隅は、射影変換で対応づけられます。
x = (a·u + b·v + c) / (g·u + h·v + 1)
y = (d·u + e·v + f) / (g·u + h·v + 1)
未知数は a〜h の8個です。点の対応1組で式が2本立つので、四隅の4組で8本になり、8元連立一次方程式を解けば係数が決まります。
// 4点の対応から射影変換(ホモグラフィ)の8係数を求める。src[i] -> dst[i]
export function solveHomography(src, dst) {
const A = [], b = [];
for (let i = 0; i < 4; i++) {
const [x, y] = src[i], [X, Y] = dst[i];
A.push([x, y, 1, 0, 0, 0, -X * x, -X * y]); b.push(X);
A.push([0, 0, 0, x, y, 1, -Y * x, -Y * y]); b.push(Y);
}
for (let col = 0; col < 8; col++) {
let piv = col; // 部分ピボット選択
for (let r = col + 1; r < 8; r++) if (Math.abs(A[r][col]) > Math.abs(A[piv][col])) piv = r;
[A[col], A[piv]] = [A[piv], A[col]]; [b[col], b[piv]] = [b[piv], b[col]];
const d = A[col][col];
if (Math.abs(d) < 1e-10) return null; // 3点が一直線など、解けない四角形
for (let c = col; c < 8; c++) A[col][c] /= d;
b[col] /= d;
for (let r = 0; r < 8; r++) {
if (r === col) continue;
const f = A[r][col];
for (let c = col; c < 8; c++) A[r][c] -= f * A[col][c];
b[r] -= f * b[col];
}
}
return b; // [a, b, c, d, e, f, g, h]
}
ガウス・ジョルダン法に部分ピボット選択を足しただけです。3点が一直線に並ぶような四角形では係数が決まらないので null を返します。実際には、解く前に「凸で、自己交差がなく、辺が短すぎない四角形か」も確認しておくと安全です。
逆写像で描く
元画像の各画素を補正後の位置へ送る(順写像)と、拡大される側で送り先に隙間ができ、穴だらけになります。そこで逆向きに、補正後の画像の各画素について「元画像のどこを読めばいいか」を計算します(逆写像)。
ここで小技があります。solveHomography を「出力の長方形 → 元画像の四角形」の向きで解けば、最初から逆写像の係数が手に入るので、3×3行列の逆行列を計算する必要がありません。
// 出力サイズ:向かい合う辺の長いほうを採用し、長辺を cap で頭打ちにする
export function outputSize([tl, tr, br, bl], cap = 1800) {
const dist = (p, q) => Math.hypot(p[0] - q[0], p[1] - q[1]);
let w = Math.max(dist(tl, tr), dist(bl, br));
let h = Math.max(dist(tl, bl), dist(tr, br));
const s = Math.min(1, cap / Math.max(w, h));
return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)) };
}
// img: { width, height, data: RGBA }(ImageData と同じ形)、quad: [TL, TR, BR, BL](ピクセル座標)
export function warpPerspective(img, quad, cap = 1800) {
const { width: sw, height: sh, data: sd } = img;
const { width: ow, height: oh } = outputSize(quad, cap);
// 出力の長方形 -> 元画像の四角形、の向きで解く(逆写像なので逆行列が要らない)
const H = solveHomography([[0, 0], [ow, 0], [ow, oh], [0, oh]], quad);
if (!H) throw new RangeError('この四角形では補正できません');
const [a, b, c, d, e, f, g, h] = H;
const out = new Uint8ClampedArray(ow * oh * 4);
for (let v = 0; v < oh; v++) {
for (let u = 0; u < ow; u++) {
const cu = u + 0.5, cv = v + 0.5; // 画素の中心で計算して
const den = g * cu + h * cv + 1;
let x = (a * cu + b * cv + c) / den - 0.5; // 配列の座標に戻す
let y = (d * cu + e * cv + f) / den - 0.5;
const o = (v * ow + u) * 4;
if (!(x >= -0.5 && y >= -0.5 && x <= sw - 0.5 && y <= sh - 0.5)) { out.fill(255, o, o + 4); continue; }
x = Math.min(sw - 1, Math.max(0, x)); y = Math.min(sh - 1, Math.max(0, y));
const x0 = x | 0, y0 = y | 0, x1 = Math.min(sw - 1, x0 + 1), y1 = Math.min(sh - 1, y0 + 1);
const fx = x - x0, fy = y - y0;
for (let k = 0; k < 4; k++) { // バイリニア補間
const p00 = sd[(y0 * sw + x0) * 4 + k], p10 = sd[(y0 * sw + x1) * 4 + k];
const p01 = sd[(y1 * sw + x0) * 4 + k], p11 = sd[(y1 * sw + x1) * 4 + k];
out[o + k] = (p00 * (1 - fx) + p10 * fx) * (1 - fy) + (p01 * (1 - fx) + p11 * fx) * fy;
}
}
}
return { width: ow, height: oh, data: out };
}
半画素ずれに注意
四隅の座標は「画像の縁」を表しますが、画素の値は「画素の中心」にあります。u + 0.5 で画素の中心を変換し、- 0.5 で配列の添字の座標に戻しているのはそのためです。これを省くと、出力全体が半画素ずれて、縁に1画素ぶんの欠けやにじみが出ます。
読み取り位置は整数にならないので、周りの4画素を距離で重み付けして混ぜる(バイリニア補間)と、文字の輪郭がガタつきません。
Canvas からの使い方
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const src = ctx.getImageData(0, 0, canvas.width, canvas.height);
const out = warpPerspective(src, quad); // quad は四隅のピクセル座標 [TL, TR, BR, BL]
const result = document.createElement('canvas');
result.width = out.width; result.height = out.height;
result.getContext('2d').putImageData(new ImageData(out.data, out.width, out.height), 0, 0);
ちゃんと戻るか検証する
Node で数値的に確かめました。300×420 の市松模様の「書類」を、既知の四隅 [[150,100],[620,160],[700,640],[90,560]] で 800×700 の「斜め写真」に描き込み、それを上のコードで補正して、元の書類と画素ごとに比べます。
| 方法 | 元の書類との平均絶対誤差(0〜255) |
|---|---|
| 上のコード(射影変換) | 1.09 |
| 四角形の外接矩形を切り抜いて引き伸ばすだけ | 87.21 |
補間を2回通しているぶんの誤差は残りますが、ほぼ元どおりです。出力の四隅を逆写像にかけると、指定した四隅の座標にぴったり戻ることも確認しました。
落とし穴:四隅だけでは縦横比が決まらない
上の検証で、実はひとつ問題が見えています。outputSize は向かい合う辺の長いほうを幅・高さにしていて、この検証では 615×487 の横長になりました。元の書類は 300×420 の縦長です。
原理的に、四角形の四隅の位置だけでは、元の紙の縦横比は決まりません。カメラの焦点距離などの情報がないと、「縦長の紙を斜めから撮った」のか「横長の紙を別の角度で撮った」のかを区別できないからです。
どのくらいずれるのか、ピンホールカメラのモデルで試算しました。縦向き 3000×4000 の写真(焦点距離 2200px)で、A4(縦横比 0.707)を上辺がカメラに近づく向きに傾けて撮った場合です。
| 傾き | 辺の長さから決めた縦横比 | ずれ | 焦点距離を使った推定(Zhang & He) |
|---|---|---|---|
| 20° | 0.840 | +18.7% | 0.707 |
| 35° | 1.023 | +44.7% | 0.707 |
| 50° | 1.340 | +89.5% | 0.707 |
35°傾けただけで、A4がほぼ正方形になってしまいます。焦点距離がわかれば、Zhang & He(Microsoft Research, MSR-TR-2003-39, 4.1節)の方法で縦横比を推定でき、この理想的な試算では正しい 0.707 が出ます。ただしブラウザに渡ってくる写真から焦点距離を確実に取れるとは限りません。
実用上の対策は次の2つです。
- 紙の種類がわかっているなら、その縦横比を使う。 A4、レター、カード(ID-1)など、書類スキャンでは紙のサイズがわかっていることが多いです。LensUp の台形補正では「元の用紙の比率」で A4・Letter・ID-1・カスタム比率を指定でき、指定しない(不明)ときだけ辺の長さを使います。
- なるべく真上から撮る。 傾きが小さいほど、辺の長さから決めてもずれは小さくなります。
実運用での工夫
LensUp に組み込むときにやったことです。
- 入力を縮める。 読み込んだ時点で長辺を 2200px に縮めてから処理しています。スマホの原寸写真を毎画素ループで回すと重すぎます。
-
出力の長辺に上限を付ける。 上の
cap(1800px)です。 -
UIを止めない。 64行ごとに
awaitでメインスレッドに処理を返し、キャンセルも受け付けるようにしています。書くなら、たとえばif (v % 64 === 0) await new Promise((r) => setTimeout(r));のような形です。 -
必要な範囲だけ読む。 64行ぶんの出力が参照する元画像の範囲を四隅から計算し、その範囲だけ
getImageDataしています。
おわりに
四隅さえ決まれば、台形補正そのものは連立方程式と補間だけで書けます。むしろ難しいのは、四隅をどう決めるかと、補正後の縦横比をどう決めるかのほうでした。
実際に動くものは LensUp の台形補正 で試せます。四隅のハンドルを書類の角に合わせて適用する方式で、処理はすべてブラウザ内で行われ、写真はアップロードされません。