Vue.jsとCloud Vision APIを使って顔検出フォームを作る

# 開発環境
* Vue.js 
* Vuetify 2.2.11
* vue-cli 4.3.1

# 作ったもの
![vuetify-vision-api-4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/d0e1df87-e638-68f8-276e-be0ac841831d.gif)

## ソースコード
https://github.com/popy1017/vuetify-face-detection

# 画像の入力フォームを作る
入力フォームはVuetify公式のサンプルをほぼそのまま引用。
[File input component — Vuetify.js](https://vuetifyjs.com/ja/components/file-inputs/#%E3%83%90%E3%83%AA%E3%83%87%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3)

```html
<template>
  <v-app>
    <v-container>
      <v-file-input
        v-model="file"
        accept="image/png, image/jpeg, image/bmp"
        prepend-icon="mdi-camera"
        placeholder="画像をドラッグ＆ドロップか選択してください。"
      ></v-file-input>
    </v-container>
  </v-app>
</template>
```
`v-model`で指定した変数`file`には、Fileオブジェクトが入る。

## Drag＆Dropでアップロードできるようにする
上記の場合、入力フォームをクリックして画像を選択することができるが、VueitfyではDrag&Dropでアップロードするような機能はデフォルトではないよう。
調べてみると、下記によると、`v-file-input`コンポーネントの親コンポーネントに`v-cloak @drop.prevent="addDropFile" @dragover.prevent`属性をつけるとできるらしい。
https://github.com/vuetifyjs/vuetify/issues/7836

イベント発生時は、`event.dataTransfer.files[0]`でファイルを受け取れる。

```html
<template>
  <v-app>
    <v-container v-cloak @drop.prevent="addDropFile" @dragover.prevent>
      <v-file-input
        v-model="file"
        accept="image/png, image/jpeg, image/bmp"
        prepend-icon="mdi-camera"
        placeholder="画像をドラッグ＆ドロップか選択してください。"
      ></v-file-input>
    </v-container>
  </v-app>
</template>

<script>
~
  methods: {
      addDropFile(e) {
        this.file = e.dataTransfer.files[0];
      },
  }
~
</script>
```

#### ここまでの動作
![vuetify-vision-api-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/44e370b3-4d04-dff1-b872-24339f7f1290.gif)

# アップロードした画像のプレビュー領域を作る
画像を表示するだけなら`img`タグやVuetifyの`v-img`コンポーネントでも可能だが、今回は顔検出を行い、検出領域を四角で囲うといったことがやりたいので、html5の`canvas`を用いる。
なんとなく中央に表示させたかったので、`<v-row justify="center" align="center"></v-row>`で囲む。

```html
<template>
  <v-app>
    <v-container v-cloak @drop.prevent="addDropFile" @dragover.prevent>
      <v-file-input
        v-model="file"
        accept="image/png, image/jpeg, image/bmp"
        prepend-icon="mdi-camera"
        placeholder="画像をドラッグ＆ドロップか選択してください。"
      ></v-file-input>
      <v-row justify="center" align="center">
        <canvas width="500" height="500" ref="preview"></canvas>
      </v-row>
    </v-container>
  </v-app>
</template>
```
## アップロードした画像をcanvasに表示させる
canvasに画像を表示させるには、`drawImage(image, dx, dy)`関数を用いる。
[drawImage(image, dx, dy)－Canvasリファレンス](http://www.htmq.com/canvas/drawImage.shtml)

### FileオブジェクトからImage要素への変換
drawImage関数ではFileオブジェクトは描画できないので、drawImage関数を使う前にFileオブジェクトをImageに変換する必要がある。

```js
if (this.file && this.file.type.startsWith("image/")) {
  const url = window.URL.createObjectURL(this.file);
  const img = new window.Image();
  img.src = url; // この時点では画像の読み込みは完了していない
  img.onload = () => {
    // 画像が読み込まれた後の処理
  }
}
```

### drawImage関数で画像を描画する
使い方は、以下のようにまず、canvasの要素を取得し、`getContext("2d")`でcontextを取得する。

```js
var canvas = document.getElementById('sample');
if (canvas.getContext) {
  var context = canvas.getContext('2d');
  context.drawImage(img, 0, 0);
}
```
Vue.jsでは`$refs`で要素を取得できるので、canvasに`ref="preview"`属性がつけてあれば、`this.$refs.preview.getContext("2d")`でcontextを取得できる。

上記より、以下のように`methods.setImage`を定義する。

```js
  methods: {
    addDropFile(e) {
      this.file = e.dataTransfer.files[0];
    },
    setImage() {
      if (this.file && this.file.type.startsWith("image/")) {
        const url = window.URL.createObjectURL(this.file);
        const img = new window.Image();
        img.src = url;
        img.onload = () => {
          const context = this.$refs.preview.getContext("2d");
          context.drawImage(img, 0, 0);
        };
      }
    },
```
そして、`setImage`メソッドを`v-file-input`で呼び出すようにするため、`@change="setImage"`を追加する。
また、Drag&Dropのイベント発生時も`setImage`を呼びたいので、`addDropFile`メソッドに、`this.setImage()`を追加する。

```html
<template>
  <v-app>
    <v-container v-cloak @drop.prevent="addDropFile" @dragover.prevent>
      <v-file-input
        v-model="file"
        @change="setImage"
        accept="image/png, image/jpeg, image/bmp"
        prepend-icon="mdi-camera"
        placeholder="画像をドラッグ＆ドロップか選択してください。"
      ></v-file-input>
      <v-row justify="center" align="center">
        <canvas width="500" height="500" ref="preview"></canvas>
      </v-row>
    </v-container>
  </v-app>
</template>
<script>
~
  methods: {
    addDropFile() {
      this.file = e.dataTransfer.files[0];
      this.setImage();
    }
  }
~
</script>
```
## 画像をcanvasの幅に合わせて縮小して表示する
こちらの記事を参考にさせていただきました。
[クライアント側でcanvasを使って画像をリサイズする - Qiita](https://qiita.com/geek_duck/items/2db28daa9e27df9b861d)

上記のコードでは、アップロードした画像がそのままの大きさでcanvas領域に描画されるため、canvasに指定した大きさより大きい画像は見切れてしまう。
そのため、`drawImage(image, dx, dy)`関数の代わりに、`drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)`関数を使う。
[drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)－Canvasリファレンス](http://www.htmq.com/canvas/drawImage_s.shtml)

>引数`(sx, sy)`と引数`(sw, sh)`は、元イメージの使用範囲の矩形を表します。 引数`(sx, sy)`は使用範囲の開始座標、引数`(sw, sh)`はその座標からの幅と高さです。 `sw, sh`のいずれかに0を指定すると、エラー（INDEX_SIZE_ERR）となります。
引数`(dx, dy)`は、描画するイメージを配置する座標を表します。 引数`(dw, dh)`は、イメージを描画する幅と高さを表します。

とあるので、`sx,sy,sw,sh`は元の画像の領域全体となるよう指定し、`dx,dy,dw,dh`はcanvasに収まるサイズを指定すれば良い。
`img.onload`を下記のように書き換える。

```js
img.onload = () => {
  const canvas = this.$refs.preview;
  const context = canvas.getContext("2d");
  console.log(canvas.width);
  if (img.width > canvas.width) {
    const scale = canvas.width / img.width;
    const sw = img.width;
    const sh = img.height;
    const dw = img.width * scale;
    const dh = img.height * scale;
    canvas.width = dw;
    canvas.height = dh;
    context.drawImage(img, 0, 0, sw, sh, 0, 0, dw, dh);
  } else {
    context.drawImage(img, 0, 0);
  }
}
```
#### メモ
`context.scale(x, y)`や、`context.setTransform(a, b, c, d, e, f)`でもサイズを変えることはできたが、この後実装するcanvasのクリアがうまく動かなかった（画像の左上の一部分しか消されなかった）ため、drawImage関数を採用した。

### (任意）クリア（ｘ）ボタンを押したときや再度画像を選択したときにcanvasをクリアする
上記のままの実装だと、再度画像を選択した際、現在表示されている画像の上に新たな画像が重なって表示されてしまう。また、`v-file-input`コンポーネントにデフォルトで備わっているクリアボタンを押しても何も動作しない状態である。~~canvasの勉強がしたいわけではなかったのでこのままでも良かったが、~~canvasに表示されている画像のリセット方法を調査した。
canvasをクリアするためには、`canvas.clearRect(x, y, w, h)`関数を使う。
[clearRect(x, y, w, h)－Canvasリファレンス](http://www.htmq.com/canvas/clearRect.shtml)
>引数(x, y)は、四角形の左上の座標です。 Canvasにおける座標は、<canvas>要素の左上端からの距離で指定します。 引数wとhは、四角形の幅と高さです。 これらの引数を指定することで、 左上が(x, y)、右上が(x+w, y)、右下が(x+w, y+h)、左下が(x, y+h)の 四角形の領域がクリアされます。

今回はcanvas全体を削除したいので、`(x,y,w,h)`は`(0,0,canvas.width,canvas.height)`とすれば良さそう。
`setImage`メソッドと同様に、methodsに`clearCanvas`を実装する。

```js
    clearCanvas() {
      const canvas = this.$refs.preview;
      const context = canvas.getContext("2d");
      context.clearRect(0, 0, canvas.width, canvas.height);
    },
```

`v-file-input`のクリアボタン押下時の処理は、`@click:clear`で設定できるので、`v-file-input`コンポーネントに`@click:clear="clearCanvas"`を追加する。
また、新たに画像が表示される前にもcanvasをクリアしたいので、`setImage`の先頭でも実行する。

#### ここまでの動作
![vuetify-vision-api-2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/3797c2f0-5a32-ba3f-a963-15b59b43bc15.gif)


# Vision APIを実行する
## APIを実行するためのボタンを用意する
ボタンはVuetifyの`v-btn`を使う。`block`属性で幅いっぱいに表示、`disabled`属性で指定した値がtrueのときボタンを非活性にできる。
`@click="detectFaces(file)`の`detectFaces`関数は、これから実装する。

```html
<template>
  <v-app>
    <v-container v-cloak @drop.prevent="addDropFile" @dragover.prevent>
      <v-file-input
        v-model="file"
        @change="setImage"
        @click:clear="clearCanvas"
        accept="image/png, image/jpeg, image/bmp"
        prepend-icon="mdi-camera"
        placeholder="画像をドラッグ＆ドロップするか選択してください。"
      ></v-file-input>
      <v-btn
        class="my-2"
        color="primary"
        :disabled="!file"
        block
        @click="detectFaces(file)"
        >Detect!!</v-btn>
       <v-row justify="center" align="center">
        <canvas :width="canvasWidth" height="450" ref="preview"></canvas>
      </v-row>
    </v-container>
  </v-app>
</template>
```
## Vision APIを実行する関数を作る
Vision APIの使い方は、公式のドキュメントや[前回の記事](https://qiita.com/popy1017/items/78cd72cd6337973c3b65)を参考にした。
[Vision API リクエストを作成する  |  Cloud Vision API  |  Google Cloud](https://cloud.google.com/vision/docs/request?hl=ja)

クライアントライブラリはサーバーサイドからの実行を想定していてブラウザから実行する場合は追加の認証？が必要になりそうなので、今回はREST API経由での呼び出しを行った。
`./libs/visionApi.js`を作成して、以下のように実装。
API KeyはあらかじめGCPのコンソールで設定しておき、`export VUE_APP_VISION_API_KEY={API_KEY}`で設定しておく。

```js
import axios from "axios";

const apiKey = process.env["VUE_APP_VISION_API_KEY"];
const visionApiUrl = `https://vision.googleapis.com/v1/images:annotate?key=${apiKey}`;

const toBase64 = (file) =>
  new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = () => resolve(reader.result);
    reader.onerror = (error) => reject(error);
  });

export default async function detectFaces(file, maxResults = 10) {
  let imageBase64 = null;
  try {
    imageBase64 = await toBase64(file);
    imageBase64 = imageBase64.replace(/^data:image\/[a-z]+;base64,/, "");
  } catch (error) {
    console.log(error);
    return null;
  }

  const options = {
    requests: [
      {
        image: {
          content: imageBase64,
        },
        features: [
          {
            type: "FACE_DETECTIOn",
            maxResults,
          },
        ],
      },
    ],
  };
  try {
    const response = await axios.post(visionApiUrl, options);
    return response.data;
  } catch (error) {
    console.error(error);
    return null;
  }
}
```
また、detectFaces関数を呼び出し、結果をいったん画面に出力するたまに、`App.vue`は下記のように書き換え。
Vision APIのレスポンスの詳細に関しては下記を参照。今回は、検出された顔を囲むような四角を描画したいので、`responses[0].faceAnnotations[x].boundingPoly`のみを使う。
[AnnotateImageResponse  |  Cloud Vision API  |  Google Cloud](https://cloud.google.com/vision/docs/reference/rest/v1/AnnotateImageResponse?hl=ja#FaceAnnotation)

```html
<template>
  <v-app>
     ~
     <v-row justify="center" align="center">
        <canvas :width="canvasWidth" height="450" ref="preview"></canvas>
      </v-row>
      {{ this.results }}
    </v-container>
  </v-app>
</template>

<script>
import detectFaces from "./libs/visionApi";

export default {
  name: "App",
  data: () => ({
    ~
    results: {},
  }),
  methods: {
    ~
    detectFaces: async function() {
      try {
        const data = await detectFaces(this.file);

        if (data && data.responses) {
          const faceAnnotations = data.responses[0].faceAnnotations;
          const boundingPolies = [];
          faceAnnotations.forEach((annotation) => {
            boundingPolies.push(annotation.boundingPoly);
          });
          this.results = boundingPolies;
        }
      } catch (error) {
        console.log(error);
      }
    },
  },
</script>
```

#### ここまでの動作
![vuetify-vision-api-3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/0d3e8684-8afa-fca8-7020-e3e6f894383d.gif)

# 検出された顔の領域に四角を描画する
canvasに四角の輪郭を書くには、`context.strokeRect(x, y, w, h)`関数を使う。
[strokeRect(x, y, w, h)－Canvasリファレンス](http://www.htmq.com/canvas/strokeRect.shtml)
また、線の太さは`context.lineWidth`、線の色・スタイルは`context.strokeStyle`で変更できる。
methodsに、頂点情報から`(左上のｘ座標、左上のy座標、幅、高さ)`を計算する`getRects`メソッドと、その情報から四角を描画する`drawRects`メソッドを追加した。
注意点として、元の画像の幅が大きい画像は縮小して表示されているため、計算された四角情報も同様のレートで縮小する必要がある。

```html
<script>
import detectFaces from "./libs/visionApi";

export default {
  name: "App",
  data: () => ({
    ~
    imgWidth: 800, /* <- scale計算のため追加 */
    results: {},
  }),
  methods: {
    setImage() {
        ~
        img.onload = () => {
          this.imgWidth = img.width;
          ~
        };
      }
    },
    getRects() {
      let rects = [];

      this.results.forEach((poly) => {
        const xArray = poly.vertices.map((v) => v.x);
        const yArray = poly.vertices.map((v) => v.y);

        const xmin = Math.min.apply(Math, xArray);
        const xmax = Math.max.apply(Math, xArray);
        const ymin = Math.min.apply(Math, yArray);
        const ymax = Math.max.apply(Math, yArray);
        const width = xmax - xmin;
        const height = ymax - ymin;

        rects.push({
          x: xmin,
          y: ymin,
          width,
          height,
        });
      });

      return rects;
    },
    drawRects() {
      const canvas = this.$refs.preview;
      const context = canvas.getContext("2d");
      const rects = this.getRects();
      context.strokeStyle = "rgb(0, 255, 0)";
      context.lineWidth = 2;
      rects.forEach((rect) => {
        let scale = 1;
        if (this.imgWidth > canvas.width) {
          scale = canvas.width / this.imgWidth;
        }
        context.strokeRect(
          rect.x * scale,
          rect.y * scale,
          rect.width * scale,
          rect.height * scale
        );
      });
    },
  },
};
</script>
```
## (任意)ローディングエフェクトを追加する
思ったよりもAPIのレスポンスに時間がかかってその間にいつの間にか四角が描画されているといった印象を受けたので、ロード中のエフェクトを追加して、処理中と処理完了をわかりやすくする。
ローディングのエフェクトはVuetifyの`v-overlay`コンポーネントで簡単に実装できる。
[Overlay component — Vuetify.js](https://vuetifyjs.com/ja/components/overlays/)

<p class="codepen" data-height="316" data-theme-id="dark" data-default-tab="html,result" data-user="popy1017" data-slug-hash="abvKGRy" style="height: 316px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="v-overlay-load">
  <span>See the Pen <a href="https://codepen.io/popy1017/pen/abvKGRy">
  v-overlay-load</a> by popy1017 (<a href="https://codepen.io/popy1017">@popy1017</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

テンプレートを以下のように修正し、`detectFaces`関数の最初と最後で、`overlay`の真偽値を変えればOK

```html
<template>
  <v-app>
    <v-container v-cloak @drop.prevent="addDropFile" @dragover.prevent>
      ~
      <v-row justify="center" align="center">
        <canvas :width="canvasWidth" height="450" ref="preview"></canvas>
      </v-row>
      <!-- 以下を追加 -->
      <v-overlay :value="overlay">
        <v-progress-circular indeterminate size="64"></v-progress-circular>
      </v-overlay>
    </v-container>
  </v-app>
</template>
```

# 感想
* canvasのお勉強になってしまったが、描画ができると他にもいろいろできそう