rehan953 commited on
Commit
f8367fc
Β·
verified Β·
1 Parent(s): f9f6641

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +89 -196
app.py CHANGED
@@ -1,8 +1,8 @@
1
  """
2
- GLM-OCR Hugging Face Space app with client-side header/footer fallback for MaaS.
3
- Works for every PDF and every image: uses API bboxes when available, else minimal band.
 
4
  """
5
- # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise
6
  import asyncio
7
  try:
8
  _orig_close = asyncio.BaseEventLoop.close
@@ -21,84 +21,44 @@ import re
21
  import tempfile
22
  import yaml
23
  import gradio as gr
24
-
25
  import glmocr
26
 
27
  log = logging.getLogger("glmocr_app")
28
 
29
- GLMOCR_BASE = os.path.dirname(glmocr.__file__)
30
- CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
31
  FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
32
 
33
- # When MaaS is enabled, the cloud API ignores local layout config and does not return
34
- # header/footer regions. We always run client-side OCR on top/bottom bands as fallback.
35
- DEFAULT_ZONE_FRAC = float(os.environ.get("GLMOCR_DEFAULT_ZONE_FRAC", "0.12"))
36
- # MaaS rejects very small images (400). Only send crops that meet minimum dimensions.
37
- MIN_CROP_HEIGHT = int(os.environ.get("GLMOCR_MIN_CROP_HEIGHT", "112"))
38
- MIN_CROP_PIXELS = int(os.environ.get("GLMOCR_MIN_CROP_PIXELS", "12544")) # 112*112
39
- # Wider PDF bands for position-based extraction (capture account numbers etc. in top/bottom)
40
- PDF_HEADER_BAND_FRAC = float(os.environ.get("GLMOCR_PDF_HEADER_BAND", "0.15")) # top 15%
41
- PDF_FOOTER_BAND_FRAC = float(os.environ.get("GLMOCR_PDF_FOOTER_BAND", "0.85")) # bottom 15%
42
-
43
- # Single shared parser to avoid "GLM-OCR initialized" per request and asyncio cleanup issues.
44
- _parser = None
45
-
46
- def get_parser():
47
- global _parser
48
- if _parser is None:
49
- from glmocr import GlmOcr
50
- _parser = GlmOcr(
51
- api_key=os.environ.get("GLMOCR_API_KEY", "4570c28bdea5493c9efae9dae68edc66.sGbA9DLlcX1GlvqV"),
52
- mode="maas",
53
- )
54
- return _parser
55
 
56
- # ---------------------------------------------------------------------------
57
- # 1. Config: set MaaS and optionally include headers/footers for non-MaaS
58
- # ---------------------------------------------------------------------------
59
  try:
60
  with open(CONFIG_PATH, "r") as f:
61
  config = yaml.safe_load(f)
 
62
  config["pipeline"]["maas"]["enabled"] = True
63
- config["pipeline"]["maas"]["api_key"] = os.environ.get("GLMOCR_API_KEY", "4570c28bdea5493c9efae9dae68edc66.sGbA9DLlcX1GlvqV")
64
-
65
- to_include_as_text = {"header", "footer"}
66
- layout_section = config.get("pipeline", {}).get("layout", {})
67
- if "label_task_mapping" in layout_section:
68
- mapping = layout_section["label_task_mapping"]
69
- abandon = mapping.get("abandon")
70
- if isinstance(abandon, list):
71
- mapping["abandon"] = [x for x in abandon if x not in to_include_as_text]
72
- text_labels = mapping.get("text")
73
- if isinstance(text_labels, list):
74
- for label in to_include_as_text:
75
- if label not in text_labels:
76
- text_labels.append(label)
77
 
 
78
  formatter_section = config.get("pipeline", {}).get("result_formatter", {})
79
- if "abandon" in formatter_section:
80
- abandon = formatter_section["abandon"]
81
- if isinstance(abandon, list):
82
- formatter_section["abandon"] = [x for x in abandon if x not in to_include_as_text]
83
- if "label_visualization_mapping" in formatter_section:
84
- text_vis = formatter_section["label_visualization_mapping"].get("text")
85
- if isinstance(text_vis, list):
86
- for label in to_include_as_text:
87
- if label not in text_vis:
88
- text_vis.append(label)
89
 
90
  with open(CONFIG_PATH, "w") as f:
91
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
92
  except Exception:
93
- pass # e.g. read-only package on Hugging Face; MaaS ignores layout anyway
94
 
95
- # ---------------------------------------------------------------------------
96
- # 2. result_formatter: stop stripping header/footer (best-effort; may be read-only)
97
- # ---------------------------------------------------------------------------
98
  try:
99
  with open(FORMATTER_PATH, "r") as f:
100
  source = f.read()
101
- for label in ('"header"', "'header'", '"footer"', "'footer'", '"doc_header"', "'doc_header'", '"doc_footer"', "'doc_footer'"):
 
102
  source = re.sub(r",\s*" + re.escape(label), "", source)
103
  source = re.sub(re.escape(label) + r"\s*,", "", source)
104
  source = re.sub(re.escape(label), "", source)
@@ -107,211 +67,144 @@ try:
107
  except Exception:
108
  pass
109
 
 
 
110
 
111
- def get_header_footer_zones(regions, norm_height=1000):
112
- if not regions:
113
- return None, None
114
- y_tops, y_bottoms = [], []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  for r in regions:
116
  bbox = r.get("bbox_2d") if isinstance(r, dict) else getattr(r, "bbox_2d", None)
117
  if bbox and len(bbox) >= 4:
118
  y_tops.append(bbox[1])
119
- y_bottoms.append(bbox[3])
120
  if not y_tops:
121
- return None, None
122
- return min(y_tops) / norm_height, max(y_bottoms) / norm_height
 
 
123
 
124
 
125
- def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac):
126
- """Extract text from a horizontal band using a clip rect."""
127
  try:
128
  import pymupdf as fitz
129
- doc = fitz.open(pdf_path)
130
  page = doc[page_num]
131
  h, w = page.rect.height, page.rect.width
132
- rect = fitz.Rect(0, h * y_start_frac, w, h * y_end_frac)
133
- text = page.get_text(clip=rect).strip()
134
  doc.close()
135
  return text
136
  except Exception:
137
  return ""
138
 
139
 
140
- def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac):
141
- """Extract all text whose word bbox falls in the given vertical band (by position).
142
- Use a wider band (e.g. top 15% / bottom 15%) to capture header/footer that clip might miss."""
143
- try:
144
- import pymupdf as fitz
145
- doc = fitz.open(pdf_path)
146
- page = doc[page_num]
147
- h = page.rect.height
148
- y_lo = h * y_start_frac
149
- y_hi = h * y_end_frac
150
- words = page.get_text("words") # list of (x0, y0, x1, y1, word, ...)
151
- doc.close()
152
- parts = []
153
- for w in words:
154
- if len(w) >= 5:
155
- y0, y1 = float(w[1]), float(w[3])
156
- if y0 < y_hi and y1 > y_lo:
157
- parts.append(w[4])
158
- return " ".join(parts).strip()
159
- except Exception:
160
- return ""
161
-
162
-
163
- def ocr_zone(image_path, y_start_frac, y_end_frac):
164
- """Run OCR on a horizontal band. Pads small crops to meet API minimum size instead of skipping."""
165
- zone_name = "header" if y_end_frac < 0.5 else "footer"
166
- try:
167
- from PIL import Image
168
- img = Image.open(image_path).convert("RGB")
169
- w, h = img.size
170
- y0 = max(0, int(h * y_start_frac))
171
- y1 = min(h, int(h * y_end_frac))
172
- if y1 <= y0:
173
- return ""
174
- crop = img.crop((0, y0, w, y1))
175
- cw, ch = crop.size
176
- # Pad to minimum size so MaaS accepts the image (avoids 400 on tiny strips)
177
- if ch < MIN_CROP_HEIGHT or (cw * ch) < MIN_CROP_PIXELS:
178
- need_h = max(ch, MIN_CROP_HEIGHT)
179
- need_w = max(cw, 1)
180
- if (need_w * need_h) < MIN_CROP_PIXELS:
181
- need_w = max(need_w, (MIN_CROP_PIXELS + need_h - 1) // need_h)
182
- canvas = Image.new("RGB", (need_w, need_h), (255, 255, 255))
183
- if zone_name == "header":
184
- canvas.paste(crop, (0, 0)) # crop at top
185
- else:
186
- canvas.paste(crop, (0, need_h - ch)) # crop at bottom
187
- crop = canvas
188
- cw, ch = crop.size
189
- fd, path = tempfile.mkstemp(suffix=".jpg")
190
- os.close(fd)
191
- try:
192
- crop.save(path, "JPEG", quality=92)
193
- parser = get_parser()
194
- out = parser.parse(path)
195
- if not isinstance(out, list):
196
- out = [out]
197
- if out and getattr(out[0], "markdown_result", None):
198
- text = (out[0].markdown_result or "").strip()
199
- return text
200
- finally:
201
- try:
202
- os.unlink(path)
203
- except Exception:
204
- pass
205
- except Exception as e:
206
- log.warning("[%s] ocr_zone failed: %s", zone_name, e, exc_info=True)
207
- return ""
208
-
209
-
210
- def get_page_md_and_regions(page_result):
211
  md = ""
212
  if hasattr(page_result, "markdown_result") and page_result.markdown_result:
213
  md = (page_result.markdown_result or "").strip()
214
  regions = []
215
  if hasattr(page_result, "json_result"):
216
  jr = page_result.json_result
217
- if isinstance(jr, dict) and "regions" in jr:
218
- regions = jr.get("regions") or []
219
- elif isinstance(jr, list) and len(jr) > 0:
220
  r = jr[0] if isinstance(jr[0], list) else jr
221
  if isinstance(r, list):
222
  regions = r
223
- elif isinstance(r, dict) and "regions" in r:
224
- regions = r.get("regions") or []
225
  return md, regions
226
 
227
 
 
228
  def run_ocr(uploaded_file):
229
  if uploaded_file is None:
230
  return "Please upload a file."
231
  try:
232
  import pymupdf as fitz
233
 
234
- path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
235
  is_pdf = path.lower().endswith(".pdf")
236
  parser = get_parser()
237
 
238
  if is_pdf:
239
- doc = fitz.open(path)
240
- page_images = []
 
241
  for i in range(len(doc)):
242
- pix = doc[i].get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False)
243
  img_path = os.path.join(tempfile.gettempdir(), f"maas_page_{i}.png")
244
  pix.save(img_path)
245
  page_images.append(img_path)
 
 
246
  doc.close()
247
  results = parser.parse(page_images)
248
  else:
249
- page_images = [path]
250
- results = parser.parse(path)
 
251
 
252
  if not isinstance(results, list):
253
  results = [results]
254
 
255
  all_pages = []
256
- for page_num, page_result in enumerate(results):
257
- page_md, regions = get_page_md_and_regions(page_result)
258
- header_end_frac, footer_start_frac = get_header_footer_zones(regions, 1000)
259
-
260
- # MaaS does not return header/footer regions; always use at least default bands
261
- he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC
262
- fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC)
263
- if he <= 0:
264
- he = DEFAULT_ZONE_FRAC # ensure we always OCR a top band
265
- if fs >= 1.0:
266
- fs = 1.0 - DEFAULT_ZONE_FRAC # ensure we always OCR a bottom band
267
 
 
 
268
  parts = []
269
 
270
- # Always run header band (top 8–12% of page)
271
- if page_num < len(page_images):
272
- img_path = page_images[page_num]
273
- hdr = ""
274
- if is_pdf:
275
- hdr = extract_zone_text_pdf(path, page_num, 0, he)
276
- if not (hdr and hdr.strip()):
277
- hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC)
278
- if not (hdr and hdr.strip()):
279
- hdr = ocr_zone(img_path, 0, he)
280
- if hdr and hdr.strip():
281
- parts.append(hdr.strip())
282
-
283
  if page_md:
284
  parts.append(page_md)
285
 
286
- # Always run footer band (bottom 8–12% of page)
287
- if page_num < len(page_images):
288
- img_path = page_images[page_num]
289
- ftr = ""
290
- if is_pdf:
291
- ftr = extract_zone_text_pdf(path, page_num, fs, 1.0)
292
- if not (ftr and ftr.strip()):
293
- ftr = extract_pdf_text_in_band(path, page_num, PDF_FOOTER_BAND_FRAC, 1.0)
294
- if not (ftr and ftr.strip()):
295
- ftr = ocr_zone(img_path, fs, 1.0)
296
- if ftr and ftr.strip():
297
- parts.append(ftr.strip())
298
 
299
  if parts:
300
  all_pages.append("\n\n".join(parts))
301
 
302
- return "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)"
 
303
  except Exception as e:
304
  import traceback
305
  log.exception("run_ocr failed: %s", e)
306
  return f"Error: {e}\n\n{traceback.format_exc()}"
307
 
308
 
 
309
  with gr.Blocks(title="GLM-OCR") as demo:
310
- gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers and footers included.")
311
- file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"])
312
- run_btn = gr.Button("Run OCR", variant="primary")
313
- out = gr.Textbox(lines=40, label="Output")
 
 
 
314
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
315
 
316
  if __name__ == "__main__":
317
- demo.launch()
 
1
  """
2
+ GLM-OCR Hugging Face Space β€” MaaS mode with header extraction.
3
+ Header is extracted from PDF text layer using bbox gap detection.
4
+ Footer is NOT extracted separately β€” API already captures it as text regions.
5
  """
 
6
  import asyncio
7
  try:
8
  _orig_close = asyncio.BaseEventLoop.close
 
21
  import tempfile
22
  import yaml
23
  import gradio as gr
 
24
  import glmocr
25
 
26
  log = logging.getLogger("glmocr_app")
27
 
28
+ GLMOCR_BASE = os.path.dirname(glmocr.__file__)
29
+ CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml")
30
  FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py")
31
 
32
+ DEFAULT_HEADER_FRAC = float(os.environ.get("GLMOCR_DEFAULT_HEADER_FRAC", "0.12"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ # ── STEP 1: Fix config ────────────────────────────────────────
 
 
35
  try:
36
  with open(CONFIG_PATH, "r") as f:
37
  config = yaml.safe_load(f)
38
+
39
  config["pipeline"]["maas"]["enabled"] = True
40
+ config["pipeline"]["maas"]["api_key"] = os.environ.get(
41
+ "GLMOCR_API_KEY", "4570c28bdea5493c9efae9dae68edc66.sGbA9DLlcX1GlvqV"
42
+ )
 
 
 
 
 
 
 
 
 
 
 
43
 
44
+ to_include = {"header", "footer"}
45
  formatter_section = config.get("pipeline", {}).get("result_formatter", {})
46
+ if "abandon" in formatter_section and isinstance(formatter_section["abandon"], list):
47
+ formatter_section["abandon"] = [
48
+ x for x in formatter_section["abandon"] if x not in to_include
49
+ ]
 
 
 
 
 
 
50
 
51
  with open(CONFIG_PATH, "w") as f:
52
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
53
  except Exception:
54
+ pass
55
 
56
+ # ── STEP 2: Fix result_formatter.py ──────────────────────────
 
 
57
  try:
58
  with open(FORMATTER_PATH, "r") as f:
59
  source = f.read()
60
+ for label in ('"header"', "'header'", '"footer"', "'footer'",
61
+ '"doc_header"', "'doc_header'", '"doc_footer"', "'doc_footer'"):
62
  source = re.sub(r",\s*" + re.escape(label), "", source)
63
  source = re.sub(re.escape(label) + r"\s*,", "", source)
64
  source = re.sub(re.escape(label), "", source)
 
67
  except Exception:
68
  pass
69
 
70
+ # ── Single shared parser ──────────────────────────────────────
71
+ _parser = None
72
 
73
+ def get_parser():
74
+ global _parser
75
+ if _parser is None:
76
+ from glmocr import GlmOcr
77
+ _parser = GlmOcr(
78
+ api_key=os.environ.get("GLMOCR_API_KEY", "4570c28bdea5493c9efae9dae68edc66.sGbA9DLlcX1GlvqV"),
79
+ mode="maas",
80
+ )
81
+ return _parser
82
+
83
+ # ── STEP 3: Header extraction helpers ────────────────────────
84
+ def get_top_gap_frac(regions, img_height):
85
+ """
86
+ Find the fraction of image height that the API missed at the top.
87
+ Uses bbox_2d of the first (topmost) region.
88
+ Returns a fraction (0–1) or None if no gap detected.
89
+ """
90
+ y_tops = []
91
  for r in regions:
92
  bbox = r.get("bbox_2d") if isinstance(r, dict) else getattr(r, "bbox_2d", None)
93
  if bbox and len(bbox) >= 4:
94
  y_tops.append(bbox[1])
 
95
  if not y_tops:
96
+ return DEFAULT_HEADER_FRAC # no bbox info β€” use default band
97
+ first_y = min(y_tops)
98
+ # Only treat as missed header if gap > 8% of image height
99
+ return (first_y / img_height) if first_y > img_height * 0.08 else None
100
 
101
 
102
+ def extract_header_text(pdf_path, page_num, y_end_frac):
103
+ """Extract text from the top zone of a PDF page using PyMuPDF."""
104
  try:
105
  import pymupdf as fitz
106
+ doc = fitz.open(pdf_path)
107
  page = doc[page_num]
108
  h, w = page.rect.height, page.rect.width
109
+ text = page.get_text(clip=fitz.Rect(0, 0, w, h * y_end_frac)).strip()
 
110
  doc.close()
111
  return text
112
  except Exception:
113
  return ""
114
 
115
 
116
+ def get_page_data(page_result):
117
+ """Extract markdown and regions from one PipelineResult."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  md = ""
119
  if hasattr(page_result, "markdown_result") and page_result.markdown_result:
120
  md = (page_result.markdown_result or "").strip()
121
  regions = []
122
  if hasattr(page_result, "json_result"):
123
  jr = page_result.json_result
124
+ if isinstance(jr, list) and len(jr) > 0:
 
 
125
  r = jr[0] if isinstance(jr[0], list) else jr
126
  if isinstance(r, list):
127
  regions = r
 
 
128
  return md, regions
129
 
130
 
131
+ # ── STEP 4: Main OCR function ─────────────────────────────────
132
  def run_ocr(uploaded_file):
133
  if uploaded_file is None:
134
  return "Please upload a file."
135
  try:
136
  import pymupdf as fitz
137
 
138
+ path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
139
  is_pdf = path.lower().endswith(".pdf")
140
  parser = get_parser()
141
 
142
  if is_pdf:
143
+ doc = fitz.open(path)
144
+ page_images = []
145
+ page_heights = []
146
  for i in range(len(doc)):
147
+ pix = doc[i].get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False)
148
  img_path = os.path.join(tempfile.gettempdir(), f"maas_page_{i}.png")
149
  pix.save(img_path)
150
  page_images.append(img_path)
151
+ # Image height at 1.5x matches bbox_2d coordinate space
152
+ page_heights.append(doc[i].rect.height * 1.5)
153
  doc.close()
154
  results = parser.parse(page_images)
155
  else:
156
+ page_images = [path]
157
+ page_heights = []
158
+ results = parser.parse(path)
159
 
160
  if not isinstance(results, list):
161
  results = [results]
162
 
163
  all_pages = []
 
 
 
 
 
 
 
 
 
 
 
164
 
165
+ for page_num, page_result in enumerate(results):
166
+ page_md, regions = get_page_data(page_result)
167
  parts = []
168
 
169
+ # ── HEADER at TOP ─────────────────────────────────
170
+ # Only for PDFs β€” extract top zone the API missed.
171
+ # We do NOT extract footer β€” the API already captures
172
+ # footer text (copyright, address) as regular text regions.
173
+ if is_pdf and page_num < len(page_heights):
174
+ top_gap = get_top_gap_frac(regions, page_heights[page_num])
175
+ if top_gap is not None:
176
+ hdr = extract_header_text(path, page_num, top_gap)
177
+ if hdr:
178
+ parts.append(hdr)
179
+
180
+ # ── BODY in MIDDLE ────────────────────────────────
 
181
  if page_md:
182
  parts.append(page_md)
183
 
184
+ # No footer extraction β€” avoids duplicating body content.
185
+ # The API captures real footer text as text regions in page_md.
 
 
 
 
 
 
 
 
 
 
186
 
187
  if parts:
188
  all_pages.append("\n\n".join(parts))
189
 
190
+ return "\n\n---\n\n".join(all_pages) if all_pages else "(No content)"
191
+
192
  except Exception as e:
193
  import traceback
194
  log.exception("run_ocr failed: %s", e)
195
  return f"Error: {e}\n\n{traceback.format_exc()}"
196
 
197
 
198
+ # ── STEP 5: Gradio UI ─────────────────────────────────────────
199
  with gr.Blocks(title="GLM-OCR") as demo:
200
+ gr.Markdown("# πŸ” GLM-OCR\nUpload a PDF or image. Headers included in correct position.")
201
+ file_in = gr.File(
202
+ label="Upload PDF or image",
203
+ file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"]
204
+ )
205
+ run_btn = gr.Button("β–Ά Run OCR", variant="primary")
206
+ out = gr.Textbox(lines=40, label="Output")
207
  run_btn.click(fn=run_ocr, inputs=file_in, outputs=out)
208
 
209
  if __name__ == "__main__":
210
+ demo.launch()