viswanani commited on
Commit
57f1eca
·
verified ·
1 Parent(s): c43789b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +90 -310
app.py CHANGED
@@ -1,329 +1,109 @@
1
- # app.py
2
  import gradio as gr
3
- import pandas as pd
4
  import pytesseract
5
- import cv2
 
6
  import re
7
- import tempfile
8
- import shutil
9
  import os
10
- import numpy as np
11
- from PIL import Image
12
- from io import BytesIO
13
- from zipfile import ZipFile
14
- from openpyxl import load_workbook
15
-
16
- # ----------------- CONFIG -----------------
17
- PRICE_REGEX = re.compile(r"(?:₹|Rs\.?|INR)?\s*([0-9]{1,6}(?:\.[0-9]{1,2})?)(?:\s*/-)?\s*$", flags=re.IGNORECASE)
18
- CATEGORY_HINTS = ["maggi", "noodles", "pizza", "burger", "rice", "continental", "beverages", "coffee", "tea"]
19
- DEFAULTS = {
20
- "Active": "1",
21
- "Priority": "",
22
- "Image": "",
23
- "Food type": "",
24
- "NoOfMains": "1",
25
- "OnlineName": "",
26
- "AlternateClassification": "",
27
- "ItemTaxInclusive": "0",
28
- "TaxPct": "",
29
- "BrandName": "",
30
- "ClassificationCode": "",
31
- "HSN Code": ""
32
- }
33
-
34
- def parse_filename(filename: str):
35
- base = os.path.splitext(os.path.basename(filename))[0]
36
- if "_" in base:
37
- left, right = base.split("_", 1)
38
- store_name = left.strip()
39
- parts = right.strip().split(" ", 1)
40
- store_code = parts[0].strip()
41
- branch_name = parts[1].strip() if len(parts) > 1 else ""
42
- else:
43
- m = re.match(r"(.+?)\s*\((.+?)\)", base)
44
- if m:
45
- store_name = m.group(1).strip()
46
- branch_name = m.group(2).strip()
47
- store_code = ""
 
 
 
 
 
 
 
 
 
 
 
 
48
  else:
49
- store_name = base
50
- store_code = ""
51
- branch_name = ""
52
- return store_name, store_code, branch_name
53
 
54
- def preprocess_image_cv(np_img):
55
- gray = cv2.cvtColor(np_img, cv2.COLOR_RGB2GRAY)
56
- h, w = gray.shape[:2]
57
- if min(h, w) < 1000:
58
- scale = max(1.5, 1000.0 / min(h, w))
59
- gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
60
- th = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
61
- cv2.THRESH_BINARY, 41, 11)
62
- kernel = np.ones((1, 1), np.uint8)
63
- opened = cv2.morphologyEx(th, cv2.MORPH_OPEN, kernel)
64
- return opened
65
 
66
- def ocr_with_confidence(pil_img):
67
- # returns full text and line-level confidences
68
- data = pytesseract.image_to_data(pil_img, output_type=pytesseract.Output.DATAFRAME, lang='eng')
69
- data = data.dropna(subset=['text'])
70
- lines = []
71
- if data.empty:
72
- return "", []
73
- data['line_key'] = data['block_num'].astype(str) + "_" + data['par_num'].astype(str) + "_" + data['line_num'].astype(str)
74
- for key, grp in data.groupby('line_key'):
75
- text = " ".join(grp['text'].astype(str).tolist()).strip()
76
- confs = grp['conf'].astype(float)
77
- confs = confs[confs >= 0]
78
- avg_conf = float(confs.mean()) if not confs.empty else 0.0
79
- lines.append({"line": text, "conf": round(avg_conf, 2)})
80
- full_text = "\n".join([l["line"] for l in lines])
81
- return full_text, lines
82
 
83
- def split_lines(text: str):
84
- cleaned = re.sub(r"[•·●\t]", " ", text)
85
- cleaned = re.sub(r"[ ]{2,}", " ", cleaned)
86
- lines = [ln.strip() for ln in cleaned.splitlines() if ln.strip()]
87
- return lines
88
 
89
- def looks_like_category(line: str):
90
- low = line.lower()
91
- if any(k in low for k in CATEGORY_HINTS):
92
- return True
93
- if not re.search(r"\d", line) and len(line.split()) <= 6:
94
- return True
95
- return False
96
 
97
- def parse_menu_lines(lines):
98
- rows = []
99
- current_parent = ""
100
- current_category = ""
101
- for ln in lines:
102
- if looks_like_category(ln):
103
- if ln.isupper() or any(k in ln.lower() for k in CATEGORY_HINTS):
104
- current_parent = ln.strip(":- ")
105
- continue
106
- else:
107
- current_category = ln.strip(":- ")
108
- continue
109
- m = PRICE_REGEX.search(ln)
110
- if m:
111
- price = m.group(1).strip()
112
- name_part = PRICE_REGEX.sub("", ln).strip(" -:.")
113
- row = {
114
- "Parent Category": current_parent,
115
- "Category": current_category,
116
- "Name": name_part,
117
- "Item Code": "",
118
- "Master Item Name": name_part,
119
- "EAN Code": "",
120
- "Price": price,
121
- "Active": DEFAULTS["Active"],
122
- "Priority": DEFAULTS["Priority"],
123
- "Image": DEFAULTS["Image"],
124
- "Food type": DEFAULTS["Food type"],
125
- "NoOfMains": DEFAULTS["NoOfMains"],
126
- "OnlineName": DEFAULTS["OnlineName"],
127
- "AlternateClassification": DEFAULTS["AlternateClassification"],
128
- "ItemTaxInclusive": DEFAULTS["ItemTaxInclusive"],
129
- "TaxPct": DEFAULTS["TaxPct"],
130
- "BrandName": DEFAULTS["BrandName"],
131
- "ClassificationCode": DEFAULTS["ClassificationCode"],
132
- "HSN Code": DEFAULTS["HSN Code"]
133
- }
134
- rows.append(row)
135
  else:
136
- if re.search(r"\d", ln):
137
- name_part = ln.strip()
138
- row = {
139
- "Parent Category": current_parent,
140
- "Category": current_category,
141
- "Name": name_part,
142
- "Item Code": "",
143
- "Master Item Name": name_part,
144
- "EAN Code": "",
145
- "Price": "",
146
- "Active": DEFAULTS["Active"],
147
- "Priority": DEFAULTS["Priority"],
148
- "Image": DEFAULTS["Image"],
149
- "Food type": DEFAULTS["Food type"],
150
- "NoOfMains": DEFAULTS["NoOfMains"],
151
- "OnlineName": DEFAULTS["OnlineName"],
152
- "AlternateClassification": DEFAULTS["AlternateClassification"],
153
- "ItemTaxInclusive": DEFAULTS["ItemTaxInclusive"],
154
- "TaxPct": DEFAULTS["TaxPct"],
155
- "BrandName": DEFAULTS["BrandName"],
156
- "ClassificationCode": DEFAULTS["ClassificationCode"],
157
- "HSN Code": DEFAULTS["HSN Code"]
158
- }
159
- rows.append(row)
160
- return rows
161
 
162
- def fill_template(template_path, rows, store_name, store_code, branch_name):
163
- wb = load_workbook(template_path)
164
- ws = wb.active
165
- ws["A1"] = store_name
166
- ws["B1"] = store_code
167
- ws["C1"] = branch_name
168
- start_row = 3
169
- r = start_row
170
- for item in rows:
171
- ws.cell(row=r, column=1, value=item["Parent Category"])
172
- ws.cell(row=r, column=2, value=item["Category"])
173
- ws.cell(row=r, column=3, value=item["Name"])
174
- ws.cell(row=r, column=4, value=item["Item Code"])
175
- ws.cell(row=r, column=5, value=item["Master Item Name"])
176
- ws.cell(row=r, column=6, value=item["EAN Code"])
177
- ws.cell(row=r, column=7, value=item["Price"])
178
- ws.cell(row=r, column=8, value=item["Active"])
179
- ws.cell(row=r, column=9, value=item["Priority"])
180
- ws.cell(row=r, column=10, value=item["Image"])
181
- ws.cell(row=r, column=11, value=item["Food type"])
182
- ws.cell(row=r, column=12, value=item["NoOfMains"])
183
- ws.cell(row=r, column=13, value=item["OnlineName"])
184
- ws.cell(row=r, column=14, value=item["AlternateClassification"])
185
- ws.cell(row=r, column=15, value=item["ItemTaxInclusive"])
186
- ws.cell(row=r, column=16, value=item["TaxPct"])
187
- ws.cell(row=r, column=17, value=item["BrandName"])
188
- ws.cell(row=r, column=18, value=item["ClassificationCode"])
189
- ws.cell(row=r, column=19, value=item["HSN Code"])
190
- r += 1
191
- out = BytesIO()
192
- wb.save(out)
193
- out.seek(0)
194
- return out
195
 
196
- # Gradio callbacks and UI
197
- def parse_uploaded(batch_images, template_file):
198
- parsed = {}
199
- for img in batch_images:
200
- raw = img.read()
201
- store_name, store_code, branch_name = parse_filename(img.name)
202
- pil = Image.open(BytesIO(raw)).convert("RGB")
203
- np_img = np.array(pil)
204
- pre = preprocess_image_cv(np_img)
205
- pil_pre = Image.fromarray(pre)
206
- full_text, lines_conf = ocr_with_confidence(pil_pre)
207
- lines = split_lines(full_text)
208
- rows = parse_menu_lines(lines)
209
- parsed[img.name] = {
210
- "store_name": store_name,
211
- "store_code": store_code,
212
- "branch_name": branch_name,
213
- "rows": rows,
214
- "raw_text": full_text,
215
- "lines_conf": lines_conf
216
- }
217
- return parsed
218
 
219
- def to_display_df(rows):
220
- if not rows:
221
- return pd.DataFrame(columns=["Parent Category","Category","Name","Item Code","Master Item Name","EAN Code","Price","Active","Priority","Image","Food type","NoOfMains","OnlineName","AlternateClassification","ItemTaxInclusive","TaxPct","BrandName","ClassificationCode","HSN Code"])
222
- return pd.DataFrame(rows)
223
-
224
- def save_single(parsed_item, template_file):
225
- rows = parsed_item["rows"]
226
- if isinstance(rows, pd.DataFrame):
227
- rows = rows.to_dict(orient="records")
228
- out_buf = fill_template(template_file.name, rows, parsed_item["store_name"], parsed_item["store_code"], parsed_item["branch_name"])
229
- return out_buf
230
-
231
- def download_all(parsed_state, template_file):
232
- tempdir = tempfile.mkdtemp()
233
- zip_path = os.path.join(tempdir, "Menu_Results.zip")
234
- with ZipFile(zip_path, "w") as zf:
235
- for name, item in parsed_state.items():
236
- try:
237
- out_buf = save_single(item, template_file)
238
- out_name = os.path.splitext(os.path.basename(name))[0] + ".xlsx"
239
- tmp_path = os.path.join(tempdir, out_name)
240
- with open(tmp_path, "wb") as f:
241
- f.write(out_buf.read())
242
- zf.write(tmp_path, arcname=out_name)
243
- except Exception as e:
244
- err_name = os.path.splitext(os.path.basename(name))[0] + "_ERROR.txt"
245
- err_path = os.path.join(tempdir, err_name)
246
- with open(err_path, "w", encoding="utf-8") as ef:
247
- ef.write(str(e))
248
- zf.write(err_path, arcname=err_name)
249
  return zip_path
250
 
251
- # UI layout
252
- with gr.Blocks() as demo:
253
- gr.Markdown("# 🍽️ Menu OCR → Excel (Batch + Validation)\nUpload multiple menu images (named like <StoreName>_<StoreCode> <BranchName>.jpg) and an Excel template. Parse → review/edit each file → download ZIP of filled Excel files.")
254
- with gr.Row():
255
- img_input = gr.File(label="Upload Menu Images (multiple)", file_count="multiple", file_types=["image"])
256
- template_input = gr.File(label="Upload Excel Template (.xlsx)", file_count="single", file_types=[".xlsx"])
257
- parse_btn = gr.Button("Parse all images")
258
- parsed_state = gr.State({})
259
- status = gr.Textbox(label="Status", interactive=False)
260
  with gr.Row():
261
- file_select = gr.Dropdown(choices=[], label="Select parsed image to review")
262
- refresh_btn = gr.Button("Refresh list")
263
- with gr.Row():
264
- raw_text_area = gr.Textbox(label="Raw OCR Text", lines=10)
265
- conf_area = gr.Dataframe(headers=["line","confidence"], interactive=False)
266
- df_editor = gr.Dataframe(headers=["Parent Category","Category","Name","Item Code","Master Item Name","EAN Code","Price","Active","Priority","Image","Food type","NoOfMains","OnlineName","AlternateClassification","ItemTaxInclusive","TaxPct","BrandName","ClassificationCode","HSN Code"], interactive=True, datatype="str")
267
- with gr.Row():
268
- save_btn = gr.Button("Save current edits (generate Excel for this file)")
269
- save_status = gr.Textbox(label="Save status", interactive=False)
270
- download_btn = gr.Button("Download ZIP of all (use after saving/edits)")
271
- download_output = gr.File(label="Download ZIP")
272
-
273
- # Callbacks
274
- def do_parse(images, template):
275
- if images is None or template is None:
276
- return {}, "Please upload images and a template", [], ""
277
- parsed = parse_uploaded(images, template)
278
- choices = list(parsed.keys())
279
- return parsed, f"Parsed {len(choices)} images", choices, ""
280
-
281
- parse_btn.click(fn=do_parse, inputs=[img_input, template_input], outputs=[parsed_state, status, file_select, raw_text_area])
282
-
283
- def refresh_choices(parsed):
284
- if not parsed:
285
- return [], ""
286
- return list(parsed.keys()), ""
287
-
288
- refresh_btn.click(fn=refresh_choices, inputs=[parsed_state], outputs=[file_select, status])
289
-
290
- def show_file_details(selected, parsed):
291
- if not parsed or not selected:
292
- return "", pd.DataFrame(), []
293
- item = parsed.get(selected)
294
- raw = item.get("raw_text","")
295
- lines_conf = item.get("lines_conf",[])
296
- df = to_display_df(item.get("rows",[]))
297
- df_conf = pd.DataFrame(lines_conf)
298
- return raw, df, df_conf
299
-
300
- file_select.change(fn=show_file_details, inputs=[file_select, parsed_state], outputs=[raw_text_area, df_editor, conf_area])
301
-
302
- def save_current(selected, parsed, edited_df, template):
303
- if not parsed or not selected:
304
- return "Nothing to save"
305
- item = parsed.get(selected)
306
- if isinstance(edited_df, pd.DataFrame):
307
- rows = edited_df.fillna("").to_dict(orient="records")
308
- else:
309
- rows = edited_df
310
- item["rows"] = rows
311
- out_buf = fill_template(template.name, rows, item["store_name"], item["store_code"], item["branch_name"])
312
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx")
313
- tmp.write(out_buf.read())
314
- tmp.close()
315
- item["generated_path"] = tmp.name
316
- parsed[selected] = item
317
- return f"Saved {selected} -> {os.path.basename(tmp.name)}"
318
-
319
- save_btn.click(fn=save_current, inputs=[file_select, parsed_state, df_editor, template_input], outputs=[save_status])
320
-
321
- def do_download(parsed, template):
322
- if not parsed:
323
- return None
324
- zip_path = download_all(parsed, template)
325
- return zip_path
326
 
327
- download_btn.click(fn=do_download, inputs=[parsed_state, template_input], outputs=[download_output])
328
 
329
- demo.launch()
 
 
 
1
  import gradio as gr
 
2
  import pytesseract
3
+ from PIL import Image, ImageOps, ImageFilter
4
+ import pandas as pd
5
  import re
 
 
6
  import os
7
+ import zipfile
8
+ import tempfile
9
+ import uuid
10
+
11
+ # -----------------------------
12
+ # Basic parsing helpers
13
+ # -----------------------------
14
+ PRICE_PATTERN = re.compile(r'(?<!\d)(?:₹\s*|Rs\.?\s*|INR\s*)?\d+(?:\.\d{1,2})?(?!\d)')
15
+ CLEAN_PRICE = re.compile(r'[^0-9.]')
16
+
17
+ def preprocess_image(img: Image.Image) -> Image.Image:
18
+ # Convert to grayscale, increase contrast, denoise lightly, sharpen
19
+ gray = ImageOps.grayscale(img)
20
+ enhanced = ImageOps.autocontrast(gray)
21
+ denoised = enhanced.filter(ImageFilter.MedianFilter(size=3))
22
+ sharpened = denoised.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=3))
23
+ return sharpened
24
+
25
+ def simple_parse_lines(text: str):
26
+ """
27
+ Heuristic parser:
28
+ - Splits text into lines
29
+ - Tries to extract Item and Price from each line
30
+ - Category guessed from headings (lines in ALL CAPS or ending with ':')
31
+ """
32
+ rows = []
33
+ current_category = None
34
+
35
+ lines = [l.strip() for l in text.splitlines() if l.strip()]
36
+ for line in lines:
37
+ # Category guess
38
+ if (line.isupper() and len(line.split()) <= 6) or line.endswith(':'):
39
+ current_category = line.rstrip(':').strip()
40
+ continue
41
+
42
+ # Find price
43
+ price_match = PRICE_PATTERN.search(line)
44
+ if price_match:
45
+ price_text = price_match.group(0)
46
+ price_value = CLEAN_PRICE.sub('', price_text)
47
+ # Item is everything before price
48
+ item = line[:price_match.start()].strip(" -:•\t")
49
+ # Cleanup item
50
+ item = re.sub(r'\s{2,}', ' ', item)
51
+ if item:
52
+ rows.append({
53
+ "Item": item,
54
+ "Price": price_value,
55
+ "Category": current_category if current_category else ""
56
+ })
57
  else:
58
+ # If no price, optionally keep as a note; comment out if you prefer dropping
59
+ pass
 
 
60
 
61
+ return rows
 
 
 
 
 
 
 
 
 
 
62
 
63
+ def process_images_to_zip(files):
64
+ # Create temp workspace
65
+ work_dir = tempfile.mkdtemp(prefix="menu_excel_")
66
+ output_files = []
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
+ for idx, file_obj in enumerate(files, start=1):
69
+ # Load image
70
+ image = Image.open(file_obj.name).convert("RGB")
71
+ image = preprocess_image(image)
 
72
 
73
+ # OCR
74
+ text = pytesseract.image_to_string(image, lang="eng")
 
 
 
 
 
75
 
76
+ # Parse
77
+ rows = simple_parse_lines(text)
78
+ if not rows:
79
+ # Fallback: dump raw text if parsing failed
80
+ df = pd.DataFrame([{"Extracted Text": text}])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  else:
82
+ df = pd.DataFrame(rows, columns=["Item", "Price", "Category"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
+ # Save Excel
85
+ excel_name = f"menu_{idx:03d}.xlsx"
86
+ excel_path = os.path.join(work_dir, excel_name)
87
+ df.to_excel(excel_path, index=False)
88
+ output_files.append(excel_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
+ # Bundle ZIP
91
+ zip_name = f"menus_output_{uuid.uuid4().hex[:8]}.zip"
92
+ zip_path = os.path.join(work_dir, zip_name)
93
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
94
+ for path in output_files:
95
+ zipf.write(path, arcname=os.path.basename(path))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  return zip_path
98
 
99
+ with gr.Blocks(title="Menu to Excel (one file per image)") as demo:
100
+ gr.Markdown("## Menu to Excel converter\nUpload menu images to get a ZIP containing separate Excel files (one per image).")
 
 
 
 
 
 
 
101
  with gr.Row():
102
+ input_files = gr.File(label="Upload menu images", file_count="multiple", type="file", file_types=[".png", ".jpg", ".jpeg"])
103
+ run_btn = gr.Button("Process")
104
+ output_zip = gr.File(label="Download ZIP")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
+ run_btn.click(fn=process_images_to_zip, inputs=[input_files], outputs=[output_zip])
107
 
108
+ if __name__ == "__main__":
109
+ demo.launch()