こんにちは!
忘年会エンジニアのたかぱい(@takapy)です。
コネヒトの忘年会は例年気合いが入っており、有志メンバーが運営となり1年の締めくくりを最高のものにすべく、尽力しています。
今回は2019年の忘年会を技術で盛り上げるべく、同じく忘年会エンジニアのあぼ氏(@abo)と奮闘したことをご紹介できればと思います。
目次
パシャりんピック(既存構成)の紹介
前回(2018年)忘年会時に、弊社エンジニアにより「パシャりんピック」という歓談タイムを盛り上げるコンテンツが爆誕しました。
ざっくり説明すると、
- 社員同士でツーショット写真を取る
- 社員1人1人に点数が初期設定されており、写真に写る人物によってその写真のスコアが算出される
- 最終的に獲得スコアの一番高い人の勝ち(たくさん撮った人が有利)
というものです。(詳しくは下記参照)
このコンテンツの評判がとても良かったので、今回はこれをアップデートさせる形で忘年会を盛り上げてやろうと画策しました。
今回のコンセプト
そもそものコンセプトが明確だったので、今回はこれに「笑顔」要素と順位と写真の見える化をプラスすることで、より盛り上がるコンテンツに仕上げようと決めました。
アップデート内容
大別して下記2点です。
- リーダーボードの実装(主にあぼ担当)
- スコア算出方法のアップデート(主にたかぱい担当)
それぞれについて、全体の構成図も交えながら説明していきます。
アーキテクチャ
以下のようなアーキテクチャを構築しました。
使用した技術は下記です。
- GCP
- Azure
- Python
- Firebase
- Flutter
リーダーボードについて(byあぼ)
今回は"人"と"写真"の2つに対して点数がつくので、点数上位の人と写真がリアルタイムで見られるようにリーダーボード(スコアボード)を作りました。
使用技術ですが、今回はFlutterをweb上で動かしました。Flutter on the webは現状beta版ではありますが、
- 私がFlutterを趣味で使っているので知見があり、好きなので開発するやる気が出る
- 保守性を考えなくても良いアプリケーションである
- 最悪ローカルの環境で特定のブラウザでさえ動けば良い
という理由で採用しました。
基本的にリーダーボード側はFirestoreから購読した値を画面に表示するだけなので、そこまでやることは多くありません。Firestoreの購読にはStreamBuilderを使って、UIはRowとColumnを駆使して組み立てていきました。
また、リーダーボードのメインのUIとは別に、当日の忘年会進行スライドとの統一感を出すために、画面右上に忘年会のロゴを表示させました。今回はStackという、Widgetを重ねられるWidgetを使って実現しました。Stackを使うことで"ロゴ"表示とメインコンテンツの"スコア"表示部分のレイアウトが分割されるので、個人的にはこっちのほうが好みです。
class BoardPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: Stack( children: <Widget>[ Positioned( top: -20, right: -50, child: Image.asset( 'logo.png', ), ), // メインコンテンツのスコア表示UIを組み立てる Column( ~~~ ), ], ), ); } }
Firebaseの機能を使えるようにする
それから、Firebaseの機能を使うのに一工夫必要だったので紹介します。まず、Flutter on the webはFirebase Hostingで動かしました。設定はfirebaseコマンドを使ってサクサクと行えます。firebase.jsonではpublicにbuild/webを指定します。これはflutter build webでweb用のリリースビルドが生成される場所です。
{ "hosting": { "site": "year-end-party-2019-board", "public": "build/web", "ignore": [ "firebase.json" ] } }
次に、Dart packagesからFirebaseパッケージをインストールするのに加えてweb/index.htmlのscriptタグでjsファイルを読み込ませるようにしました。この辺はFirebaseをJavaScriptアプリケーションにインストールするドキュメントを参考にしました。これでFirebaseの機能が使えるようになりました。
pubspec.yaml
dependencies: flutter: sdk: flutter firebase: ^7.1.0
web/index.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>year-end-party-2019-board</title> </head> <body> <script src="https://www.gstatic.com/firebasejs/6.2.0/firebase-app.js"></script> <script src="https://www.gstatic.com/firebasejs/6.2.0/firebase-auth.js"></script> <script src="https://www.gstatic.com/firebasejs/6.2.0/firebase-firestore.js"></script> <script src="main.dart.js" type="application/javascript"></script> </body> </html>
スコア計算について (byたかぱい)
忘年会エンジニア兼・機械学習エンジニアなので、スコア算出に機械学習の要素を取り入れました。
「笑顔」という時点で察している方もいらっしゃると思いますが、写真に写る人物の笑顔度合いを数値化してスコア算出を行いました。
本来であれば、複数の写真(学習データ)を用意して、笑顔度のアノテーションをして、学習させて・・・というフローが必要なところですが、今のご時世APIを1つ叩くだけでやりたいことができます。
という訳で、笑顔の数値化にはAzure Face APIを使用しました。
Azure Face APIには、Python SDKがあり、普段Pythonを触っている人であれば比較的スムーズに使用できるのではないかと思います。
Azure Face APIの使用例
例えば、画像をpostしてレスポンスを受け取る場合は下記のように記述します。
FACE_API_URL = 'https://japaneast.api.cognitive.microsoft.com/face/v1.0/detect' KEY = 'hogehoge' HEADER = {'Ocp-Apim-Subscription-Key': KEY} def call_api(image_url): params = { 'returnFaceId': 'false', 'returnFaceLandmarks': 'true', 'returnFaceAttributes': 'smile,emotion' } response = requests.post( FACE_API_URL, params=params, headers=HEADER, json={"url": image_url} ) return response # image_urlには画像のURLを設定 response = call_api(image_url)
ここから笑顔の値を取得したい場合は下記のように記述します。
smile = response.json()[0]['faceAttributes']['smile']
これで笑顔度(0〜1)を取得することができます。
Azure Face APIが笑顔に寛大すぎる問題
試しに何枚か写真を撮って笑顔度を算出してみると、ある問題に気付きました。。。
「割と簡単に最高値が取得できてしまう🤔」
これでは盛り上がるコンテンツにならないのでは?と思い、とあるロジックを追加しました。
それは「写っている人物の鼻端が近ければ近いほど、高得点になる」というもので、これを「仲の良さ」と銘打ってスコア計算に組み込みました。
(近い位置で写真撮ってる方が仲良さそうですよね?そうでしょ?)
鼻端のx座標、y座標に関しては上記のAPIを使用すれば取得できるので、「近さ」の定義だけ考えました。
「近さ」の定義
通常であればユークリッド距離を計算すれば良いのですが、ハックできる要素を含ませた方がコンテンツとして面白いのでは?と思い、x座標の差分の絶対値を「近さ」の定義として計算することにしました。
どうすればハックできるかはもちろん伝えていなかったのですが、開始10分くらいで気付かれはじめ、最終的には高得点の写真ばかりになっていました・・・笑(詳細は後述)
「近さ」を考慮したスコア計算
以下が「近さ」を考慮したスコア計算例です。(スコアの幅は0点〜1000点にしています)
def calc_score(response, width): score = 0 nosetip = 0 count = 0 if len(response.json()) > 2: count = 2 else: count = len(response.json()) for i in range(count): smile = response.json()[i]['faceAttributes']['smile'] # 笑顔度 0 ~ 1 sadness = response.json()[i]['faceAttributes']['emotion']['sadness'] # 悲しみ 0 ~ 1 score += (smile - sadness) * 100 # 鼻端の近さ if nosetip == 0: nosetip = response.json()[i]['faceLandmarks']['noseTip']['x'] else: nosetip -= response.json()[i]['faceLandmarks']['noseTip']['x'] # 鼻端の距離スコアを計算 nosetip = 1 - (abs(nosetip) / width) if score == 0: return 0 return max(0, int(round(((score / 2) * nosetip), 1)*10)) # responseは前項で取得したFace APIのレスポンス、widthは写真の横幅 score = calc_score(response, width)
このようにすることで、隣り合う2人の距離(鼻端のx座標)が近ければ近いほど高得点が出るようにしました。
やってみてどうだったか
「笑顔」という条件を提示することで、いろんな人の様々な表情を垣間見ることができました!
と同時に、今回はリーダーボードがほぼリアルタイム*1で更新されたので、リーダーボードに映っている、スコアの高い写真を参考にどのように写真を取れば高得点が叩き出せるか試行錯誤するという一味違った体験を提供することができ、大盛況に終わることができたのではないかと思っています!
想定外だったことといえば、先にも述べた通り開始10分くらいでハックされたことです(汗)
今回はx座標のみスコアに寄与するため、横並びではなく、縦並びで写真を撮るとかなり高いスコアを叩き出すことができます。*2
これにより、忘年会後半はほぼ全員が縦並びで写真を撮るという見慣れない光景が広がっていました(笑)が、結果的に仲良さそうに写真を撮っていたので運営側としても大成功&嬉しかったです!
最後に
告知です!
2月6日にMeetUpを開催します!
ママ向けNo1アプリ「ママリ」を運営している社員とざっくばらんにお話ししてみませか?
当日はCEO、CTO、デザイナー、新規事業開発責任者、人事責任者など様々な職種の社員が参加します!ちょっと話聞きたいなくらいの温度感でOKなのでご参加お待ちしてます!
(忘年会の様子とか聞いてみたいな〜という方も気軽にお越しください(笑))