OpenCV.jsで全ての画像に白黒つけてやる

突然ですが、皆さんは決断することが得意でしょうか？

私は決断がとても苦手で、いつも優柔不断だと言われてしまいます。
物事を白か黒か判断するのって難しいですよね。

そんな訳で、今日はOpenCV.jsの力を借りることで、画像が白か黒かを自動的に判定できるようにしようと思います。
※判定ルールが独自のため見た目の白黒と結果が異なる場合があります

# 作成したもの

- [Black or White](https://masaoblue.github.io/dev/black-or-white/index.html#/)

画像を指定してJUDGMENTボタンを押すと、白か黒かを判定して結果を表示します。

実行例
![2019-12-22_0-24-50_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105321/fc167729-6642-9255-cd3b-8acadadde0f6.png)


# 作業環境
- Windows10
- Google Chrome
- Git for Windows
- Docker Desktop

# 公式ドキュメント
- [OpenCV.js Tutorials](https://docs.opencv.org/master/d5/d10/tutorial_js_root.html)

# 目次
1. OpenCV.jsをビルドする
2. ブラウザでOpenCV.jsを読み込む
1. 画像をグレースケールに変換する
1. 白黒判定のしきい値を求める
1. 白か黒かを判定する

# OpenCV.jsのビルド
以下の手順でOpenCV.jsをビルドします。
Windowsの場合、このページの下の方にある「Building OpenCV.js with Docker」の手順を使ってDocker上でビルドするのが簡単です。

- [Build OpenCV.js](https://docs.opencv.org/master/d4/da1/tutorial_js_setup.html)

```sh
# PowerShellを起動して以下を実行する
git clone https://github.com/opencv/opencv.git
cd opencv
# 最新バージョンのタグにチェックアウト
git checkout 4.1.2
# OpenCV.jsをビルド
docker run --rm --workdir /code -v "$(get-location):/code" "trzeci/emscripten:latest" python ./platforms/js/build_js.py build
```

私の環境ではビルド完了まで20分程度かかりました。

# ブラウザでOpenCV.jsを読み込む

- [OpenCV: Using OpenCV.js](https://docs.opencv.org/master/d0/d84/tutorial_js_usage.html)

OpenCV.jsは重たい(9MB)ため、scriptタグでasyncを指定して非同期で読み込むようにします。上のページではonloadでメソッドを呼び出していますが、今回はPromiseで待てるようにしたいので以下のように実装してみました。

```html
<!-- 入出力画像を表示するためのcanvasを定義 -->
<canvas id="canvasInput" width="400" height="400"></canvas>
<canvas id="canvasOutput" width="400" height="400"></canvas>

<!-- OpenCV.js読み込み -->
<script id="opencvScript" type="text/javascript" src="opencv.js" async></script>
<script type="text/javascript">
  (() => {
    window.openCvLoadPromise = new Promise((resolve, reject) => {
      const script = document.querySelector("#opencvScript");
      script.addEventListener("load", () => {
        console.log("OpenCV.jsの読み込み完了", cv);
        resolve();
      });

      script.addEventListener("error", () => {
        console.log("OpenCV.jsの読み込みに失敗しました...");
        reject();
      });
    });

    window.openCvLoadPromise.then(() => {
      // OpenCV.jsの読み込み完了後に実行する処理を記述
      const canvas = document.querySelector("#canvasInput");
      const ctx = canvas.getContext('2d');

      // 任意の画像を読み込んでcanvasに表示する
      const img = new Image();
      img.onload = function () {
        canvas.width = img.width;
        canvas.height = img.height;
        ctx.drawImage(img, 0, 0, img.width, img.height);
      };
      img.src = "lena.jpg";
    });
  })();
</script>
```

# 画像をグレースケールに変換する
今回は白か黒かを判定したいので、色空間をRGBAからグレースケールに変換します。

```js
// カラーの画像を読み込む
const src = cv.imread('canvasInput');
const dst = new cv.Mat();
cv.cvtColor(src, dst, cv.COLOR_RGBA2GRAY, 0);
// グレースケールの画像を書き込む
cv.imshow('canvasOutput', dst);
src.delete(); dst.delete();
```

- [Changing Colorspaces](https://docs.opencv.org/master/db/d64/tutorial_js_colorspaces.html)

# 判定のためのしきい値を求める
次に、白か黒かを判別するために明度を使ってしきい値を求めます。

明度は256段階で表現されており中央値は128ですが、そのまま使うと全体が暗い場合などに常に黒の判定になってしまって面白くありません。そのため全体の明度の平均値をしきい値とします。

平均値は画素ごとの明度の値を合計して全体の画素数で割ることで求められますが、今回は以下の記事を参考に「画像を1px × 1pxにリサイズする」という方法を使ってみることにしました。
※値を平均した場合とは結果が異なる可能性があります。

- [橋本商会 » 画像のだいたいの色を求める](http://shokai.org/blog/archives/4961)
- [OpenCV: Geometric Transformations of Images](https://docs.opencv.org/3.4/dd/d52/tutorial_js_geometric_transformations.html)

```js
// グレースケールの画像を読み込む
const src = cv.imread('canvasOutput');
const dst = new cv.Mat();
const dsize = new cv.Size(1, 1);
// 画像を1px × 1pxに縮小
cv.resize(src, dst, dsize, 0, 0, cv.INTER_AREA);
// 明度のみを取り出すため、色空間をHSVに変換
cv.cvtColor(dst, dst, cv.COLOR_BGR2HSV);
src.delete(); dst.delete();

// dst.dataには[色相(Hue),彩度(Saturation),明度(Value)]が入っているので明度を取り出す
return dst.data[2];
```

# 白か黒かを判定する
上記で求めたしきい値より明るい画素を白、暗い画素を黒として考え、平均値からの分散が大きい方を結果として返すようにします。

```typescript
const threshold; // 上記で求めたしきい値を設定する

// グレースケールの画像を読み込む
const src = cv.imread(this.outputCanvas);
const dst = new cv.Mat();

// 色空間をHSVに変換
cv.cvtColor(src, dst, cv.COLOR_BGR2HSV);

// 色相、彩度、明度ごとに切り出す
const hsvVector = new cv.MatVector();
cv.split(dst, hsvVector);
// 明度のMatのみを取得する
const brightnessMat = hsvVector.get(2);

// 平均値を基準に各画素の差分の二乗を求める
const variances = {
  black: new Array<number>(),
  white: new Array<number>()
};
(brightnessMat.data as Array<number>).forEach(d => {
  const variance = Math.pow(threshold - d, 2);
  if (d > threshold) result.white.push(variance);
  else if (d < threshold) result.black.push(variance);
});

// black、whiteそれぞれの分散を求める
const calcAverage = (array: number[]) => {
  return array.reduce((p, c) => p + c, 0) / array.length;
};
const blackAverage = calcAverage(result.black);
const whiteAverage = calcAverage(result.white);

// 分散が大きい方を結果として返す
if (blackAverage > whiteAverage) return "Black";
else if (blackAverage < whiteAverage) return "White";
else return "Gray";
```

cv.splitについては以下の「Splitting and Merging Image Channels」に説明があります。

- [OpenCV: Basic Operations on Images](https://docs.opencv.org/3.4/de/d06/tutorial_js_basic_ops.html)

# おわり
撮影した写真を白か黒か判定したくなった時に使ってみてください。