diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..bd1dc50 --- /dev/null +++ b/.env.sample @@ -0,0 +1 @@ +OPENAI_API_KEY=sk-1234567890abcdef1234567890abcdef diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e711ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..11ce296 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# AI Calorie Counter App + +This GPT-4o powered Flask web app tells you how many calories there are in an image of a meal you upload + +## Quick Start + +1. Start the server: + +```sh +$ python3 server.py +``` + +2. Go to http://localhost:5000 + + +## Terminal usage + +You can also use it from the terminal: + +```sh +$ python3 calorie_counter.py IMAGE_FILE +``` diff --git a/calorie_counter.py b/calorie_counter.py new file mode 100644 index 0000000..2c607d6 --- /dev/null +++ b/calorie_counter.py @@ -0,0 +1,59 @@ +from openai import OpenAI +from dotenv import load_dotenv +import base64 +import json +import sys + +load_dotenv() +client = OpenAI() + +def get_calories_from_image(image_path): + with open(image_path, "rb") as image: + base64_image = base64.b64encode(image.read()).decode("utf-8") + + response = client.chat.completions.create( + model="gpt-4o", + response_format={"type": "json_object"}, + messages=[ + { + "role": "system", + "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: + +{ + "reasoning": "reasoning for the total calories", + "food_items": [ + { + "name": "food item name", + "calories": "calories in the food item" + } + ], + "total": "total calories in the meal" +}""" + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "How many calories is in this meal?" + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}" + } + } + ] + }, + ], + ) + + response_message = response.choices[0].message + content = response_message.content + + return json.loads(content) + +if __name__ == "__main__": + image_path = sys.argv[1] + calories = get_calories_from_image(image_path) + print(json.dumps(calories, indent=4)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8a30ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +openai +flask +python-dotenv diff --git a/server.py b/server.py new file mode 100644 index 0000000..f7cdf7f --- /dev/null +++ b/server.py @@ -0,0 +1,32 @@ +from flask import Flask, render_template, request +import tempfile + +from calorie_counter import get_calories_from_image + +app = Flask(__name__) + +@app.route("/") +def index(): + return render_template("index.html") + +@app.route("/upload", methods=["POST"]) +def upload(): + image = request.files["image"] + + if image.filename == "": + return { + "error": "No image uploaded", + }, 400 + + temp_file = tempfile.NamedTemporaryFile() + image.save(temp_file.name) + + calories = get_calories_from_image(temp_file.name) + temp_file.close() + + return { + "calories": calories, + } + +if __name__ == "__main__": + app.run(debug=True) diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..2fd98d4 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,60 @@ +body { + margin: 0; + padding: 0; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: #f5f5f5; + font-family: Arial, sans-serif; +} +.container { + display: flex; + flex-direction: column; + align-items: center; + align-content: center; + justify-content: center; + background-color: white; + border-radius: 15px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + padding: 20px; + width: 200px; + height: 200px; +} +#calorie-count { + width: 60%; + height: 20px; + border: 1px solid #ccc; + border-radius: 5px; + text-align: center; + margin-top: 20px; +} +#upload { + border: none; + outline: none; + padding: 0; + cursor: pointer; + background: none; +} +#upload img { + width: 90px; +} + +#spinner { + display: none; + width: 50px; + height: 50px; + border: 5px solid #f3f3f3; + border-top: 5px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/static/images/camera.png b/static/images/camera.png new file mode 100644 index 0000000..09c39ce Binary files /dev/null and b/static/images/camera.png differ diff --git a/static/js/script.js b/static/js/script.js new file mode 100644 index 0000000..b8f59c7 --- /dev/null +++ b/static/js/script.js @@ -0,0 +1,41 @@ +const upload_button = document.querySelector("#upload"); +const calorie_count = document.querySelector("#calorie-count"); + +upload_button.addEventListener("click", () => { + // ask to upload a file or take an image + const file_input = document.createElement("input"); + file_input.type = "file"; + file_input.accept = "image/*"; + file_input.click(); + + file_input.addEventListener("change", () => { + loading(); + + const file = file_input.files[0]; + + const fd = new FormData(); + fd.append("image", file); + + fetch("/upload", { + method: "POST", + body: fd + }).then(response => response.json()) + .then(data => { + stop_loading(); + calorie_count.textContent = data.calories.total + }); + }); +}); + + +function loading() { + document.querySelector("#upload").style.display = "none"; + document.querySelector("#calorie-count").style.display = "none"; + document.querySelector("#spinner").style.display = "block"; +} + +function stop_loading() { + document.querySelector("#spinner").style.display = "none"; + document.querySelector("#upload").style.display = "block"; + document.querySelector("#calorie-count").style.display = "block"; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..b31bed8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,17 @@ + + +
+ + +