はじめに
「ブラウザで何か撮影をする際にフロントサイドのみで画像を加工・解析をしたい」といった需要が一部あると思いますが、公開されている技術記事はあまりないと感じています。
そのため、前回ReactとOpenCV.jsで枠認識をしてみようを投稿しました。
今回はその続きとして、書類などの四角形の被写体が撮影された際の台形補正を行います。
前提
前回のReactとOpenCV.jsで枠認識をしてみようの記事で作成したデモページに機能追加する形で機能を紹介します。デモページのソースを改めて貼ります。
デモページのコード
import React, { useState } from 'react';
import './App.css'
const App: React.FC = () => {
const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
console.log(selectedImage);
const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedImage(file);
// 画像のプレビューURLを作成
const imageUrl = URL.createObjectURL(file);
setPreviewUrl(imageUrl);
}
};
// 画像を白黒変換する
const convertBlackAndWhite = () => {
// OpenCV準備
const win = window;
const cv = win.cv;
// 画像の準備
const img = new Image();
if (previewUrl) {
img.src = previewUrl;
}
const src = cv.imread(img); // OpenCV読み込み
const dst = new cv.Mat(); // 画像の情報を格納する多次元配列「Mat」の準備
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY); // 画像を白黒に変換
cv.imshow('cvCanvas', dst); // Canvasに表示
// メモリを明示的に開放する
src.delete();
dst.delete();
};
// 画像に枠認識を実行し可視化する
const recognizeBorder = () => {
// OpenCV準備
const win = window;
const cv = win.cv;
// 画像の準備
const img = new Image();
if (previewUrl) {
img.src = previewUrl;
}
const src = cv.imread(img); // OpenCV読み込み
const dst = new cv.Mat(); // 画像の情報を格納する多次元配列「Mat」の準備
const hierarchy = new cv.Mat();
const contours = new cv.MatVector(); // Matをさらに格納する配列
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY); // 画像を白黒に変換
cv.threshold(dst, dst, 10, 255, cv.THRESH_OTSU); // しきい値処理により画像を二値化
// 輪郭検出
cv.findContours(
dst, // 検出対象の画像
contours, // 輪郭の座標が格納される
hierarchy, // 輪郭線のベクトル情報が階層として格納される
cv.RETR_EXTERNAL, // 最も外側の輪郭線のみを取得するよう定数指定
cv.CHAIN_APPROX_TC89_L1, // 輪郭近似法の指定オプション
);
for (let i = 0; i < contours.size(); i++) {
// ある程度のサイズ以上の輪郭のみ処理
const area = cv.contourArea(contours.get(i), false);
if (area > 19000) {
// 被写体の輪郭を赤で描画
cv.drawContours(
src, // 描画対象の画像
contours, // findContoursで取得した座標
i, // 取得した輪郭の内、何番目の輪郭を指定するかのインデックス
new cv.Scalar(255, 0, 0, 255), // 輪郭の色指定
30, // 輪郭の線の太さ
cv.LINE_8, // 線の種類指定
hierarchy, // 輪郭の階層構造
100, // 描画される輪郭の最大レベル指定。階層構造の内、どの程度の深さまで描画するかの指定
);
}
}
cv.imshow('cvCanvas', src); // Canvasに表示
// メモリを明示的に開放する
src.delete();
dst.delete();
hierarchy.delete();
contours.delete();
};
return (
<div style={{ textAlign: 'center', marginTop: '20px' }}>
<h1>画像アップロード</h1>
<input
type="file"
accept="image/*"
onChange={handleImageChange}
style={{ marginBottom: '20px' }}
/>
{previewUrl && (
<div style={{ display: 'flex' }}>
<div>
<img
src={previewUrl}
alt="Uploaded Preview"
style={{ maxWidth: '100%', maxHeight: '300px', margin: '20px 5px' }}
/>
</div>
<div>
<canvas
id="cvCanvas"
style={{ maxWidth: '100%', maxHeight: '300px', margin: '20px 5px' }}
/>
<br />
<button
onClick={convertBlackAndWhite}
style={{ marginRight: '5px' }}
>
白黒変換
</button>
<button onClick={recognizeBorder}>枠認識</button>
</div>
</div>
)}
{!previewUrl && <p>画像を選択してください。</p>}
</div>
);
};
export default App;
台形補正の用のUI追加
台形補正を実行するボタンを追加します。
// 画像を台形補正する
const correctTrapezoid = () => {
// OpenCV準備
const win = window;
const cv = win.cv;
// 画像の準備
const img = new Image();
if (previewUrl) {
img.src = previewUrl;
}
const src = cv.imread(img); // OpenCV読み込み
cv.imshow('cvCanvas', src); // Canvasに表示
// メモリを明示的に開放する
src.delete();
};
// 中略
<button
onClick={convertBlackAndWhite}
style={{ marginRight: '5px' }}
>
白黒変換
</button>
<button
onClick={recognizeBorder}
style={{ marginRight: '5px' }}
>
枠認識
</button>
<button onClick={correctTrapezoid}>台形補正</button>
被写体の枠を取得する
前回と同様、correctTrapezoid()に枠認識するロジックを追加します。
// 画像を台形補正する
const correctTrapezoid = () => {
// OpenCV準備
const win = window as any; // eslint-disable-line
const cv = win.cv;
// 画像の準備
const img = new Image();
if (previewUrl) {
img.src = previewUrl;
}
const src = cv.imread(img); // OpenCV読み込み
const dst = new cv.Mat(); // 画像の情報を格納する多次元配列「Mat」の準備
const hierarchy = new cv.Mat();
const contours = new cv.MatVector();
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY); // 画像を白黒に変換
cv.threshold(dst, dst, 10, 255, cv.THRESH_OTSU); // しきい値処理
// 輪郭検出
cv.findContours(
dst,
contours,
hierarchy,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_TC89_L1,
);
let boxPoints = null; // 台形の頂点
// 画像の幅・高さを取得しておく
const canvasWidth: number = dst.cols;
const canvasHeight: number = dst.rows;
for (let i = 0; i < contours.size(); i++) {
// ある程度のサイズ以上の輪郭のみ処理
const area = cv.contourArea(contours.get(i), false);
if (area > 19000) {
// ここに追加処理を記述する
}
}
// メモリを明示的に開放する
src.delete();
dst.delete();
hierarchy.delete();
contours.delete();
};
枠から角の頂点4つを取得する
枠の輪郭の頂点を取得し、4点であることで四角形であることを担保します。
if (area > 19000) {
const approx = new cv.Mat();
// cv.Matは行列で、幅1, 高さ4のものが4頂点に近似できた範囲になる
cv.approxPolyDP(
contours.get(i),
approx,
0.01 * cv.arcLength(contours.get(i), true),
true,
);
if (approx.size().width === 1 && approx.size().height === 4) {
let xPoint = -1;
const pointList = [];
for (const point of contours.get(i).data32S) {
if (xPoint < 0) {
xPoint = point;
} else {
pointList.push({ x: xPoint, y: point });
xPoint = -1;
}
}
// 帳票の角四点の座標を取得
let xTopLeft = 0;
let yTopLeft = 0;
let topLeftIdx = 0;
let xTopRight = 0;
let yTopRight = 0;
let topRightIdx = 0;
let xBottomLeft = 0;
let yBottomLeft = 0;
let bottomLeftIdx = 0;
let xBottomRight = 0;
let yBottomRight = 0;
let bottomRightIdx = 0;
for (let i = 0; i < pointList.length; i++) {
const point = pointList[i]
if (i === 0) {
xTopLeft = point.x;
xBottomLeft = point.x;
xBottomRight = point.x;
xTopRight = point.x;
yTopLeft = point.y;
yTopRight = point.y;
yBottomLeft = point.y;
yBottomRight = point.y;
} else {
if (point.x <= xTopLeft || point.y <= yTopLeft) {
if (
point.x + point.y <
xTopLeft + yTopLeft
) {
// 左上の角(0,0)に近い場合更新
xTopLeft = point.x;
yTopLeft = point.y;
topLeftIdx = i;
}
}
if (point.x >= xTopRight || point.y <= yTopRight) {
// 右上の上辺と右辺を超えている (x大、y小)
if (
(canvasWidth - point.x) +
point.y <
(canvasWidth - xTopRight) +
yTopRight
) {
// 右上の角(canvasWidth,0)に近い場合更新
xTopRight = point.x;
yTopRight = point.y;
topRightIdx = i;
}
}
if (point.x <= xBottomLeft || point.y >= yBottomLeft) {
// 左下の下辺と左辺を超えている (x小、y大)
if (
point.x +
(canvasHeight - point.y) <
xBottomLeft +
(canvasHeight - yBottomLeft)
) {
// 左下の角(0,canvasHeight)に近い場合更新
xBottomLeft = point.x;
yBottomLeft = point.y;
bottomLeftIdx = i;
}
}
if (point.x >= xBottomRight || point.y >= yBottomRight) {
// 右下の下辺と右辺を超えている (x大、y大)
if (
(canvasWidth - point.x)+
(canvasHeight - point.y) <
(canvasWidth - xBottomRight) +
(canvasHeight - yBottomRight)
) {
// 右下の角(canvasWidth,canvasHeight)に近い場合更新
xBottomRight = point.x;
yBottomRight = point.y;
bottomRightIdx = i;
}
}
}
}
// 各頂点を多次元配列に変換
boxPoints = cv.matFromArray(4, 1, cv.CV_32FC2, [
pointList[topLeftIdx].x,
pointList[topLeftIdx].y,
pointList[topRightIdx].x,
pointList[topRightIdx].y,
pointList[bottomRightIdx].x,
pointList[bottomRightIdx].y,
pointList[bottomLeftIdx].x,
pointList[bottomLeftIdx].y,
]);
}
approx.delete();
}
台形補正実行
warpPerspective()で台形補正を実行。その後画面に表示します。
approx.delete();
}
}
if (boxPoints) {
// 頂点が取得できたら台形補正実行
const transformedIm = new cv.Mat();
const rows = src.rows;
const cols = src.cols;
const dsize = new cv.Size(cols, rows);
const toPoints = cv.matFromArray(4, 1, cv.CV_32FC2, [ // 台形補正後の画像の4頂点
0, 0, cols, 0, cols, rows, 0, rows
]);
const M = cv.getPerspectiveTransform(boxPoints, toPoints); // 投影行列の作成
// 射影変換
// 四角形を任意の四角形に変換している
cv.warpPerspective(
src,
transformedIm,
M,
dsize,
cv.INTER_LINEAR,
cv.BORDER_CONSTANT,
new cv.Scalar(0, 0, 0, 255),
);
boxPoints.delete();
toPoints.delete();
cv.imshow('cvCanvas', transformedIm); // Canvasに表示
transformedIm.delete();
} else {
// 頂点の取得に失敗したらそのまま画像を表示する
cv.imshow('cvCanvas', src);
}
// メモリを明示的に開放する
src.delete();
dst.delete();
hierarchy.delete();
contours.delete();
};
描画した結果、以下のような形になります。
