ReactとOpenCV.jsで台形補正をしてみよう

はじめに

「ブラウザで何か撮影をする際にフロントサイドのみで画像を加工・解析をしたい」といった需要が一部あると思いますが、公開されている技術記事はあまりないと感じています。
そのため、前回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();
};

描画した結果、以下のような形になります。

一覧へ戻る

お問い合わせ