rehan953 commited on
Commit
194f537
·
verified ·
1 Parent(s): 25d1f6c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +112 -303
app.py CHANGED
@@ -5,67 +5,40 @@ import os
5
  import re
6
  import tempfile
7
  import uuid
8
- from dataclasses import dataclass
9
- from typing import List, Optional, Tuple
10
 
11
- log = logging.getLogger("glmocr_improved_app")
12
  logging.basicConfig(level=logging.INFO)
13
 
14
  # Fine-tuned model repo on HuggingFace
15
  MERGED_MODEL_DIR = os.environ.get("MODEL_DIR", "SimpleCodeAI/glm-ocr-finetuned")
16
 
17
- # Primary render profile (fast)
18
- RENDER_SCALE = 2.6
19
- PAD_LEFT_FRAC = 0.04
20
- PAD_RIGHT_FRAC = 0.16 # right-side amounts are often clipped
21
- PAD_TOP_FRAC = 0.02
22
- PAD_BOTTOM_FRAC = 0.02
23
 
24
- # Retry render profile (quality)
25
- RETRY_RENDER_SCALE = 3.1
26
- RETRY_PAD_RIGHT_FRAC = 0.24
27
 
28
  ENABLE_CONTRAST = True
29
- CONTRAST_FACTOR = 1.20
30
  ENABLE_UNSHARP = True
31
  UNSHARP_RADIUS = 0.78
32
- UNSHARP_PERCENT = 82
33
  UNSHARP_THRESHOLD = 1
34
 
35
  PAGE_PNG_COMPRESS_LEVEL = 3
36
- MAX_IMAGE_SIDE = 1792
37
- MAX_NEW_TOKENS = 3800
38
 
39
  # Model singleton
40
  _model = None
41
  _processor = None
42
 
43
 
44
- @dataclass
45
- class RenderProfile:
46
- scale: float
47
- pad_left: float
48
- pad_right: float
49
- pad_top: float
50
- pad_bottom: float
51
-
52
-
53
- PRIMARY_PROFILE = RenderProfile(
54
- scale=RENDER_SCALE,
55
- pad_left=PAD_LEFT_FRAC,
56
- pad_right=PAD_RIGHT_FRAC,
57
- pad_top=PAD_TOP_FRAC,
58
- pad_bottom=PAD_BOTTOM_FRAC,
59
- )
60
- RETRY_PROFILE = RenderProfile(
61
- scale=RETRY_RENDER_SCALE,
62
- pad_left=PAD_LEFT_FRAC,
63
- pad_right=RETRY_PAD_RIGHT_FRAC,
64
- pad_top=PAD_TOP_FRAC,
65
- pad_bottom=PAD_BOTTOM_FRAC,
66
- )
67
-
68
-
69
  def _load_model():
70
  global _model, _processor
71
  if _model is not None:
@@ -79,15 +52,9 @@ def _load_model():
79
  MERGED_MODEL_DIR,
80
  trust_remote_code=True,
81
  )
82
-
83
- dtype = torch.bfloat16
84
- if not torch.cuda.is_available():
85
- # CPU path is safer with fp32
86
- dtype = torch.float32
87
-
88
  _model = AutoModelForImageTextToText.from_pretrained(
89
  MERGED_MODEL_DIR,
90
- dtype=dtype,
91
  device_map="auto",
92
  trust_remote_code=True,
93
  )
@@ -125,74 +92,7 @@ def _resize_for_inference(img):
125
  return img.resize(new_size, Image.LANCZOS)
126
 
127
 
128
- def _normalize_tables(text: str) -> str:
129
- """
130
- Normalize plain-text table rows into markdown table rows.
131
- This is critical for downstream parsers that expect markdown-table structure.
132
- """
133
- lines = text.split("\n")
134
- result: List[str] = []
135
-
136
- amt_re = re.compile(r"[\d,]+\.\d{2}$")
137
- date_re = re.compile(r"^\d{2}/\d{2}\s")
138
- header_re = re.compile(
139
- r"^(POSTING\s+DATE|DATE)\s+(DESCRIPTION|SERIAL\s+NO\.?|NO\.?\s+CHECKS)",
140
- re.IGNORECASE,
141
- )
142
- subtotal_re = re.compile(r"^(Subtotal:)\s+([\d,]+\.\d{2})", re.IGNORECASE)
143
-
144
- in_plain_table = False
145
-
146
- for raw_line in lines:
147
- line = raw_line.rstrip()
148
- stripped = line.strip()
149
-
150
- if not stripped:
151
- in_plain_table = False
152
- result.append(line)
153
- continue
154
-
155
- if stripped.startswith("|"):
156
- in_plain_table = False
157
- result.append(line)
158
- continue
159
-
160
- if header_re.match(stripped):
161
- parts = [p.strip() for p in re.split(r"\s{2,}", stripped) if p.strip()]
162
- if len(parts) >= 2:
163
- result.append("| " + " | ".join(parts) + " |")
164
- result.append("| " + " | ".join(["---"] * len(parts)) + " |")
165
- in_plain_table = True
166
- continue
167
-
168
- if date_re.match(stripped):
169
- parts = [p.strip() for p in re.split(r"\s{2,}", stripped) if p.strip()]
170
-
171
- # Recover lines where spacing collapsed and only amount is clearly separable.
172
- if len(parts) == 1 and amt_re.search(stripped):
173
- m = re.search(r"^(.*?)\s+([\d,]+\.\d{2})$", stripped)
174
- if m:
175
- parts = [m.group(1).strip(), m.group(2).strip()]
176
-
177
- if len(parts) >= 2:
178
- result.append("| " + " | ".join(parts) + " |")
179
- in_plain_table = True
180
- continue
181
-
182
- if in_plain_table:
183
- m = subtotal_re.match(stripped)
184
- if m:
185
- result.append(f"| | **{m.group(1)}** | **{m.group(2)}** |")
186
- continue
187
-
188
- in_plain_table = False
189
- result.append(line)
190
-
191
- return "\n".join(result)
192
-
193
-
194
- def _strip_code_fences(text: str) -> str:
195
- # Some generations wrap markdown in code fences; strip only outer wrappers.
196
  t = text.strip()
197
  if t.startswith("```") and t.endswith("```"):
198
  t = re.sub(r"^```[a-zA-Z0-9_-]*\n?", "", t)
@@ -200,136 +100,61 @@ def _strip_code_fences(text: str) -> str:
200
  return t.strip()
201
 
202
 
203
- def _postprocess_markdown(text: str) -> str:
204
- text = _strip_code_fences(text)
205
- text = _html_to_markdown_tables(text)
206
- text = _normalize_tables(text)
207
- return text.strip()
208
-
209
-
210
- def _build_prompt(strict_table_mode: bool = False) -> str:
211
  base = (
212
  "Document Parsing to markdown.\n"
213
  "Rules:\n"
214
- "1) Preserve every row and amount exactly as seen.\n"
215
- "2) Keep transaction/payment/check sections in markdown table format.\n"
216
- "3) Never drop right-most numeric columns (amount/balance).\n"
217
- "4) Keep page content order exactly.\n"
218
- "5) Do not summarize.\n"
219
- "6) Output markdown only.\n"
220
- "7) Do not output HTML tags (<table>, <tr>, <td>, <thead>, <tbody>, etc.).\n"
221
- "8) Do not invent sample/demo rows.\n"
222
  )
223
- if strict_table_mode:
224
  base += (
225
- "9) If a row starts with a date (MM/DD), output it as a table row and include trailing amount.\n"
226
- "10) Prefer explicit table rows over plain text for statement activity.\n"
227
  )
228
  return base
229
 
230
 
231
- def _count_amounts(text: str) -> int:
232
- return len(re.findall(r"\b\d{1,3}(?:,\d{3})*\.\d{2}\b", text))
233
-
234
-
235
- def _count_dated_rows(text: str) -> int:
236
- return len(re.findall(r"(?m)^\s*\d{2}/\d{2}\b", text))
237
-
238
-
239
- def _html_to_markdown_tables(text: str) -> str:
240
- """
241
- Convert basic HTML table blocks into pipe markdown tables.
242
- This keeps downstream parsing deterministic even if model emits HTML.
243
- """
244
- table_re = re.compile(r"<table[\s\S]*?</table>", re.IGNORECASE)
245
- row_re = re.compile(r"<tr[\s\S]*?</tr>", re.IGNORECASE)
246
- cell_re = re.compile(r"<t[dh][^>]*>([\s\S]*?)</t[dh]>", re.IGNORECASE)
247
- br_re = re.compile(r"<br\s*/?>", re.IGNORECASE)
248
- tag_re = re.compile(r"<[^>]+>")
249
-
250
- def _clean_cell(s: str) -> str:
251
- s = br_re.sub(" ", s)
252
- s = tag_re.sub(" ", s)
253
- s = s.replace("&nbsp;", " ")
254
- s = s.replace("&amp;", "&")
255
- s = re.sub(r"\s+", " ", s).strip()
256
- return s
257
-
258
- def _convert_one(table_html: str) -> str:
259
- rows = []
260
- for row_html in row_re.findall(table_html):
261
- cells = [_clean_cell(c) for c in cell_re.findall(row_html)]
262
- cells = [c for c in cells if c != ""]
263
- if cells:
264
- rows.append(cells)
265
- if not rows:
266
- return table_html
267
-
268
- width = max(len(r) for r in rows)
269
- norm_rows = [r + [""] * (width - len(r)) for r in rows]
270
- header = norm_rows[0]
271
- out = [
272
- "| " + " | ".join(header) + " |",
273
- "| " + " | ".join(["---"] * width) + " |",
274
- ]
275
- for r in norm_rows[1:]:
276
- out.append("| " + " | ".join(r) + " |")
277
- return "\n".join(out)
278
-
279
- return table_re.sub(lambda m: _convert_one(m.group(0)), text)
280
-
281
-
282
- def _is_hallucinated_table_block(text: str) -> bool:
283
- """
284
- Detect obvious synthetic outputs like repeated '100.00' rows or
285
- placeholder transaction lists not present in statement pages.
286
- """
287
  low = text.lower()
288
- if "<table" not in low and "| transaction | amount |" not in low:
289
- return False
290
-
291
- repeated_100 = len(re.findall(r"\b100\.00\b", text))
292
- credit_card_rows = len(re.findall(r"\bcredit card\b", low))
293
- bank_transfer_rows = len(re.findall(r"\bbank transfer\b", low))
294
-
295
- if repeated_100 >= 18 and (credit_card_rows + bank_transfer_rows) >= 10:
296
  return True
297
  if re.search(r"<!--\s*row\s+\d+\s*-->", low):
298
  return True
 
 
 
 
 
 
299
  return False
300
 
301
 
 
 
 
 
302
  def _looks_amount_missing(text: str) -> bool:
303
- """
304
- Heuristic: many dated activity rows but very low amount density.
305
- This catches pages where payment rows were parsed but right-side amounts vanished.
306
- """
307
  low = text.lower()
308
- likely_activity = any(
309
  key in low
310
  for key in (
311
- "daily account activity",
312
  "electronic payments",
313
  "electronic deposits",
 
314
  "checks paid",
315
  "other withdrawals",
316
- "service charges",
317
  )
318
- )
319
- if not likely_activity:
320
  return False
321
-
322
- dated = _count_dated_rows(text)
323
- amts = _count_amounts(text)
324
- return dated >= 12 and amts <= max(3, dated // 10)
325
 
326
 
327
- def _infer_image(
328
- image_path: str,
329
- strict_table_mode: bool = False,
330
- conservative_mode: bool = False,
331
- ) -> str:
332
- """Run fine-tuned model on a single image file and return markdown."""
333
  import torch
334
  from PIL import Image
335
 
@@ -343,19 +168,12 @@ def _infer_image(
343
  try:
344
  img.save(resized_path, "PNG")
345
 
346
- prompt = _build_prompt(strict_table_mode)
347
- if conservative_mode:
348
- prompt += (
349
- "\n11) If unsure, keep original text line-by-line; do not fabricate values.\n"
350
- "12) Never generate template/example/sample transaction rows.\n"
351
- )
352
-
353
  messages = [
354
  {
355
  "role": "user",
356
  "content": [
357
  {"type": "image", "url": resized_path},
358
- {"type": "text", "text": prompt},
359
  ],
360
  }
361
  ]
@@ -377,14 +195,15 @@ def _infer_image(
377
  **inputs,
378
  max_new_tokens=MAX_NEW_TOKENS,
379
  do_sample=False,
380
- repetition_penalty=1.08,
381
  )
382
 
383
  result = processor.decode(
384
  ids[0][inputs["input_ids"].shape[1] :],
385
  skip_special_tokens=True,
386
  )
387
- return _postprocess_markdown(result)
 
388
  finally:
389
  try:
390
  os.unlink(resized_path)
@@ -392,14 +211,14 @@ def _infer_image(
392
  pass
393
 
394
 
395
- def _render_pdf_page_to_image(pdf_path: str, page_index: int, profile: RenderProfile) -> str:
396
  import pymupdf as fitz
397
  from PIL import Image
398
 
399
  doc = fitz.open(pdf_path)
400
  try:
401
- page = doc[page_index]
402
- pix = page.get_pixmap(matrix=fitz.Matrix(profile.scale, profile.scale), alpha=False)
403
  finally:
404
  doc.close()
405
 
@@ -407,27 +226,20 @@ def _render_pdf_page_to_image(pdf_path: str, page_index: int, profile: RenderPro
407
  img = _enhance_raster_for_ocr(img)
408
 
409
  w, h = img.size
410
- pad_l = int(w * profile.pad_left)
411
- pad_r = int(w * profile.pad_right)
412
- pad_t = int(h * profile.pad_top)
413
- pad_b = int(h * profile.pad_bottom)
414
 
415
  if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)):
416
- canvas = Image.new(
417
- "RGB",
418
- (w + pad_l + pad_r, h + pad_t + pad_b),
419
- (255, 255, 255),
420
- )
421
  canvas.paste(img, (pad_l, pad_t))
422
  img = canvas
423
 
424
  uniq = uuid.uuid4().hex[:10]
425
- out_path = os.path.join(
426
- tempfile.gettempdir(),
427
- f"glmocr_page_{os.getpid()}_{uniq}_{page_index}.png",
428
- )
429
- img.save(out_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
430
- return out_path
431
 
432
 
433
  def _pdf_page_count(pdf_path: str) -> int:
@@ -440,74 +252,71 @@ def _pdf_page_count(pdf_path: str) -> int:
440
  doc.close()
441
 
442
 
 
 
 
 
 
 
 
 
 
 
 
443
  def run_ocr(uploaded_file):
444
  if uploaded_file is None:
445
  return "Please upload a file."
446
 
447
- temp_paths: List[str] = []
448
  try:
449
  path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
450
  is_pdf = path.lower().endswith(".pdf")
451
 
452
- all_pages: List[str] = []
453
-
454
- if not is_pdf:
455
- page_md = _infer_image(path, strict_table_mode=True, conservative_mode=True)
456
- if _is_hallucinated_table_block(page_md):
457
- page_md = _infer_image(path, strict_table_mode=True, conservative_mode=True)
458
- if page_md:
459
- all_pages.append(page_md)
460
- else:
461
  page_total = _pdf_page_count(path)
462
- for page_idx in range(page_total):
463
- log.info("Processing page %d / %d ...", page_idx + 1, page_total)
464
-
465
- # Pass 1: primary profile
466
- img_path = _render_pdf_page_to_image(path, page_idx, PRIMARY_PROFILE)
467
- temp_paths.append(img_path)
468
- page_md = _infer_image(img_path, strict_table_mode=False)
469
-
470
- # Pass 1.5: recover from synthetic/hallucinated table outputs
471
- if _is_hallucinated_table_block(page_md):
472
- log.info(
473
- "Page %d flagged as hallucinated-table; retrying conservative prompt.",
474
- page_idx + 1,
475
- )
476
- page_md_retry = _infer_image(
477
- img_path,
478
- strict_table_mode=True,
479
- conservative_mode=True,
480
- )
481
- if not _is_hallucinated_table_block(page_md_retry):
482
- page_md = page_md_retry
483
-
484
- # Pass 2: retry for amount-missing pages
485
- if _looks_amount_missing(page_md):
486
- log.info(
487
- "Page %d flagged as amount-missing; retrying high-quality render.",
488
- page_idx + 1,
489
- )
490
- retry_img = _render_pdf_page_to_image(path, page_idx, RETRY_PROFILE)
491
- temp_paths.append(retry_img)
492
- retry_md = _infer_image(
493
- retry_img,
494
- strict_table_mode=True,
495
- conservative_mode=True,
496
- )
497
-
498
- # Keep better output by amount density.
499
- if _count_amounts(retry_md) > _count_amounts(page_md):
500
  page_md = retry_md
501
 
 
 
 
 
 
502
  if page_md:
503
  all_pages.append(page_md)
504
 
505
- merged = (
506
- "\n\n---page-separator---\n\n".join(all_pages)
507
- if all_pages
508
- else "(No content extracted)"
509
- )
510
- return merged
 
 
 
 
 
 
 
 
 
 
511
 
512
  except Exception as e:
513
  import traceback
@@ -516,7 +325,7 @@ def run_ocr(uploaded_file):
516
  return f"Error: {e}\n\n{traceback.format_exc()}"
517
 
518
  finally:
519
- for p in temp_paths:
520
  try:
521
  if (
522
  isinstance(p, str)
@@ -531,8 +340,8 @@ def run_ocr(uploaded_file):
531
  def _create_gradio_demo():
532
  import gradio as gr
533
 
534
- with gr.Blocks(title="GLM-OCR Fine-tuned (Improved)") as demo:
535
- gr.Markdown("# GLM-OCR (Fine-tuned, Improved)")
536
  file_in = gr.File(
537
  label="Upload PDF or image",
538
  file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"],
 
5
  import re
6
  import tempfile
7
  import uuid
8
+ from typing import List, Tuple
 
9
 
10
+ log = logging.getLogger("glmocr_simple_app")
11
  logging.basicConfig(level=logging.INFO)
12
 
13
  # Fine-tuned model repo on HuggingFace
14
  MERGED_MODEL_DIR = os.environ.get("MODEL_DIR", "SimpleCodeAI/glm-ocr-finetuned")
15
 
16
+ RENDER_SCALE = 2.0
17
+ PAD_LEFT_FRAC = 0.035
18
+ PAD_RIGHT_FRAC = 0.10
19
+ PAD_TOP_FRAC = 0.018
20
+ PAD_BOTTOM_FRAC = 0.018
 
21
 
22
+ # Slightly stronger retry profile for right-side amount clipping
23
+ RETRY_RENDER_SCALE = 2.6
24
+ RETRY_PAD_RIGHT_FRAC = 0.18
25
 
26
  ENABLE_CONTRAST = True
27
+ CONTRAST_FACTOR = 1.18
28
  ENABLE_UNSHARP = True
29
  UNSHARP_RADIUS = 0.78
30
+ UNSHARP_PERCENT = 76
31
  UNSHARP_THRESHOLD = 1
32
 
33
  PAGE_PNG_COMPRESS_LEVEL = 3
34
+ MAX_IMAGE_SIDE = 1568
35
+ MAX_NEW_TOKENS = 3000
36
 
37
  # Model singleton
38
  _model = None
39
  _processor = None
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def _load_model():
43
  global _model, _processor
44
  if _model is not None:
 
52
  MERGED_MODEL_DIR,
53
  trust_remote_code=True,
54
  )
 
 
 
 
 
 
55
  _model = AutoModelForImageTextToText.from_pretrained(
56
  MERGED_MODEL_DIR,
57
+ dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
58
  device_map="auto",
59
  trust_remote_code=True,
60
  )
 
92
  return img.resize(new_size, Image.LANCZOS)
93
 
94
 
95
+ def _strip_outer_code_fences(text: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  t = text.strip()
97
  if t.startswith("```") and t.endswith("```"):
98
  t = re.sub(r"^```[a-zA-Z0-9_-]*\n?", "", t)
 
100
  return t.strip()
101
 
102
 
103
+ def _build_prompt(strict: bool = False) -> str:
 
 
 
 
 
 
 
104
  base = (
105
  "Document Parsing to markdown.\n"
106
  "Rules:\n"
107
+ "1) Preserve content exactly in reading order.\n"
108
+ "2) Do not summarize.\n"
109
+ "3) Do not invent rows, values, subtotals, or examples.\n"
110
+ "4) Output markdown only; do not output HTML tags.\n"
 
 
 
 
111
  )
112
+ if strict:
113
  base += (
114
+ "5) For statement activity tables, preserve date/description/amount rows exactly.\n"
115
+ "6) If uncertain, keep raw line text instead of fabricating table rows.\n"
116
  )
117
  return base
118
 
119
 
120
+ def _is_hallucinated(text: str) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  low = text.lower()
122
+ if "<table" in low or "<thead" in low or "<tbody" in low:
 
 
 
 
 
 
 
123
  return True
124
  if re.search(r"<!--\s*row\s+\d+\s*-->", low):
125
  return True
126
+ # Signature from your bad sample
127
+ if (
128
+ len(re.findall(r"\b100\.00\b", text)) >= 15
129
+ and len(re.findall(r"\bcredit card\b|\bbank transfer\b", low)) >= 8
130
+ ):
131
+ return True
132
  return False
133
 
134
 
135
+ def _count_amounts(text: str) -> int:
136
+ return len(re.findall(r"\b\d{1,3}(?:,\d{3})*\.\d{2}\b", text))
137
+
138
+
139
  def _looks_amount_missing(text: str) -> bool:
 
 
 
 
140
  low = text.lower()
141
+ if not any(
142
  key in low
143
  for key in (
 
144
  "electronic payments",
145
  "electronic deposits",
146
+ "daily account activity",
147
  "checks paid",
148
  "other withdrawals",
 
149
  )
150
+ ):
 
151
  return False
152
+ dated_rows = len(re.findall(r"(?m)^\s*\d{2}/\d{2}\b", text))
153
+ return dated_rows >= 10 and _count_amounts(text) <= max(2, dated_rows // 12)
 
 
154
 
155
 
156
+ def _infer_image(image_path: str, strict: bool = False) -> str:
157
+ """Run model on a single image and return markdown string."""
 
 
 
 
158
  import torch
159
  from PIL import Image
160
 
 
168
  try:
169
  img.save(resized_path, "PNG")
170
 
 
 
 
 
 
 
 
171
  messages = [
172
  {
173
  "role": "user",
174
  "content": [
175
  {"type": "image", "url": resized_path},
176
+ {"type": "text", "text": _build_prompt(strict)},
177
  ],
178
  }
179
  ]
 
195
  **inputs,
196
  max_new_tokens=MAX_NEW_TOKENS,
197
  do_sample=False,
198
+ repetition_penalty=1.1,
199
  )
200
 
201
  result = processor.decode(
202
  ids[0][inputs["input_ids"].shape[1] :],
203
  skip_special_tokens=True,
204
  )
205
+ return _strip_outer_code_fences(result)
206
+
207
  finally:
208
  try:
209
  os.unlink(resized_path)
 
211
  pass
212
 
213
 
214
+ def _render_page(pdf_path: str, page_idx: int, scale: float, right_pad_frac: float) -> str:
215
  import pymupdf as fitz
216
  from PIL import Image
217
 
218
  doc = fitz.open(pdf_path)
219
  try:
220
+ page = doc[page_idx]
221
+ pix = page.get_pixmap(matrix=fitz.Matrix(scale, scale), alpha=False)
222
  finally:
223
  doc.close()
224
 
 
226
  img = _enhance_raster_for_ocr(img)
227
 
228
  w, h = img.size
229
+ pad_l = int(w * PAD_LEFT_FRAC)
230
+ pad_r = int(w * right_pad_frac)
231
+ pad_t = int(h * PAD_TOP_FRAC)
232
+ pad_b = int(h * PAD_BOTTOM_FRAC)
233
 
234
  if any(p > 0 for p in (pad_l, pad_r, pad_t, pad_b)):
235
+ canvas = Image.new("RGB", (w + pad_l + pad_r, h + pad_t + pad_b), (255, 255, 255))
 
 
 
 
236
  canvas.paste(img, (pad_l, pad_t))
237
  img = canvas
238
 
239
  uniq = uuid.uuid4().hex[:10]
240
+ img_path = os.path.join(tempfile.gettempdir(), f"glmocr_page_{os.getpid()}_{uniq}_{page_idx}.png")
241
+ img.save(img_path, "PNG", compress_level=PAGE_PNG_COMPRESS_LEVEL)
242
+ return img_path
 
 
 
243
 
244
 
245
  def _pdf_page_count(pdf_path: str) -> int:
 
252
  doc.close()
253
 
254
 
255
+ def render_pdf_pages_to_images(pdf_path: str) -> Tuple[List[str], List[int]]:
256
+ page_images: List[str] = []
257
+ page_heights: List[int] = []
258
+ total = _pdf_page_count(pdf_path)
259
+ for i in range(total):
260
+ path = _render_page(pdf_path, i, RENDER_SCALE, PAD_RIGHT_FRAC)
261
+ page_images.append(path)
262
+ page_heights.append(0)
263
+ return page_images, page_heights
264
+
265
+
266
  def run_ocr(uploaded_file):
267
  if uploaded_file is None:
268
  return "Please upload a file."
269
 
270
+ page_images: List[str] = []
271
  try:
272
  path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file)
273
  is_pdf = path.lower().endswith(".pdf")
274
 
275
+ if is_pdf:
 
 
 
 
 
 
 
 
276
  page_total = _pdf_page_count(path)
277
+ all_pages: List[str] = []
278
+
279
+ for page_num in range(page_total):
280
+ log.info("Processing page %d / %d ...", page_num + 1, page_total)
281
+ img_path = _render_page(path, page_num, RENDER_SCALE, PAD_RIGHT_FRAC)
282
+ page_images.append(img_path)
283
+
284
+ page_md = _infer_image(img_path, strict=False)
285
+
286
+ # Retry when output looks clearly wrong.
287
+ if _is_hallucinated(page_md) or _looks_amount_missing(page_md):
288
+ retry_img = _render_page(path, page_num, RETRY_RENDER_SCALE, RETRY_PAD_RIGHT_FRAC)
289
+ page_images.append(retry_img)
290
+ retry_md = _infer_image(retry_img, strict=True)
291
+
292
+ # Prefer non-hallucinated output; otherwise keep original.
293
+ if not _is_hallucinated(retry_md):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  page_md = retry_md
295
 
296
+ # Hard guard: if still hallucinated, drop page instead of poisoning full markdown.
297
+ if _is_hallucinated(page_md):
298
+ log.warning("Skipping hallucinated output on page %d", page_num + 1)
299
+ continue
300
+
301
  if page_md:
302
  all_pages.append(page_md)
303
 
304
+ merged = (
305
+ "\n\n---page-separator---\n\n".join(all_pages)
306
+ if all_pages
307
+ else "(No content extracted)"
308
+ )
309
+ return merged
310
+
311
+ page_md = _infer_image(path, strict=True)
312
+ if _is_hallucinated(page_md):
313
+ # Last fallback for single image
314
+ page_md_retry = _infer_image(path, strict=True)
315
+ if not _is_hallucinated(page_md_retry):
316
+ page_md = page_md_retry
317
+ if _is_hallucinated(page_md):
318
+ return "(Output rejected: detected hallucinated HTML/sample table content)"
319
+ return page_md
320
 
321
  except Exception as e:
322
  import traceback
 
325
  return f"Error: {e}\n\n{traceback.format_exc()}"
326
 
327
  finally:
328
+ for p in page_images:
329
  try:
330
  if (
331
  isinstance(p, str)
 
340
  def _create_gradio_demo():
341
  import gradio as gr
342
 
343
+ with gr.Blocks(title="GLM-OCR Fine-tuned") as demo:
344
+ gr.Markdown("# GLM-OCR (Fine-tuned)")
345
  file_in = gr.File(
346
  label="Upload PDF or image",
347
  file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"],