W miniony weekend (24 - 25 listopada) w Warszawie miał miejsce hackathon HackYeah. Ja i mój team realizowaliśmy bardzo ambitny pomysł na aplikację dla osób cierpiących na niewydolność nerek lub cukrzycę. Jak później się okazało, właściwie apka mogłaby być używana przez każdego.
TL;DR: Pomaga śledzić co jemy, ile ma to wartości odżywczych i wody. Dla osób z takimi problemami zdrowotnymi, jakie wymieniłem wyżej, śledzenie przyjmowanych pokarmów czasem może być kwestią nie tylko odpowiedniego samopoczucia, ale nawet życia. Dlatego stworzyliśmy aplikację.
Idea
Użytkownik pobiera naszą aplikację, by zacząć kontrolować swoje nawyki żywieniowe. Nadchodzi czas pierwszego posiłku, wyjmuje telefon, odpala apkę. Robi zdjęcie jedzenia, wysyła. Po chwili dostaje odpowiedź - ‘zupa pomidorowa’. Użytkownik dopisuje 300 gramów wagi posiłku i zapisuje w bazie danych. Później może wybrać go z listy i sprawdzić jakie składniki odżywcze i ile wody znajdowało się w zjedzonym daniu, przekąsce lub napoju.
Rzeczywistość
Mając 24 godziny na zrealizowanie tak złożonego pomysłu nie udało się ukończyć go w stu procentach. Mimo to, w niedzielę rano byliśmy pod wrażeniem jak dużo udało się osiągnać! Co prawda, gdy aplikacja wysyłała zdjęcie pomarańczy, to najczęściej otrzymywała odpowiedź ‘Fruit’ lub “Citrus fruit”, ale hej! TO DZIAŁA!
Jak zacząłem rozpoznawać obrazy w Rubim
Coś tam słyszałem o Tensorflow, ale nie za wiele. Zgłębiłem się zatem w temat. Szybko znalazłem repozytorium tensorflow.rb. Pomyślałem ‘wow! świetnie! wszystko odpalę u siebie na komputerze i nie będzie problemu!’. Gdy próbowałem zainstalować Tensorflow na moim lapku i po raz trzeci system został kompletnie zawieszony, stwierdziłem, że musi istnieć inne rozwiązanie. Znalazłem AWS Rekognition.
Amazon Web Services udostępnia usługę rozpoznawania obrazów za darmo do 5000 requestów na miesiąc. Idealny plan na hackathon. Konto na AWS już miałem, więc musiałem tylko dodać tą usługę do użytkownika. Po drodze znalazłem całkiem przydatny artykuł na Medium.
Dodałem wygenerowane klucze do rails credentials
(moim zdaniem super rozwiązanie), zainstalowałem gem aws-sdk
i byłem niemal gotów do rozpoznawania obiektów na obrazach wysyłanych z aplikacji!
Ustaliliśmy, że będę otrzymywał obrazki zakodowane w base64. Dzięki temu mogłem zrezygnować z przechowywania obrazów w formie plików na serwerze, więc deploy na Heroku był o wiele prostszy i szybszy. Tak się złożyło, że Ruby świetnie sobie poradził z przekonwertowaniem base64 do bajcików, które chciał otrzymać AWS. A więc jedyne co musiałem zrobić to:
# app/controllers/concerns/file_converter.rb
module FileConverter
def convert_file(base64)
Base64.decode64(base64)
end
end
Taki moduł includowałem w kontrolerze dla plików i wysyłałem na AWS (chamsko skopiowany z artykułu na medium):
# app/controllers/concerns/aws_caller.rb
require 'aws-sdk'
module AwsCaller
def process_image(image)
client = Aws::Rekognition::Client.new(
access_key_id: Rails.application.credentials.aws_access_key,
secret_access_key: Rails.application.credentials.aws_secret_key,
region: 'eu-west-1'
)
resp = client.detect_labels(
image: { bytes: image }
)
# resp.labels.each do |label|
# puts "#{label.name}-#{label.confidence.to_i}"
# end
resp.labels.first.name
end
end
Jak widać w ostatnich linijkach zakomentowałem listowanie wyników i ich prawdopodobieństwa wystąpienia na obrazie i zwracałem tylko pierwszy rekord (z największym prawdopodobieństwa). Dla zdjęcia z pomarańczą resp.labels
wyglądał mniej więcej tak:
Citrus Fruit-99
Lemon-98
Fruit-98
Plant-97
...
Cały kontroler odpowiedzialny za obsługę zdjęć wyglądał tak:
# app/controllers/api/files_controller.rb
class Api::FilesController < ApplicationController
include Api::AwsCaller
include Api::FileConverter
def create
picture = convert_file(files_params[:base])
resp = process_image(picture)
json_response("Successfully created file", true, resp, :ok)
end
private
def files_params
params.require(:file)
end
end
json_response
jest tutaj funkcją pomocniczą includowaną w ApplicationControllerze. To taki formater do zwracania ładnego JSONa.
Zdaję sobie sprawę, że kod tutaj napisany mógłby być lepiej uporządkowany, wrzucony pewnie do katalogu /lib
, ale z racji tego, że powstał w 24h i rozpoznaje obrazy z dowolnego zdjęcia - jestem z niego zadowolony! :D
Mamy nazwę i co dalej?
Stwierdziliśmy, że nie ma co kombinować i zostaliśmy przy języku angielskim. Ułatwiło to bardzo sprawę, ponieważ znaleźliśmy NutritionixAPI, który jest fantastyczną usługą z ogromną bazą danych jedzenia i jego wartości odżywczych. Zapytania do API można pisać w naturalnym języku(!), więc (niestety dopiero po hackathonie to do mnie dotarło) użytkownik zamiast pisać wagi posiłku w gramach w osobnym polu, mógłby pisać po prostu ‘a cup of tea’ albo ‘bowl of tomato soup’. I to rzeczywiście działa! Oczywiście jeśli podamy gramy, wszystkie wartości odżywcze zostają przeliczone ile ich jest rzeczywiście w takiej ilości danego pokarmu. Rozwiązanie fenomenalne - wystarczyło tylko wysyłać do niego wynik rozpoznanego obrazu.
Krótka uwaga - wynik z AWS Rekognition wysyłałem spowrotem do aplikacji, tak aby użytkownik mógł skorygować, to co zostało wygenerowane - czasem zdarzało się, że wynikiem było po prostu “Food”, wtedy Nutritionix nie miał za wiele do powiedzenia. W takim wypadku użytkownik mógł manualnie wprowadzić nazwę posiłku i zatwierdzić.
Faraday
Do odpytywania zewnętrzengo API wykorzystałem gem Faraday
. I tak, tutaj pozwoliłem już sobie na mały refactor po weekendzie, stworzyłem moduł Nutritionix
:
# lib/nutritionix.rb
require 'faraday'
require 'json'
module Nutritionix
module Caller
def nutritionix_connection
Faraday.new(:url => 'https://trackapi.nutritionix.com')
end
def search_for(meal)
query = "#{meal.name} + #{meal.weight}g"
conn = nutritionix_connection
request = conn.post do |req|
req.url "/v2/natural/nutrients"
req.body = { 'query': query }
req.headers['x-app-id'] = Rails.application.credentials.x_app_id
req.headers['x-app-key'] = Rails.application.credentials.x_app_key
req.headers['x-remote-user-id'] = '0'
end
JSON.parse(request.body)
end
end
module Parser
def parse_nutritionix_object(nutri_obj)
data = nutri_obj['foods'][0]
result = {
calories: data['nf_calories'],
total_fat: data['nf_total_fat'],
cholesterol: data['nf_cholesterol'],
sodium: data['nf_sodium'],
total_carbohydrate: data['nf_total_carbohydrate'],
dietary_fiber: data['nf_dietary_fiber'],
sugar: data['nf_sugars'],
protein: data['nf_protein'],
potasium: data['nf_potassium'],
water: data['full_nutrients'].select
{|data| data['attr_id'] == 255}[0]['value']
}
end
end
end
Trochę sporo się dzieje, ale nie ma tu nic skomplikowanego. (attr_id == 255 dlatego, że takie id miała właśnie woda) W Nutritionix::Caller
najpierw ‘ustanawiam’ połączenie z API i zwracam wynik zapytania (połączenie nazwy posiłku z jego wagą), a w Nutritionix::Parser
porządkuję odpowiedź, żeby była bardziej przystępna dla aplikacji mobilnej. Nutritionix zwraca bardzo dużo informacji, których nie do końca potrzebowaliśmy w tym momencie, więc po prostu nie puszczałem ich dalej.
W kontrolerze wywoływałem tylko metody z modelu, które odwoływały się do modułu powyżej.
# controller meals_controller.rb
def show
json_response(@meal.name, true,
{ meal: @meal, nutrients: @meal.view_nutrients }, :ok)
end
# model meal.rb
include Nutritionix::Caller
include Nutritionix::Parser
def view_nutrients
parse_nutritionix_object(get_nutrients)
end
def get_nutrients
search_for(self)
end
Tym oto sposobem do apki leciały wartości odżywcze naszego posiłku :)