Wills17 commited on
Commit
a0657cc
·
verified ·
1 Parent(s): 5d36bbb

Upload 16 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10
2
+
3
+ # workdir
4
+ WORKDIR /app
5
+
6
+ # System dependencies
7
+ RUN apt-get update && apt-get install -y git build-essential
8
+
9
+ # Copy project
10
+ COPY . .
11
+
12
+ # install Python deps
13
+ RUN pip install --upgrade pip
14
+ RUN pip install -r requirements.txt
15
+
16
+
17
+ ENV PORT=7860
18
+ EXPOSE 7860
19
+
20
+ CMD ["uvicorn", "app.FastAPI_app:app", "--host", "0.0.0.0", "--port", "7860"]
FastAPI_app.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI application for Fridge2Dish
2
+
3
+ # import libraries
4
+ import os
5
+ import io
6
+ import numpy as np
7
+ import traceback
8
+ from dotenv import load_dotenv
9
+ import time
10
+ import uvicorn
11
+
12
+
13
+ # Heavy imports
14
+ import tensorflow as tf
15
+ from PIL import Image
16
+ from fastapi import FastAPI, Form, UploadFile, File, Request, HTTPException
17
+ from fastapi.responses import HTMLResponse
18
+ from fastapi.staticfiles import StaticFiles
19
+ from fastapi.templating import Jinja2Templates
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ import google.generativeai as genai
22
+
23
+
24
+ # Load environment variables from .env file
25
+ load_dotenv(True)
26
+
27
+
28
+ # Load model (global) once startup.
29
+ MODEL_PATH = "models/ingredient_model.h5"
30
+ MODEL = tf.keras.models.load_model(MODEL_PATH)
31
+
32
+ # Class names
33
+ CLASS_NAMES = sorted(os.listdir("dataset/dataset_2/train"))
34
+
35
+ # Infer uploaded image function
36
+ def infer_image(pil_image):
37
+ img = pil_image.resize((224, 224))
38
+ IMG = np.expand_dims(np.array(img) / 255.0, axis=0)
39
+
40
+ preds = MODEL.predict(IMG)[0]
41
+
42
+ top_idxs = np.argsort(preds)[::-1][:3]
43
+
44
+ # ingredient list
45
+ ingredients = []
46
+
47
+ for i in top_idxs:
48
+ confidence = float(preds[i])
49
+
50
+ ingredients.append({
51
+ "name": CLASS_NAMES[i].capitalize(),
52
+ "confidence": confidence
53
+ })
54
+
55
+ # Limit to top 5 ingredients
56
+ if len(ingredients) >= 5:
57
+ break
58
+
59
+ # incase of no prediction.
60
+ if not ingredients:
61
+ return [{"name": "unknown", "confidence": 0.0}]
62
+
63
+ return ingredients
64
+
65
+
66
+ # Fallback Recipe Generator -> GPT-2
67
+ def generate_recipe_local(ingredient_names):
68
+ ingredients = ", ".join(ingredient_names)
69
+ return f"""
70
+ # Simple Local Fallback Recipe
71
+
72
+ Since no API key was provided, here is a simple offline recipe.
73
+
74
+ ## Ingredients
75
+ - {ingredients}
76
+
77
+ ## Steps
78
+ 1. Combine {ingredients} in a bowl.
79
+ 2. Add salt and seasoning as desired.
80
+ 3. Cook for 10 minutes.
81
+ 4. Serve warm.
82
+
83
+ *(Generated locally without external AI models.)*
84
+ """.strip()
85
+
86
+
87
+ # initialize FastAPI app
88
+ app = FastAPI(
89
+ title="Fridge2Dish API",
90
+ description="Upload an image → Detect ingredients → Generate recipes",
91
+ version="2.0.0"
92
+ )
93
+
94
+ # Serve static files
95
+ app.mount("/static", StaticFiles(directory="static"), name="static")
96
+ templates = Jinja2Templates(directory="templates")
97
+
98
+ # CORS
99
+ app.add_middleware(
100
+ CORSMiddleware,
101
+ allow_origins=["*"],
102
+ allow_credentials=True,
103
+ allow_methods=["*"],
104
+ allow_headers=["*"],
105
+ )
106
+
107
+
108
+ # ROUTES
109
+
110
+ # Home Route
111
+ @app.get("/", response_class=HTMLResponse)
112
+ def home(request: Request):
113
+ return templates.TemplateResponse("index.html", {"request": request})
114
+
115
+
116
+ # upload-image route
117
+ @app.post("/upload-image/")
118
+ async def upload_image(
119
+ file: UploadFile = File(...),
120
+ user_api_key: str = Form(alias="api_key", default="")
121
+ ):
122
+
123
+ try:
124
+ # check image file
125
+ if not file.filename.lower().endswith((".jpg", ".jpeg", ".png")):
126
+ raise HTTPException(status_code=400, detail="Invalid image format.")
127
+
128
+ # Load image
129
+ img_bytes = await file.read()
130
+ pil_img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
131
+ print("\nImage loaded successfully.")
132
+
133
+ # Detect ingredients
134
+ start_time = time.time()
135
+ ingredients = infer_image(pil_img)
136
+ end_time = time.time()
137
+
138
+ print(f"Ingredient detection took {end_time - start_time:.2f} seconds")
139
+
140
+ print(f"Detected ingredients: {ingredients}")
141
+
142
+ if not ingredients:
143
+ return {"ingredients": [],
144
+ "recipe": "No ingredients detected, Try to take a clearer picture."}
145
+
146
+
147
+ ingredient_names = [item["name"] for item in ingredients]
148
+
149
+
150
+ # Recipe generation using Gemini
151
+ # Get api key from user input
152
+ api_key = user_api_key.strip()
153
+
154
+ if api_key:
155
+
156
+ try:
157
+ # Try Gemini first...
158
+ genai.configure(api_key=api_key)
159
+ model = genai.GenerativeModel("gemini-2.5-flash")
160
+
161
+ prompt = f"""
162
+ You are an AI chef. Create a short recipe using only: {', '.join(ingredient_names)}.
163
+ Include:
164
+ - Recipe name
165
+ - One-sentence description
166
+ - Ingredients list with quantities
167
+ - 6-10 concise steps
168
+ - Optional fun tips or variations
169
+ Make it easy to follow and appetizing!
170
+
171
+ Do not include any lines like "Sure! Here's a recipe...", "Here's a simple..." or similar.
172
+ Return results in markdown format.
173
+ """
174
+
175
+ print("\n\nTrying Gemini...")
176
+ response = model.generate_content(prompt)
177
+ recipe_text = response.text.strip()
178
+ # print(recipe_text)
179
+
180
+
181
+ except Exception as e1:
182
+ print("\nGemini failed. Falling to GPT-2:", e1)
183
+ recipe_text = generate_recipe_local(ingredient_names)
184
+
185
+ else:
186
+ # Since no api_key -> fallback to GPT-2
187
+ print("\n\nNo API key provided. Using GPT-2 to generate recipe")
188
+ recipe_text = generate_recipe_local(ingredient_names)
189
+
190
+ # results
191
+ return {
192
+ "ingredients": ingredients,
193
+ "recipe": recipe_text,
194
+ }
195
+
196
+ except Exception as e2:
197
+ traceback.print_exc()
198
+ raise HTTPException(status_code=500, detail=f"Server Error: {str(e2)}")
199
+
200
+
201
+ # Health check
202
+ @app.get("/health")
203
+ def health():
204
+ return {"status": "ok"}
205
+
206
+
207
+ # Run app
208
+ if __name__ == "__main__":
209
+ uvicorn.run(app, host="0.0.0.0", port=7860)
README.md CHANGED
@@ -1,10 +1 @@
1
- ---
2
- title: Fridge2Dish
3
- emoji: 👁
4
- colorFrom: green
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ ## Still in progress.
 
 
 
 
 
 
 
 
 
detector/infer.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from tensorflow.keras.applications.mobilenet_v2 import MobileNetV2, preprocess_input, decode_predictions
3
+ from tensorflow.keras.preprocessing import image as keras_image
4
+ import numpy as np
5
+ import json
6
+ import io
7
+ from PIL import Image
8
+ import os
9
+
10
+ # load model once
11
+ _model = MobileNetV2(weights="imagenet")
12
+
13
+ # simple mapping from some ImageNet labels to fridge ingredients
14
+ _MAPPING = {
15
+ "egg": ["egg"],
16
+ "banana": ["banana"],
17
+ "lemon": ["lemon"],
18
+ "orange": ["orange"],
19
+ "apple": ["apple"],
20
+ "cheeseburger": ["cheese","bun","patty"],
21
+ "pizza": ["cheese","tomato","dough"],
22
+ "loaf": ["bread"],
23
+ "bagel": ["bread"],
24
+ "butter": ["butter"],
25
+ "milk_can": ["milk"],
26
+ "yogurt": ["yogurt"],
27
+ "strawberry": ["strawberry","fruit"],
28
+ "cucumber": ["cucumber"],
29
+ "tomato": ["tomato"],
30
+ "onion": ["onion"],
31
+ "potato": ["potato"],
32
+ "carrot": ["carrot"],
33
+ }
34
+
35
+ def infer_image(pil_image, top_k=3):
36
+ """Return list of guessed ingredients from a PIL image (placeholder)."""
37
+ img = pil_image.resize((224,224))
38
+ x = np.array(img)[...,:3]
39
+ x = np.expand_dims(x, axis=0)
40
+ x = preprocess_input(x.astype("float32"))
41
+ preds = _model.predict(x)
42
+ decoded = decode_predictions(preds, top=top_k)[0] # list of (class, name, prob)
43
+ ingredients = []
44
+ for _, name, prob in decoded:
45
+ # normalize name (ImageNet labels can be like "red_wine")
46
+ key = name.split(",")[0].split("_")[0]
47
+ if name in _MAPPING:
48
+ ingredients.extend(_MAPPING[name])
49
+ elif key in _MAPPING:
50
+ ingredients.extend(_MAPPING[key])
51
+
52
+ # Use raw name if it looks like a food
53
+ else:
54
+ # small heuristic to include food-like names
55
+ food_keywords = ["egg","tomato","cheese","milk","bread","onion","potato","banana","apple","lemon","orange","butter","yogurt","strawberry","cucumber","carrot"]
56
+ for kw in food_keywords:
57
+ if kw in name:
58
+ ingredients.append(kw)
59
+
60
+ # deduplicate and limit
61
+ ingredients = list(dict.fromkeys(ingredients))
62
+ return ingredients if ingredients else ["unknown"]
detector/infer2.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import tensorflow as tf
3
+ from tensorflow.keras.preprocessing import image
4
+ import numpy as np
5
+ import os
6
+
7
+ MODEL_PATH = "models/ingredient_model_2.h5"
8
+ MODEL = tf.keras.models.load_model(MODEL_PATH)
9
+ CLASS_NAMES = sorted(os.listdir("dataset/dataset_2/train")) # folder names = class names
10
+
11
+
12
+ def infer_image(pil_image):
13
+
14
+ # Preprocess
15
+ img = pil_image.resize((224, 224))
16
+ IMG = np.expand_dims(np.array(img) / 255.0, axis=0)
17
+
18
+ # Model prediction and probabilities
19
+ preds = MODEL.predict(IMG)[0]
20
+
21
+ # Use top predictions
22
+ top_idxs = np.argsort(preds)[::-1][:3]
23
+
24
+ # Build ingredient list
25
+ ingredients = []
26
+
27
+ for i in top_idxs:
28
+ confidence = float(preds[i])
29
+ if confidence < 0.20:
30
+ continue # skip ingredients with confidence less than 20%
31
+
32
+ ingredients.append({
33
+ "name": CLASS_NAMES[i],
34
+ "confidence": confidence
35
+ })
36
+
37
+ # Limit to top 3–5 ingredients if you want
38
+ if len(ingredients) >= 5:
39
+ break
40
+
41
+ # incase of no prediction.
42
+ if not ingredients:
43
+ return [{"name": "unknown", "confidence": 0.0}]
44
+
45
+ return ingredients
detector/train.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
download_images.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # download images for specific labels with bing_image_downloader library
2
+
3
+ from bing_image_downloader import downloader
4
+
5
+ labels = ["egg", "tomato", "onion", "carrot", "milk", "bread",
6
+ "banana", "apple", "cheese", "potato", "butter", "lemon", "yogurt"]
7
+
8
+ for label in labels:
9
+ downloader.download(label, limit=80, output_dir='dataset', adult_filter_off=True)
models/ingredient_model.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bd3752319812cd2aced247d36aebe32cd205d2b603b96429b7f2c55279456074
3
+ size 11428600
recipe_generator/recipe_local.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Recipe generator using Hugging Face Transformers
2
+
3
+ # import libraries
4
+ from transformers import pipeline
5
+
6
+ # initialize text generation pipeline
7
+ generator = pipeline("text-generation", model="gpt2", trust_remote_code=True)
8
+
9
+ # generate recipe function
10
+ def generate_recipe(ingredients):
11
+ prompt = f"Create a short creative recipe using {', '.join(ingredients)}:\n\n"
12
+ result = generator(prompt, max_length=120, num_return_sequences=1, temperature=0.9)
13
+ return result[0]['generated_text'].split("\n")[0]
14
+
recipe_generator/recipe_math.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Rule-based recipe generator
2
+
3
+ RECIPE_TEMPLATES = [
4
+ {
5
+ "name": "Quick Omelette",
6
+ "requires": ["egg"],
7
+ "steps": [
8
+ "Beat the eggs in a bowl, add salt and pepper.",
9
+ "Chop any veggies (tomato, onion, pepper) and fold into the eggs.",
10
+ "Heat a pan, add butter or oil, cook on medium-low until set."
11
+ ]
12
+ },
13
+ {
14
+ "name": "Tomato Toast",
15
+ "requires": ["bread","tomato"],
16
+ "steps": [
17
+ "Toast the bread.",
18
+ "Slice tomatoes, season with salt, pepper, a splash of oil.",
19
+ "Top the toast with tomato slices and serve."
20
+ ]
21
+ },
22
+ {
23
+ "name": "Milk & Fruit Bowl",
24
+ "requires": ["milk","banana","strawberry"],
25
+ "steps": [
26
+ "Slice fruit into a bowl.",
27
+ "Pour milk or yogurt over it. Add honey or sugar if desired."
28
+ ]
29
+ },
30
+ {
31
+ "name": "Veggie Stir Fry",
32
+ "requires": ["onion","carrot","tomato"],
33
+ "steps": [
34
+ "Heat oil in a pan, saute chopped onion until translucent.",
35
+ "Add chopped carrot and other veggies, stir fry until tender.",
36
+ "Season with soy sauce or salt and serve with bread or rice."
37
+ ]
38
+ }
39
+ ]
40
+
41
+ def generate_recipe(ingredients):
42
+ ingredients = [i.lower() for i in ingredients]
43
+ # try to match template
44
+ for t in RECIPE_TEMPLATES:
45
+ if any(req in ingredients for req in t["requires"]):
46
+ ingr_list = ", ".join([i for i in ingredients if i in sum([t["requires"]],[]) or True][:5])
47
+ steps = "\n".join([f"{i+1}. {s}" for i,s in enumerate(t["steps"])])
48
+ return f"**{t['name']}**\nIngredients I saw: {', '.join(ingredients)}\n\nSteps:\n{steps}"
49
+ # fallback
50
+ return f"Can't find a perfect match. You have: {', '.join(ingredients)}. Try: scrambled eggs, toast, or a simple salad."
recipe_generator/recipe_online.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Recipe generator using Gemini API.
2
+
3
+ # import libraries
4
+ import os
5
+ import google.generativeai as genai
6
+ from dotenv import load_dotenv
7
+
8
+
9
+ # Load environment variables from .env file
10
+ load_dotenv()
11
+
12
+ # Configure Gemini client
13
+ api_key = os.getenv("GEMINI_API_KEY")
14
+ genai.configure(api_key=api_key)
15
+
16
+ # generate recipe function
17
+ def generate_recipe(ingredients):
18
+ model = genai.GenerativeModel("gemini-2.5-flash")
19
+
20
+ prompt = f"""
21
+ You are an AI chef. Create a short recipe using only: {', '.join(ingredients)}.
22
+ Include:
23
+ - Recipe name
24
+ - One-sentence description
25
+ - Ingredients list with quantities
26
+ - 6-10 concise steps
27
+ - Optional fun tips or variations
28
+ Make it easy to follow and appetizing!
29
+
30
+ Do not include any lines like "Sure! Here's a recipe...", "Here's a simple..." or similar.
31
+ """
32
+
33
+ response = model.generate_content(prompt)
34
+ print(response.text.strip())
35
+ return response.text.strip()
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit
2
+ fastapi
3
+ uvicorn[standard]
4
+ tensorflow-cpu==2.13.0
5
+ transformers==4.39.3
6
+ torch==2.1.0
7
+ pillow
8
+ python-dotenv
9
+ google-generativeai
10
+ numpy
static/scripts.js ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // State management
2
+ const state = {
3
+ uploadedImage: null,
4
+ detectedIngredients: [],
5
+ recipes: [],
6
+ isProcessing: false,
7
+ geminiApiKey: "",
8
+ skipApiKeyWarning: false
9
+ };
10
+
11
+ // DOM elements
12
+ const elements = {
13
+ apiKeyInput: document.getElementById('apiKey'),
14
+ uploadArea: document.getElementById('uploadArea'),
15
+ fileInput: document.getElementById('fileInput'),
16
+ previewSection: document.getElementById('previewSection'),
17
+ previewImage: document.getElementById('previewImage'),
18
+ resetButton: document.getElementById('resetButton'),
19
+ scanButton: document.getElementById('scanButton'),
20
+ scanButtonText: document.getElementById('scanButtonText'),
21
+ heroSection: document.getElementById('heroSection'),
22
+ resultsSection: document.getElementById('resultsSection'),
23
+ ingredientsList: document.getElementById('ingredientsList'),
24
+ recipesSection: document.getElementById('recipesSection'),
25
+ recipesList: document.getElementById('recipesList'),
26
+ darkModeToggle: document.getElementById('darkModeToggle')
27
+ };
28
+
29
+ // Initialize app
30
+ function init() {
31
+ setupEventListeners();
32
+ loadStoredPreferences();
33
+ loadDarkModePreference();
34
+ }
35
+
36
+ // Load stored API key and preferences
37
+ function loadStoredPreferences() {
38
+ const savedKey = localStorage.getItem('geminiApiKey');
39
+ const skipWarning = localStorage.getItem('skipApiKeyWarning') === 'true';
40
+
41
+ if (savedKey) {
42
+ state.geminiApiKey = savedKey;
43
+ elements.apiKeyInput.value = savedKey;
44
+ }
45
+
46
+ state.skipApiKeyWarning = skipWarning;
47
+ }
48
+
49
+ // load dark mode preference
50
+ function loadDarkModePreference() {
51
+ if (localStorage.getItem('darkMode') === 'true') {
52
+ document.documentElement.classList.add('dark');
53
+ }
54
+ }
55
+
56
+ // setup event listeners
57
+ function setupEventListeners() {
58
+
59
+ // API key field
60
+ elements.apiKeyInput.addEventListener('input', (e) => {
61
+ const key = e.target.value.trim();
62
+ state.geminiApiKey = key;
63
+ localStorage.setItem('geminiApiKey', key);
64
+ });
65
+
66
+ // Upload area click triggers file input
67
+ elements.uploadArea.addEventListener('click', () => elements.fileInput.click());
68
+
69
+ // Drag & drop support
70
+ elements.uploadArea.addEventListener('dragover', (e) => {
71
+ e.preventDefault();
72
+ elements.uploadArea.style.borderColor = 'var(--primary)';
73
+ });
74
+
75
+ elements.uploadArea.addEventListener('dragleave', () => {
76
+ elements.uploadArea.style.borderColor = 'var(--border)';
77
+ });
78
+
79
+ elements.uploadArea.addEventListener('drop', (e) => {
80
+ e.preventDefault();
81
+ elements.uploadArea.style.borderColor = 'var(--border)';
82
+ if (e.dataTransfer.files.length > 0) {
83
+ handleFileUpload(e.dataTransfer.files[0]);
84
+ }
85
+ });
86
+
87
+ // File input change
88
+ elements.fileInput.addEventListener('change', (e) => {
89
+ if (e.target.files.length > 0) {
90
+ handleFileUpload(e.target.files[0]);
91
+ }
92
+ });
93
+
94
+ // Reset
95
+ elements.resetButton.addEventListener('click', resetUpload);
96
+
97
+ // Scan button
98
+ elements.scanButton.addEventListener('click', handleScan);
99
+
100
+ // Dark mode toggle
101
+ elements.darkModeToggle.addEventListener('click', () => {
102
+ const isDark = document.documentElement.classList.toggle('dark');
103
+ localStorage.setItem('darkMode', isDark);
104
+ });
105
+ }
106
+
107
+
108
+ // File Upload + Preview
109
+ function handleFileUpload(file) {
110
+ if (!file.type.startsWith('image/')) {
111
+ alert('Please upload a valid image file.');
112
+ return;
113
+ }
114
+
115
+ const reader = new FileReader();
116
+ reader.onload = (e) => {
117
+ state.uploadedImage = e.target.result;
118
+ showImagePreview(e.target.result);
119
+ };
120
+ reader.readAsDataURL(file);
121
+ }
122
+
123
+ function showImagePreview(imageUrl) {
124
+ elements.previewImage.src = imageUrl;
125
+
126
+ elements.uploadArea.style.display = 'none';
127
+ elements.previewSection.style.display = 'block';
128
+ elements.scanButton.style.display = 'flex';
129
+ elements.heroSection.style.display = 'none';
130
+ }
131
+
132
+ // Reset upload
133
+ function resetUpload() {
134
+ state.uploadedImage = null;
135
+ state.detectedIngredients = [];
136
+ state.recipes = [];
137
+
138
+ elements.fileInput.value = '';
139
+ elements.uploadArea.style.display = 'block';
140
+ elements.previewSection.style.display = 'none';
141
+ elements.scanButton.style.display = 'none';
142
+ elements.resultsSection.style.display = 'none';
143
+ elements.heroSection.style.display = 'block';
144
+ }
145
+
146
+
147
+ // Image Scan and Backend Processing
148
+ async function handleScan() {
149
+ if (!state.uploadedImage) {
150
+ alert('Please upload an image first.');
151
+ return;
152
+ }
153
+
154
+ await handleMissingApiKeyWarning();
155
+
156
+ state.isProcessing = true;
157
+ updateScanButton(true);
158
+
159
+ try {
160
+ const formData = new FormData();
161
+
162
+ // Convert Base64 → Blob → File
163
+ const blob = await (await fetch(state.uploadedImage)).blob();
164
+ formData.append("file", new File([blob], "upload.jpg", { type: blob.type }));
165
+ formData.append("api_key", (state.geminiApiKey || "").trim());
166
+
167
+ const response = await fetch("/upload-image/", { method: "POST", body: formData });
168
+
169
+ if (!response.ok) throw new Error("Backend error: " + response.status);
170
+
171
+ const data = await response.json();
172
+
173
+ // Convert backend output to frontend format
174
+ state.detectedIngredients = (data.ingredients || []).map(item => ({
175
+ name: item.name,
176
+ confidence: item.confidence
177
+ }));
178
+
179
+ state.recipes = [{
180
+ name: "AI-Generated Recipe",
181
+ ingredients: (data.ingredients || []).map(i => i.name),
182
+ steps: [data.recipe]
183
+ }];
184
+
185
+
186
+ displayIngredients(state.detectedIngredients);
187
+ displayRecipes(state.recipes);
188
+ elements.resultsSection.style.display = 'block';
189
+
190
+ } catch (err) {
191
+ console.error(err);
192
+ alert("Something went wrong while processing the image.");
193
+ }
194
+
195
+ state.isProcessing = false;
196
+ updateScanButton(false);
197
+ }
198
+
199
+
200
+ // Missing API Key Warning
201
+ async function handleMissingApiKeyWarning() {
202
+ if (state.geminiApiKey || state.skipApiKeyWarning) return;
203
+
204
+ const proceed = confirm(
205
+ "⚠️ Continue without a Gemini API key?\n\n" +
206
+ "• Recipe quality may be downgraded\n" +
207
+ "• AI creativity reduced\n\n" +
208
+ "Proceed anyway?"
209
+ );
210
+
211
+ if (!proceed) throw new Error("User cancelled scan.");
212
+
213
+ const dontShowAgain = confirm("Skip this warning next time?");
214
+ if (dontShowAgain) {
215
+ state.skipApiKeyWarning = true;
216
+ localStorage.setItem('skipApiKeyWarning', 'true');
217
+ }
218
+ }
219
+
220
+ // UI Helper: Scan Button State
221
+ function updateScanButton(isLoading) {
222
+ elements.scanButton.disabled = isLoading;
223
+ elements.scanButtonText.textContent = isLoading ? "Processing..." : "Scan Ingredients";
224
+ }
225
+
226
+
227
+ // Rendering Ingredients UI
228
+ function displayIngredients(ingredients) {
229
+ elements.ingredientsList.innerHTML = '';
230
+
231
+ ingredients.forEach((ingredient, index) => {
232
+ const item = document.createElement('div');
233
+ item.className = 'ingredient-item';
234
+ item.style.animation = `fadeIn 1s ease-out ${index * 0.08}s forwards`;
235
+
236
+ // Make sure confidence is numerical
237
+ const confidence = Number(ingredient.confidence);
238
+
239
+ // Determine bar & badge color
240
+ let barColor;
241
+ if (confidence >= 0.7) barColor = "#2ecc71"; // green
242
+ else if (confidence >= 0.4) barColor = "#f1c40f"; // yellow
243
+ else barColor = "#e74c3c"; // red
244
+
245
+ item.innerHTML = `
246
+ <div class="ingredient-header">
247
+ <div class="ingredient-info">
248
+ <span class="ingredient-name">${ingredient.name}</span>
249
+ <span class="confidence-badge"
250
+ style="
251
+ background: ${barColor};
252
+ color: ${confidence >= 0.4 ? '#000' : '#fff'};
253
+ ">
254
+ ${Math.round(confidence * 100)}% confidence
255
+ </span>
256
+ </div>
257
+ </div>
258
+
259
+ <div class="confidence-bar">
260
+ <div class="confidence-fill"
261
+ style="
262
+ width: 0%;
263
+ background: ${barColor};
264
+ transition: width 0.9s ease-out;
265
+ ">
266
+ </div>
267
+ </div>
268
+ `;
269
+
270
+ elements.ingredientsList.appendChild(item);
271
+
272
+ // Animate bar fill
273
+ requestAnimationFrame(() => {
274
+ const fill = item.querySelector('.confidence-fill');
275
+ fill.style.width = `${confidence * 100}%`;
276
+ });
277
+ });
278
+ }
279
+
280
+
281
+
282
+
283
+ // Rendering Recipes UI (Markdown support)
284
+ function displayRecipes(recipes) {
285
+ elements.recipesSection.style.display = 'block';
286
+ elements.recipesList.innerHTML = '';
287
+
288
+ recipes.forEach((recipe, index) => {
289
+ const card = document.createElement('div');
290
+ card.className = 'recipe-card';
291
+ card.style.animation = `fadeIn 1s ease-out ${index * 0.15}s forwards`;
292
+
293
+ // Short / long ingredients
294
+ const shortIngredients = (recipe.ingredients || [])
295
+ .map(i => (typeof i === "string" ? i : (i.name ?? "Unknown")))
296
+ .slice(0, 5);
297
+
298
+ const hasMoreIngredients = (recipe.ingredients || []).length > 5;
299
+
300
+ // Steps handling: steps may be an array of short steps, or a single big markdown string
301
+ const stepsArr = recipe.steps || [];
302
+ const isSingleLongMarkdown = stepsArr.length === 1 && (stepsArr[0].includes('\n\n') || stepsArr[0].includes('#') || stepsArr[0].includes('- '));
303
+
304
+ // Build Ingredients html
305
+ const ingredientsHtml = `
306
+ <div class="recipe-section">
307
+ <h5 class="section-title">Ingredients</h5>
308
+ <div class="ingredients-grid" data-full="${encodeURIComponent(JSON.stringify(recipe.ingredients || []))}">
309
+ ${shortIngredients.map(i => `<span class="ingredient-tag">${i}</span>`).join('')}
310
+ ${hasMoreIngredients ? `<button class="show-more-btn ingredient-more">+${(recipe.ingredients || []).length - 5} more</button>` : ''}
311
+ </div>
312
+ </div>
313
+ `;
314
+
315
+ // Build Steps html
316
+ let stepsHtml = '';
317
+ if (isSingleLongMarkdown) {
318
+ // render whole markdown blob (not inside <ol>)
319
+ const mdText = stepsArr[0] || '';
320
+ const htmlFromMd = (typeof marked !== 'undefined')
321
+ ? marked.parse(mdText)
322
+ : mdText.replace(/\n\n/g, '<br><br>').replace(/\n/g, '<br>');
323
+ stepsHtml = `
324
+ <div class="recipe-section">
325
+ <h5 class="section-title">Steps</h5>
326
+ <div class="recipe-markdown">${htmlFromMd}</div>
327
+ </div>
328
+ `;
329
+ } else {
330
+ // render as ordered list of steps (short step items)
331
+ const shortSteps = stepsArr.slice(0, 3);
332
+ const hasMoreSteps = stepsArr.length > 3;
333
+ const shortStepsHtml = shortSteps
334
+ .map(s => {
335
+ const rendered = (typeof marked !== 'undefined') ? marked.parseInline(s) : escapeHtml(s);
336
+ return `<li>${rendered}</li>`;
337
+ })
338
+ .join('');
339
+ stepsHtml = `
340
+ <div class="recipe-section">
341
+ <h5 class="section-title">Steps</h5>
342
+ <ol class="steps-list" data-full="${encodeURIComponent(JSON.stringify(stepsArr))}">
343
+ ${shortStepsHtml}
344
+ </ol>
345
+ ${hasMoreSteps ? `<button class="show-more-btn steps-more">Show ${stepsArr.length - 3} more</button>` : ''}
346
+ </div>
347
+ `;
348
+ }
349
+
350
+ card.innerHTML = `
351
+ <div class="recipe-header">
352
+ <h4 class="recipe-name">${escapeHtml(recipe.name || 'Recipe')}</h4>
353
+ </div>
354
+
355
+ ${ingredientsHtml}
356
+ ${stepsHtml}
357
+ `;
358
+
359
+ elements.recipesList.appendChild(card);
360
+ setupExpandButtons(card);
361
+ });
362
+ }
363
+
364
+ // Setup expand buttons with markdown handling
365
+ function setupExpandButtons(card) {
366
+ // Ingredients expand
367
+ const ingBtn = card.querySelector(".ingredient-more");
368
+ if (ingBtn) {
369
+ ingBtn.onclick = () => {
370
+ const container = ingBtn.parentElement;
371
+ const fullList = JSON.parse(decodeURIComponent(container.dataset.full || '[]'));
372
+ container.innerHTML = fullList.map(i => `<span class="ingredient-tag">${escapeHtml(i)}</span>`).join('');
373
+ };
374
+ }
375
+
376
+ // Steps expand (only applies when steps were short-array style)
377
+ const stepBtn = card.querySelector(".steps-more");
378
+ if (stepBtn) {
379
+ stepBtn.onclick = () => {
380
+ const ol = stepBtn.previousElementSibling;
381
+ const fullList = JSON.parse(decodeURIComponent(ol.dataset.full || '[]'));
382
+ if (typeof marked !== 'undefined') {
383
+ ol.innerHTML = fullList.map(s => `<li>${marked.parseInline(s)}</li>`).join('');
384
+ } else {
385
+ ol.innerHTML = fullList.map(s => `<li>${escapeHtml(s)}</li>`).join('');
386
+ }
387
+ stepBtn.remove();
388
+ };
389
+ }
390
+ }
391
+
392
+ // small helper to escape HTML when marked is not available or for text content
393
+ function escapeHtml(str) {
394
+ if (!str) return '';
395
+ return str
396
+ .replace(/&/g, '&amp;')
397
+ .replace(/</g, '&lt;')
398
+ .replace(/>/g, '&gt;');
399
+ }
400
+
401
+
402
+ // Fade Animations
403
+ const styleElement = document.createElement('style');
404
+ styleElement.textContent = `
405
+ @keyframes fadeIn {
406
+ from { opacity: 0; transform: translateY(10px); }
407
+ to { opacity: 1; transform: translateY(0); }
408
+ }
409
+ `;
410
+ document.head.appendChild(styleElement);
411
+
412
+
413
+ // Start Application
414
+ document.readyState === 'loading'
415
+ ? document.addEventListener('DOMContentLoaded', init)
416
+ : init();
static/styles.css ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Design System Variables */
2
+ :root {
3
+ --background: hsl(42, 100%, 98%);
4
+ --foreground: hsl(25, 30%, 15%);
5
+ --card: hsl(0, 0%, 100%);
6
+ --card-foreground: hsl(25, 30%, 15%);
7
+ --primary: hsl(14, 85%, 58%);
8
+ --primary-foreground: hsl(0, 0%, 100%);
9
+ --secondary: hsl(142, 65%, 48%);
10
+ --secondary-foreground: hsl(0, 0%, 100%);
11
+ --muted: hsl(42, 40%, 92%);
12
+ --muted-foreground: hsl(25, 20%, 45%);
13
+ --accent: hsl(38, 92%, 62%);
14
+ --accent-foreground: hsl(25, 30%, 15%);
15
+ --border: hsl(42, 30%, 88%);
16
+ --input: hsl(42, 30%, 88%);
17
+ --ring: hsl(14, 85%, 58%);
18
+ --radius: 0.75rem;
19
+ --transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
20
+ }
21
+
22
+ .dark {
23
+ --background: hsl(25, 30%, 8%);
24
+ --foreground: hsl(42, 40%, 95%);
25
+ --card: hsl(25, 25%, 12%);
26
+ --card-foreground: hsl(42, 40%, 95%);
27
+ --primary: hsl(14, 85%, 58%);
28
+ --primary-foreground: hsl(0, 0%, 100%);
29
+ --secondary: hsl(142, 65%, 48%);
30
+ --secondary-foreground: hsl(0, 0%, 100%);
31
+ --muted: hsl(25, 20%, 18%);
32
+ --muted-foreground: hsl(42, 30%, 65%);
33
+ --accent: hsl(38, 92%, 62%);
34
+ --accent-foreground: hsl(25, 30%, 8%);
35
+ --border: hsl(25, 20%, 20%);
36
+ --input: hsl(25, 20%, 20%);
37
+ --ring: hsl(14, 85%, 58%);
38
+ }
39
+
40
+ * {
41
+ margin: 0;
42
+ padding: 0;
43
+ box-sizing: border-box;
44
+ }
45
+
46
+ body {
47
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
48
+ background-color: var(--background);
49
+ color: var(--foreground);
50
+ line-height: 1.6;
51
+ min-height: 100vh;
52
+ }
53
+
54
+ .container {
55
+ max-width: 1200px;
56
+ margin: 0 auto;
57
+ padding: 0 1rem;
58
+ }
59
+
60
+ /* Header */
61
+ .header {
62
+ background-color: hsla(0, 0%, 100%, 0.5);
63
+ backdrop-filter: blur(8px);
64
+ border-bottom: 1px solid var(--border);
65
+ position: sticky;
66
+ top: 0;
67
+ z-index: 50;
68
+ }
69
+
70
+ .dark .header {
71
+ background-color: hsla(25, 30%, 8%, 0.8);
72
+ }
73
+
74
+ .header-content {
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: space-between;
78
+ gap: 0.75rem;
79
+ padding: 1rem 0;
80
+ }
81
+
82
+ .logo-container {
83
+ background: linear-gradient(135deg, hsl(14, 85%, 58%), hsl(38, 92%, 62%));
84
+ padding: 0.5rem;
85
+ border-radius: var(--radius);
86
+ }
87
+
88
+ .chef-hat-icon {
89
+ color: white;
90
+ }
91
+
92
+ .logo-text {
93
+ font-size: 1.5rem;
94
+ font-weight: bold;
95
+ background: linear-gradient(135deg, hsl(14, 85%, 58%), hsl(38, 92%, 62%));
96
+ -webkit-background-clip: text;
97
+ -webkit-text-fill-color: transparent;
98
+ background-clip: text;
99
+ }
100
+
101
+ /* Dark Mode Toggle */
102
+ .dark-mode-toggle {
103
+ position: relative;
104
+ width: 48px;
105
+ height: 48px;
106
+ border-radius: 50%;
107
+ background: hsl(var(--muted));
108
+ border: none;
109
+ cursor: pointer;
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ transition: var(--transition-smooth);
114
+ margin-left: auto;
115
+ }
116
+
117
+ .dark-mode-toggle:hover {
118
+ background: hsl(var(--muted) / 0.8);
119
+ transform: scale(1.05);
120
+ }
121
+
122
+ .dark-mode-toggle .sun-icon,
123
+ .dark-mode-toggle .moon-icon {
124
+ position: absolute;
125
+ color: hsl(var(--foreground));
126
+ transition: opacity 0.3s ease, transform 0.3s ease;
127
+ }
128
+
129
+ .dark-mode-toggle .moon-icon {
130
+ opacity: 0;
131
+ transform: rotate(90deg) scale(0.8);
132
+ }
133
+
134
+ .dark .dark-mode-toggle .sun-icon {
135
+ opacity: 0;
136
+ transform: rotate(-90deg) scale(0.8);
137
+ }
138
+
139
+ .dark .dark-mode-toggle .moon-icon {
140
+ opacity: 1;
141
+ transform: rotate(0deg) scale(1);
142
+ }
143
+
144
+ /* Main Content */
145
+ .main-content {
146
+ padding: 2rem 0;
147
+ }
148
+
149
+ /* Hero Section */
150
+ .hero-section {
151
+ max-width: 42rem;
152
+ margin: 0 auto 3rem;
153
+ text-align: center;
154
+ }
155
+
156
+ .hero-title {
157
+ font-size: 2.5rem;
158
+ font-weight: bold;
159
+ color: var(--foreground);
160
+ margin-bottom: 1rem;
161
+ }
162
+
163
+ .gradient-text {
164
+ background: linear-gradient(135deg, hsl(14, 85%, 58%), hsl(38, 92%, 62%));
165
+ -webkit-background-clip: text;
166
+ -webkit-text-fill-color: transparent;
167
+ background-clip: text;
168
+ }
169
+
170
+ .hero-description {
171
+ font-size: 1.125rem;
172
+ color: var(--muted-foreground);
173
+ }
174
+
175
+ /* Upload Section */
176
+ .upload-section {
177
+ max-width: 42rem;
178
+ margin: 0 auto 2rem;
179
+ }
180
+
181
+ .upload-card {
182
+ background: var(--card);
183
+ border: 1px solid var(--border);
184
+ border-radius: var(--radius);
185
+ padding: 2rem;
186
+ box-shadow: 0 2px 12px hsla(25, 30%, 15%, 0.06);
187
+ }
188
+
189
+ .api-key-section {
190
+ margin-bottom: 1.5rem;
191
+ }
192
+
193
+ .api-key-label {
194
+ display: flex;
195
+ align-items: center;
196
+ gap: 0.5rem;
197
+ font-size: 0.875rem;
198
+ font-weight: 500;
199
+ color: var(--foreground);
200
+ margin-bottom: 0.5rem;
201
+ }
202
+
203
+ .api-key-input {
204
+ width: 100%;
205
+ padding: 0.625rem 0.75rem;
206
+ font-size: 0.875rem;
207
+ border: 1px solid var(--input);
208
+ border-radius: calc(var(--radius) - 0.125rem);
209
+ background: var(--background);
210
+ color: var(--foreground);
211
+ transition: all 0.2s;
212
+ }
213
+
214
+ .api-key-input:focus {
215
+ outline: none;
216
+ border-color: var(--ring);
217
+ box-shadow: 0 0 0 2px hsla(14, 85%, 58%, 0.1);
218
+ }
219
+
220
+ /* Upload Area */
221
+ .upload-area {
222
+ border: 2px dashed var(--border);
223
+ border-radius: var(--radius);
224
+ padding: 3rem 1.5rem;
225
+ text-align: center;
226
+ cursor: pointer;
227
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
228
+ }
229
+
230
+ .upload-area:hover {
231
+ border-color: var(--primary);
232
+ background: linear-gradient(135deg, hsla(14, 85%, 58%, 0.05), hsla(142, 65%, 48%, 0.05));
233
+ transform: scale(1.02);
234
+ }
235
+
236
+ .upload-icon {
237
+ color: var(--primary);
238
+ margin-bottom: 1rem;
239
+ }
240
+
241
+ .upload-text {
242
+ font-size: 1.125rem;
243
+ font-weight: 500;
244
+ color: var(--foreground);
245
+ margin-bottom: 0.5rem;
246
+ }
247
+
248
+ .upload-subtext {
249
+ font-size: 0.875rem;
250
+ color: var(--muted-foreground);
251
+ }
252
+
253
+ /* Preview Section */
254
+ .preview-section {
255
+ position: relative;
256
+ }
257
+
258
+ .preview-image {
259
+ width: 100%;
260
+ border-radius: var(--radius);
261
+ box-shadow: 0 4px 20px hsla(14, 85%, 58%, 0.08);
262
+ }
263
+
264
+ .reset-button {
265
+ position: absolute;
266
+ top: 0.75rem;
267
+ right: 0.75rem;
268
+ display: flex;
269
+ align-items: center;
270
+ gap: 0.5rem;
271
+ padding: 0.5rem 1rem;
272
+ background: var(--card);
273
+ border: 1px solid var(--border);
274
+ border-radius: calc(var(--radius) - 0.125rem);
275
+ color: var(--foreground);
276
+ font-size: 0.875rem;
277
+ cursor: pointer;
278
+ transition: all 0.2s;
279
+ }
280
+
281
+ .reset-button:hover {
282
+ background: var(--muted);
283
+ }
284
+
285
+ /* Scan Button */
286
+ .scan-button {
287
+ width: 100%;
288
+ display: flex;
289
+ align-items: center;
290
+ justify-content: center;
291
+ gap: 0.5rem;
292
+ padding: 0.75rem 1.5rem;
293
+ margin-top: 1.5rem;
294
+ background: var(--primary);
295
+ color: var(--primary-foreground);
296
+ border: none;
297
+ border-radius: calc(var(--radius) - 0.125rem);
298
+ font-size: 1rem;
299
+ font-weight: 500;
300
+ cursor: pointer;
301
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
302
+ }
303
+
304
+ .scan-button:hover:not(:disabled) {
305
+ background: hsl(14, 85%, 52%);
306
+ transform: translateY(-2px);
307
+ box-shadow: 0 4px 20px hsla(14, 85%, 58%, 0.3);
308
+ }
309
+
310
+ .scan-button:disabled {
311
+ opacity: 0.6;
312
+ cursor: not-allowed;
313
+ }
314
+
315
+ .scan-button.processing {
316
+ background: var(--muted);
317
+ color: var(--muted-foreground);
318
+ }
319
+
320
+ /* Results Section */
321
+ .results-section {
322
+ max-width: 56rem;
323
+ margin: 0 auto;
324
+ }
325
+
326
+ .results-card {
327
+ background: var(--card);
328
+ border: 1px solid var(--border);
329
+ border-radius: var(--radius);
330
+ padding: 1.5rem;
331
+ margin-bottom: 2rem;
332
+ box-shadow: 0 2px 12px hsla(25, 30%, 15%, 0.06);
333
+ }
334
+
335
+ .card-header {
336
+ display: flex;
337
+ align-items: center;
338
+ gap: 0.5rem;
339
+ margin-bottom: 1.5rem;
340
+ }
341
+
342
+ .card-header h3 {
343
+ font-size: 1.25rem;
344
+ font-weight: 600;
345
+ }
346
+
347
+ .icon-success {
348
+ color: var(--secondary);
349
+ }
350
+
351
+ .icon {
352
+ flex-shrink: 0;
353
+ }
354
+
355
+ /* Ingredients List */
356
+ .ingredients-list {
357
+ display: flex;
358
+ flex-direction: column;
359
+ gap: 1.5rem;
360
+ }
361
+
362
+ .ingredient-item {
363
+ display: flex;
364
+ flex-direction: column;
365
+ gap: 0.5rem;
366
+ opacity: 0;
367
+ transform: translateY(8px);
368
+ }
369
+
370
+ .ingredient-header {
371
+ display: flex;
372
+ align-items: center;
373
+ justify-content: space-between;
374
+ }
375
+
376
+ .ingredient-info {
377
+ display: flex;
378
+ align-items: center;
379
+ gap: 0.75rem;
380
+ }
381
+
382
+ .ingredient-name {
383
+ font-weight: 500;
384
+ color: var(--foreground);
385
+ }
386
+
387
+ /* Base confidence bar container */
388
+ .confidence-badge {
389
+ display: inline-flex;
390
+ align-items: center;
391
+ padding: 0.25rem 0.625rem;
392
+ font-size: 0.75rem;
393
+ font-weight: 600;
394
+ border-radius: 9999px;
395
+ }
396
+
397
+ .confidence-bar {
398
+ width: 100%;
399
+ height: 8px;
400
+ background: #e0e0e0;
401
+ border-radius: 6px;
402
+ overflow: hidden;
403
+ margin-top: 6px;
404
+ }
405
+
406
+ .confidence-fill {
407
+ height: 100%;
408
+ border-radius: 6px;
409
+ }
410
+
411
+
412
+ /* Recipes Section */
413
+ .recipes-title {
414
+ display: flex;
415
+ align-items: center;
416
+ gap: 0.5rem;
417
+ font-size: 1.5rem;
418
+ font-weight: 600;
419
+ margin-bottom: 1.5rem;
420
+ }
421
+
422
+ .recipes-list {
423
+ display: grid;
424
+ gap: 1.5rem;
425
+ }
426
+
427
+ .recipe-card {
428
+ background: var(--card);
429
+ border: 1px solid var(--border);
430
+ border-radius: var(--radius);
431
+ padding: 1.5rem;
432
+ box-shadow: 0 2px 12px hsla(25, 30%, 15%, 0.06);
433
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
434
+ }
435
+
436
+ .recipe-card:hover {
437
+ box-shadow: 0 8px 32px hsla(25, 30%, 15%, 0.12);
438
+ transform: translateY(-4px);
439
+ }
440
+
441
+ .recipe-header {
442
+ display: flex;
443
+ align-items: center;
444
+ gap: 0.75rem;
445
+ margin-bottom: 1rem;
446
+ }
447
+
448
+ .recipe-icon {
449
+ background: linear-gradient(135deg, hsl(14, 85%, 58%), hsl(38, 92%, 62%));
450
+ padding: 0.5rem;
451
+ border-radius: calc(var(--radius) - 0.125rem);
452
+ color: white;
453
+ }
454
+
455
+ .recipe-title-section {
456
+ flex: 1;
457
+ }
458
+
459
+ .recipe-name {
460
+ font-size: 1.25rem;
461
+ font-weight: 600;
462
+ margin-bottom: 0.25rem;
463
+ }
464
+
465
+ .recipe-time {
466
+ display: flex;
467
+ align-items: center;
468
+ gap: 0.375rem;
469
+ font-size: 0.875rem;
470
+ color: var(--muted-foreground);
471
+ }
472
+
473
+ .recipe-section {
474
+ margin-bottom: 1.5rem;
475
+ }
476
+
477
+ .recipe-section:last-child {
478
+ margin-bottom: 0;
479
+ }
480
+
481
+ .section-title {
482
+ font-size: 0.875rem;
483
+ font-weight: 600;
484
+ color: var(--muted-foreground);
485
+ text-transform: uppercase;
486
+ letter-spacing: 0.05em;
487
+ margin-bottom: 0.75rem;
488
+ }
489
+
490
+ .ingredients-grid {
491
+ display: flex;
492
+ flex-wrap: wrap;
493
+ gap: 0.5rem;
494
+ }
495
+
496
+ .ingredient-tag {
497
+ display: inline-flex;
498
+ align-items: center;
499
+ padding: 0.25rem 0.75rem;
500
+ background: var(--muted);
501
+ color: var(--foreground);
502
+ border-radius: 9999px;
503
+ font-size: 0.875rem;
504
+ }
505
+
506
+ .steps-list {
507
+ list-style: none;
508
+ counter-reset: step-counter;
509
+ }
510
+
511
+ .step-item {
512
+ counter-increment: step-counter;
513
+ display: flex;
514
+ gap: 0.75rem;
515
+ margin-bottom: 0.75rem;
516
+ }
517
+
518
+ .step-item::before {
519
+ content: counter(step-counter);
520
+ display: flex;
521
+ align-items: center;
522
+ justify-content: center;
523
+ width: 1.5rem;
524
+ height: 1.5rem;
525
+ background: var(--primary);
526
+ color: white;
527
+ border-radius: 50%;
528
+ font-size: 0.75rem;
529
+ font-weight: 600;
530
+ flex-shrink: 0;
531
+ }
532
+
533
+ .step-text {
534
+ flex: 1;
535
+ padding-top: 0.125rem;
536
+ }
537
+
538
+ .show-more-btn {
539
+ background: none;
540
+ border: none;
541
+ color: var(--primary);
542
+ font-size: 0.875rem;
543
+ font-weight: 500;
544
+ cursor: pointer;
545
+ padding: 0;
546
+ margin-top: 0.5rem;
547
+ }
548
+
549
+ .show-more-btn:hover {
550
+ text-decoration: underline;
551
+ }
552
+
553
+ /* Footer */
554
+ .footer {
555
+ border-top: 1px solid var(--border);
556
+ margin-top: 4rem;
557
+ padding: 2rem 0;
558
+ }
559
+
560
+ .footer-text {
561
+ text-align: center;
562
+ color: var(--muted-foreground);
563
+ }
564
+
565
+
566
+ /* Loading Animation */
567
+ @keyframes spin {
568
+ to { transform: rotate(360deg); }
569
+ }
570
+
571
+ .spinning {
572
+ animation: spin 1s linear infinite;
573
+ }
574
+
575
+
576
+ /* Responsive Design */
577
+ @media (min-width: 768px) {
578
+ .hero-title {
579
+ font-size: 3rem;
580
+ }
581
+ }
582
+
583
+ @media (max-width: 640px) {
584
+ .hero-title {
585
+ font-size: 2rem;
586
+ }
587
+
588
+ .upload-card {
589
+ padding: 1.5rem;
590
+ }
591
+ }
streamlit_app.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # import libraries and modules
2
+ import streamlit as st
3
+ from PIL import Image
4
+ import numpy as np
5
+ from detector.infer2 import infer_image
6
+
7
+ # Streamlit app
8
+ st.set_page_config(page_title="Fridge2Dish", layout="centered")
9
+
10
+ # Title and description
11
+ st.title("Fridge2Dish — AI Chef from Leftovers 🍳🥦")
12
+ st.write("Upload a photo of your fridge or ingredients and get a recipe suggestion.")
13
+
14
+ # Upload Image
15
+ uploaded = st.file_uploader("Upload fridge/photo", type=["jpg","jpeg","png", "webp"])
16
+ if uploaded:
17
+ image = Image.open(uploaded).convert("RGB")
18
+ st.image(image, caption="Input image", use_column_width=True)
19
+
20
+ with st.spinner("Detecting ingredients..."):
21
+ ingredients = infer_image(image) # returns list of strings
22
+ st.markdown("**Detected ingredients:**")
23
+ st.write(ingredients)
24
+
25
+ with st.spinner("Generating recipe..."):
26
+
27
+ from recipe_generator.recipe_online import generate_recipe
28
+ recipe = generate_recipe(ingredients)
29
+
30
+ st.markdown("### Suggested Recipe")
31
+ st.write(recipe)
32
+ else:
33
+ st.info("Try uploading a clear photo of a few ingredients (eg. eggs, tomato, bread).")
34
+
templates/index.html ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Fridge2Dish - AI Recipe Generator from Your Ingredients</title>
7
+ <meta name="description" content="Transform your ingredients into delicious recipes with AI-powered ingredient detection and personalized recipe suggestions">
8
+ <meta name="author" content="Fridge2Dish">
9
+
10
+ <meta property="og:title" content="Fridge2Dish - AI Recipe Generator">
11
+ <meta property="og:description" content="Upload your ingredients and get AI-powered recipe suggestions instantly">
12
+ <meta property="og:type" content="website">
13
+
14
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <!--markdown library-->
15
+ <link rel="stylesheet" href="/static/styles.css">
16
+
17
+
18
+ </head>
19
+ <body>
20
+ <!-- Header -->
21
+ <header class="header">
22
+ <div class="container">
23
+ <div class="header-content">
24
+ <div class="logo-container">
25
+ <svg class="chef-hat-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
26
+ <path d="M6 13.87A4 4 0 0 1 7.41 6a5.11 5.11 0 0 1 1.05-1.54 5 5 0 0 1 7.08 0A5.11 5.11 0 0 1 16.59 6 4 4 0 0 1 18 13.87V21H6Z"/>
27
+ <line x1="6" x2="18" y1="17" y2="17"/>
28
+ </svg>
29
+ </div>
30
+ <h1 class="logo-text">Fridge2Dish</h1>
31
+
32
+ <!-- Dark Mode Toggle -->
33
+ <button id="darkModeToggle" class="dark-mode-toggle" aria-label="Toggle dark mode">
34
+ <svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
35
+ <circle cx="12" cy="12" r="4"/>
36
+ <path d="M12 2v2"/>
37
+ <path d="M12 20v2"/>
38
+ <path d="m4.93 4.93 1.41 1.41"/>
39
+ <path d="m17.66 17.66 1.41 1.41"/>
40
+ <path d="M2 12h2"/>
41
+ <path d="M20 12h2"/>
42
+ <path d="m6.34 17.66-1.41 1.41"/>
43
+ <path d="m19.07 4.93-1.41 1.41"/>
44
+ </svg>
45
+ <svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
46
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
47
+ </svg>
48
+ </button>
49
+
50
+ </div>
51
+ </div>
52
+ </header>
53
+
54
+ <!-- Main Content -->
55
+ <main class="main-content">
56
+ <div class="container">
57
+ <!-- Hero Section -->
58
+ <div id="heroSection" class="hero-section">
59
+ <h2 class="hero-title">
60
+ Turn Your Ingredients Into
61
+ <span class="gradient-text">Delicious Meals</span>
62
+ </h2>
63
+ <p class="hero-description">
64
+ Upload a photo of your ingredients and let AI suggest amazing recipes you can cook right now
65
+ </p>
66
+ </div>
67
+
68
+ <!-- Image Upload Section -->
69
+ <div class="upload-section">
70
+ <div class="upload-card">
71
+ <!-- API Key Input -->
72
+ <div class="api-key-section">
73
+ <label for="apiKey" class="api-key-label">
74
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
75
+ <path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/>
76
+ <path d="m21 2-9.6 9.6"/>
77
+ <circle cx="7.5" cy="15.5" r="5.5"/>
78
+ </svg>
79
+ Gemini API Key
80
+ </label>
81
+ <input
82
+ type="password"
83
+ id="apiKey"
84
+ class="api-key-input"
85
+ placeholder="Enter your Gemini API key"
86
+ >
87
+ </div>
88
+
89
+ <!-- Upload Area -->
90
+ <div id="uploadArea" class="upload-area">
91
+ <svg class="upload-icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
92
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
93
+ <polyline points="17 8 12 3 7 8"/>
94
+ <line x1="12" x2="12" y1="3" y2="15"/>
95
+ </svg>
96
+ <p class="upload-text">Drop your ingredient photo here or click to browse</p>
97
+ <p class="upload-subtext">Supported formats: JPG, JPEG, PNG, WEBP</p>
98
+ <input type="file" id="fileInput" accept="image/*" hidden>
99
+ </div>
100
+
101
+ <!-- Image Preview -->
102
+ <div id="previewSection" class="preview-section" style="display: none;">
103
+ <img id="previewImage" class="preview-image" alt="Uploaded ingredients">
104
+ <button id="resetButton" class="reset-button">
105
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
106
+ <path d="M18 6 6 18"/>
107
+ <path d="m6 6 12 12"/>
108
+ </svg>
109
+ Reset
110
+ </button>
111
+ </div>
112
+
113
+ <!-- Scan Button -->
114
+ <button id="scanButton" class="scan-button" style="display: none;">
115
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
116
+ <path d="M3 7V5a2 2 0 0 1 2-2h2"/>
117
+ <path d="M17 3h2a2 2 0 0 1 2 2v2"/>
118
+ <path d="M21 17v2a2 2 0 0 1-2 2h-2"/>
119
+ <path d="M7 21H5a2 2 0 0 1-2-2v-2"/>
120
+ <rect width="10" height="8" x="7" y="8" rx="1"/>
121
+ </svg>
122
+ <span id="scanButtonText">Scan for Ingredients</span>
123
+ </button>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Results Section -->
128
+ <div id="resultsSection" class="results-section" style="display: none;">
129
+ <!-- Detected Ingredients -->
130
+ <div class="results-card">
131
+ <div class="card-header">
132
+ <svg class="icon-success" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
133
+ <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/>
134
+ <path d="m9 12 2 2 4-4"/>
135
+ </svg>
136
+ <h3>Detected Ingredients</h3>
137
+ </div>
138
+ <div id="ingredientsList" class="ingredients-list"></div>
139
+ </div>
140
+
141
+ <!-- Recipe Cards -->
142
+ <div id="recipesSection" style="display: none;">
143
+ <h3 class="recipes-title">
144
+ <svg class="icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
145
+ <path d="M6 13.87A4 4 0 0 1 7.41 6a5.11 5.11 0 0 1 1.05-1.54 5 5 0 0 1 7.08 0A5.11 5.11 0 0 1 16.59 6 4 4 0 0 1 18 13.87V21H6Z"/>
146
+ <line x1="6" x2="18" y1="17" y2="17"/>
147
+ </svg>
148
+ Suggested Recipes
149
+ </h3>
150
+ <div id="recipesList" class="recipes-list"></div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </main>
155
+
156
+ <!-- Footer -->
157
+ <footer class="footer">
158
+ <div class="container">
159
+ <p class="footer-text">AI-Powered • Made by <a href="https://github.com/Wills17" target="_blank">Wills</a></p>
160
+ </div>
161
+ </footer>
162
+
163
+ <script src="/static/scripts.js" defer></script>
164
+ </body>
165
+ </html>