├── .env.sample ├── .gitignore ├── README.md ├── calorie_counter.py ├── config ├── README.md ├── calorieapp.service └── calorieapp_apache.conf ├── requirements.txt ├── server.py ├── static ├── css │ └── style.css ├── images │ └── camera.png └── js │ └── script.js ├── templates └── index.html └── test_images ├── DALL·E 2024-05-23 08.13.42 - A realistic image of a healthy lunch consisting of a grilled chicken salad with mixed greens, cherry tomatoes, sliced cucumbers, and a light vinaigret.webp ├── DALL·E 2024-05-23 08.16.12 - A photo-realistic image depicting a healthy lunch arranged on a plate, as if taken with a smartphone. The lunch consists of a colorful salad with mixe.webp ├── DALL·E 2024-05-23 08.17.46 - A realistic image of a delicious burger meal. The burger is large, with a glossy sesame seed bun, layers of fresh lettuce, sliced tomatoes, juicy beef.webp └── DALL·E 2024-05-23 08.19.51 - A realistic image of an unhealthy lunch meal consisting of a greasy cheeseburger with melted cheddar oozing out, a large side of golden, crispy french.webp /.env.sample: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=sk-1234567890abcdef1234567890abcdef 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | __pycache__/ 3 | venv/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Calorie Counter App 2 | 3 | This GPT-4o powered Flask web app tells you how many calories there are in an image of a meal you upload 4 | 5 | ## Quick Start 6 | 7 | 0. Rename the `.env.sample` file into `.env` and add your OpenAI API key 8 | 9 | 1. Start the server: 10 | 11 | ```sh 12 | $ python3 server.py 13 | ``` 14 | 15 | 2. Go to http://localhost:5000 16 | 17 | 18 | ## Terminal usage 19 | 20 | You can also use it from the terminal: 21 | 22 | ```sh 23 | $ python3 calorie_counter.py IMAGE_FILE 24 | ``` 25 | -------------------------------------------------------------------------------- /calorie_counter.py: -------------------------------------------------------------------------------- 1 | from openai import OpenAI 2 | from dotenv import load_dotenv 3 | import base64 4 | import json 5 | import sys 6 | 7 | load_dotenv() 8 | client = OpenAI() 9 | 10 | def get_calories_from_image(image_path): 11 | with open(image_path, "rb") as image: 12 | base64_image = base64.b64encode(image.read()).decode("utf-8") 13 | 14 | response = client.chat.completions.create( 15 | model="gpt-4o", 16 | response_format={"type": "json_object"}, 17 | messages=[ 18 | { 19 | "role": "system", 20 | "content": """You are a dietitian. A user sends you an image of a meal and you tell them how many calories are in it. Use the following JSON format: 21 | 22 | { 23 | "reasoning": "reasoning for the total calories", 24 | "food_items": [ 25 | { 26 | "name": "food item name", 27 | "calories": "calories in the food item" 28 | } 29 | ], 30 | "total": "total calories in the meal" 31 | }""" 32 | }, 33 | { 34 | "role": "user", 35 | "content": [ 36 | { 37 | "type": "text", 38 | "text": "How many calories is in this meal?" 39 | }, 40 | { 41 | "type": "image_url", 42 | "image_url": { 43 | "url": f"data:image/jpeg;base64,{base64_image}" 44 | } 45 | } 46 | ] 47 | }, 48 | ], 49 | ) 50 | 51 | response_message = response.choices[0].message 52 | content = response_message.content 53 | 54 | return json.loads(content) 55 | 56 | if __name__ == "__main__": 57 | image_path = sys.argv[1] 58 | calories = get_calories_from_image(image_path) 59 | print(json.dumps(calories, indent=4)) 60 | -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 | ## Deployment instructions 2 | 3 | Here's how you can deploy the app on an AWS EC2 instance (Ubuntu 24.04) 4 | 5 | Install required packages 6 | 7 | ```sh 8 | $ sudo apt update && sudo apt install python3.12-venv python3-pip apache2 9 | ``` 10 | 11 | Create a directory for the project and set up permissions. 12 | 13 | ```sh 14 | $ sudo mkdir /srv/calorieapp 15 | $ sudo chown ubuntu:ubuntu /srv/calorieapp/ 16 | ``` 17 | 18 | Install Python virtual environment and requirements 19 | 20 | ```sh 21 | $ cd /srv/calorieapp/ 22 | $ python3 -m venv venv 23 | $ source venv/bin/activate 24 | $ pip install -r requirements.txt 25 | ``` 26 | 27 | Add Gunicorn user and set up permissions 28 | 29 | ```sh 30 | $ sudo adduser --system --no-create-home --group gunicorn 31 | $ sudo chown -R gunicorn:gunicorn /srv/calorieapp/ 32 | ``` 33 | 34 | Install background service 35 | 36 | ```sh 37 | $ sudo cp config/calorieapp.service /etc/systemd/system/ 38 | $ sudo systemctl daemon-reload 39 | $ sudo systemctl enable calorieapp 40 | $ sudo service calorieapp start 41 | $ sudo service calorieapp status 42 | ``` 43 | 44 | Install Apache configuration 45 | 46 | ```sh 47 | $ sudo cp config/calorieapp_apache.conf /etc/apache2/sites-available/ 48 | $ sudo a2enmod proxy proxy_http 49 | $ sudo a2ensite calorieapp_apache.conf 50 | $ sudo service apache2 reload 51 | ``` 52 | 53 | Install Let's Encrypt SSL certificate 54 | 55 | ```sh 56 | $ sudo snap install --classic certbot 57 | $ sudo ln -s /snap/bin/certbot /usr/bin/certbot 58 | $ sudo certbot --apache 59 | ``` 60 | 61 | Set up cron job for automatic SSL certificate renewal 62 | 63 | ```sh 64 | $ sudo crontab -e 65 | ``` 66 | 67 | Add this to crontab: 68 | 69 | ``` 70 | 0 4 * * 1 /usr/bin/certbot --renew && /usr/sbin/service apache2 reload 71 | ``` 72 | 73 | Crate swapfile (bonus) 74 | 75 | ```sh 76 | $ sudo fallocate -l 1G /swapfile 77 | $ sudo chmod 0600 /swapfile 78 | $ sudo mkswap /swapfile 79 | $ sudo swapon /swapfile 80 | ``` 81 | 82 | Add swapfile to `/etc/fstab` to persist over boot 83 | 84 | ```sh 85 | $ echo "/swapfile none swap sw 0 0" > /etc/fstab 86 | ``` 87 | -------------------------------------------------------------------------------- /config/calorieapp.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Calorie App Server 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/srv/calorieapp/venv/bin/gunicorn server:app -w 4 -b 127.0.0.1:8000 7 | WorkingDirectory=/srv/calorieapp/ 8 | Restart=on-failure 9 | User=gunicorn 10 | Group=gunicorn 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /config/calorieapp_apache.conf: -------------------------------------------------------------------------------- 1 | 2 | ServerName howmanycaloriesisthis.com 3 | ServerAlias www.howmanycaloriesisthis.com 4 | 5 | ProxyPass / http://127.0.0.1:8000/ 6 | ProxyPassReverse / http://127.0.0.1:8000/ 7 | 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | openai 2 | flask 3 | python-dotenv 4 | gunicron 5 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request 2 | import tempfile 3 | 4 | from calorie_counter import get_calories_from_image 5 | 6 | app = Flask(__name__) 7 | 8 | @app.route("/") 9 | def index(): 10 | return render_template("index.html") 11 | 12 | @app.route("/upload", methods=["POST"]) 13 | def upload(): 14 | image = request.files["image"] 15 | 16 | if image.filename == "": 17 | return { 18 | "error": "No image uploaded", 19 | }, 400 20 | 21 | temp_file = tempfile.NamedTemporaryFile() 22 | image.save(temp_file.name) 23 | 24 | calories = get_calories_from_image(temp_file.name) 25 | temp_file.close() 26 | 27 | return { 28 | "calories": calories, 29 | } 30 | 31 | if __name__ == "__main__": 32 | app.run(debug=True) 33 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | height: 100vh; 8 | background-color: #f5f5f5; 9 | font-family: Arial, sans-serif; 10 | } 11 | .container { 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | align-content: center; 16 | justify-content: center; 17 | background-color: white; 18 | border-radius: 15px; 19 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 20 | padding: 20px; 21 | width: 200px; 22 | height: 200px; 23 | text-align: center; 24 | } 25 | #calorie-count { 26 | width: 60%; 27 | height: 20px; 28 | border: 1px solid #ccc; 29 | border-radius: 5px; 30 | text-align: center; 31 | margin-top: 20px; 32 | } 33 | #upload { 34 | border: none; 35 | outline: none; 36 | padding: 0; 37 | cursor: pointer; 38 | background: none; 39 | } 40 | #upload img { 41 | width: 90px; 42 | } 43 | 44 | #spinner { 45 | display: none; 46 | width: 50px; 47 | height: 50px; 48 | border: 5px solid #f3f3f3; 49 | border-top: 5px solid #3498db; 50 | border-radius: 50%; 51 | animation: spin 1s linear infinite; 52 | } 53 | 54 | @keyframes spin { 55 | 0% { 56 | transform: rotate(0deg); 57 | } 58 | 100% { 59 | transform: rotate(360deg); 60 | } 61 | } 62 | 63 | h2 { 64 | text-transform: uppercase; 65 | font-size: 1.1em; 66 | margin: 0 0 10px 0; 67 | } 68 | 69 | p { 70 | font-size: 0.7em; 71 | margin: 0 0 10px 0; 72 | width: 150px; 73 | } -------------------------------------------------------------------------------- /static/images/camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unconv/calorieapp/640379b660ef2ee64465c8e8c6d1f6ccd7d90895/static/images/camera.png -------------------------------------------------------------------------------- /static/js/script.js: -------------------------------------------------------------------------------- 1 | const upload_button = document.querySelector("#upload"); 2 | const calorie_count = document.querySelector("#calorie-count"); 3 | 4 | upload_button.addEventListener("click", () => { 5 | // ask to upload a file or take an image 6 | const file_input = document.createElement("input"); 7 | file_input.type = "file"; 8 | file_input.accept = "image/*"; 9 | file_input.click(); 10 | 11 | file_input.addEventListener("change", () => { 12 | loading(); 13 | 14 | const file = file_input.files[0]; 15 | 16 | const fd = new FormData(); 17 | fd.append("image", file); 18 | 19 | fetch("/upload", { 20 | method: "POST", 21 | body: fd 22 | }).then(response => response.json()) 23 | .then(data => { 24 | stop_loading(); 25 | calorie_count.textContent = data.calories.total 26 | }); 27 | }); 28 | }); 29 | 30 | 31 | function loading() { 32 | document.querySelector("#upload").style.display = "none"; 33 | document.querySelector("#calorie-count").style.display = "none"; 34 | document.querySelector("#spinner").style.display = "block"; 35 | } 36 | 37 | function stop_loading() { 38 | document.querySelector("#spinner").style.display = "none"; 39 | document.querySelector("#upload").style.display = "block"; 40 | document.querySelector("#calorie-count").style.display = "block"; 41 | } 42 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Calorie Counter 7 | 8 | 9 | 10 |
11 |

Calorie Counter

12 |

Upload a meal image to calculate its calories

13 | 14 |
15 |
16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test_images/DALL·E 2024-05-23 08.13.42 - A realistic image of a healthy lunch consisting of a grilled chicken salad with mixed greens, cherry tomatoes, sliced cucumbers, and a light vinaigret.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unconv/calorieapp/640379b660ef2ee64465c8e8c6d1f6ccd7d90895/test_images/DALL·E 2024-05-23 08.13.42 - A realistic image of a healthy lunch consisting of a grilled chicken salad with mixed greens, cherry tomatoes, sliced cucumbers, and a light vinaigret.webp -------------------------------------------------------------------------------- /test_images/DALL·E 2024-05-23 08.16.12 - A photo-realistic image depicting a healthy lunch arranged on a plate, as if taken with a smartphone. The lunch consists of a colorful salad with mixe.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unconv/calorieapp/640379b660ef2ee64465c8e8c6d1f6ccd7d90895/test_images/DALL·E 2024-05-23 08.16.12 - A photo-realistic image depicting a healthy lunch arranged on a plate, as if taken with a smartphone. The lunch consists of a colorful salad with mixe.webp -------------------------------------------------------------------------------- /test_images/DALL·E 2024-05-23 08.17.46 - A realistic image of a delicious burger meal. The burger is large, with a glossy sesame seed bun, layers of fresh lettuce, sliced tomatoes, juicy beef.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unconv/calorieapp/640379b660ef2ee64465c8e8c6d1f6ccd7d90895/test_images/DALL·E 2024-05-23 08.17.46 - A realistic image of a delicious burger meal. The burger is large, with a glossy sesame seed bun, layers of fresh lettuce, sliced tomatoes, juicy beef.webp -------------------------------------------------------------------------------- /test_images/DALL·E 2024-05-23 08.19.51 - A realistic image of an unhealthy lunch meal consisting of a greasy cheeseburger with melted cheddar oozing out, a large side of golden, crispy french.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unconv/calorieapp/640379b660ef2ee64465c8e8c6d1f6ccd7d90895/test_images/DALL·E 2024-05-23 08.19.51 - A realistic image of an unhealthy lunch meal consisting of a greasy cheeseburger with melted cheddar oozing out, a large side of golden, crispy french.webp --------------------------------------------------------------------------------