opencv.jsとvue.js（vuetify）によるカメラ撮影の円検出

# はじめに

opencvは画像処理によく使われる優秀なライブラリーです。普段はPythonやC++で使われるので、ウェブサイトにで動かせるのに向いていないと思っていましたが、実はJavaScriptとTypeScriptで実装できる**opencv.js**も存在している、と最近知りました。

opencv.jsを使うことでブラウザの中でほぼリアルタイムで画像処理や分析をして表示することができて便利です。

前回の記事でopencv.jsの基本を紹介しました。

https://qiita.com/phyblas/items/d06118b3d911ead863b0

今回の記事では具体的な用例として、**vue.js**（TypeScript + vuetify）と一緒に使って画像分析のウェブアプリを作ることにします。作るのは「カメラで撮ったビデオの中で円を検出して表示する」というアプリです。

opencvは画像から円を検出するために`HoughCircles`という関数があります。今回はただopencv.jsで実装する方法の説明なので詳しくは割愛しますが、これは[ハフ変換](https://ja.wikipedia.org/wiki/ハフ変換)というアルゴリズムを使う興味深い方法です。


# できたアプリ

今回作りたいアプリはカメラが必要なので、主にスマホで使うという前提にします。

完成したアプリをサーバーに載せてスマホのブラウザでアクセスしてみたらこのような画面になります。

![ccvhc01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/78631f9d-22ee-18d9-9d7e-40398bc7ed30.jpeg)

上はカメラで撮っているビデオで、白はオン・オフボタンととカメラ切り替えボタンと調整できるパラメータです。

円が見つかったら赤い円で囲まれます。

又、スマホを横に回した場合、このようにボタンとパラメータ調整は右に移ります。この自動並び替えはvuetifyの仕様です。

![ccvhc02.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/eb0aeb5d-1770-905c-a9d2-ea20050723ac.jpeg)

`HoughCircles`のパラメータは6もあって、これを変えたら検索結果も大きく変わります。どれくらいがいいか一概に言えないので、このように調整しながら試せるアプリはいいですね。

パラメータについて説明する記事も既に沢山あるので、ここでは割愛します。


# 準備

vuetifyを使うので、このようなコマンドでyarn又はnpmによるプロジェクト作成

```
yarn create vuetify
```

プロジェクト作成で現れた選択はこのように選びます。
```
✔ Which preset would you like to install? › Barebones (Only Vue & Vuetify)
✔ Use TypeScript? … Yes
✔ Would you like to install dependencies with yarn, npm, pnpm, or bun? › yarn
✔ Install Dependencies? … Yes
```

その後プロジェクトフォルダに入ってopencv.jsをインストールします。
```
yarn add @techstark/opencv-js
```


# 実装コード

プロジェクトを作成した後、色んなファイルが出てくるが、ここで編集する必要があるのは`src/App.vue`だけです。今作りたいのはただ小さくて簡単なアプリなので、コンポーネントを分ける必要もありません。

実装のコードはこうなります。
```vue:src/App.vue
<template>
  <v-app>
    <v-main>
      <v-container class="px-2 py-2">
        <v-row>
          <v-col :cols="tateka ? 12 : 6" class="mx-0 my-0 px-2 py-2">
            <div ref="vcontainer" style="position: relative;">
              <video ref="videoelm" style="position: absolute;" playsinline></video>
              <canvas ref="vcanvas" style="position: absolute;"></canvas>
            </div>
          </v-col>
          <v-col :cols="tateka ? 12 : 6" class="mx-0 my-0 px-2 py-2">
            <v-btn v-if="satsueichuu" @click="camera_teishi" width="60%" color="red">検出停止</v-btn>
            <v-btn v-else @click="camera_kidou" width="60%" color="green">検出再開</v-btn>
            <v-btn @click="camera_kirikae" :disabled="!satsueichuu" width="40%" color="primary">カメラ切り替え</v-btn>
            <v-row>
              <v-col cols="6" class="mb-1 pb-0">
                <v-slider v-model="log_dp" min="-0.695" max="0.695" step="0.005" color="lime" hide-details />
                dp:
                <span class="px-2 py-1 hparam">{{ dp }}</span>
              </v-col>
              <v-col cols="6" class="mb-1 pb-0">
                <v-slider v-model="minDist" min="1" max="200" step="1" color="pink" hide-details />
                minDist:
                <span class="px-2 py-1 hparam">{{ minDist }}</span>
              </v-col>
            </v-row>
            <v-row>
              <v-col cols="6" class="my-1 py-0">
                <v-slider v-model="param1" min="10" max="200" step="1" color="amber" hide-details />
                param1:
                <span class="px-2 py-1 hparam">{{ param1 }}</span>
              </v-col>
              <v-col cols="6" class="my-1 py-0">
                <v-slider v-model="param2" min="10" max="100" step="1" color="teal" hide-details />
                param2:
                <span class="px-2 py-1 hparam">{{ param2 }}</span>
              </v-col>
            </v-row>
            <v-row>
              <v-col cols="12" class="my-0 py-0">
                <v-range-slider v-model="minmax" min="4" max="64" step="1" color="indigo" hide-details />
              </v-col>
            </v-row>
            <v-row>
              <v-col cols="6" class="my-1 py-0">
                minRadius:
                <span class="px-2 py-1 hparam">{{ minRadius }}</span>
              </v-col>
              <v-col cols="6" class="my-1 py-0">
                maxRadius:
                <span class="px-2 py-1 hparam">{{ maxRadius }}</span>
              </v-col>
            </v-row>
          </v-col>
        </v-row>
      </v-container>
    </v-main>
  </v-app>
</template>

<script lang="ts">
import cv from "@techstark/opencv-js";

export default {
  data() {
    return {
      interval_id: -1,
      satsueichuu: false, // カメラが起動しているか
      tateka: true, // ウィンドウは横より縦の方が大きいか
      canvas_width: 0, // キャンバスの広さピクセル
      canvas_height: 0, // キャンバスの高さピクセル
      facingMode: "environment", // 前か後ろのカメラを使うか
      // 以下円検出の6つのパラメータ
      dp: 1,
      minDist: 30,
      param1: 80,
      param2: 40,
      minRadius: 30,
      maxRadius: 60,
    };
  },
  computed: {
    videoelm() { // カメラが入るvideoタグ
      return this.$refs.videoelm as HTMLVideoElement;
    },
    vcanvas() { // カメラの要素の上に置いて検出結果を描くcanvasタグ
      return this.$refs.vcanvas as HTMLCanvasElement;
    },
    vcontainer() { // 上記のvideoとcanvasを纏めるdivタグ
      return this.$refs.vcontainer as HTMLDivElement;
    },
    minmax: { // 最小と最大の半径の配列
      get() {
        return [this.minRadius, this.maxRadius];
      },
      set(minmax: [number, number]) {
        [this.minRadius, this.maxRadius] = minmax;
      }
    },
    log_dp: { // スクローラーに表示するためのdpの対数
      get() {
        return Math.log(this.dp);
      },
      set(log_dp: number) {
        this.dp = Math.round(Math.exp(log_dp) * 100) / 100;
      }
    },
  },
  methods: {
    camera_kidou() { // カメラ起動
      navigator.mediaDevices.getUserMedia({
        audio: false,
        video: { facingMode: this.facingMode }
      }).then((stream: MediaStream) => {
        // カメラで撮った結果をvideoタグに表示させる
        this.videoelm.srcObject = stream;
        this.videoelm.play();
        // 0.1秒一回検出を行うように設定
        this.interval_id = setInterval(() => {
          this.kenshutsu();
        }, 100);
        this.satsueichuu = true;
      }).catch(() => { // 上手く起動できない場合
        alert("カメラは正常に起動できないのだ！");
      });
    },
    camera_teishi() { // 停止ボタンが押された時の処理
      clearInterval(this.interval_id);
      this.videoelm.pause();
      let stream = this.videoelm.srcObject as MediaStream;
      stream.getVideoTracks()[0].stop();
      this.satsueichuu = false;
    },
    camera_kirikae() { // カメラ切り替え
      if (this.facingMode == "environment") {
        this.facingMode = "user";
      }
      else {
        this.facingMode = "environment";
      }
      clearInterval(this.interval_id);
      this.camera_kidou();
    },
    async kenshutsu() { // 検出処理
      let cap = new cv.VideoCapture(this.videoelm);
      let mat_imgcap = new cv.Mat(this.videoelm.height, this.videoelm.width, cv.CV_8UC4);
      cap.read(mat_imgcap);
      let mat_hough = new cv.Mat(); // 検出結果を入れるための空っぽの行列を準備
      // まずグレースケールにする
      let mat_imggrey = new cv.Mat();
      cv.cvtColor(mat_imgcap, mat_imggrey, cv.COLOR_RGBA2GRAY, 0);
      mat_imgcap.delete(); // 使い終わった行列を削除してメモリーを開放する。とても重要！
      // ハフ変換による検出開始
      cv.HoughCircles(
        mat_imggrey,
        mat_hough,
        cv.HOUGH_GRADIENT,
        this.dp,
        this.minDist,
        this.param1,
        this.param2,
        this.minRadius,
        this.maxRadius,
      );
      mat_imggrey.delete();
      // 検出結果を表示するcanvasタグ
      let ctx = this.vcanvas.getContext("2d") as CanvasRenderingContext2D;
      ctx.clearRect(0, 0, this.canvas_width, this.canvas_height);
      if (mat_hough.cols > 0) {
        let i = 0;
        while (i < mat_hough.cols) {
          let x = mat_hough.data32F[i];
          let y = mat_hough.data32F[i + 1];
          let r = mat_hough.data32F[i + 2];
          ctx.strokeStyle = "#d24";
          ctx.beginPath();
          ctx.arc(x, y, r, 0, 2 * Math.PI);
          ctx.stroke();
          i += 3;
        }
      }
      mat_hough.delete();
    },
    canvas_resize() { // ウィンドウのサイズが変更されたら中身の要素のサイズも変える
      this.canvas_width = this.vcontainer.clientWidth;
      this.canvas_height = this.canvas_width * this.videoelm.videoHeight / this.videoelm.videoWidth;
      this.vcontainer.style.height = this.canvas_height + "px";
      this.videoelm.width = this.canvas_width;
      this.videoelm.height = this.canvas_height;
      this.vcanvas.width = this.canvas_width;
      this.vcanvas.height = this.canvas_height;
      this.tateka = window.innerHeight > window.innerWidth;
    },
  },
  mounted() { // 最初にやること
    this.camera_kidou();
    // カメラがvideoタグに読み込まれた後実行する
    this.videoelm.addEventListener("loadedmetadata", () => {
      new ResizeObserver(this.canvas_resize).observe(this.vcontainer);
    });
  },
}
</script>

<style scoped>
.hparam {
  background-color: #788;
  width: 50px;
  display: inline-block;
  text-align: right;
}
</style>
```

大事な注意点としては、使い終わった後の行列を`.delete()`で削除してメモリーを開放することです。そうしなければメモリーが開放されないまま消耗されていくからです。0.1秒ごとに処理が行われるから、暫く放っておいたら大変なことになります。

又、今指定されている範囲のパラメータではほぼリアルタイムで反映できる程度の処理ですが、その中で`maxRadius`（検出する最大の円の半径）は一番処理コストに影響を与えるパラメータです。だからここでは上限を64くらいに設定しています。更に大きくしたら劇的に処理が重くなって使い物にならなくなります。


# 参考

### ハフ変換
- [[OpenCV] 円を検出する](https://qiita.com/kotai2003/items/ebfd07b89a0de6335598)
- [Hough変換と、局所的な傾きを用いたその拡張](https://qiita.com/inoz/items/dbc18a7845e81d050886)
- [やさしいハフ(Hough)変換講座](https://qiita.com/YSRKEN/items/ee94c7c22599c2374722)
- [分かりやすいHough変換の解説](https://qiita.com/Mas-sensyn/items/d957ad5984278a70faa8)
- [ハフ変換を使った円形の物体認識](https://qiita.com/fukuit/items/fb43959a78671e0fbef2)
- [cv2の円検出を用いて弓道の採点簿を解析　(Google Colaboratory)](https://qiita.com/66b/items/898bce1f93ec4e6dfbba)
- [画像処理入門講座 : OpenCVとPythonで始める画像処理](https://postd.cc/image-processing-101/)
- [アイトラッカー作ってみた．](https://qiita.com/shinnkun/items/7dcab137b2cfc0f593e8)

### opencv.js
- [opencv.js: Hough Circle Transform](https://docs.opencv.org/4.x/d3/de5/tutorial_js_houghcircles.html)
- [OpenCVをJavaScriptで使うには？【opencv.js】](https://shikaku-mafia.com/opencv-javascript-how-to-use/)
- [openCV.js を使ってスマホ上で画像処理をしてみた](https://qiita.com/pythonista/items/2b30c1beb99eea93f496)
- [OpenCV.jsをサクッと試す](https://qiita.com/puku0x/items/8b71efa61fce72e22188)

### vue.js
- [Vue.jsでref属性を利用してResizeObserverを使用する](https://qiita.com/manzoku_bukuro/items/18186bf75285e56c0c02)
- [VueコンポーネントでWindowサイズ変更検知＆値取得](https://qiita.com/techneconn/items/ede7c087843d2f7ef979)
- [Vue勉強 算出プロパティ computed でgetter,setterを使う。](https://qiita.com/sasuke_sss/items/90fbb469d7e3d32b58be)

### vuetify
- [スライダー (Slider): v-slider / v-range-slider](https://zenn.dev/bbled/books/vuetify3_book/viewer/sec2_4_slider)
- [【随時追記予定】Vue.js/Vuetify オレオレコンポーネント集](https://qiita.com/blachocolat/items/d41454116b6b8afbb3b2)
- [Vuetify3 でよく使うコンポーネントのメモ（１）](https://qiita.com/mml/items/ec0a4c63f9d29d8c0203)

### ビデオとカメラ
- [vue.jsで複数のvideoタグを扱う](https://qiita.com/dbgso/items/271d903237b41dffcc6d)
- [ブラウザからカメラを起動して録画，サーバにアップロードするまで](https://qiita.com/rubbishmt/items/a2f57e8d0d4a8822b7f1)
- [スマホのインカメラ/アウトカメラ切り替えを実装する【技術メモ】](https://qiita.com/U_sagi/items/ae165498e32a81b2f22e)
- [mediaDevices.getUserMedia() と iOS の Safari でハマった](https://qiita.com/tinymouse/items/8b82f3578e167627d209)
- [JavaScriptでカメラ使うアプリケーションを実装するぞ！](https://qiita.com/4245Ryomt/items/ab7869e2f0608741e7ff)
- [N予備校の授業をコメントと一緒にピクチャー イン ピクチャーで見たい！](https://qiita.com/nymphaea/items/0e72c040375be51c564e)
- [https://motojapan.hateblo.jp/entry/2018/02/08/084948](https://motojapan.hateblo.jp/entry/2018/02/08/084948)