はじめに
みなさんは食事管理が得意ですか?
「痩せたくて食事制限をしようとしても面倒ですぐにやめてしまう」
そんなことありませんか?
私はそんな面倒くさがり屋のために食事管理のハードルを下げたアプリ「Sugarjudge」を作成しました。
サービス概要
アプリURL https://www.sugarjudge.com/
GitHub https://github.com/udakohei/Sugarjudge
痩せたいけど食事を管理、制限することが面倒で難しいという悩みを持った人に、
簡単でざっくりと楽しく糖質を管理、制限できる環境を提供する、
糖質収支ジャッジアプリです。
- 糖質収支を、一食の摂取糖質量から一食に摂取しても良い上限の糖質量を引いた値として考える。糖質を取りすぎたら赤字、制限内だったら黒字。
- 続けることは考えず一食一食の管理、制限を目指し、簡単な入力だけでその食事による自身の糖質収支が黒字か赤字かを判定
- 自己管理は難しいので誰かの目が必要。アプリ内、SNSで食事の写真と判定内容を投稿、共有する
なぜ作ったのか
私には「食欲が抑えられず、ついついご飯をおかわりするなど、食べすぎて太ってしまう」という悩みがあります。学生の頃、糖質制限をおこない、半年で10キロ痩せた経験がありますが、その後2年でまた10キロ太ってしまいました。それなのに、面倒くさがり屋なので現在は運動や食事制限などはあまり続かず、習慣にするのにはハードルを感じています。自己管理だけじゃ続かないと考え、「簡単に緩くはじめられて、投稿した食事の写真がアプリ内で自動で共有されることで誰かの目を感じつつ、だけどネタにできるサービス」があったらいいなと思いこのサービスを作りたいと思いました。
機能
一般ユーザー
- 自身の名前、性別、制限レベルの3つの質問の入力と食事の写真と写真の解析から出した選択肢から選択することで、その食事の糖質収支が黒字か赤字かジャッジされる機能
- 判定内容をアプリ内で自動共有させる
- 自分の食事が赤字だった場合に謝罪文を打ち込むことができる
- 全判定にコメントを打ち込むことができる
- 他のユーザーの判定内容を閲覧できる
- 判定内容をアプリ内、Twitterで共有できる
ログインユーザー
- 上記に加え、自身の判定内容、累積収支を振り返られる機能
使用技術
- Ruby 3.0.2
- Rails 6.0.4.1
- jQuery
- Bootstrap v5.0
- Chart.js
- Google Cloud Vision API
- Google Translate API
- Sorcery
- Heroku
- AWS S3
苦労した点
画像投稿→食品候補リスト表示までの流れの実装
ユーザーが自分の食べた食事をアップロードしてから、
自分の食べた食品を選択する表を表示させるまでの流れの実装が、難しかったです。
なぜ難しかったか
「外部のGoogleのサービスで画像解析を行っている + 解析結果を食品のデータベース内で検索し、候補の食品のデータを集めて表示させる」
といった、データの動きが見えないところで多方面かつ内容を変更させると言った複雑性をもっていたからです。
逆にいうと、ここの機能の部分がサーバーサイドのメインの実装となったので楽しかったです。
どうやって解決したか
- 紙に書いて、データがどのように流れるか図に書いて、整理しました。
- 流れの各順序をどのように実装するか考え、タスクを分解していきました。
def create
@meal = using_user.meals.build(meal_params)
if @meal.save
sent_image = File.open(meal_params['meal_image'].tempfile)
@meal.update!(analyzed_foods: @meal.image_analysis(sent_image))
redirect_to edit_meal_path(@meal), success: t('.success')
まずはコントローラーでアップロードされた画像(画像を属性にもつ食事オブジェクト)が保存された場合、4行目で画像を取得し、5行目のimage_analysisメソッドに渡します。
def image_analysis(meal_image)
image_annotator = Google::Cloud::Vision.image_annotator
translate = Google::Cloud::Translate::V2.new
response = image_annotator.label_detection(
image: meal_image
)
results = []
response.responses.each do |res|
res.label_annotations.each do |label|
translation = translate.translate label.description.downcase, to: 'ja'
results << translation.text
end
end
results.join(',')
image_analysisメソッドはモデルのインスタンスメソッドとして定義されております。
meal.rbの2行目と3行目でそれぞれ「Google Cloud Vision API」と「Google Translate API」にメソッドを呼び出しただけでリクエスト通信できるようになるオブジェクトを作りました。これはGoogleの提供するGemを使用することで実現しました。(APIキーなどの設定は別途で記述する必要あり)
次に5~7行目で先ほどのオブジェクトのメソッドを呼び出すことで「Google Cloud Vision API」に通信します。引数の画像であるmeal_imageを飛ばしてその画像に何が写っているかなど複数の情報が言葉でコレクションの形でかえってきます。コントローラーで渡したsent_imageを実際の挙動では解析しています。
そこから下の部分では、まず上の結果をeachメソッドで全ての結果を一つひとつ取り出し、使いたい部分だけ取り出して再度eachメソッドを使います。この最後に絞られた一つひとつを「Google Translate API」の機能で日本語に翻訳します。これも最初に作ったオブジェクトのメソッドを呼び出すだけで実行しています。
その結果を空の配列resultsに入れていき、最後にjoinメソッドで文字列にします。
文字列にする理由はデータベースに保存するためです。なぜならGoogleの解析結果が保存されていないと、後に紹介する食品選択ページに遷移されるたびに同じ写真を毎回APIに飛ばす必要が出てしまうからです。そうなってしまうと外部と通信するためリロード時間が毎回かかったり、APIの使用回数が無駄に上がってしまうからです。
def create
@meal = using_user.meals.build(meal_params)
if @meal.save
sent_image = File.open(meal_params['meal_image'].tempfile)
@meal.update!(analyzed_foods: @meal.image_analysis(sent_image))
redirect_to edit_meal_path(@meal), success: t('.success')
そしてコントローラーに戻り、5行目で結果をanalyzed_foods属性に保存しています。
6行目で食品選択ページに遷移します。
食品選択ページでは食べた食品の候補の表(チェックボックス)を表示させます。
def edit
@meal = using_user.meals.find(params[:id])
@concrete_foods = Food.searched_foods(@meal)
3行目のsearched_foodsというFoodクラスのクラスメソッドで候補の食品を検索、取得しています。
詳細はモデルに記述しており、
def self.searched_foods(meal)
foods_from_foods = concrete.search_foods(meal.pass_to_sql)
foods_from_genres = Genre.search_genres(meal.pass_to_sql).map(&:foods)
(foods_from_foods + foods_from_genres).flatten.uniq
end
2行目と3行目のpass_to_sqlはMealモデルのインスタンスメソッドであり、先ほど保存した結果の文字列を配列に戻しています。
search_foodsとsearch_genresメソッドは、解析結果の配列の全ての要素をfoodsテーブルとgenresテーブル内で検索するスコープです。
scope :search_foods, lambda { |analyzed_foods|
where('name LIKE ? OR name LIKE ? OR name LIKE ? OR name LIKE ? OR
name LIKE ? OR name LIKE ? OR name LIKE ? OR name LIKE ? OR name LIKE ? OR name LIKE ?',
"%#{analyzed_foods[0]}%", "%#{analyzed_foods[1]}%", "%#{analyzed_foods[2]}%", "%#{analyzed_foods[3]}%",
"%#{analyzed_foods[4]}%", "%#{analyzed_foods[5]}%", "%#{analyzed_foods[6]}%", "%#{analyzed_foods[7]}%",
"%#{analyzed_foods[8]}%", "%#{analyzed_foods[9]}%")
}
こうすることで、解析結果をもとに当てはまる食品を検索、取得することができます。
genresテーブルでも検索しているのは、解析の精度がジャンル名で返ってくることが多いためです。例えば、ラーメンとは返ってこないけど、麺とは返ってくるようなイメージです。
取得できたらビューで表にして表示させて、終わりです。
<table class="table table-hover table-bordered">
<thead>
<tr>
<th><%= Food.human_attribute_name(:name) %></th>
<th>選択する/しない</th>
</tr>
</thead>
<tbody>
<%= f.collection_check_boxes :food_ids, @concrete_foods, :id, :name do |food| %>
<%= food.label do %>
<tr class="js-checkbox-<%= food.object.role %>">
<td><%= food.object.name %></td>
<td><%= food.check_box class: 'js-chk' %></td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
表は実際の食品を選べるように、チェックボックスの表になるようtableタグとcollection_check_boxesメソッドを組み合わせました。
以上で無事、画像投稿→食品候補リスト表示までの流れの実装を終わりました。
工夫したところ
- ジャッジをしたら自動でアプリ内に共有されるところ
- Chart.jsを使ってグラフで黒字、赤字の大小がわかりやすくなるよう実装
* 画像解析の精度をカバーするため候補の表に食品がないとき、抽象的な食品を選べるように実装
- 赤字の(糖質を取りすぎた)場合謝罪文を投稿できる機能。ネタにする意味をこめました。
終わりに
糖質収支ジャッジアプリ、使ってくださると嬉しいです!
また、何かご意見、ご感想がございましたら、こちらのDMでくださると嬉しいです!
https://twitter.com/udaudakohei/
ぜひ、皆さんのジャッジお待ちしております。