📸

初めてのPythonとCloud FunctionsとVision APIを使った話

2022/07/11に公開

とりあえずやったことの整理がてら書きました。

背景

  • この4月からプログラミングを学び始め、HTML/CSS/JavaScript/PHPをやり、FirebaseとかMySQLとか触った感じです。直近はPHPでBtoBマッチングプラットフォーム作ったりしました。

  • 「1週間ちょっとでチーム開発してデプロイする」機会があったので、まだ触ったことのない言語とGoogle Cloud Platformを使って色々動かしてみたら楽しいんじゃないかということで取り組んでみました。

やったこと

  • 毎朝8時にお題が送られてきて、それに合わせて写真を撮り、Vision APIによる分析と投稿に対するいいね数でランキング付されて夜20時に結果が表示されるアプリを作りました。
  • バックエンドをGCP上のCloud Functionsを使ってVision APIと連携しました。Pythonを初めて使ってみました。PythonはUdemyとか本とかを読んで基本的なことをとりあえずざっとインプットした感じでスタート

やろうとしたことはこんな感じ

フロントの部分をやったチームメンバー達の記事とか
https://zenn.dev/tatukane/articles/1985d23cfae8d8

取り組みの内容

Cloud Functionsを理解する

  • GCPは初めてなのでまずはアカウントの作成。$300分は無料なので一応安心して始められた。
    テスト開発なのでそもそも超えることはないと思うが、一応、無料枠を超えたらアラートが来るように設定。

  • これでCloud Functionsが使えるようになったので、以下構成を設定。
    [関数の作成]⇨[環境:第一世代]⇨[関数名:funciton-1(とりあえず)]⇨[リージョン:東京]⇨[トリガー:HTTP]⇨[認証:認証が必要]

  • あとはコードを書くだけなので、早速デプロイ。
    さあ、どんな風に動くかなとみてみると・・・

(あれ、なんか、ずっとデプロイしてるぞ。。。)

リロードやらなんやらしてもずっとデプロイしているので、調べてみるとどうやらそういう仕様らしい。

待つこと2,3分。
やっとデプロイが完了したので、早速テスト。
テストは[テスト中]の中の[関数をテストする]で実行できます。
すると・・・

Error: function terminated. Recommended action: inspect logs for termination reason. Additional troubleshooting documentation can be found at https://cloud.google.com/functions/docs/troubleshooting#logging Details:
500 Internal Server Error: The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.

・・・・

・・・だ、だるい!!だるすぎる!!!

毎回2,3分待って、エラーを出されるなんて、これは相当大変だな、ということで調べてみると。
どうやらfunction frameworkというものを使えばローカルでコードを書いてテストできるらしいということなので、早速実行。

function frameworkのインストール

調べてみると結構簡単で、以下を書く。

% pip install functions-framework

あとはVSCodeなどで関数を書いてターミナルで以下を実行

% functions-framework --target=関数名

その後 http://localhost:8080 にアクセスすると関数が実行されています。
(自分はそれ以外のところでエラーが起きて手間取ったのですが、普通なら上記でいけます)

これで書いてすぐ確認ができるようになったので、早速どんどん試していきます。
なおCloud Functonsは表示させたいものをreturnで返さないとエラーになってしまうようです。

1.Vision APIを使った感情分析をしたい

  • 公式ドキュメント を読んで準備
  • 今回はCloud Strageに画像がアップロードされたらVision APIを叩く感じにしたので、最初の関数作成ではトリガーをCloud Storageに設定。
main.py
import json
from google.cloud import storage
from google.cloud import vision
from google.cloud import firestore

main.pyではStorageとVisionへのアクセス、それと取得したデータはFireStoreに入れるので、ここにもアクセスできるようにしています。

それとライブラリを読み込むためにはrequirements.txtの設定も必要なので、以下記載

requirements.txt
# Function dependencies, for example:
# package>=version
google-cloud-storage==2.1.0
google-cloud-vision==2.7.3
google-cloud-firestore==1.2.0

そして関数を書く

main.py
def detect_faces_uri(data, context):
    bucket = data['bucket'] #画像の保存されているバケット名
    name = data['name'] #画像のオブジェクト名。gsuriがバケット名+オブジェクト名で構成されている
    
    client = vision.ImageAnnotatorClient()
    image = vision.Image()
    image.source.image_uri = 'gs://{}/{}'.format(bucket,name) #アップロードされた画像のリンク先を取得

    response = client.face_detection(image=image)
    faces = response.face_annotations #printすると色々な情報が取れる。
    
    likelihood_name = ('UNKNOWN', 'VERY_UNLIKELY', 'UNLIKELY', 'POSSIBLE',
                       'LIKELY', 'VERY_LIKELY') #vision APIでは上記6つの判定があるみたい
    
    print('Faces:')

    for face in faces:
        d={
            'anger': '{}'.format(likelihood_name[face.anger_likelihood]),
            'joy':'{}'.format(likelihood_name[face.joy_likelihood]),
	    'surprise':'{}'.format(likelihood_name[face.surprise_likelihood]),
            'sorrow': '{}'.format(likelihood_name[face.sorrow_likelihood]),

        }
        print(d)
	db = firestore.Client()
        doc_ref = db.collection('imagedata')  #imagedataにアクセス
        doc_ref.add(d) #取得したデータを挿入
        return str(d) 
    if response.error.message:
        raise Exception(
            '{}\nFor more info on error messages, check: '
            'https://cloud.google.com/apis/design/errors'.format(
                response.error.message))

メモ

  • gsutilURIではなくて認証済みURLを取得する形で書くとエラーが起きるのでgsutilURIでアップロードされたデータを取得(知らんかった)
  • Firestoreにはlist型では読み込んでもらえない?のでdでdict型にした。
  • Firestoreの構成は[コレクション]-[ドキュメント]-[フィールド]となっているのだが、ドキュメント名を指定する場合はadd()、指定しないで任意のユニークkeyを取得する場合はset()で書く必要がある

関数が書けたので、ニヤニヤしながらテストを実行すると・・・

Error: function terminated. Recommended action: inspect logs for termination reason. Additional troubleshooting documentation can be found at https://cloud.google.com/functions/docs/troubleshooting#logging Details:
500 Internal Server Error: The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.

「なんでやねん!」

ということでログを見ると、長々と書かれたログの中に以下の文字が

in detect_faces_uri bucket = data['bucket'] KeyError: 'bucket'

は?? bucketがkeyって公式に書いてたやないか!!と小一時間調べた結果、ふと気づいたことが、

(あれ?storageへのアップロードがトリガーなのに、そもそも画像入れずにテストしてない??)

ということで急いでStorageに画像をアップロードした結果・・・

おおお!!!ちゃんと取れている!

ということでこれで連携は完了。

あとは、

  • 取得したデータをランキングにするには'UNLIKELY'とかを数値化しないといけないな、ということで、どうしようかと考えたところ、取得している'faces'をprintして確認すると色々な認識情報(ランドマークとか顔の位置とか、髪が生えてるかどうかとか)を返しているのが分かり、'face.anger_likelihood'で'UNLIKELY'などを取得していることが確認できたので
    以下を追加
main.py
    likelihood_num = {
        'UNKNOWN':0, 
        'VERY_UNLIKELY':1, 
        'UNLIKELY':2, 
        'POSSIBLE':3,
        'LIKELY':4, 
        'VERY_LIKELY':5
    }
    
    for face in faces:
        # print(face)
	a = likelihood_name[face.anger_likelihood]
	j = likelihood_name[face.joy_likelihood]
	s = likelihood_name[face.surprise_likelihood]
	so = likelihood_name[face.sorrow_likelihood]
	total = likelihood_num[a]+likelihood_num[j]+likelihood_num[s]+likelihood_num[so]
	
	d={
	    'anger': likelihood_num[a],
	    'joy':likelihood_num[j],
	    'surprise': likelihood_num[s],
	    'sorrow': likelihood_num[so],
	}

これで各感情の分析値が点数で表示できました。

合わせて

  • アップロードされた各画像の投稿者の情報と、取得した分析値を紐づけてimagedataテーブルに入れたい
  • 毎日変わるお題別に重要視される分析値が変わるので、その計算を入れたい

ということで、以下を追記しました。

main.py
    iu = 'https://storage.cloud.google.com/{}/{}'.format(bucket,name)

    db = firestore.Client()
    docs = db.collection('image').where("cloudurl", "==", iu).get()
    id_list = []
    for docid in docsid:
        id_list.append(docid.to_dict())
  • フロント側から画像アップロード時にgsuriを取得できなかったので画像の認証済みURLを取得して、Firestoreに'cloudurl'名で入れています。それをstorageのトリガーに合わせて取得した通知情報'iu'と照らし合わせてデータを取ってきています。
  • Firestoreでのwhere文の参考

最後に今日のお題が入っているtodaytopicから各感情別weightを取得してきて計算して、imagedataへの登録内容を少し変更して終了。

main.py
doc_d = db.collection('todaytopic').get()
doc_list=[]
for doc in doc_d:
	doc_list.append(doc.to_dict())

anger = doc_list[0]['anger']
joy = doc_list[0]['joy']
surprise = doc_list[0]['surprise']
sorrow = doc_list[0]['sorrow']
for face in faces:
        a = likelihood_name[face.anger_likelihood]
        j = likelihood_name[face.joy_likelihood]
        s = likelihood_name[face.surprise_likelihood]
        so = likelihood_name[face.sorrow_likelihood]
total = likelihood_num[a]*anger+likelihood_num[j]*joy+likelihood_num[s]*surprise+likelihood_num[so]*sorrow
        d={
            'anger': likelihood_num[a],
            'joy':likelihood_num[j],
            'surprise':likelihood_num[s],
            'sorrow': likelihood_num[so],
            'total':total,
            'cloudurl':'{}'.format(iu),
            'imgurl':'{}'.format(id_list[0]['imgurl']),
            'uid':'{}'.format(id_list[0]['uid']),
            'cloudtime':'{}'.format(id_list[0]['cloudtime']),
            'name':'{}'.format(id_list[0]['name']),
            'profurl':'{}'.format(id_list[0]['profurl'])
	}

2.いいね数と感情分析値の総合計を出したい

  • 朝8時のお題の後は、投稿された各ユーザーの画像が一覧表示されて「いいね」ができる仕様にしています。最終的なランクは分析値といいね数の合計の高い人から表示しようということで、それを合算させたいと思います。なおいいね数はフロント側から取得して'image'テーブルに入れられています。
image:アップロード画像URL及びアップロードしたユーザー情報
imagedata:アップロードしたユーザー情報と感情分析値
imagecount:分析値といいね数が結合されたテーブル #今回やりたいところ

いいね数と分析値をユーザ別に紐づけて収めておくテーブルを'imagecount'と設定。
まず、毎日テーブルに入っている既存のデータを削除したいので、その関数を書こうと思います。

main.py
def get_count(data,context):
    db = firestore.Client()

    doc_d = db.collection('imagecount').get() #既存のデータの取得
    doc_list = []
    for doc in doc_d:
        doc_list.append(doc.id) #imagecountの各Key名を取得
    # print(doc_list)
    len_d = len(doc_list) #リストの長さ
    
    #一つずつid名を取得して削除
    for i in range(len_d):
        deld = doc_list[i]
        # print(deld)
        doc_del = db.collection('imagecount').document('{}'.format(deld))
        doc_del.delete()

メモ

  • 公式ドキュメントを見ると、delete()を使うことで削除できる様子。一つずつドキュメント名指定して削除するかコレクションごと削除するかという書き方。ドキュメントは全部削除するとコレクションごと削除されてしまうので、関数内でfor文で一つずつドキュメントkeyを取得して削除していく部分と、この後のいいね数と分析値を結合する部分を同時に書くことにしました。
  • key名はdoc.idで取得可能

ということで続いてimageのテーブルからアップロードしたユーザーのアップロード画像情報といいね数を取得

main.py
    docis = db.collection('image').get()
    img_list = []
    for doci in docis:
        img_list.append(doci.to_dict())
    len_il = len(img_list) 

    count_list = []
    for i in range(len_il):
        d = {
            "count":"{}".format(img_list[i]['count']),
            "cloudurl":"{}".format(img_list[i]['cloudurl'])
        }
        count_list.append(d)
	
    len_cl = len(count_list)

ついでに今日の日時も取得(imageテーブルの今日投稿された画像だけを取得するため)

main.py
    now = datetime.date.today()
    td = now.strftime("%Y/%m/%d")

最後に、imagedataの分析値といいね数を結合。
そのためにはimagedataにあるアップロード画像url'cloudurl'が、imageのcloudurlと一致して、かつ今日の画像だけ取ってくるというsqlを書く必要があるのですが・・・

「ん・・・? where文の中でandで繋いでもうまくいかないぞ」

ということで色々調べた結果、公式ドキュメントの別件の例の中にwhere分を二つ繋いでいる例がしれっと載ってるぞ!!ということで書いてみたら成功しました

main.py
    for i in range(len_cl):
        doccs = db.collection('imagedata').where("cloudurl", "==", count_list[i]['cloudurl']).where("cloudtime", "==", td).get()
        for docc in doccs:
            check_dcc = docc.to_dict()
            check_cl = count_list[i]
            check_dcc.update(check_cl)
            doc_ref = db.collection('imagecount').document('{}'.format(i))
            doc_ref.set(check_dcc)

これで、アップロード画像別の分析値といいね数がimagecountに入ったので、それを足し算する関数を書いてあらたにimagewithcountに挿入しました(・・・分かりにくすぎる名前w)

最後にこれをランキングでソートしたテーブルを作って、フロント側から表示してもらいます

3.ユーザー別の合計値をランク付けして並び替えたい

いよいよ最後の部分、ranksortテーブルの作成です。

image:アップロード画像URL及びアップロードしたユーザー情報
imagedata:アップロードしたユーザー情報と感情分析値
imagecount:分析値といいね数が結合されたテーブル 
imagewithcount:分析値といいね数の合計値が追加されたテーブル
ranksort:ランキング付けして並び替えたテーブル #今回やりたいところ
  • 今日のデータだけを挿入したいので既存のデータは消した上で挿入したい(前やったのと同じ)
  • ランク別にデータを並び替えてranksortテーブルに入れたい

ということで最初の削除は使い回しで、 後はimagewithcountテーブルから取得したデータをsortすればいいかなと思ってやりました。

main.py
import json
from google.cloud import firestore

def get_insert(data,context):
    db = firestore.Client()
    #データ削除文----
    doc_d = db.collection('ranksort').get()
    doc_list = []
    for doc in doc_d:
        doc_list.append(doc.id)
    print(doc_list)
    len_d = len(doc_list)

    for i in range(len_d):
        deld = doc_list[i]
        print(deld)
        doc_del = db.collection('ranksort').document('{}'.format(deld))
        doc_del.delete()
   #-----ここまで
   
    docs = db.collection('imagewithcount').get()
    rank_list = []
    for doc in docs:
        rank_list.append(doc.to_dict())
	
    return_json = json.dumps(rank_list, ensure_ascii=False)
    int_json = eval(return_json)
    d = sorted(int_json, key=lambda x: x["total"],reverse=True)
    rd = {"rank": d}

    doc_ref = db.collection('ranksort')
    doc_ref.add(d)  

メモ

  • 最初、降順に並び替えるコードを書いた結果、「JSON型式にしてください」的なエラーコードが出たのでJson.dumpsでエラーにした後、sorted関数でsortしてreverseで降順にしましたが、今度は「sortしたいならint型にせい!」というエラーが出たので、eval関数でint型にしています。

これでフロントチームに渡したところ、「いや、これすごい取り出しにくい構造になってます。もうちょいシンプルにテーブルに入れてくれない?」ということだったので、
sort関数を使うのをやめて以下のように修正

main.py

 docs = db.collection('imagewithcount').order_by(
 'finaltotal', direction=firestore.Query.DESCENDING).get()
 rank_list = []
 for doc in docs:
   rank_list.append(doc.to_dict())
   len_rl = len(rank_list) 

   for i in range(len_rl):
       d= {
           'finaltotal':rank_list[i]['finaltotal'],
           'imgurl':'{}'.format(rank_list[i]['imgurl']),
           'uid':'{}'.format(rank_list[i]['uid']),
           'name':'{}'.format(rank_list[i]['name']),
           'profurl':'{}'.format(rank_list[i]['profurl']),
           'rank':i

       }
       doc_ref = db.collection('ranksort').document("{}".format(i))
       doc_ref.set(d)

メモ

  • Firestoreもorderby使えることを発見。なので降順で取得して、rank付けしながら、一つずつ順番にranksortテーブルに入れていくやり方に変えてみました。

後は、各関数のトリガーを設定。
今回はCloud SchedulerのPUB/SUBを使って毎日特定の時間に実行されることで設定しました。
Cloud Scheduler自体は簡単だけど関数を書く前に設定が必要なので注意。

最後に

  • 初めてのチーム開発、「一週間で何できるかな?使ったことないものとか興味あるもの使って色々やってみよ!」からスタートしたのでずっと楽しくできた。
  • ちょっとした改善をめちゃくちゃ褒めるの大事。自己肯定感高まる。
  • フロントエンドでできないことをバックエンドで工夫する(その逆も)みたいなの、すごい良かった(語彙力)

GCP便利ですね。
引き続き精進します。

Discussion