Tech系サービスやガジェットの使い心地、自分の作業環境、資産運用について気が向いたときに記録を残しています。

記事内のAmazonアソシエイト適格販売及び、Google Adsenseでお小遣いを得ています。

Custom Vision Service(Object Detection)で東方キャラを検出してみた

Custom Visionとは?

Custom Vision Service とは https://docs.microsoft.com/ja-jp/azure/cognitive-services/custom-vision-service/home

Custom Vision Service は、カスタム画像分類子の構築を支援する Microsoft Cognitive Services です。 画像の分類子を簡単に素早く構築、デプロイし、その性能を向上させることができます。 Custom Vision Service には、画像をアップロードして分類子をトレーニングするための REST API や Web インターフェイスが用意されています。

ざっくりいうと、画像認識のためのロジックは全部Microsoftがやってくれるので、ユーザーは学習データの投入・タグ付けをやるだけでAPI経由で推論結果を得られるというサービスです。 サービスイン当初は分類器のみでしたが、5/7のアップデートで物体検出(特定部分の座標取得)ができるようになりました。仕事で軽くプロトタイプを作ってみたところ、そこそこ使えそうな精度になったのでプライベート向けの題材で学習して公開してみることにしました。

東方Projectのキャラ判定機を作ろう

物体検出の題材として、学習データの分量を用意できることが必須かつ「分類することの意味」にもこだわってみたかったので東方キャラを題材に選んでみました。学習材料は自宅にある薄い本+公式書籍+αとなっております。 作業を簡略化するため、下記の条件に当てはまる箇所を撮影して学習データを用意します。

  • 表紙
  • 裏表紙
  • フルカラー

コードサンプルはこちら

GitHub : gensobunya/try-customvision

1.学習データを準備

  1. ログイン後、規約に同意してプロジェクト作成を選択すると、下のような画像アップロード画面に移動します。 f:id:gensobunya:20180630130525j:plain

  2. 学習データ(薄い本)を用意して1枚づつ裏表紙を撮影していきます。 f:id:gensobunya:20180630131414j:plain

  3. アップロードします f:id:gensobunya:20180630141126j:plain

  4. ドラッグ&ドロップでエリア選択をして、タグ付けします f:id:gensobunya:20180630141218j:plain

基本的にすべてマニュアル作業となりますが、編集画面で物体検出を自動でやってくれたりして結構スマートです。点線は自動検出部分。

f:id:gensobunya:20180630141424j:plain

2.学習させる

右上の「Train」ボタンを押すだけ…なのですが1タグあたり15枚以上の画像が必須となっているので、微妙に足りないキャラの学習データをフルカラーイラスト集から追加したり、全く足りないキャラのタグを削除したりします。
そもそも50タグが無料版の限界なので、東方キャラ全員の検知は不可能ですね…

f:id:gensobunya:20180630144324j:plain

初期状態で残ったキャラは以下の通りです。

学習が完了すると、試験結果も表示してくれます。特に何もしていないのでバランスが悪いですね… 学習結果と学習内容はIteration単位で保存されていますので、バージョン管理に活用できます。

検出の確信度閾値は左上のスライダーで調整できます。 よほど閾値を下げない限り、Precision>>>Recallとなっているので、そもそもキャラを拾い出すことに苦労している感じですね。

f:id:gensobunya:20180716144039j:plain

APIで結果を得ることもできるのですが、まずは画面上のQuick Testを使って、学習に使っていない電撃Playstationの表紙を判定させてみます。

f:id:gensobunya:20200312182427p:plain

霊夢は正しく検出できました!魔理沙は…がんばりましょう。

3.学習内容を改善する

まだまだ使いものにならないのでもうちょっとマシな学習内容を目指します。 閾値を0にして確認すると、霊夢は12%の確率で魔理沙ですが魔理沙は1.9%の確率でしか魔理沙と思われていません。

f:id:gensobunya:20200312182439p:plain

学習ロジックはいじれないので、学習データを下記の通りいじって再度Trainingします。だいたい5分から10分くらいでモデルが出来上がるので爆速と言って過言ではないでしょう。

  • データの少ないキャラタグを削除
  • 衣装変更(コート着・社会派ルポライターあや・他)を削除
  • 学習データ追加

結果、下記のキャラ(数字はデータ枚数)が残りました。主人公sと書籍キャラが多く残りましたね…

f:id:gensobunya:20200312182454p:plain

もう1度、電撃Playstationの表紙でテストします。

f:id:gensobunya:20200312182505p:plain 未だ魔理沙発見できず。0%時の候補からも消えてしまいました。 他の画像でも霊夢が射命丸になったりするので、あまりいいモデルとは言えません。

仕事で作ったときのように、全タグの学習枚数を揃えると精度があがるかもしれませんが、時間がかかるので今回はここまでにします。

4.WEBアプリ化する

APIを用いてWEBアプリ化します。PredictionURLをクリックして、必要な情報を確認できます。

f:id:gensobunya:20200312182520p:plain

Vue.jsとAxiosを使って適当に実装します。公開するときはAPIキーを隠すべきですがとりあえずローカルで動かしたいだけなので気にせず直接書きます。 APIJSON形式で検出したエリアを返してくるので、確信度でフィルタした後に画像にオーバーレイしてループしてボックス表示します。

  <template>
    <div id="app">

        <h2>SanteMedical Detector(Proto)</h2>
        <div>
            <input type="file" name="file" @change="onFileChange" class="waves-effect waves-light btn">
        </div>
        <div class="buttonwrapper" v-if="image">
          <button @click="removeImage" class="waves-effect waves-light btn">Remove image</button>
          <button @click="submitImage" class="waves-effect waves-light btn">Submit image
            <i class="material-icons right">send</i>
          </button>
        </div>
      <div v-if="image">
        <div class="imagewrapper">
          <img :src="image" />
          <div v-if="predictionData" v-for="prediction in predictionData" :key="prediction.tagID" class="detectionBox" :style="{width:prediction.boundingBox.width*100+'%',height:prediction.boundingBox.height*100+'%',left:prediction.boundingBox.left*100+'%',top:prediction.boundingBox.top*100+'%'}"></div>
        </div>
      </div>
      <div v-if="predictionData" class="tagwrapper">
        <ul class="collection">
          <li class="collection-header">
            <h4>Detected Tags</h4>
          </li>
          <li v-for="prediction in predictionData" :key="prediction.tagID" class="collection-item">
            <b>{{prediction.tagName}}</b>:{{prediction.probability}}
          </li>
        </ul>
      </div>
    </div>
</template>

<script>
import loadImage from 'blueimp-load-image'
import axios from 'axios'

const projectId = "YOUR PROJECT ID";
const predictionKey = "YOUR PREDICTION KEY";
const postURL = "https://southcentralus.api.cognitive.microsoft.com/customvision/v2.0/Prediction/"+projectId+"/image";
const probabilityLine = 0.15; //確信度閾値


export default {
  name: 'app',
  data: function() {
    return {
      image: '',
      imgName: '',
      imgHeight: '',
      imgWidth:'',
      uploadFile: '',
      predictionData:''
    }
  },
  methods: {
      onFileChange: function(e){
        let files = e.target.files || e.dataTransfer.files;
        if (!files.length) {
            return;
        }
        if (!files[0].type.match('image.*')) {
            return;
        }

        this.createImage(files[0]);
        this.uploadFile = files[0];
        this.predictionData = '';
        this.occupancyRate ='';
        console.log(postURL);
      },
      createImage: function(file) {
        let reader = new FileReader();

        reader.onload = (e) => {
          //ローテーション
          loadImage.parseMetaData(file, (data) => {
            const options = {
              canvas: true
            };
            if (data.exif) {
              options.orientation = data.exif.get('Orientation');
            }
            loadImage(e.target.result, (canvas) => {
              const dataUri = canvas.toDataURL('image/jpeg');
              this.image = dataUri;
              this.imgHeight = canvas.height;
              this.imgWidth = canvas.width;
            }, options);
          });
          //ローテーションここまで
        }
        reader.readAsDataURL(file);
        this.imgName = file.name;
      },
      removeImage: function() {
        this.image = '';
        this.imgName = '';
        this.predictionData = '';
        this.occupancyRate ='';
      },
      submitImage:function() {
        let formData = new FormData();
        formData.append('shelfImage', this.uploadFile);
        let config = {
            method : 'post',
            headers: {
                'content-type': "multipart/form-data",
                'Prediction-Key': predictionKey
                }
        };

        axios.post(postURL, formData, config)
            .then(response => {
                //response 処理
                //確信度フィルタリング
                this.predictionData = response.data.predictions.filter( (items) => Number(items.probability) > probabilityLine);
              })
            .catch(error => {
                  // error 処理
                  this.predictionData = error;
            })
      }
  }
};
</script>

<style>
#app {
    text-align: center;
  }
img {
    width: 100%;
    margin: auto;
    display: block;
    margin-bottom: 10px;
  }
button{
  margin: 10px;
}
.imagewrapper{
  display: inline-block;
  width: 30%;
  position: relative;
  margin: 0px;
  padding: 0px;
  align-self: center;
}
.buttonwrapper{
  padding: 1em;
}
.detectionBox{
  position: absolute;
  border-width: 2px;
  border-style: solid;
  border-color: red;
}
</style>

Materializeを使って適当に装飾して、このように表示されます。 f:id:gensobunya:20200312182530p:plain