multimodalart HF Staff commited on
Commit
36db09e
Β·
verified Β·
1 Parent(s): 59199b5

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/en.JPEG filter=lfs diff=lfs merge=lfs -text
37
+ examples/exampaper.jpg filter=lfs diff=lfs merge=lfs -text
38
+ examples/formula.png filter=lfs diff=lfs merge=lfs -text
39
+ examples/table.png filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,13 +1,32 @@
1
  ---
2
- title: Monkey Ocr V2
3
- emoji: 🐒
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: MonkeyOCRv2 Document Parser
3
+ emoji: πŸ“„
4
+ colorFrom: red
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 6.15.1
 
8
  app_file: app.py
9
+ short_description: Parse document images into structured Markdown
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
  ---
13
 
14
+ # MonkeyOCRv2 Document Parser
15
+
16
+ This Space demonstrates **MonkeyOCRv2-B-Parsing**, a visual-text foundation model for Document AI.
17
+ Upload a document image and the model will:
18
+
19
+ 1. **Detect layout** β€” identify text blocks, tables, formulas, pictures, headers, footers, and their reading order
20
+ 2. **Recognize content** β€” extract text, render tables as HTML, formulas as LaTeX, and pictures as embedded images
21
+ 3. **Output structured Markdown** β€” a clean, readable representation of the document
22
+
23
+ ## Model
24
+
25
+ - **HF Model**: [`zenosai/MonkeyOCRv2-B-Parsing`](https://huggingface.co/zenosai/MonkeyOCRv2-B-Parsing)
26
+ - **Paper**: [MonkeyOCRv2: A Visual-Text Foundation Model for Document AI](https://huggingface.co/papers/2607.11562)
27
+ - **GitHub**: [Yuliang-Liu/MonkeyOCRv2](https://github.com/Yuliang-Liu/MonkeyOCRv2)
28
+
29
+ ## Supported Document Types
30
+
31
+ Receipts, academic papers, forms, tables, mathematical formulas, multi-language documents
32
+ (ZH, EN, AR, DE, ES, FR, HI, ID, IT, JP, KO, NL, PT, RU, TH, VI).
app.py ADDED
@@ -0,0 +1,687 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
4
+ os.environ.setdefault("MOCR2_MAX_PIXELS", "1003520")
5
+
6
+ import spaces # MUST be before any CUDA-touching import
7
+ import torch
8
+ import gradio as gr
9
+ import re
10
+ import json
11
+ import base64
12
+ import tempfile
13
+ from io import BytesIO
14
+ from pathlib import Path
15
+ from html import escape
16
+ from typing import Union, List, Optional, Tuple
17
+ from PIL import Image, ImageFile, ImageDraw
18
+
19
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
20
+
21
+ from transformers import AutoModelForCausalLM, AutoProcessor
22
+
23
+ MODEL_ID = "zenosai/MonkeyOCRv2-B-Parsing"
24
+
25
+ # ── Prompts (from the official parsing pipeline) ──────────────────────────────
26
+
27
+ ALL_PROMPT = {
28
+ "Caption": "Please output the text content from the image.",
29
+ "List-item": "Please output the text content from the image.",
30
+ "Page-footer": "Please output the text content from the image.",
31
+ "Page-header": "Please output the text content from the image.",
32
+ "Section-header": "Please output the text content from the image.",
33
+ "Text": "Please output the text content from the image.",
34
+ "Title": "Please output the text content from the image.",
35
+ "Formula": "Please write out the expression of the formula in the image using LaTeX format.",
36
+ "Table": "Please extract the table from the image and represent it in OTSL format.",
37
+ "LAYOUT": "Please output the categories and coordinates of the document elements in reading order.",
38
+ }
39
+
40
+ # ── Model loading (module scope, eager .to("cuda")) ──────────────────────────
41
+
42
+ processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
43
+ model = AutoModelForCausalLM.from_pretrained(
44
+ MODEL_ID,
45
+ torch_dtype=torch.bfloat16,
46
+ trust_remote_code=True,
47
+ attn_implementation="sdpa",
48
+ ).to("cuda").eval()
49
+
50
+ # ── Helper functions (ported from parse.py) ──────────────────────────────────
51
+
52
+
53
+ def _safe_eval(text: str):
54
+ return eval(text, {"__builtins__": {}}, {})
55
+
56
+
57
+ def _normalize_item(item):
58
+ if not isinstance(item, dict):
59
+ return None
60
+ if "bbox" not in item or "label" not in item:
61
+ return None
62
+ bbox = item["bbox"]
63
+ label = item["label"]
64
+ if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
65
+ return None
66
+ try:
67
+ bbox = [float(v) for v in bbox]
68
+ except Exception:
69
+ return None
70
+ if not isinstance(label, str):
71
+ label = str(label)
72
+ return {"bbox": bbox, "label": label}
73
+
74
+
75
+ def _normalize_list(obj):
76
+ if not isinstance(obj, list):
77
+ return []
78
+ out = []
79
+ for x in obj:
80
+ nx = _normalize_item(x)
81
+ if nx is not None:
82
+ out.append(nx)
83
+ return out
84
+
85
+
86
+ def _extract_balanced_blocks(text: str, lch: str, rch: str):
87
+ res = []
88
+ depth = 0
89
+ start = -1
90
+ for i, ch in enumerate(text):
91
+ if ch == lch:
92
+ if depth == 0:
93
+ start = i
94
+ depth += 1
95
+ elif ch == rch and depth > 0:
96
+ depth -= 1
97
+ if depth == 0 and start != -1:
98
+ res.append(text[start : i + 1])
99
+ start = -1
100
+ return res
101
+
102
+
103
+ def _dedup_keep_order(seq):
104
+ seen = set()
105
+ out = []
106
+ for x in seq:
107
+ if x not in seen:
108
+ seen.add(x)
109
+ out.append(x)
110
+ return out
111
+
112
+
113
+ def _extract_tolerant_list_blocks(text: str):
114
+ blocks = _extract_balanced_blocks(text, "[", "]")
115
+ first = text.find("[")
116
+ if first != -1:
117
+ tail = text[first:].strip()
118
+ if tail:
119
+ lcnt, rcnt = tail.count("["), tail.count("]")
120
+ if lcnt > rcnt:
121
+ tail = tail + ("]" * (lcnt - rcnt))
122
+ blocks.append(tail)
123
+ return _dedup_keep_order(blocks)
124
+
125
+
126
+ def _extract_tolerant_dict_blocks(text: str):
127
+ blocks = _extract_balanced_blocks(text, "{", "}")
128
+ n = len(text)
129
+ for i, ch in enumerate(text):
130
+ if ch != "{":
131
+ continue
132
+ depth = 0
133
+ end = None
134
+ for j in range(i, n):
135
+ cj = text[j]
136
+ if cj == "{":
137
+ depth += 1
138
+ elif cj == "}":
139
+ depth -= 1
140
+ if depth == 0:
141
+ end = j + 1
142
+ break
143
+ if end is None:
144
+ blk = text[i:] + ("}" * max(depth, 1))
145
+ else:
146
+ blk = text[i:end]
147
+ blocks.append(blk)
148
+ return _dedup_keep_order(blocks)
149
+
150
+
151
+ def _parse_one_output(text: str):
152
+ text = (text or "").strip()
153
+ if not text:
154
+ return []
155
+ try:
156
+ obj = _safe_eval(text)
157
+ full = _normalize_list(obj)
158
+ if full:
159
+ return full
160
+ except Exception:
161
+ pass
162
+ best = []
163
+ for blk in _extract_tolerant_list_blocks(text):
164
+ try:
165
+ obj = _safe_eval(blk)
166
+ cur = _normalize_list(obj)
167
+ if len(cur) > len(best):
168
+ best = cur
169
+ except Exception:
170
+ continue
171
+ dict_items = []
172
+ for blk in _extract_tolerant_dict_blocks(text):
173
+ try:
174
+ obj = _safe_eval(blk)
175
+ nobj = _normalize_item(obj)
176
+ if nobj is not None:
177
+ dict_items.append(nobj)
178
+ except Exception:
179
+ continue
180
+ if len(dict_items) > len(best):
181
+ best = dict_items
182
+ return best
183
+
184
+
185
+ def _map_bbox_to_image(bbox, w, h):
186
+ x1, y1, x2, y2 = bbox
187
+ x1 = x1 / 1000.0 * w
188
+ x2 = x2 / 1000.0 * w
189
+ y1 = y1 / 1000.0 * h
190
+ y2 = y2 / 1000.0 * h
191
+ if x1 > x2:
192
+ x1, x2 = x2, x1
193
+ if y1 > y2:
194
+ y1, y2 = y2, y1
195
+ x1 = max(0, min(int(round(x1)), w - 1 if w > 0 else 0))
196
+ y1 = max(0, min(int(round(y1)), h - 1 if h > 0 else 0))
197
+ x2 = max(x1 + 1, min(int(round(x2)), w))
198
+ y2 = max(y1 + 1, min(int(round(y2)), h))
199
+ return [x1, y1, x2, y2]
200
+
201
+
202
+ def otsl_to_html(otsl_str):
203
+ if not otsl_str or not otsl_str.strip():
204
+ return "<table></table>"
205
+ rows_tokens = otsl_str.split("<nl>")
206
+ if rows_tokens and rows_tokens[-1] == "":
207
+ rows_tokens.pop()
208
+ grid = []
209
+ for r_idx, row_str in enumerate(rows_tokens):
210
+ if not row_str.strip():
211
+ if r_idx >= len(grid):
212
+ grid.append([])
213
+ continue
214
+ parts = re.findall(r"<([a-z]+)>(.*?)(?=<[a-z]+>|$)", row_str)
215
+ if r_idx >= len(grid):
216
+ grid.append([])
217
+ col_idx = 0
218
+ for tag, content in parts:
219
+ while True:
220
+ while len(grid[r_idx]) <= col_idx:
221
+ grid[r_idx].append(None)
222
+ if grid[r_idx][col_idx] is not None:
223
+ col_idx += 1
224
+ else:
225
+ break
226
+ if tag == "fcel" or tag == "ecel":
227
+ text = content.strip() if tag == "fcel" else ""
228
+ grid[r_idx][col_idx] = {
229
+ "text": text,
230
+ "rowspan": 1,
231
+ "colspan": 1,
232
+ "valid": True,
233
+ }
234
+ col_idx += 1
235
+ elif tag == "lcel":
236
+ search_c = col_idx - 1
237
+ found = False
238
+ while search_c >= 0:
239
+ if len(grid[r_idx]) > search_c:
240
+ cell = grid[r_idx][search_c]
241
+ if cell and cell.get("valid"):
242
+ cell["colspan"] += 1
243
+ found = True
244
+ break
245
+ search_c -= 1
246
+ if found:
247
+ grid[r_idx][col_idx] = {"valid": False, "type": "lcel"}
248
+ else:
249
+ grid[r_idx][col_idx] = {
250
+ "text": "",
251
+ "rowspan": 1,
252
+ "colspan": 1,
253
+ "valid": True,
254
+ }
255
+ col_idx += 1
256
+ elif tag == "ucel":
257
+ search_r = r_idx - 1
258
+ found = False
259
+ while search_r >= 0:
260
+ if len(grid[search_r]) > col_idx:
261
+ cell = grid[search_r][col_idx]
262
+ if cell and cell.get("valid"):
263
+ cell["rowspan"] += 1
264
+ found = True
265
+ break
266
+ search_r -= 1
267
+ if found:
268
+ grid[r_idx][col_idx] = {"valid": False, "type": "ucel"}
269
+ else:
270
+ grid[r_idx][col_idx] = {
271
+ "text": "",
272
+ "rowspan": 1,
273
+ "colspan": 1,
274
+ "valid": True,
275
+ }
276
+ col_idx += 1
277
+ elif tag == "xcel":
278
+ grid[r_idx][col_idx] = {"valid": False, "type": "xcel"}
279
+ col_idx += 1
280
+ else:
281
+ col_idx += 1
282
+ html_parts = ["<table>"]
283
+ for row in grid:
284
+ html_parts.append("<tr>")
285
+ for cell in row:
286
+ if cell is None:
287
+ continue
288
+ elif cell.get("valid"):
289
+ attrs = []
290
+ if cell["rowspan"] > 1:
291
+ attrs.append(f'rowspan="{cell["rowspan"]}"')
292
+ if cell["colspan"] > 1:
293
+ attrs.append(f'colspan="{cell["colspan"]}"')
294
+ attr_str = " " + " ".join(attrs) if attrs else ""
295
+ text = escape(cell["text"])
296
+ html_parts.append(f"<td{attr_str}>{text}</td>")
297
+ html_parts.append("</tr>")
298
+ html_parts.append("</table>")
299
+ return "".join(html_parts)
300
+
301
+
302
+ def process_formula(content: str):
303
+ content = content.strip("$").strip()
304
+ content = re.sub(r"(?:\\quad\s*){5,}", r"\\quad ", content)
305
+ content = re.sub(r"(?:\\qquad\s*){5,}", r"\\qquad ", content).strip()
306
+ match = re.search(
307
+ r"(?:\\quad|\\qquad|\\eqno)\s*\(([^()]*)\)\s*$"
308
+ r"|\\tag\{([^{}]*)\}\s*$",
309
+ content,
310
+ )
311
+ extracted = None
312
+ if match:
313
+ extracted = match.group(1)
314
+ content = content[: match.start()].rstrip()
315
+ begin_env = None
316
+ has_end = False
317
+ begin_match = re.match(r"^\\begin\{([^\}]+)\}", content)
318
+ if begin_match:
319
+ begin_env = begin_match.group(1)
320
+ content = content[begin_match.end() :].lstrip()
321
+ end_pattern = rf"\\end\{{{re.escape(begin_env)}\}}\s*$"
322
+ end_match = re.search(end_pattern, content)
323
+ if end_match:
324
+ has_end = True
325
+ content = content[: end_match.start()].rstrip()
326
+ match = re.search(
327
+ r"(?:\\quad|\\qquad|\\eqno)\s*\(([^()]*)\)\s*$"
328
+ r"|\\tag\{([^{}]*)\}\s*$",
329
+ content,
330
+ )
331
+ if match:
332
+ extracted = match.group(1)
333
+ content = content[: match.start()].rstrip()
334
+ if begin_env:
335
+ content = f"\\begin{{{begin_env}}}\n{content}\n\\end{{{begin_env}}}"
336
+ return content, extracted
337
+
338
+
339
+ def detect_repeat_token(
340
+ predicted_tokens: str,
341
+ base_max_repeats: int = 4,
342
+ window_size: int = 500,
343
+ cut_from_end: int = 0,
344
+ scaling_factor: float = 3.0,
345
+ ):
346
+ if cut_from_end > 0:
347
+ predicted_tokens = predicted_tokens[:-cut_from_end]
348
+ for seq_len in range(1, window_size // 2 + 1):
349
+ candidate_seq = predicted_tokens[-seq_len:]
350
+ max_repeats = int(base_max_repeats * (1 + scaling_factor / seq_len))
351
+ repeat_count = 0
352
+ pos = len(predicted_tokens) - seq_len
353
+ if pos < 0:
354
+ continue
355
+ while pos >= 0:
356
+ if predicted_tokens[pos : pos + seq_len] == candidate_seq:
357
+ repeat_count += 1
358
+ pos -= seq_len
359
+ else:
360
+ break
361
+ if repeat_count > max_repeats:
362
+ return True
363
+ return False
364
+
365
+
366
+ def _should_retry_repeat_output(raw: str) -> bool:
367
+ raw = raw or ""
368
+ return detect_repeat_token(raw) or (
369
+ len(raw) > 50 and detect_repeat_token(raw, cut_from_end=50)
370
+ )
371
+
372
+
373
+ def image_to_png_data_uri(image: Image.Image) -> str:
374
+ buffer = BytesIO()
375
+ image.convert("RGB").save(buffer, format="PNG")
376
+ encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
377
+ return f"data:image/png;base64,{encoded}"
378
+
379
+
380
+ # ── Inference helpers ─────────────────────────────────────────────────────────
381
+
382
+
383
+ def _load_image_for_model(image: Image.Image, max_pixels: int = None, min_pixels: int = None) -> Image.Image:
384
+ img = image.convert("RGB")
385
+ if min_pixels and img.size[0] * img.size[1] < min_pixels:
386
+ scale = (min_pixels / (img.size[0] * img.size[1])) ** 0.5
387
+ new_size = (int(img.size[0] * scale), int(img.size[1] * scale))
388
+ img = img.resize(new_size, Image.LANCZOS)
389
+ if max_pixels and img.size[0] * img.size[1] > max_pixels:
390
+ scale = (max_pixels / (img.size[0] * img.size[1])) ** 0.5
391
+ new_size = (int(img.size[0] * scale), int(img.size[1] * scale))
392
+ img = img.resize(new_size, Image.LANCZOS)
393
+ return img
394
+
395
+
396
+ def _build_prompt(question: str) -> str:
397
+ return (
398
+ "system\nYou are a helpful assistant.\n"
399
+ f"user\n<|vision_start|><|image_pad|><|vision_end|>"
400
+ f"{question}\n"
401
+ "assistant\n"
402
+ )
403
+
404
+
405
+ def _generate(image: Image.Image, question: str, max_new_tokens: int = 5000, temperature: float = 0.0, do_sample: bool = False) -> str:
406
+ """Single-image, single-question generation using transformers."""
407
+ max_pixels = int(os.environ.get("MOCR2_MAX_PIXELS", "1003520"))
408
+ img = _load_image_for_model(image, max_pixels=max_pixels)
409
+
410
+ prompt_text = _build_prompt(question)
411
+ messages = [{"role": "user", "content": [
412
+ {"type": "image", "image": img},
413
+ {"type": "text", "text": question},
414
+ ]}]
415
+
416
+ inputs = processor.apply_chat_template(
417
+ messages,
418
+ tokenize=True,
419
+ add_generation_prompt=True,
420
+ return_dict=True,
421
+ return_tensors="pt",
422
+ ).to("cuda")
423
+
424
+ with torch.inference_mode():
425
+ out = model.generate(
426
+ **inputs,
427
+ max_new_tokens=max_new_tokens,
428
+ do_sample=do_sample,
429
+ temperature=temperature if do_sample else 1.0,
430
+ )
431
+
432
+ generated_ids = out[:, inputs["input_ids"].shape[1]:]
433
+ text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
434
+ return text
435
+
436
+
437
+ def _batch_generate(images: List[Image.Image], questions: List[str], max_new_tokens: int = 5000) -> List[str]:
438
+ """Batch generation with repeat-token retry."""
439
+ results = []
440
+ for img, q in zip(images, questions):
441
+ raw = _generate(img, q, max_new_tokens=max_new_tokens)
442
+ # Check for repeat tokens and retry with sampling
443
+ if _should_retry_repeat_output(raw):
444
+ for attempt in range(3):
445
+ retry_temp = min(0.2 * (attempt + 1), 0.8)
446
+ raw = _generate(img, q, max_new_tokens=max_new_tokens, temperature=retry_temp, do_sample=True)
447
+ if not _should_retry_repeat_output(raw):
448
+ break
449
+ results.append(raw)
450
+ return results
451
+
452
+
453
+ # ── Two-stage parsing pipeline ────────────────────────────────────────────────
454
+
455
+
456
+ def get_layout(images: List[Image.Image]) -> List[List[dict]]:
457
+ outputs = _batch_generate(images, [ALL_PROMPT["LAYOUT"]] * len(images), max_new_tokens=5000)
458
+ page_layouts = []
459
+ for i, out in enumerate(outputs):
460
+ parsed = _parse_one_output(out)
461
+ w, h = images[i].size
462
+ mapped = []
463
+ for item in parsed:
464
+ mapped.append({
465
+ "bbox": _map_bbox_to_image(item["bbox"], w, h),
466
+ "label": item["label"],
467
+ })
468
+ page_layouts.append(mapped)
469
+ return page_layouts
470
+
471
+
472
+ def parse_images(images: List[Image.Image]) -> Tuple[List[List[dict]], List[List[dict]]]:
473
+ """Two-stage parsing: layout detection β†’ element recognition."""
474
+ layouts_per_page = get_layout(images)
475
+
476
+ tasks = []
477
+ for page_idx, items in enumerate(layouts_per_page):
478
+ img = images[page_idx]
479
+ w, h = img.size
480
+ for item in items:
481
+ x1, y1, x2, y2 = item["bbox"]
482
+ x1 = max(0, min(x1, w - 1 if w > 0 else 0))
483
+ y1 = max(0, min(y1, h - 1 if h > 0 else 0))
484
+ x2 = max(x1 + 1, min(int(round(x2)), w))
485
+ y2 = max(y1 + 1, min(int(round(y2)), h))
486
+ label = item["label"]
487
+ crop = img.crop((x1, y1, x2, y2))
488
+ tasks.append({
489
+ "image": crop,
490
+ "bbox": [x1, y1, x2, y2],
491
+ "label": label,
492
+ "question": ALL_PROMPT.get(label, ""),
493
+ "need_infer": label in ALL_PROMPT,
494
+ "page_idx": page_idx,
495
+ })
496
+
497
+ infer_indices = [i for i, t in enumerate(tasks) if t["need_infer"]]
498
+ infer_images = [tasks[i]["image"] for i in infer_indices]
499
+ infer_questions = [tasks[i]["question"] for i in infer_indices]
500
+
501
+ infer_outputs = _batch_generate(infer_images, infer_questions, max_new_tokens=5000) if infer_indices else []
502
+
503
+ raw_outputs = [""] * len(tasks)
504
+ for k, t_idx in enumerate(infer_indices):
505
+ raw_outputs[t_idx] = infer_outputs[k]
506
+
507
+ page_results = [[] for _ in images]
508
+ for t, raw in zip(tasks, raw_outputs):
509
+ label = t["label"]
510
+ content = (raw or "").strip()
511
+ if label == "Formula":
512
+ content, extracted = process_formula(content)
513
+ content = "$$\n" + content + "\n$$\n"
514
+ if extracted:
515
+ content = content + extracted
516
+ elif label == "Table":
517
+ content = otsl_to_html(content)
518
+ elif label == "Picture":
519
+ image_ref = image_to_png_data_uri(t["image"])
520
+ content = f"![image]({image_ref})"
521
+ elif label == "Title":
522
+ content = "# " + content.replace("\n", "\n# ")
523
+ elif label == "Section-header":
524
+ content = "## " + content.replace("\n", "\n## ")
525
+ elif not t["need_infer"]:
526
+ content = ""
527
+ rec = {
528
+ "bbox": t["bbox"],
529
+ "label": label,
530
+ "content": content,
531
+ "page_num": 1,
532
+ }
533
+ page_results[t["page_idx"]].append(rec)
534
+
535
+ return page_results, layouts_per_page
536
+
537
+
538
+ def result_to_markdown(results: List[List[dict]], keep_header_footer: bool = False) -> str:
539
+ lines = []
540
+ for item in results:
541
+ if not keep_header_footer and item.get("label") in {"Page-header", "Page-footer"}:
542
+ continue
543
+ content = (item.get("content") or "").strip()
544
+ if content:
545
+ lines.append(content)
546
+ md = "\n\n".join(lines).strip() + ("\n" if lines else "")
547
+ md = md.replace("\ufffd", "")
548
+ return md
549
+
550
+
551
+ def draw_layout(image: Image.Image, layout: List[dict]) -> Image.Image:
552
+ """Draw bounding boxes on the image for layout visualization."""
553
+ canvas = image.convert("RGB").copy()
554
+ draw = ImageDraw.Draw(canvas)
555
+ colors = {
556
+ "Title": (255, 0, 0),
557
+ "Section-header": (255, 128, 0),
558
+ "Text": (0, 255, 0),
559
+ "Formula": (0, 0, 255),
560
+ "Table": (128, 0, 255),
561
+ "Picture": (255, 0, 255),
562
+ "Caption": (0, 255, 255),
563
+ "Page-header": (128, 128, 128),
564
+ "Page-footer": (128, 128, 128),
565
+ "List-item": (255, 165, 0),
566
+ }
567
+ for i, it in enumerate(layout):
568
+ x1, y1, x2, y2 = it["bbox"]
569
+ label = it.get("label", "")
570
+ color = colors.get(label, (255, 0, 0))
571
+ draw.rectangle([x1, y1, x2, y2], outline=color, width=2)
572
+ ty = max(0, y1 - 12)
573
+ draw.text((x1, ty), f"{i}: {label}", fill=color)
574
+ return canvas
575
+
576
+
577
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
578
+
579
+ CSS = """
580
+ #col-container { max-width: 1200px; margin: 0 auto; }
581
+ .dark .gradio-container { color: var(--body-text-color); }
582
+ """
583
+
584
+
585
+ def _estimate_duration(image, *args, **kwargs):
586
+ return 120
587
+
588
+
589
+ @spaces.GPU(duration=_estimate_duration)
590
+ def parse_document(
591
+ image: Image.Image,
592
+ keep_header_footer: bool = False,
593
+ show_layout: bool = True,
594
+ ) -> Tuple[str, Optional[Image.Image]]:
595
+ """Parse a document image into structured Markdown.
596
+
597
+ Uses MonkeyOCRv2-B-Parsing, a visual-text foundation model for Document AI.
598
+ The model performs two-stage parsing: (1) layout detection to identify
599
+ document elements (text, tables, formulas, images) and their reading order,
600
+ then (2) content recognition for each element.
601
+
602
+ Args:
603
+ image: Document image to parse.
604
+ keep_header_footer: Whether to keep page headers/footers in the markdown.
605
+ show_layout: Whether to visualize detected layout bounding boxes.
606
+
607
+ Returns:
608
+ A tuple of (parsed_markdown, layout_visualization_image).
609
+ """
610
+ if image is None:
611
+ return "Please upload a document image.", None
612
+
613
+ images = [image]
614
+ results, layouts = parse_images(images)
615
+ markdown = result_to_markdown(results, keep_header_footer=keep_header_footer)
616
+ layout_img = draw_layout(image, layouts[0]) if show_layout and layouts else None
617
+
618
+ return markdown, layout_img
619
+
620
+
621
+ with gr.Blocks(theme=gr.themes.Citrus(), css=CSS) as demo:
622
+ gr.Markdown(
623
+ """
624
+ # MonkeyOCRv2: Document AI Parsing Demo
625
+
626
+ Upload a document image (receipts, papers, forms, tables, formulas, etc.) and the model will
627
+ detect the layout, extract text, tables (as HTML), formulas (as LaTeX), and pictures β€”
628
+ returning structured Markdown.
629
+
630
+ **Model**: [`zenosai/MonkeyOCRv2-B-Parsing`](https://huggingface.co/zenosai/MonkeyOCRv2-B-Parsing)
631
+ | **Paper**: [MonkeyOCRv2](https://huggingface.co/papers/2607.11562)
632
+ | **GitHub**: [Yuliang-Liu/MonkeyOCRv2](https://github.com/Yuliang-Liu/MonkeyOCRv2)
633
+ """
634
+ )
635
+
636
+ with gr.Row():
637
+ with gr.Column(scale=1):
638
+ input_image = gr.Image(
639
+ label="Document Image",
640
+ type="pil",
641
+ height=500,
642
+ )
643
+ with gr.Accordion("Options", open=False):
644
+ keep_hf = gr.Checkbox(
645
+ label="Keep headers & footers",
646
+ value=False,
647
+ )
648
+ show_layout_cb = gr.Checkbox(
649
+ label="Show layout visualization",
650
+ value=True,
651
+ )
652
+ run_btn = gr.Button("Parse Document", variant="primary")
653
+
654
+ with gr.Column(scale=1):
655
+ markdown_out = gr.Code(
656
+ label="Parsed Markdown",
657
+ language="markdown",
658
+ lines=25,
659
+ )
660
+ layout_out = gr.Image(
661
+ label="Layout Visualization",
662
+ type="pil",
663
+ height=400,
664
+ )
665
+
666
+ run_btn.click(
667
+ fn=parse_document,
668
+ inputs=[input_image, keep_hf, show_layout_cb],
669
+ outputs=[markdown_out, layout_out],
670
+ api_name="parse",
671
+ )
672
+
673
+ gr.Examples(
674
+ examples=[
675
+ ["examples/en.JPEG"],
676
+ ["examples/exampaper.jpg"],
677
+ ["examples/table.png"],
678
+ ["examples/formula.png"],
679
+ ],
680
+ inputs=[input_image],
681
+ outputs=[markdown_out, layout_out],
682
+ fn=parse_document,
683
+ cache_examples=True,
684
+ cache_mode="lazy",
685
+ )
686
+
687
+ demo.launch(mcp_server=True)
examples/en.JPEG ADDED

Git LFS Details

  • SHA256: 8bc287b3f29aa904096456b7c39aaaf5d5ec8a8b86185f8d1a24b58a3616659d
  • Pointer size: 131 Bytes
  • Size of remote file: 521 kB
examples/exampaper.jpg ADDED

Git LFS Details

  • SHA256: 095591cfff3b030f2d6a86074df5f578bd1d91e6f46ac4f93c231d1f8f48c8a5
  • Pointer size: 131 Bytes
  • Size of remote file: 211 kB
examples/formula.png ADDED

Git LFS Details

  • SHA256: aaebdcdb02e377238bc499f9f1e58009338d229e78582334d0e5c5f835fb5834
  • Pointer size: 131 Bytes
  • Size of remote file: 123 kB
examples/table.png ADDED

Git LFS Details

  • SHA256: 23a8a9327c8c2eb6f1349825dafba17e60f827da24291bf6470ff7fa9902da67
  • Pointer size: 131 Bytes
  • Size of remote file: 240 kB
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ transformers
2
+ accelerate
3
+ torchvision
4
+ pillow
5
+ einops