viswanani commited on
Commit
a1fd711
·
verified ·
1 Parent(s): 325160c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +361 -69
app.py CHANGED
@@ -1,80 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- PRICE_PATTERN = re.compile(r'(?<!\d)(?:₹\s*|Rs\.?\s*|INR\s*)?\d+(?:\.\d{1,2})?(?!\d)')
12
- CLEAN_PRICE = re.compile(r'[^0-9.]')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- def preprocess_image(img: Image.Image) -> Image.Image:
15
- gray = ImageOps.grayscale(img)
16
- enhanced = ImageOps.autocontrast(gray)
17
- denoised = enhanced.filter(ImageFilter.MedianFilter(size=3))
18
- sharpened = denoised.filter(ImageFilter.UnsharpMask(radius=1.5, percent=150, threshold=3))
19
- return sharpened
 
 
 
 
 
20
 
21
- def simple_parse_lines(text: str):
22
- rows = []
23
- current_category = None
24
- lines = [l.strip() for l in text.splitlines() if l.strip()]
25
- for line in lines:
26
- if (line.isupper() and len(line.split()) <= 6) or line.endswith(':'):
27
- current_category = line.rstrip(':').strip()
 
 
 
 
 
 
 
 
28
  continue
29
- price_match = PRICE_PATTERN.search(line)
30
- if price_match:
31
- price_text = price_match.group(0)
32
- price_value = CLEAN_PRICE.sub('', price_text)
33
- item = line[:price_match.start()].strip(" -:•\t")
34
- item = re.sub(r'\s{2,}', ' ', item)
35
- if item:
36
- rows.append({
37
- "Item": item,
38
- "Price": price_value,
39
- "Category": current_category if current_category else ""
40
- })
41
- return rows
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- def process_images_to_zip(files):
44
- work_dir = tempfile.mkdtemp(prefix="menu_excel_")
45
- output_files = []
46
- for idx, file_path in enumerate(files, start=1):
47
- image = Image.open(file_path).convert("RGB")
48
- image = preprocess_image(image)
49
- text = pytesseract.image_to_string(image, lang="eng")
50
- rows = simple_parse_lines(text)
51
- if not rows:
52
- df = pd.DataFrame([{"Extracted Text": text}])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  else:
54
- df = pd.DataFrame(rows, columns=["Item", "Price", "Category"])
55
- excel_name = f"menu_{idx:03d}.xlsx"
56
- excel_path = os.path.join(work_dir, excel_name)
57
- df.to_excel(excel_path, index=False)
58
- output_files.append(excel_path)
59
- zip_name = f"menus_output_{uuid.uuid4().hex[:8]}.zip"
60
- zip_path = os.path.join(work_dir, zip_name)
61
- with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
62
- for path in output_files:
63
- zipf.write(path, arcname=os.path.basename(path))
64
- return zip_path
65
-
66
- with gr.Blocks(title="Menu to Excel (one file per image)") as demo:
67
- gr.Markdown("## Menu to Excel converter\nUpload menu images to get a ZIP containing separate Excel files (one per image).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  with gr.Row():
69
- input_files = gr.File(
70
- label="Upload menu images",
71
- file_count="multiple",
72
- type="filepath", # ✅ correct
73
- file_types=[".png", ".jpg", ".jpeg"]
74
- )
75
- run_btn = gr.Button("Process")
76
- output_zip = gr.File(label="Download ZIP")
77
- run_btn.click(fn=process_images_to_zip, inputs=[input_files], outputs=[output_zip])
78
-
79
- if __name__ == "__main__":
80
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app.py
2
+ """
3
+ Menu OCR -> Excel (Batch) Hugging Face Space app (Gradio)
4
+
5
+ Features:
6
+ - Batch upload of menu images (expects filename format: <StoreName>_<StoreCode> <BranchName>.<ext>)
7
+ - Parses filename to fill A1 (Store Name), B1 (Store Code), C1 (Branch Name)
8
+ - OCR with Tesseract via pytesseract
9
+ - Shows raw OCR text, line confidences, editable table for user validation
10
+ - Saves one Excel per image (copy of uploaded template with rows starting at row 3)
11
+ - Returns a ZIP of all processed Excel files
12
+
13
+ IMPORTANT:
14
+ - This app requires system Tesseract OCR to be installed on the host (Hugging Face Spaces often has it).
15
+ If you see errors about "Tesseract not found", install Tesseract or use a runtime that includes it.
16
+ """
17
+
18
  import gradio as gr
 
 
19
  import pandas as pd
20
+ import pytesseract
21
+ from pytesseract import Output
22
+ import cv2
23
  import re
 
 
24
  import tempfile
25
+ import shutil
26
+ import os
27
+ import numpy as np
28
+ from PIL import Image
29
+ from io import BytesIO
30
+ from zipfile import ZipFile
31
+ from openpyxl import load_workbook
32
+ import logging
33
+ logging.basicConfig(level=logging.INFO)
34
+
35
+ # ---------- CONFIG ----------
36
+ PRICE_REGEX = re.compile(r"(?:₹|Rs\.?|INR)?\s*([0-9]{1,6}(?:\.[0-9]{1,2})?)(?:\s*/-)?\s*$", flags=re.IGNORECASE)
37
+ CATEGORY_HINTS = ["maggi", "noodles", "pizza", "burger", "rice", "continental", "beverages", "coffee", "tea"]
38
+ DEFAULTS = {
39
+ "Active": "1",
40
+ "Priority": "",
41
+ "Image": "",
42
+ "Food type": "",
43
+ "NoOfMains": "1",
44
+ "OnlineName": "",
45
+ "AlternateClassification": "",
46
+ "ItemTaxInclusive": "0",
47
+ "TaxPct": "",
48
+ "BrandName": "",
49
+ "ClassificationCode": "",
50
+ "HSN Code": ""
51
+ }
52
+ # ----------------------------
53
 
54
+ def parse_filename(filename: str):
55
+ base = os.path.splitext(os.path.basename(filename))[0]
56
+ if "_" in base:
57
+ left, right = base.split("_", 1)
58
+ store_name = left.strip()
59
+ parts = right.strip().split(" ", 1)
60
+ store_code = parts[0].strip()
61
+ branch_name = parts[1].strip() if len(parts) > 1 else ""
62
+ else:
63
+ m = re.match(r"(.+?)\s*\((.+?)\)", base)
64
+ if m:
65
+ store_name = m.group(1).strip()
66
+ branch_name = m.group(2).strip()
67
+ store_code = ""
68
+ else:
69
+ store_name = base
70
+ store_code = ""
71
+ branch_name = ""
72
+ return store_name, store_code, branch_name
73
 
74
+ def preprocess_image(np_img):
75
+ gray = cv2.cvtColor(np_img, cv2.COLOR_RGB2GRAY)
76
+ h, w = gray.shape[:2]
77
+ if min(h, w) < 1000:
78
+ scale = max(1.5, 1000.0 / min(h, w))
79
+ gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
80
+ th = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
81
+ cv2.THRESH_BINARY, 41, 11)
82
+ kernel = np.ones((1, 1), np.uint8)
83
+ opened = cv2.morphologyEx(th, cv2.MORPH_OPEN, kernel)
84
+ return opened
85
 
86
+ def ocr_with_confidence(pil_img):
87
+ # Returns full text and list of dicts: {"line":..., "conf":...}
88
+ try:
89
+ data = pytesseract.image_to_data(pil_img, output_type=Output.DICT, lang='eng')
90
+ except Exception as e:
91
+ raise RuntimeError(f"Tesseract OCR failed: {e}. Ensure Tesseract is installed on the host.")
92
+ texts = data.get('text', [])
93
+ confs = data.get('conf', [])
94
+ block_nums = data.get('block_num', [])
95
+ par_nums = data.get('par_num', [])
96
+ line_nums = data.get('line_num', [])
97
+ # Group tokens into lines using block/par/line
98
+ lines_map = {}
99
+ for t, c, b, p, l in zip(texts, confs, block_nums, par_nums, line_nums):
100
+ if t is None or str(t).strip()=="":
101
  continue
102
+ key = f"{b}_{p}_{l}"
103
+ if key not in lines_map:
104
+ lines_map[key] = {"tokens": [], "confs": []}
105
+ lines_map[key]["tokens"].append(str(t))
106
+ try:
107
+ conf_val = float(c)
108
+ except:
109
+ conf_val = -1.0
110
+ if conf_val >= 0:
111
+ lines_map[key]["confs"].append(conf_val)
112
+ lines = []
113
+ for key in sorted(lines_map.keys(), key=lambda x: tuple(map(int, x.split("_")))):
114
+ tokens = lines_map[key]["tokens"]
115
+ confs_line = lines_map[key]["confs"]
116
+ text_line = " ".join(tokens).strip()
117
+ avg_conf = round(sum(confs_line)/len(confs_line),2) if confs_line else 0.0
118
+ lines.append({"line": text_line, "conf": avg_conf})
119
+ full_text = "\n".join([l["line"] for l in lines])
120
+ return full_text, lines
121
+
122
+ def split_lines(text: str):
123
+ cleaned = re.sub(r"[•·●\t]", " ", text)
124
+ cleaned = re.sub(r"[ ]{2,}", " ", cleaned)
125
+ return [ln.strip() for ln in cleaned.splitlines() if ln.strip()]
126
 
127
+ def looks_like_category(line: str):
128
+ low = line.lower()
129
+ if any(k in low for k in CATEGORY_HINTS):
130
+ return True
131
+ if not re.search(r"\d", line) and len(line.split()) <= 6:
132
+ return True
133
+ return False
134
+
135
+ def parse_menu_lines(lines):
136
+ rows = []
137
+ current_parent = ""
138
+ current_category = ""
139
+ for ln in lines:
140
+ if looks_like_category(ln):
141
+ if ln.isupper() or any(k in ln.lower() for k in CATEGORY_HINTS):
142
+ current_parent = ln.strip(":- ")
143
+ continue
144
+ else:
145
+ current_category = ln.strip(":- ")
146
+ continue
147
+ m = PRICE_REGEX.search(ln)
148
+ if m:
149
+ price = m.group(1).strip()
150
+ name_part = PRICE_REGEX.sub("", ln).strip(" -:.")
151
+ row = {
152
+ "Parent Category": current_parent,
153
+ "Category": current_category,
154
+ "Name": name_part,
155
+ "Item Code": "",
156
+ "Master Item Name": name_part,
157
+ "EAN Code": "",
158
+ "Price": price,
159
+ "Active": DEFAULTS["Active"],
160
+ "Priority": DEFAULTS["Priority"],
161
+ "Image": DEFAULTS["Image"],
162
+ "Food type": DEFAULTS["Food type"],
163
+ "NoOfMains": DEFAULTS["NoOfMains"],
164
+ "OnlineName": DEFAULTS["OnlineName"],
165
+ "AlternateClassification": DEFAULTS["AlternateClassification"],
166
+ "ItemTaxInclusive": DEFAULTS["ItemTaxInclusive"],
167
+ "TaxPct": DEFAULTS["TaxPct"],
168
+ "BrandName": DEFAULTS["BrandName"],
169
+ "ClassificationCode": DEFAULTS["ClassificationCode"],
170
+ "HSN Code": DEFAULTS["HSN Code"]
171
+ }
172
+ rows.append(row)
173
  else:
174
+ if re.search(r"\d", ln):
175
+ name_part = ln.strip()
176
+ row = {
177
+ "Parent Category": current_parent,
178
+ "Category": current_category,
179
+ "Name": name_part,
180
+ "Item Code": "",
181
+ "Master Item Name": name_part,
182
+ "EAN Code": "",
183
+ "Price": "",
184
+ "Active": DEFAULTS["Active"],
185
+ "Priority": DEFAULTS["Priority"],
186
+ "Image": DEFAULTS["Image"],
187
+ "Food type": DEFAULTS["Food type"],
188
+ "NoOfMains": DEFAULTS["NoOfMains"],
189
+ "OnlineName": DEFAULTS["OnlineName"],
190
+ "AlternateClassification": DEFAULTS["AlternateClassification"],
191
+ "ItemTaxInclusive": DEFAULTS["ItemTaxInclusive"],
192
+ "TaxPct": DEFAULTS["TaxPct"],
193
+ "BrandName": DEFAULTS["BrandName"],
194
+ "ClassificationCode": DEFAULTS["ClassificationCode"],
195
+ "HSN Code": DEFAULTS["HSN Code"]
196
+ }
197
+ rows.append(row)
198
+ return rows
199
+
200
+ def fill_template_bytes(template_path, rows, store_name, store_code, branch_name):
201
+ wb = load_workbook(template_path)
202
+ ws = wb.active
203
+ ws["A1"] = store_name
204
+ ws["B1"] = store_code
205
+ ws["C1"] = branch_name
206
+ start_row = 3
207
+ r = start_row
208
+ for item in rows:
209
+ ws.cell(row=r, column=1, value=item.get("Parent Category",""))
210
+ ws.cell(row=r, column=2, value=item.get("Category",""))
211
+ ws.cell(row=r, column=3, value=item.get("Name",""))
212
+ ws.cell(row=r, column=4, value=item.get("Item Code",""))
213
+ ws.cell(row=r, column=5, value=item.get("Master Item Name",""))
214
+ ws.cell(row=r, column=6, value=item.get("EAN Code",""))
215
+ ws.cell(row=r, column=7, value=item.get("Price",""))
216
+ ws.cell(row=r, column=8, value=item.get("Active",""))
217
+ ws.cell(row=r, column=9, value=item.get("Priority",""))
218
+ ws.cell(row=r, column=10, value=item.get("Image",""))
219
+ ws.cell(row=r, column=11, value=item.get("Food type",""))
220
+ ws.cell(row=r, column=12, value=item.get("NoOfMains",""))
221
+ ws.cell(row=r, column=13, value=item.get("OnlineName",""))
222
+ ws.cell(row=r, column=14, value=item.get("AlternateClassification",""))
223
+ ws.cell(row=r, column=15, value=item.get("ItemTaxInclusive",""))
224
+ ws.cell(row=r, column=16, value=item.get("TaxPct",""))
225
+ ws.cell(row=r, column=17, value=item.get("BrandName",""))
226
+ ws.cell(row=r, column=18, value=item.get("ClassificationCode",""))
227
+ ws.cell(row=r, column=19, value=item.get("HSN Code",""))
228
+ r += 1
229
+ out = BytesIO()
230
+ wb.save(out)
231
+ out.seek(0)
232
+ return out
233
+
234
+ # Gradio UI
235
+ with gr.Blocks() as demo:
236
+ 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.")
237
+ with gr.Row():
238
+ img_input = gr.File(label="Upload Menu Images (multiple)", file_count="multiple", file_types=["image"])
239
+ template_input = gr.File(label="Upload Excel Template (.xlsx)", file_count="single", file_types=[".xlsx"])
240
+ parse_btn = gr.Button("Parse all images")
241
+ parsed_state = gr.State({})
242
+ status = gr.Textbox(label="Status", interactive=False)
243
+ with gr.Row():
244
+ file_select = gr.Dropdown(choices=[], label="Select parsed image to review")
245
+ refresh_btn = gr.Button("Refresh list")
246
+ with gr.Row():
247
+ raw_text_area = gr.Textbox(label="Raw OCR Text", lines=10)
248
+ conf_area = gr.Dataframe(headers=["line","confidence"], interactive=False)
249
+ 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")
250
  with gr.Row():
251
+ save_btn = gr.Button("Save current edits (generate Excel for this file)")
252
+ save_status = gr.Textbox(label="Save status", interactive=False)
253
+ download_btn = gr.Button("Download ZIP of all (use after saving/edits)")
254
+ download_output = gr.File(label="Download ZIP")
255
+
256
+ def parse_all(images, template):
257
+ if images is None or template is None:
258
+ return {}, "Please upload images and a template", [], ""
259
+ parsed = {}
260
+ for img in images:
261
+ try:
262
+ raw = img.read()
263
+ store_name, store_code, branch_name = parse_filename(img.name)
264
+ pil = Image.open(BytesIO(raw)).convert("RGB")
265
+ np_img = np.array(pil)
266
+ pre = preprocess_image(np_img)
267
+ pil_pre = Image.fromarray(pre)
268
+ full_text, lines_conf = ocr_with_confidence(pil_pre)
269
+ lines = split_lines(full_text)
270
+ rows = parse_menu_lines(lines)
271
+ parsed[img.name] = {
272
+ "store_name": store_name,
273
+ "store_code": store_code,
274
+ "branch_name": branch_name,
275
+ "rows": rows,
276
+ "raw_text": full_text,
277
+ "lines_conf": lines_conf
278
+ }
279
+ except Exception as e:
280
+ parsed[img.name] = {
281
+ "error": str(e)
282
+ }
283
+ choices = list(parsed.keys())
284
+ return parsed, f"Parsed {len(choices)} images", choices, ""
285
+
286
+ parse_btn.click(fn=parse_all, inputs=[img_input, template_input], outputs=[parsed_state, status, file_select, raw_text_area])
287
+
288
+ def refresh_choices(parsed):
289
+ if not parsed:
290
+ return [], ""
291
+ return list(parsed.keys()), ""
292
+
293
+ refresh_btn.click(fn=refresh_choices, inputs=[parsed_state], outputs=[file_select, status])
294
+
295
+ def show_file(selected, parsed):
296
+ if not parsed or not selected:
297
+ return "", pd.DataFrame(), []
298
+ item = parsed.get(selected)
299
+ if "error" in item:
300
+ return f"Error parsing {selected}: {item['error']}", pd.DataFrame(), []
301
+ raw = item.get("raw_text","")
302
+ df = pd.DataFrame(item.get("rows",[]))
303
+ df_conf = pd.DataFrame(item.get("lines_conf",[]))
304
+ return raw, df, df_conf
305
+
306
+ file_select.change(fn=show_file, inputs=[file_select, parsed_state], outputs=[raw_text_area, df_editor, conf_area])
307
+
308
+ def save_current(selected, parsed, edited_df, template):
309
+ if not parsed or not selected:
310
+ return "Nothing to save"
311
+ item = parsed.get(selected)
312
+ if "error" in item:
313
+ return f"Cannot save: {item['error']}"
314
+ if isinstance(edited_df, pd.DataFrame):
315
+ rows = edited_df.fillna("").to_dict(orient="records")
316
+ else:
317
+ rows = edited_df
318
+ item["rows"] = rows
319
+ out_buf = fill_template_bytes(template.name, rows, item["store_name"], item["store_code"], item["branch_name"])
320
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx")
321
+ tmp.write(out_buf.read())
322
+ tmp.close()
323
+ item["generated_path"] = tmp.name
324
+ parsed[selected] = item
325
+ return f"Saved {selected} -> {os.path.basename(tmp.name)}"
326
+
327
+ save_btn.click(fn=save_current, inputs=[file_select, parsed_state, df_editor, template_input], outputs=[save_status])
328
+
329
+ def download_all(parsed, template):
330
+ if not parsed:
331
+ return None
332
+ tempdir = tempfile.mkdtemp()
333
+ zip_path = os.path.join(tempdir, "Menu_Results.zip")
334
+ with ZipFile(zip_path, "w") as zf:
335
+ for name, item in parsed.items():
336
+ if "generated_path" in item:
337
+ try:
338
+ out_name = os.path.splitext(os.path.basename(name))[0] + ".xlsx"
339
+ zf.write(item["generated_path"], arcname=out_name)
340
+ except Exception as e:
341
+ err_name = os.path.splitext(os.path.basename(name))[0] + "_ERROR.txt"
342
+ err_path = os.path.join(tempdir, err_name)
343
+ with open(err_path, "w", encoding="utf-8") as ef:
344
+ ef.write(str(e))
345
+ zf.write(err_path, arcname=err_name)
346
+ else:
347
+ # if not saved by user, auto-generate now
348
+ if "error" in item:
349
+ err_name = os.path.splitext(os.path.basename(name))[0] + "_PARSE_ERROR.txt"
350
+ err_path = os.path.join(tempdir, err_name)
351
+ with open(err_path, "w", encoding="utf-8") as ef:
352
+ ef.write(item["error"])
353
+ zf.write(err_path, arcname=err_name)
354
+ else:
355
+ try:
356
+ out_buf = fill_template_bytes(template.name, item.get("rows",[]), item.get("store_name",""), item.get("store_code",""), item.get("branch_name",""))
357
+ out_name = os.path.splitext(os.path.basename(name))[0] + ".xlsx"
358
+ tmpf = os.path.join(tempdir, out_name)
359
+ with open(tmpf, "wb") as f:
360
+ f.write(out_buf.read())
361
+ zf.write(tmpf, arcname=out_name)
362
+ except Exception as e:
363
+ err_name = os.path.splitext(os.path.basename(name))[0] + "_SAVE_ERROR.txt"
364
+ err_path = os.path.join(tempdir, err_name)
365
+ with open(err_path, "w", encoding="utf-8") as ef:
366
+ ef.write(str(e))
367
+ zf.write(err_path, arcname=err_name)
368
+ return zip_path
369
+
370
+ download_btn.click(fn=download_all, inputs=[parsed_state, template_input], outputs=[download_output])
371
+
372
+ demo.launch()