AutoML Visionでミートソースとボロネーゼを見分けるモデルを作ったあとで、Webサイトを突貫でこしらえる

AutoML Visionでミートソースとボロネーゼを見分けるモデルを作ったあとで、Webサイトを突貫でこしらえる:

AutoML Visionというサービスを使う機会があったので、忘れないうちに使い方をまとめました。このサービスについて説明されているわかりやすい記事はいくつかありましたが、Node.jsで利用する情報が少なめだったので、そこを中心に書いています。モデルの作成だけでは実感がわかなかったので、Webサイトも突貫で構築しました。

<参考>
AutoML Visionをためしてみた
Cloud Functions を使って、AutoML Vision API を呼び出してみた。



anime01.gif

https://meat.noplan.cc


AutoML Visionというサービス

機械学習を手軽に利用できるGCPのサービスです。今回使用した画像処理のVision以外にも、翻訳と自然言語処理のサービスも用意されていました。「顔認識」や「不適切なコンテンツの判定」などの、よく使いそうな用途に絞って提供しているサービスとは異なり、自分でモデルを作成できる点が特徴です。

AutoML Vision enables you to train machine learning models to classify your images according to your own defined labels.

AutoML Vision Documentation
ざっくりいうと、たくさん画像を用意して、ラベルを振って学習させるだけで、それっぽいモデルが手に入ります。2018年11月11日時点ではβ版です。


価格

機械学習はお金をたくさん積まないと使えないという勝手なイメージがありましたが、思っていたよりも現実的な価格で使えそうな印象です。とはいえ、画像の数が多い場合は結構ふくらみそう。


トレーニングの費用

トレーニングさせる際に使用するコンピュータの時間に対して料金が発生します。
毎月10個のモデルまで、モデルごとの最初の1時間は無料です。

それ以降は1時間あたり$20かかります。参考までに、ミートソース・ボロネーゼの計200枚のトレーニングには15分ぐらいかかりました。


分類予測の費用

作成したモデルを使用して予測する際の費用です。

最初の1000枚は無料で、それ以降は1000枚あたり$3かかります。


Cloud Storageの費用

分析に使用する画像はCloud Storageに置かれるので、その分の料金がかかります。

Pricing | Cloud AutoML Vision


対象の選定

データセットをたくさん集めるのが面倒だったので、ラベルを2つに絞ることにしました。明らかに違うものよりも、似ているけど微妙に違うものの方が機械学習の凄さを実感できるかなと思い、ミートソースとボロネーゼにしました。最初は白飯とお粥で進めていたのですが、画像を集めていて虚しくなってしまったので、途中で変更しています。


ミートソースとボロネーゼ

レシピ上の大きな違いとしては、ボロネーゼが主としてトマトを殆ど使わずにワインで煮込むのに対し、ミートソースはトマトで煮込む。

ボロネーゼ - Wikipedia
細かい違いはあるようですが、イタリアのボローニャ地方が発祥のおしゃれなやつがボロネーゼで、日本向けにローカライズされたのがミートソースみたい(違うかも)。

画像を検索して、ざっと見たときの感じた違いはこちら。

▼ ミートソース

・ソースの赤みが強い

・ドロッとしているものが多い

・麺が丸い

・盛り付けが豪快

▼ ボロネーゼ

・ソースが茶色っぽい

・肉の塊感が強い

・麺がタリアテッレ(平打ち)のものが多い

・盛り付けが上品

ソースの色や麺の形状、盛り付けなどが結構違ったので、これなら学習できるかもしれない。多くのミートソースが豪快に盛り付けられている一方で、ボロネーゼはモンブランのような上品な形状で整えられているのが印象に残りました。個人的には、ミートソーススパゲティの大盛りに、粉チーズをどばどばかけて食べるのが好き。


AutoMLでモデルを作る

対象が決まったのでモデルを作っていきます。


画像の収集

We recommend about 1000 training images per label. The minimum per label is 10, or 50 for advanced models.

Preparing your training data
ミートソースとボロネーゼの画像を気合いで集めます。

1000枚は厳しいので、切りの良いところで100枚づつにしました。

どうでもいいけど、ボロネーゼのカチューシャが売ってた。しかも高い。

ボロネーゼのカチューシャ


画像の整理

make_zip.png

集めた画像を、それぞれのラベル名を設定したフォルダにいれて、zipを作成します。
Cloud Storageにアップして、CSVでパスとラベルを指定する方法もあるので、数が多い場合はそっちの方が良さそう。


AutoML Visionへ

GCPのコンソールのナビゲーションから、下の方にあるVisionを選択すると別タブが開くので、Get started with AutoMLを選びます。権限を設定してください〜みたいなページが表示されたら、内容を確認しながら設定します。


アップロード

ヘッダーのNEW DATASETを選択し、データセットの名前を設定して画像をインポートします。

アップロードが終わったら、先ほどフォルダ名に設定したラベルがしっかり振られているか確認します。



68747470733a2f2f71696974612d696d6167652d



トレーニング



68747470733a2f2f71696974612d696d6167652d


あとは、TRAINタブを選択して、START TRAININGして、モデル名を入力したらトレーニング開始です。


結果の確認



68747470733a2f2f71696974612d696d6167652d


待つこと15分ほどでトレーニングが終わりました。
EVALUATEタブで結果を確認できます。
Precisionが94%というのは良い感じに思えるけど、実際のところどうなんだろうか。


予測のテスト



68747470733a2f2f71696974612d696d6167652d


PREDICTタブから、作成したモデルのテストができます。

A confidence estimate between 0.0 and 1.0. A higher value means greater confidence that the annotation is positive.

ClassificationAnnotation | Node.js Client
アップした画像に対して、その画像がどのラベルに分類されるかのアノテーションがつきます。スコアは0.0-1.0で、高いほうが信頼できます。これは紛れもないミートソース。

これでモデルの作成は終わりです。

拍子抜けするほど楽。


サービスアカウントの作成

のちほどAPIでモデルを使用する際に必要になるので、ドキュメントを参考にサービスアカウントキーをJSON形式で作成します。Role(役割)にはAutoML Predictor(AutoML予測者)を設定しました。GCPを使うのがはじめてなので、少し自信がない・・・

認証の開始


Nuxt.js+Firebase

ふだんは迷わずにAWSを選ぶんですが、せっかくGCPのサービスを使っているので、Firebaseを使うことにしました。フロントエンドは、プレーンなHTMLとJSでも良かったのですが、サクッと作るためにNuxt.jsを使っています。

FirebaseとNuxt.jsの組み合わせについては、素晴らしい記事がたくさんあるのでそちらを見てしていただくとして、AutoML Visionと画像の扱いに絡む部分を中心にまとめました。


構成

先ほど作成したモデルを利用するAPIをCloud Functions for Firebaseで作成し、Nuxt.jsで静的に生成したファイルをFirebase Hostingにホスティングする構成です。


準備

ここの細かい内容は省略を。

# Nuxt.jsの雛形作成 
$ npx create-nuxt-app meat-sauce 
$ cd meat-sauce 
 
# Firebase CLIのインストール 
$ npm install -g firebase-tools 
 
# 認証 
$ firebase login 
 
# プロジェクトディレクトリを初期化 
# FunctionsとHostingを選択 
$ firebase init 


APIの作成

まずは、AutoML Visionのモデルを使用するAPIを作成していきます。


Node.js 8を使いたい

デフォルトではランタイムがNode.js 6のようですが、async/awaitを使えたほうが楽なので、Node.js 8を使えるようにしました(まだβ版)。
New runtime configuration options with Cloud Functions for Firebase

package.json
{ 
  "engines": { "node": "8" } 
} 


関数のデプロイ

デプロイはコマンドで手軽にできます。

# firebase deploy --only functions 
$ npm run deploy 


ファイルのアップロード処理

フロントエンドから画像を投げる必要があるので、アップロードされたファイルの処理を追加します。

Cloud Functions では HTTP リクエストの本文のサイズが 10 MB に制限されるため、この制限を超えるリクエストは、関数が実行される前に拒否されます。
今回は、画像を保存する必要がないので、HTTPのmultipart/form-dataでアップするようしましたが、保存する必要がある場合や、ファイルサイズが大きい場合はCloud Storageをうまく使って処理したほうが良いと思います。(たぶんその方がFirebaseらしい使い方)

Expressが使えると書いてあったのでmulterというミドルウェアに任せようと思ったら、Cloud functionsがやってる前処理が〜とかでファイルを取得できなかったので、おとなしくドキュメントにあるとおりbusboyを使いました。multerのソースを見たら、Streamを結合してくれる便利なライブラリを使っていたのでマネしています。

ファイルアップロードの処理

$ npm install --save busboy concat-stream 
functions/lib/parser
const Busboy = require('busboy') 
const concat = require('concat-stream') 
 
exports.parseFile = (req, { target }) => 
  new Promise((resolve, reject) => { 
    const busboy = new Busboy({ headers: req.headers }) 
 
    let file 
 
    busboy.on('file', (fieldname, fileStream, filename, encoding, mimetype) => { 
      if (fieldname === target) { 
        fileStream.pipe( 
          concat({ encoding: 'buffer' }, buffer => { 
            file = { fieldname, mimetype, buffer } 
          }) 
        ) 
      } else { 
        fileStream.resume() 
      } 
    }) 
 
    busboy.on('finish', () => resolve(file)) 
 
    busboy.end(req.rawBody) 
  }) 
functions/index.js
const functions = require('firebase-functions') 
const { parseFile } = require('./lib/parser') 
 
const VALID_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'] 
 
const validateRequest = file => file && VALID_MIME_TYPES.includes(file.mimetype) 
 
exports.predict = functions.https.onRequest(async (req, res) => { 
  if (req.method !== 'POST') { 
    return res.status(405).send() 
  } 
 
  const file = await parseFile(req, { target: 'image' }) 
 
  if (!validateRequest(file)) { 
    return res.status(400).send() 
  } 
 
  const image = file.buffer.toString('base64') 
 
  // 試しにBase64で画像を返す 
  res.status(200).send(image) 
}) 


環境設定

AutoML VisionのAPIを利用するために、先ほど取得したサービスアカウントのJSONと、モデル名などをfunctions:config:setコマンドで設定します。今回は、直接本番で動作確認してしまってましたが、ステージング環境を用意する場合や、ローカルで確認する場合は、ここらへんの設定を工夫する必要があると思います。

環境の構成 | Firebase

# サービスアカウントの設定 
# https://github.com/firebase/firebase-tools/issues/406#issuecomment-353017349 
$ firebase functions:config:set service_account="$(cat service-account.json)" 
 
# モデル名など 
$ firebase functions:config:set vision.project=meat-sauce-ml 
$ firebase functions:config:set vision.region=us-central1 
$ firebase functions:config:set vision.model=ICN123456789 


モデルの利用

AutoML VisionのJavaScript向けライブラリが用意されているので、それを使ってアップロードされた画像にマッチするラベルとスコアを取得します。先ほど設定した環境設定は、functions.config()で取得できます。

predictメソッドには画像をBase64で指定し、返り値は配列のひとつめにPredictResponseが入っているので、その中身だけ返すようにしています。

$ npm install --save @google-cloud/automl 
functions/lib/automl.js
const functions = require('firebase-functions') 
const automl = require('@google-cloud/automl') 
const client = new automl.PredictionServiceClient({ 
  credentials: functions.config().service_account 
}) 
 
const VISION_PROJECT = functions.config().vision.projet 
const VISION_REGION = functions.config().vision.region 
const VISION_MODEL = functions.config().vision.model 
 
exports.getPrediction = imageBytes => 
  new Promise((resolve, reject) => { 
    const params = { 
      name: client.modelPath(VISION_PROJECT, VISION_REGION, VISION_MODEL), 
      payload: { 
        image: { imageBytes } 
      } 
    } 
 
    client 
      .predict(params) 
      .then(([data]) => resolve(data.payload)) 
      .catch(err => reject(err)) 
  }) 
functions/index.js
const functions = require('firebase-functions') 
const { parseFile } = require('./lib/parser') 
const { getPrediction } = require('./lib/automl') 
 
exports.predict = functions.https.onRequest(async (req, res) => { 
  // 少し省略してます 
  const file = await parseFile(req, { target: 'image' }) 
  const imageBytes = file.buffer.toString('base64') 
 
  try { 
    const scores = await getPrediction(imageBytes) 
 
    res.status(200).json(scores) 
  } catch (err) { 
    console.log(err) 
    res.status(500).send() 
  } 
}) 


フロントエンドの構築

最後にフロントエンドを構築していきます。


ディレクトリの整理

現状はプロジェクトのルートディレクトリにfunctionsとNuxt.jsのものが混在しているので、Nuxt.jsのものをappディレクトリにまとめて、不要なディレクトリは削除しました。

# 使わないディレクトリを削除 
$ rm -fr components middleware plugins store static 
 
# appにまとめる 
$ mkdir app 
$ mv components layouts pages static app 
# こんな感じです 
├ app/ 
│  ├ assets 
│  ├ layouts 
│  └ pages 
├ functions 
│  ├ index.js 
│  └ package.json 
├ firebase.json 
├ nuxt.config.js 
└ package.json 


nuxt.config.jsの設定

srcDirgenerate.dirを設定して、axiosのモジュールを追加しています。

nuxt.config.js
module.exports = { 
  mode: 'universal', 
  head: { 
    title: 'タイトルが入ります', 
    meta: [ 
      { charset: 'utf-8' }, 
      { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 
      { hid: 'description', name: 'description', content: '説明が入ります' } 
    ] 
  }, 
  srcDir: 'app', 
  generate: { 
    dir: 'app/dist' 
  }, 
  modules: ['@nuxtjs/axios'] 
} 


プロキシの設定

Firebaseに、Hostingへのリクエストをfunctionに投げられるありがたい機能があったので設定しました。CORSを考えずに済むので助かります。

関数にHostingリクエストを送信する

firebase.json
{ 
  "hosting": { 
    "rewrites": [ 
      { "source": "/api/predict", "function": "predict" } 
    ] 
  } 
} 
 
ローカルで開発する際に、Nuxt.jsとfirebase serveをうまく協調する方法がパッとは浮かばなかったので、firebase serveは使わずに、Nuxt.js側でproxyを設定するようにしました。ゴリ押し感が強いのでもう少し綺麗に解決したい。。

$ npm install --save @nuxtjs/dotenv 
.env
PREDICTION_URL=https://us-central1-meat-sauce-68fdf.cloudfunctions.net 
HOSTING_URL=https://meat.noplan.cc 
nuxt.config.js
require('dotenv').config() 
 
const isProduction = process.env.NODE_ENV === 'production' 
 
const proxy = isProduction 
  ? {} 
  : { '/api/': { target: process.env.PREDICTION_URL, pathRewrite: { '^/api/': '' } } } 
 
module.exports = { 
  modules: [ 
    '@nuxtjs/dotenv', 
    '@nuxtjs/axios' 
  ], 
  axios: { 
    baseURL: isProduction ? process.env.HOSTING_URL : 'http://localhost:3000', 
    proxy: !isProduction 
  }, 
  proxy 
} 


見た目を作る



68747470733a2f2f71696974612d696d6167652d


突貫なので細かいことは考えずにpages/index.vueにすべてをぶち込みました。
https://github.com/noplan1989/meat-sauce/blob/master/app/pages/index.vue


ファイルを選択したらプレビュー

FileReader.readAsDataURLのページを参考に、ユーザーが画像をアップしたらその場で確認できるようにしました。

<template> 
  <div class="input"> 
    <label for="upload" class="input-label"> 
      <input type="file" accept="image/*" id="upload" ref="upload" class="input-upload" @change="handleChange" /> 
    </label> 
    <div class="input-preview" v-if="previewSrc"><img :src="previewSrc" alt="" /></div> 
  </div> 
</template> 
 
<script> 
export default { 
  data() { 
    return { 
      isSelected: false, 
      previewSrc: null 
    } 
  }, 
  methods: { 
    handleChange() { 
      const file = this.$refs.upload.files[0] 
 
      if (file) { 
        this.preview(file) 
      } 
    }, 
    preview(file) { 
      const reader = new FileReader() 
 
      reader.addEventListener('load', () => { 
        this.isSelected = true 
        this.previewSrc = reader.result 
      }) 
 
      reader.readAsDataURL(file) 
    } 
  } 
} 
</script> 


予測のAPIをたたく

最後に、先ほどCloud Functionsで作成した、画像の分類を予測するAPIをたたく部分を構築したら完成です。

<template> 
  <div class="content"> 
    <div class="input"> 
      <label for="upload" class="input-label"> 
        <input type="file" id="upload" ref="upload" class="input-upload" @change="handleChange" /> 
      </label> 
      <div class="input-preview" v-if="previewSrc"><img :src="previewSrc" alt="" /></div> 
    </div> 
 
    <p class="result">{{ result }}</p> 
  </div> 
</template> 
 
<script> 
export default { 
  data() { 
    return { 
      isSelected: false, 
      isPredicting: false, 
      previewSrc: null, 
      scores: [], 
      error: false 
    } 
  }, 
  computed: { 
    result() { 
      if (!this.scores.length) { 
        return 
      } 
 
      const labelToResult = { 
        meatsource: 'たぶん、ミートソース', 
        bolognese: 'たぶん、ボロネーゼ', 
        kimurakaela: 'それは、木村カエラ' 
      } 
 
      return labelToResult[this.scores[0].displayName] 
    } 
  }, 
  methods: { 
    handleChange() { 
      const file = this.$refs.upload.files[0] 
 
      if (file) { 
        this.predict(file) 
      } 
    }, 
    async predict(file) { 
      const formData = new FormData() 
 
      formData.append('image', file) 
 
      this.scores = await this.$axios.$post('/api/predict', formData) 
    } 
  } 
} 
</script> 


デプロイ



anime01.gif

https://meat.noplan.cc

静的ファイルを生成したあとで、Firebase Hostingへデプロイします。

$ npm run generate 
$ firebase deploy --only hosting 


サンマなのにボロネーゼ



anime02.gif


試しにサンマの画像をアップしてみたところ「たぶん、ボロネーゼ」の表示が。違うよ。

それも0.8ぐらいの、まぁまぁのスコアでボロネーゼなもんだから、スコアでフィルターも難しい。サンマに限らず、全然違う画像がアップされた場合にも、該当なしではなく何かしらのアノテーションがついてしまうことが多くて困りました。が、対応を考えるのが面倒になってしまったので、サンマがボロネーゼというのも悪くないなと開き直り、そのままにしてます。

スコアをconsole.logで確認できるようにしているので、点数が気になるかたはWebサイトでご確認ください。


おわり

このあとで、カスタムドメインの設定なども試しに行ってみましたが、本筋からそれるので省略しました。

もしWebサイトを確認していただいて、ミートソースの精度がイマイチだったとしても、それはモデルの作り方が悪いだけなので、AutoML Visionのことは嫌いにならないでください。今回のサイトはあれですが、アイディア次第では面白いものがサクッと作れると思うのでぜひ。まだβ版なので、その点だけご注意を。

長くなりましたが以上です。

※ 記事内のコードが長くならないように省略している部分があるので、詳しくはGitHubでご確認ください。
https://github.com/noplan1989/meat-sauce

コメント

このブログの人気の投稿

投稿時間:2021-06-17 22:08:45 RSSフィード2021-06-17 22:00 分まとめ(2089件)

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2021-06-17 05:05:34 RSSフィード2021-06-17 05:00 分まとめ(1274件)