srilathatata commited on
Commit
4864b5a
·
1 Parent(s): 09a7abe

feat: receipt OCR working - MiniCPM-V 4.6 parsing real receipts

Browse files
__pycache__/modal_services.cpython-312.pyc ADDED
Binary file (4.19 kB). View file
 
__pycache__/test_modal.cpython-312.pyc ADDED
Binary file (1.3 kB). View file
 
app.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import sqlite3
3
+ import json
4
+ import os
5
+ from datetime import datetime, timedelta
6
+
7
+ DB_PATH = "db/pantry.db"
8
+
9
+ def init_db():
10
+ os.makedirs("db", exist_ok=True)
11
+ conn = sqlite3.connect(DB_PATH)
12
+ c = conn.cursor()
13
+ c.execute('''CREATE TABLE IF NOT EXISTS pantry (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ item_name TEXT,
16
+ quantity TEXT,
17
+ purchase_date TEXT,
18
+ estimated_expiry TEXT,
19
+ category TEXT,
20
+ used INTEGER DEFAULT 0
21
+ )''')
22
+ conn.commit()
23
+ conn.close()
24
+
25
+ SHELF_LIFE = {
26
+ "spinach": 5, "baby spinach": 5, "tomatoes": 7, "cherry tomatoes": 7,
27
+ "yoghurt": 14, "greek yoghurt": 14, "milk": 7, "eggs": 21,
28
+ "chicken": 2, "basmati rice": 180, "rice": 180, "lentils": 365,
29
+ "coconut milk": 365, "butter": 30, "cheese": 21, "bread": 7,
30
+ "onion": 30, "garlic": 60, "potato": 30, "carrot": 21,
31
+ "cucumber": 7, "pepper": 7, "courgette": 7, "mushroom": 5,
32
+ }
33
+
34
+ def estimate_expiry(item_name):
35
+ name_lower = item_name.lower()
36
+ for key, days in SHELF_LIFE.items():
37
+ if key in name_lower:
38
+ expiry = datetime.now() + timedelta(days=days)
39
+ return expiry.strftime("%Y-%m-%d")
40
+ expiry = datetime.now() + timedelta(days=7)
41
+ return expiry.strftime("%Y-%m-%d")
42
+
43
+ def expiry_status(expiry_str):
44
+ try:
45
+ expiry = datetime.strptime(expiry_str, "%Y-%m-%d")
46
+ days_left = (expiry - datetime.now()).days
47
+ if days_left <= 1:
48
+ return "🔴", days_left
49
+ elif days_left <= 5:
50
+ return "🟡", days_left
51
+ else:
52
+ return "🟢", days_left
53
+ except:
54
+ return "🟢", 999
55
+
56
+ DUMMY_ITEMS = [
57
+ {"name": "Baby spinach 200g", "qty": "1", "expiry": (datetime.now() + timedelta(days=0)).strftime("%Y-%m-%d")},
58
+ {"name": "Cherry tomatoes 400g", "qty": "1", "expiry": (datetime.now() + timedelta(days=3)).strftime("%Y-%m-%d")},
59
+ {"name": "Greek yoghurt 500g", "qty": "1", "expiry": (datetime.now() + timedelta(days=5)).strftime("%Y-%m-%d")},
60
+ {"name": "Basmati rice 1kg", "qty": "1", "expiry": (datetime.now() + timedelta(days=180)).strftime("%Y-%m-%d")},
61
+ {"name": "Coconut milk 400ml", "qty": "2", "expiry": (datetime.now() + timedelta(days=365)).strftime("%Y-%m-%d")},
62
+ ]
63
+
64
+ CHARACTERS = {
65
+ "Grandma (Paati)": "You are Paati, a warm but judgemental South Indian grandma. You speak in a mix of English with occasional Tamil words. You scold gently about food waste, express love through feeding people, and always know the best traditional recipe for any ingredient.",
66
+ "Chef": "You are a sharp, no-nonsense professional chef. You are direct, technically precise, slightly impatient with bad ingredients, but brilliant at making something out of nothing.",
67
+ "Fitness Coach": "You are an enthusiastic fitness coach. Every recipe must be high protein, low waste, macro-balanced. You relate everything back to gains, recovery, and clean eating.",
68
+ "Food Critic": "You are a pompous but secretly warm food critic. You critique the ingredients dramatically before reluctantly producing a brilliant recipe.",
69
+ }
70
+
71
+ CUISINES = ["South Indian", "North Indian", "Italian", "Mediterranean", "East Asian", "Surprise me"]
72
+
73
+ def parse_receipt(image):
74
+ if image is None:
75
+ return format_parsed_items(DUMMY_ITEMS), DUMMY_ITEMS
76
+ return format_parsed_items(DUMMY_ITEMS), DUMMY_ITEMS
77
+
78
+ def format_parsed_items(items):
79
+ lines = []
80
+ for item in items:
81
+ emoji, days = expiry_status(item["expiry"])
82
+ if days <= 1:
83
+ label = "expires today"
84
+ elif days <= 5:
85
+ label = f"expires in {days} days"
86
+ else:
87
+ label = f"expires in {days} days"
88
+ lines.append(f"{emoji} {item['name']} (qty: {item['qty']}) — {label}")
89
+ return "\n".join(lines)
90
+
91
+ def save_to_pantry(items_state):
92
+ init_db()
93
+ conn = sqlite3.connect(DB_PATH)
94
+ c = conn.cursor()
95
+ for item in items_state:
96
+ c.execute('''INSERT INTO pantry (item_name, quantity, purchase_date, estimated_expiry, category)
97
+ VALUES (?, ?, ?, ?, ?)''',
98
+ (item["name"], item["qty"], datetime.now().strftime("%Y-%m-%d"),
99
+ item["expiry"], "general"))
100
+ conn.commit()
101
+ conn.close()
102
+ return "Saved to pantry"
103
+
104
+ def get_pantry():
105
+ init_db()
106
+ conn = sqlite3.connect(DB_PATH)
107
+ c = conn.cursor()
108
+ c.execute("SELECT id, item_name, quantity, estimated_expiry FROM pantry WHERE used=0 ORDER BY estimated_expiry ASC")
109
+ rows = c.fetchall()
110
+ conn.close()
111
+ if not rows:
112
+ return "Your pantry is empty — scan a receipt to get started."
113
+ lines = []
114
+ expiring_today = 0
115
+ for row in rows:
116
+ emoji, days = expiry_status(row[3])
117
+ if days <= 1:
118
+ expiring_today += 1
119
+ label = "TODAY"
120
+ elif days <= 5:
121
+ label = f"{days} days"
122
+ else:
123
+ label = f"{days} days"
124
+ lines.append(f"{emoji} {row[1]} (x{row[2]}) — {label}")
125
+ header = ""
126
+ if expiring_today > 0:
127
+ header = f"🚨 {expiring_today} item(s) expiring today — cook them now!\n\n"
128
+ return header + "\n".join(lines)
129
+
130
+ def mark_used(item_name):
131
+ init_db()
132
+ conn = sqlite3.connect(DB_PATH)
133
+ c = conn.cursor()
134
+ c.execute("UPDATE pantry SET used=1 WHERE item_name=? AND used=0", (item_name,))
135
+ conn.commit()
136
+ conn.close()
137
+ return get_pantry()
138
+
139
+ def generate_recipe(character, cuisine, use_expiring):
140
+ expiring = [i for i in DUMMY_ITEMS if expiry_status(i["expiry"])[1] <= 5]
141
+ all_items = DUMMY_ITEMS
142
+ items_to_use = expiring if use_expiring and expiring else all_items
143
+
144
+ char_voice = {
145
+ "Grandma (Paati)": "🧓 Paati says:\n\"Ayo! That spinach is dying and you haven't touched it? Shame on you! Come, I'll show you a proper keerai kootu.\"\n\n",
146
+ "Chef": "👨‍🍳 Chef says:\n\"Right. You've got spinach on its last legs and some tomatoes. We're making something. Pay attention.\"\n\n",
147
+ "Fitness Coach": "💪 Coach says:\n\"Perfect! Spinach is iron-rich, tomatoes are antioxidants. We're making a macro-friendly power bowl. Let's GO.\"\n\n",
148
+ "Food Critic": "⭐ Critic says:\n\"These ingredients are... humble. And yet. With sufficient technique, even the wilting spinach can achieve greatness.\"\n\n",
149
+ }
150
+
151
+ cuisine_recipes = {
152
+ "South Indian": {
153
+ "name": "Keerai kootu with coconut dal",
154
+ "ingredients": ["Baby spinach", "Cherry tomatoes", "Coconut milk", "Basmati rice", "Mustard seeds", "Curry leaves", "Turmeric"],
155
+ "steps": [
156
+ "Blanch spinach for 2 mins, drain and roughly chop.",
157
+ "Heat oil, temper mustard seeds and curry leaves.",
158
+ "Add tomatoes, cook till soft — about 5 mins.",
159
+ "Add coconut milk and turmeric, simmer 3 mins.",
160
+ "Fold in spinach, season with salt.",
161
+ "Serve over steamed basmati rice."
162
+ ]
163
+ },
164
+ "Italian": {
165
+ "name": "Spinach and tomato pasta",
166
+ "ingredients": ["Baby spinach", "Cherry tomatoes", "Garlic", "Olive oil", "Pasta", "Parmesan"],
167
+ "steps": [
168
+ "Cook pasta al dente, reserve 1 cup pasta water.",
169
+ "Sauté garlic in olive oil until golden.",
170
+ "Add tomatoes, cook until they burst — 4 mins.",
171
+ "Wilt spinach in the pan — 1 min.",
172
+ "Toss pasta with vegetables and pasta water.",
173
+ "Finish with parmesan and black pepper."
174
+ ]
175
+ },
176
+ }
177
+
178
+ recipe = cuisine_recipes.get(cuisine, cuisine_recipes["South Indian"])
179
+ voice = char_voice.get(character, char_voice["Grandma (Paati)"])
180
+
181
+ expiring_tags = [f"⚠️ {i['name']}" for i in items_to_use if expiry_status(i["expiry"])[1] <= 3]
182
+
183
+ output = voice
184
+ output += f"🍽️ Recipe: {recipe['name']}\n"
185
+ output += f"Cuisine: {cuisine}\n\n"
186
+ if expiring_tags:
187
+ output += f"Using before they go: {', '.join(expiring_tags)}\n\n"
188
+ output += "Ingredients:\n"
189
+ for ing in recipe["ingredients"]:
190
+ output += f" • {ing}\n"
191
+ output += "\nSteps:\n"
192
+ for i, step in enumerate(recipe["steps"], 1):
193
+ output += f" {i}. {step}\n"
194
+ output += "\n[🔊 Bark TTS voice coming soon]"
195
+ return output
196
+
197
+ init_db()
198
+
199
+ with gr.Blocks(title="ZeroWasteKitchen", theme=gr.themes.Soft()) as app:
200
+ items_state = gr.State(DUMMY_ITEMS)
201
+
202
+ gr.Markdown("# 🌿 ZeroWasteKitchen\n*Scan receipts · track expiry · cook with personality*")
203
+
204
+ with gr.Tabs():
205
+
206
+ with gr.Tab("1 · Upload receipt"):
207
+ gr.Markdown("### Upload your grocery receipt")
208
+ receipt_image = gr.Image(label="Receipt photo", type="pil", sources=["upload", "webcam"])
209
+ parse_btn = gr.Button("Parse receipt", variant="primary")
210
+ parsed_output = gr.Textbox(label="Parsed items", lines=8, interactive=False)
211
+ save_btn = gr.Button("Save to pantry →")
212
+ save_status = gr.Textbox(label="", interactive=False, max_lines=1)
213
+
214
+ parse_btn.click(fn=parse_receipt, inputs=[receipt_image], outputs=[parsed_output, items_state])
215
+ save_btn.click(fn=lambda s: save_to_pantry(s), inputs=[items_state], outputs=[save_status])
216
+
217
+ with gr.Tab("2 · My pantry"):
218
+ gr.Markdown("### Your current pantry")
219
+ refresh_btn = gr.Button("Refresh", size="sm")
220
+ pantry_output = gr.Textbox(label="Pantry status", lines=12, interactive=False, value=get_pantry())
221
+ with gr.Row():
222
+ mark_item = gr.Textbox(label="Item name to mark as used", placeholder="e.g. Baby spinach 200g")
223
+ mark_btn = gr.Button("Mark as used", size="sm")
224
+ refresh_btn.click(fn=get_pantry, outputs=[pantry_output])
225
+ mark_btn.click(fn=mark_used, inputs=[mark_item], outputs=[pantry_output])
226
+
227
+ with gr.Tab("3 · Cook"):
228
+ gr.Markdown("### Pick your character and cuisine")
229
+ with gr.Row():
230
+ character = gr.Dropdown(
231
+ choices=list(CHARACTERS.keys()),
232
+ value="Grandma (Paati)",
233
+ label="Character"
234
+ )
235
+ cuisine = gr.Dropdown(
236
+ choices=CUISINES,
237
+ value="South Indian",
238
+ label="Cuisine"
239
+ )
240
+ use_expiring = gr.Checkbox(label="Prioritise expiring items", value=True)
241
+ cook_btn = gr.Button("Cook with what I have", variant="primary")
242
+ recipe_output = gr.Textbox(label="Your recipe", lines=20, interactive=False)
243
+ cook_btn.click(fn=generate_recipe, inputs=[character, cuisine, use_expiring], outputs=[recipe_output])
244
+
245
+ if __name__ == "__main__":
246
+ app.launch()
modal_services.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import modal
2
+
3
+ app = modal.App("zerowastekitchen")
4
+
5
+ image = (
6
+ modal.Image.debian_slim()
7
+ .pip_install(
8
+ "torch",
9
+ "torchvision",
10
+ "numpy",
11
+ "transformers>=5.7.0",
12
+ "accelerate",
13
+ "pillow",
14
+ "av"
15
+ )
16
+ )
17
+
18
+ @app.function(gpu="T4", image=image, timeout=180, memory=12288,
19
+ secrets=[modal.Secret.from_name("huggingface-secret")])
20
+ def parse_receipt(image_bytes: bytes) -> dict:
21
+ import torch
22
+ from transformers import AutoModelForImageTextToText, AutoProcessor
23
+ from PIL import Image
24
+ import io
25
+ import json
26
+
27
+ model_id = "openbmb/MiniCPM-V-4.6"
28
+ processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
29
+ model = AutoModelForImageTextToText.from_pretrained(
30
+ model_id,
31
+ torch_dtype="auto",
32
+ device_map="auto",
33
+ trust_remote_code=True
34
+ )
35
+
36
+ img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
37
+
38
+ prompt = """Extract all grocery items from this receipt.
39
+ Return as JSON only, no other text:
40
+ {
41
+ "shop": "shop name if visible",
42
+ "items": [
43
+ {"name": "item name", "quantity": "1", "price": "price if visible"}
44
+ ]
45
+ }"""
46
+
47
+ messages = [
48
+ {
49
+ "role": "user",
50
+ "content": [
51
+ {"type": "image", "image": img},
52
+ {"type": "text", "text": prompt}
53
+ ]
54
+ }
55
+ ]
56
+
57
+ text = processor.apply_chat_template(
58
+ messages,
59
+ tokenize=False,
60
+ add_generation_prompt=True
61
+ )
62
+
63
+ inputs = processor(
64
+ text=text,
65
+ images=[img],
66
+ return_tensors="pt"
67
+ ).to(model.device)
68
+
69
+ with torch.no_grad():
70
+ outputs = model.generate(**inputs, max_new_tokens=2048)
71
+
72
+ result = processor.decode(outputs[0], skip_special_tokens=True)
73
+
74
+ try:
75
+ result = result.split("</think>")[-1].strip()
76
+ json_start = result.find("{")
77
+ json_end = result.rfind("}") + 1
78
+ raw_json = result[json_start:json_end]
79
+
80
+ # Fix missing commas between objects
81
+ import re
82
+ raw_json = re.sub(r'}\s*{', '},{', raw_json)
83
+
84
+ parsed = json.loads(raw_json)
85
+
86
+ # Filter junk rows — keep only real grocery items
87
+ parsed["items"] = [
88
+ item for item in parsed.get("items", [])
89
+ if item.get("name", "").strip()
90
+ and item["name"] not in ["Total", "Total To Pay", "Card", "Promotion Discount", "General Discount", ""]
91
+ and not item["name"].startswith("D Credit")
92
+ ]
93
+
94
+ return parsed
95
+
96
+ except Exception as e:
97
+ return {"shop": "unknown", "items": [], "raw": result}
98
+
99
+ @app.local_entrypoint()
100
+ def main():
101
+ with open("test_receipt.jpg", "rb") as f:
102
+ image_bytes = f.read()
103
+ result = parse_receipt.remote(image_bytes)
104
+ print(result)
test_modal.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import modal
2
+
3
+ app = modal.App("test-zerowastekitchen")
4
+
5
+ image = modal.Image.debian_slim().pip_install("torch", "numpy")
6
+
7
+ @app.function(gpu="T4", image=image)
8
+ def hello():
9
+ import torch
10
+ result = f"GPU available: {torch.cuda.is_available()}"
11
+ if torch.cuda.is_available():
12
+ result += f", Device: {torch.cuda.get_device_name(0)}"
13
+ print(result)
14
+ return result
15
+
16
+ @app.local_entrypoint()
17
+ def main():
18
+ result = hello.remote()
19
+ print(f"Result: {result}")