Cloud Vision,GAS,mgramで高校の同窓会を盛り上げたかった

この記事は[Google Cloud Platform その２ Advent Calendar](https://qiita.com/advent-calendar/2018/gcp2)17日目の記事です。

こんにちは。卒論に苦しむ大学生です。


突然ですが、[mgram](https://mgram.me/ja/)をご存知ですか？

105個の質問に答えることで、性格診断ができるサービスです。ちょっと前に流行りましたよね


「今度の同窓会で、自分と似てる性格の人とお喋りできたら楽しそうだえ」

そう思ったのが、この物語のはじまりでした。

## 作成物
Go で書いたプログラムに、mgram診断のURLを引数として渡すと・・・
![mgram.gif](https://qiita-image-store.s3.amazonaws.com/0/201590/df3383ce-ca0d-88d6-f425-ce1218f38432.gif)

診断結果をGoogle Spread Sheetに記述してくれます。
<img width="1072" alt="スクリーンショット 2018-12-17 1.45.38.png" src="https://qiita-image-store.s3.amazonaws.com/0/201590/4c13633a-797c-9985-9fe9-56959c83eb06.png">


本当はwebアプリにして、
自分のURLを入力→性格が近い人を表示
という形にしたかったのですが・・・。
ごめんなさい。学歴を得るための修行(*1)で忙しく、開発ができませんでした。


以下の記事では、今回使用した

- Google Cloud Vision API
- Google Apps Script
- Go
- goquery
 
のプログラムを晒していこうと思います。
最終的な全コードは[github](https://github.com/Spice-Z/mgram-app)においておきます。

## Google Cloud Vision APIで画像からテキストを抽出する編
### Cloud Vision APIとは
Google Cloud上で使える、機械学習を使った画像分析のサービスです。画像から物体情報を抽出したり、テキストを抽出する、といったことがREST API形式でリクエストを投げるだけで簡単に実現できます。
[公式サイト](https://cloud.google.com/vision/?hl=ja)では、ブラウザから画像を上げることでCloud Visionをお試しできるので、気軽に使ってみませう！

[料金について](https://cloud.google.com/vision/pricing?hl=JA)は、テキスト検出や顔検出などのそれぞれの機能が一ヶ月1,000回まで無料で、その後は1,000回あたり$1.50となっています。詳しくは[公式サイト](https://cloud.google.com/vision/pricing?hl=JA)を確認してください。

また、[公式ドキュメント]()も充実しており、APIを使用するための[クライアントライブラリ(ベータ版)](https://cloud.google.com/vision/docs/libraries?hl=JA#client-libraries-install-go)もC#,Go,Java,Nodejs,PHP,Python,Ruby向けに用意されている上に、各言語ごとのサンプルコードとAPIドキュメントも公開されているので、割とハードル低めにつかうことができました。:smile:


今回は、Goのクライアントライブラリを使ってCloud Vision APIを利用しました。ライブラリを使うための手順は、前述した[公式ドキュメント](https://cloud.google.com/vision/docs/libraries?hl=JA#client-libraries-install-go)を参照するのが確実なので、割愛！

### mgramのwebページから画像などを抜き取る
mgarmの診断URLから、ユーザーの名前と画像のURLを抜き取ります。いわゆるスクレイピングです。
個人的に何度か使ったことのある[goquery](https://github.com/PuerkitoBio/goquery)を使用しました。jQueryぽくセレクタを指定することができます。

コードは以下の通り。

```go

//　標準出力からURLを受け取る
mgramURL := os.Args[1]
doc, err := goquery.NewDocument(mgramURL)
if err != nil {
	fmt.Print("url scarapping failed")
}

// セレクタを指定してURLなど取得
imgURL, _ := doc.Find(".image-frame > img").Attr("src")
userName := doc.Find(".introSection > .sectionHeading").Text()

//「〇〇の診断結果」を「〇〇」にする（=名前だけ抽出）ため、テキスト末尾５文字をさようなら
userName = string([]rune(userName)[0 : utf8.RuneCountInString(userName)-5])

```
これで、診断画像のURLと、名前を取得できました。

### 画像をCloud Vision APIに投げてみる
先程のコードで画像のURLが取得できているので、今度はAPIにリクエストを飛ばす部分です。
といっても、公式のサンプルコードがあれば簡単に投げれてしまう:man::baseball:
サンプルコードに少し変更加えたものがこちらです。

```go
ctx := context.Background()
client, err := vision.NewImageAnnotatorClient(ctx)
if err != nil {
	return nil, err
}

//先程取得したmgram診断画像のURLを使う
image, err := vision.NewImageFromURI(imgURL)
texts, err := client.DetectTexts(ctx, image, nil, 1000)
if err != nil {
	fmt.Println("error is occured")
	log.Fatal(err)
	return nil, err
}
if len(texts) == 0 {
	fmt.Println("No text found.")
	return nil, nil
}

// 取得したテキストを標準出力で表示
for _, annotation := range texts {
	fmt.Println(annotation.Description)
}

//はじめの要素ｎに全てのテキストが改行区切りで入っているので配列にする
textCandidate := strings.Split(texts[0].Description, "\n")

//取得したテキストには不要な文字列も入っているので、性格因子の部分だけ抽出
for _, value := range textCandidate {
	fmt.Println(value)
	if strings.HasPrefix(value, "#") && len(value) > 0 && !checkRegexp(`[A-Za-z]`, value) {
		personalities = append(personalities, string([]rune(value)[1:]))
	}
}
fmt.Println("vision api successed")

```

公式のサンプルコードからの変更点は、

- URLから画像を取得する部分
- mgramの性格因子だけ抜き取る部分
です。

しかし、ここで問題点！
mgramの診断画像では、文字の背景色がランダム（？）に違い、一部の背景色ではCloudVisionがうまく文字を認識してくれない事案が発生しました:sleeping:

そこで、診断画像を加工しました！:smile:
### テキスト抽出のしやすい画像に加工する
加工した画像はこんな感じ。

|before|after|
|:--|--:|
|![spice.png](https://qiita-image-store.s3.amazonaws.com/0/201590/bb446cbf-91f2-ffdf-fd79-9f91159cc616.png)|![out.png](https://qiita-image-store.s3.amazonaws.com/0/201590/723d17ae-384c-8eb6-e1d1-a61535ea589d.png)
|

画像の２値化。これならテキストをちゃんと読み取ってくれるでしょう。
（ついでに画像中央のいらない文字を消し去りました。）

こんな加工も、Goなら標準ライブラリで完結しちゃいます！すごー
コードは以下の通りです。


```go
//mgramの画像を読取る
response, err := http.Get(imgURL)
defer response.Body.Close()
srcImg, _, err := image.Decode(response.Body)
if err != nil {
	return
}

// 加工後の画像の入れ物を作る
srcBounds := srcImg.Bounds()

//書き出し用のイメージを作る
dest := image.NewRGBA(srcBounds)

// 1ピクセルごとに編集していきます
for v := srcBounds.Min.Y; v < srcBounds.Max.Y; v++ {
	for h := srcBounds.Min.X; h < srcBounds.Max.X; h++ {
        //該当ピクセルの色を取得してグレースケールに変換
		c := color.GrayModel.Convert(srcImg.At(h, v))
		gray, _ := c.(color.Gray)
        
		// しきい値で白と黒に二値化
		if gray.Y > 250 {
			gray.Y = 255
		} else {
			gray.Y = 0
		}
        // ２値化した色を書き込む
		dest.Set(h, v, gray)
        
		//画像中央部分を消す
		if srcBounds.Max.Y/3 < v && v < srcBounds.Max.Y*2/3 && srcBounds.Max.X/3 < h && h < srcBounds.Max.X*2/3 {
			dest.Set(h, v, color.RGBA{255, 0, 0, 0})
		}
	}
}

outfile, _ := os.Create("temp.png")
defer outfile.Close()
png.Encode(outfile, dest)

```
難しいことはしておらず、１ピクセルごとに色を変換しているだけです。
これをCloud Visionに投げれば、大丈夫だろう。


## GASでスプレッドシートに登録する編
### Google Apps Script とは
Google Apps Script(GAS) は、Googleが提供するサービス(Google Spread SheetやGoogle Documentsなど)と連携したアプリを作るためのプラットフォームです。
([公式サイト](https://developers.google.com/apps-script/overview))
これを使えばGoogleのツールを使っていろいろできるようです。

今回はスプレッドシートに紐づけてGASを使う！

### とりあえずを動かしてみる
とりあえず、GETでアクセスすると反応してくれるコードを書きます。
新しいスプレッドシートを作り、「ツール」メニューから「スクリプトエディタ」を開きます。


エディタに、以下のコードを貼り付けます。

```js
function doGet(e) {
  return ContentService.createTextOutput("hello...");
}
```
そして、公開メニューから、「ウェブアプリケーションとして導入」を選択し、
アプリケーションにアクセスできるユーザーを「全員（匿名ユーザー含む）」に設定。
その後導入ボタンを押すと、承認が求められるので、承認します。
画面に従ってページ遷移すると、注意されますが、詳細リンクを開いて移動します。
<img width="671" alt="ログイン_-_Google_アカウント.png" src="https://qiita-image-store.s3.amazonaws.com/0/201590/60b0cfbd-f78c-2969-0627-6987670629af.png">

公開します。


これで準備完了。
### GETパラメータをスプレッドシートに登録する
GASの方で、GETパラメータをスプレッドシートに書き込むようにします。
スクリプトエディタから、以下のようにコードを変更します。

```js
function doGet(e) {
  var rowData = {};  

  if (e.parameter == undefined) {

      //パラメータ不良の場合はundefinedで返す
      var getvalue = "has no parameter"

      rowData.value = getvalue;
      var result = JSON.stringify(rowData);
      return ContentService.createTextOutput(result);

  }else{

      var id = '自分のスプレッドシートのid';

      var sheet = SpreadsheetApp.openById(id).getSheetByName("書き込みたいシート名");

      var array = [ e.parameter.name,e.parameter.mgramURL, e.parameter.p1 , e.parameter.p2 , e.parameter.p3 , e.parameter.p4, e.parameter.p5, e.parameter.p6, e.parameter.p7, e.parameter.p8 ];

      //シートに配列を書き込み
      sheet.appendRow(array);

      //書き込み終わったらOKを返す
      var getvalue = "ok"

      rowData.value = getvalue;
      var result = JSON.stringify(rowData);
      return ContentService.createTextOutput(result);

  }
}

```
idやシート名は、適宜自分のものに変える必要があります。
idについては、スプレッドシートのURLが```https://docs.google.com/spreadsheets/d/「id」```　になっているので、
そこからコピペしましょう！

コードを変更したら、先程と同じように公開をします。
コードを更新するためには、プロジェクトバージョンを「新規作成」にする必要があります。

更新した上で、「在のウェブ アプリケーションの URL」にパラメータ（例えば、?name=テスト&p1=テスト）をつけてアクセスするとシートにパラメータの値が入力されているはずです。

## Cloud Vision APIの結果をスプレッドシートに書き込む
残すは、Cloud Vision APIの結果をスプレッドシートに書き込むよう連携するだけです。
といってもやることは、
文字化けしないようにパラメータをエンコードしてhttpリクエストを飛ばす
だけです。

```go
//パラメータを作る
params := "?name="
params += url.QueryEscape(name)
params += "&mgramURL="
params += url.QueryEscape(mgramURL)
for key, value := range personalities {
	params += "&p"
	params += strconv.Itoa((key + 1))
	params += "="
	params += url.QueryEscape(value)
}

//　GETリクエスト飛ばす
resp, error := http.Get(sheetURL + params)
if error != nil {
	fmt.Println("errror occured")
	return error
}
defer resp.Body.Close()
```

これで必要な処理は下記終わりました。
あとは、これまでのコードを連携させるだけです。
連携したコードは[github](https://github.com/Spice-Z/mgram-app)をみてください！


## おわりに
### 感想
実装してみて、
- CloudVisionで単純な画像で文字認識させたい場合、画像を加工してから投げるのがよさそう
- Goを使えば簡単な画像加工はすぐにできて楽しい
- スプレッドシートにデータを保存する方法として、パラメータを使うのは簡単でよい
- Goらしい書き方とか全く知らないので、そろそろちゃんと勉強したい

といった気持ち。

今回くらい単純な機能であれば、CloudVisonもGASも難なく使うことができました。
さくっとアプリを作りたいときに使えそうです。

しかしやっぱり、webアプリにしたい。

修行(*1)が厳しい中、完成するかはわかりませんが、完成したらまたQiitaに書きたいと思います。


## 参考
https://qiita.com/roana0229/items/fbff1a03d127dac7bdc4
https://qiita.com/takanakahiko/items/e8123ee6b565c6ee8d8e
https://twinbird-htn.hatenablog.com/entry/2017/04/23/001743

*1) 卒論の執筆をして、大学を卒業するために頑張る行為