Files changed (2) hide show
  1. Readme.md +105 -0
  2. app.py +274 -104
Readme.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Chandra OCR 2
3
+ emoji: πŸ“„
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: gradio
7
+ app_file: app.py
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: Layout-aware document OCR to markdown + extracted figures
11
+ models:
12
+ - datalab-to/chandra-ocr-2
13
+ tags:
14
+ - ocr
15
+ - document-ai
16
+ - vision-language
17
+ suggested_hardware: zero-a10g
18
+ ---
19
+
20
+ # Chandra OCR 2 β€” Space demo
21
+
22
+ Gradio demo for [`datalab-to/chandra-ocr-2`](https://huggingface.co/datalab-to/chandra-ocr-2),
23
+ Datalab's layout-aware document OCR model. Upload PDFs or images and get back
24
+ markdown with tables, math, forms, and reading order preserved β€” **plus** a
25
+ separate figure-extraction stage that crops charts, diagrams, photos and stamps
26
+ at full resolution and reports the model's caption and any structured data it
27
+ read from them.
28
+
29
+ ## Hardware
30
+
31
+ **This will not run on the free CPU tier.** The model is ~10B parameters,
32
+ roughly 20 GB in bf16.
33
+
34
+ | Hardware | Works? |
35
+ |---|---|
36
+ | CPU basic (free) | No β€” OOM at load |
37
+ | ZeroGPU (H200 slice) | Yes β€” recommended |
38
+ | RTX PRO 6000 (Blackwell, sm_120) | Yes |
39
+ | L40S / A100 | Yes |
40
+ | T4 (16 GB) | No, unless you add 4-bit quantisation |
41
+
42
+ Set this under **Settings β†’ Hardware** after creating the Space.
43
+ `suggested_hardware` in the frontmatter is only a hint to visitors; it does not
44
+ provision anything.
45
+
46
+ ## How it works
47
+
48
+ - PDFs are rasterised page by page with PyMuPDF (no poppler/apt needed).
49
+ - Each page is capped on its long edge before inference β€” visual token count
50
+ scales with area, so this is the main lever on latency and memory.
51
+ - Pages are batched `PAGES_PER_GPU_CALL` at a time so each ZeroGPU allocation
52
+ finishes inside its duration budget.
53
+ - **Stage 1 (text):** raw layout output β†’ markdown via `parse_markdown`.
54
+ - **Stage 2 (figures):** `parse_chunks` locates figure-like blocks, which are
55
+ cropped from the **full-resolution** source page (the bbox is per-axis
56
+ normalised, so it maps cleanly onto any resolution with the same aspect
57
+ ratio). Captions and structured data come from the model's layout output and
58
+ are labelled **degraded** when the fallback path was used.
59
+
60
+ Output is offered as rendered markdown, markdown source, the raw model string,
61
+ a figure gallery + metadata table, a combined `.md`, and a `.zip` containing
62
+ per-page markdown, per-page raw output, extracted figure images, and a
63
+ `manifest.json`.
64
+
65
+ The app prefers the official `chandra` package (`generate_hf` + `BatchInputItem`
66
+ + `parse_markdown` + `parse_chunks`). If that import fails, it falls back to
67
+ driving the chat template through plain `transformers` so the Space still boots
68
+ β€” and reports the degradation in the status panel and diagnostics.
69
+
70
+ ## Configuration
71
+
72
+ Edit the constants at the top of `app.py`:
73
+
74
+ | Constant | Default | Purpose |
75
+ |---|---|---|
76
+ | `PAGES_PER_GPU_CALL` | 3 | Pages per ZeroGPU allocation |
77
+ | `GPU_DURATION` | 180 | Seconds requested per allocation |
78
+ | `MAX_PAGES` | 20 | Per-run page cap |
79
+
80
+ Prompt types are **enumerated from the package** (`PROMPT_MAPPING`), not
81
+ hardcoded. Only `ocr_layout` and `ocr` are currently exposed; the dropdown is
82
+ editable so you can try undocumented values.
83
+
84
+ ## Troubleshooting
85
+
86
+ | Symptom | Cause / fix |
87
+ |---|---|
88
+ | Space won't start, opaque "upload failed" | Model load failed at import β€” the diagnostics panel auto-opens with the exact error. |
89
+ | `CUDA out of memory` per page | Lower **Max image side**; it's the biggest VRAM lever. |
90
+ | No figures in the gallery | The model returned no figure bboxes for this prompt type, or the `chandra` package is unavailable (outputs marked degraded). |
91
+ | `flash-attn` build error | Do **not** add flash-attn β€” Blackwell (sm_120) has no prebuilt wheels. SDPA is used. |
92
+ | `torch` version mismatch | Do **not** pin torch β€” the Space image ships a build matched to the driver/CUDA. |
93
+
94
+ ## Licence β€” read before making this public
95
+
96
+ The Space **code** here is Apache-2.0. The **model weights** are not:
97
+
98
+ > Code is Apache 2.0. Model weights are under a modified OpenRAIL-M license.
99
+ > Free for research, personal use, and startups under $2M funding/revenue.
100
+ > Cannot be used competitively with our API.
101
+
102
+ A public, free, hosted OCR endpoint is plausibly "competitive with our API."
103
+ If you are past the revenue threshold, or intend this as a product rather than a
104
+ demo, check with Datalab first. Setting the Space to **private** avoids the
105
+ question entirely.
app.py CHANGED
@@ -4,8 +4,25 @@ Chandra OCR 2 β€” Hugging Face Space demo.
4
  Model: datalab-to/chandra-ocr-2 (~10B, bf16)
5
  Docs: https://huggingface.co/datalab-to/chandra-ocr-2
6
 
7
- Hardware: needs ZeroGPU (H200 slice) or a paid A100/L40S.
 
 
 
 
 
 
 
8
  The model is ~20 GB in bf16 and will NOT run on the free CPU tier.
 
 
 
 
 
 
 
 
 
 
9
  """
10
 
11
  import inspect
@@ -25,13 +42,6 @@ from PIL import Image
25
 
26
  MODEL_ID = "datalab-to/chandra-ocr-2"
27
 
28
- # Chandra is prompt-type driven rather than free-text prompted. 'ocr_layout' is
29
- # the one shown in the model card Quickstart; the others are exposed because the
30
- # card advertises markdown / HTML / JSON output. If one errors, the package
31
- # doesn't support that name in your installed version β€” check `chandra` docs.
32
- PROMPT_TYPES = ["ocr_layout", "ocr", "ocr_html", "ocr_json", "layout"]
33
- DEFAULT_PROMPT_TYPE = "ocr_layout"
34
-
35
  PAGES_PER_GPU_CALL = 3 # keep each ZeroGPU allocation inside its duration budget
36
  GPU_DURATION = 180 # seconds requested per allocation
37
  MAX_PAGES = 20 # guard against someone uploading a 500-page PDF
@@ -40,12 +50,20 @@ OUT_DIR = Path(os.environ.get("CHANDRA_OUT_DIR", "/tmp/chandra_out"))
40
  OUT_DIR.mkdir(parents=True, exist_ok=True)
41
 
42
  IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
 
 
 
 
 
 
 
 
 
43
 
44
  # ---------------------------------------------------------------------------
45
  # ZeroGPU shim β€” lets the same file run locally without the `spaces` package
46
  # ---------------------------------------------------------------------------
47
 
48
- # Set by the platform on ZeroGPU hardware.
49
  ON_ZERO = os.environ.get("SPACES_ZERO_GPU") == "true"
50
 
51
  try:
@@ -69,25 +87,40 @@ except ImportError: # local / non-ZeroGPU deploy
69
  # ---------------------------------------------------------------------------
70
 
71
  CHANDRA_ERR = None
 
 
 
 
72
  try:
73
  from chandra.model import generate_hf
74
  from chandra.model.schema import BatchInputItem
 
75
 
76
  try:
77
- from chandra.output import parse_markdown
78
  except ImportError:
79
- from chandra.model.output import parse_markdown
80
 
81
  USE_CHANDRA = True
 
 
82
  except Exception as e: # noqa: BLE001
83
- USE_CHANDRA = False
84
  CHANDRA_ERR = f"{type(e).__name__}: {e}"
 
 
 
 
85
 
86
 
87
  # ---------------------------------------------------------------------------
88
- # Model
89
  # ---------------------------------------------------------------------------
90
 
 
 
 
 
 
91
  def _load():
92
  from transformers import AutoProcessor
93
 
@@ -99,13 +132,15 @@ def _load():
99
  # On ZeroGPU there is no GPU visible at import time, so accelerate's
100
  # device_map="auto" would strand the model on CPU. ZeroGPU instead
101
  # intercepts .to("cuda") at global scope. Elsewhere, device_map is fine.
102
- kw = dict(low_cpu_mem_usage=True)
 
103
  if not ON_ZERO:
104
  kw["device_map"] = "auto"
105
 
106
  try:
107
  m = VLM.from_pretrained(MODEL_ID, dtype=torch.bfloat16, **kw)
108
  except TypeError: # transformers < 4.56 spelled it torch_dtype
 
109
  m = VLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, **kw)
110
 
111
  if ON_ZERO:
@@ -118,40 +153,46 @@ def _load():
118
  return m, p
119
 
120
 
121
- model = processor = None
122
- MODEL_ERR = None
123
-
124
- print(f"Loading {MODEL_ID} ...")
125
- _t0 = time.time()
126
  try:
127
- model, processor = _load()
128
- print(f"Loaded in {time.time() - _t0:.0f}s")
 
 
 
129
  except Exception as e: # noqa: BLE001
130
- # Do NOT re-raise. A crash here kills the container and every interaction
131
- # afterwards fails with an opaque error, including the file picker.
132
- import traceback
133
- MODEL_ERR = f"{type(e).__name__}: {e}"
134
- traceback.print_exc()
135
- print(f"!! MODEL FAILED TO LOAD: {MODEL_ERR}")
136
 
137
- print(f"chandra pkg: {USE_CHANDRA} ({CHANDRA_ERR or 'ok'})")
138
 
 
 
 
139
 
140
- def diagnostics() -> str:
141
- import platform
142
  try:
143
- import transformers as _tf
144
- tfv = _tf.__version__
145
  except Exception: # noqa: BLE001
146
- tfv = "?"
147
- gpu_name = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "none visible"
148
- return (
149
- f"python {platform.python_version()} | torch {torch.__version__} | "
150
- f"transformers {tfv} | gradio {gr.__version__}\n"
151
- f"ZeroGPU: {ON_ZERO} | cuda now: {torch.cuda.is_available()} ({gpu_name})\n"
152
- f"chandra package: {'ok' if USE_CHANDRA else CHANDRA_ERR}\n"
153
- f"model: {'loaded' if model is not None else 'FAILED β€” ' + str(MODEL_ERR)}"
154
- )
 
 
 
 
 
 
 
 
 
155
 
156
 
157
  # ---------------------------------------------------------------------------
@@ -159,13 +200,15 @@ def diagnostics() -> str:
159
  # ---------------------------------------------------------------------------
160
 
161
  def as_path(f) -> Path:
162
- """Normalise str / Path / Gradio file object into a real Path.
163
 
164
- Note pathlib.Path also has .name, but there it's the basename β€” checking
165
- hasattr first would silently drop the directory.
 
166
  """
167
  if isinstance(f, (str, os.PathLike)):
168
  return Path(f)
 
169
  return Path(getattr(f, "name", str(f)))
170
 
171
 
@@ -186,21 +229,22 @@ def pdf_to_images(path: Path, dpi: int):
186
 
187
 
188
  def collect_pages(files, dpi: int):
189
- """Returns (pages, skipped). Never raises on a single bad file."""
190
- pages, skipped = [], []
 
191
  for f in files:
192
  p = as_path(f)
193
  ext = p.suffix.lower()
194
  try:
195
- if ext == ".pdf":
196
  pages.extend(pdf_to_images(p, dpi))
197
  elif ext in IMAGE_EXTS:
198
  pages.append((p.stem, Image.open(p).convert("RGB")))
199
  else:
200
- skipped.append(f"{p.name} (unsupported type '{ext or 'none'}')")
201
  except Exception as e: # noqa: BLE001
202
- skipped.append(f"{p.name} ({type(e).__name__}: {e})")
203
- return pages, skipped
204
 
205
 
206
  def fit(img: Image.Image, max_side: int) -> Image.Image:
@@ -222,37 +266,32 @@ def fit(img: Image.Image, max_side: int) -> Image.Image:
222
  @torch.inference_mode()
223
  def _infer_chunk(images, prompt_type: str, max_new_tokens: int):
224
  """OCR a small batch of PIL images. Returns a list of raw model strings."""
225
- if model is None:
226
- raise RuntimeError(
227
- f"Model never loaded: {MODEL_ERR}. Check the Container logs and "
228
- f"confirm Settings > Hardware is ZeroGPU or a GPU tier."
229
- )
230
 
231
  if USE_CHANDRA:
232
  batch = [BatchInputItem(image=im, prompt_type=prompt_type) for im in images]
233
- try:
234
- results = generate_hf(batch, model, max_tokens=max_new_tokens)
235
- except TypeError:
236
- results = generate_hf(batch, model)
237
  return [getattr(r, "raw", None) or getattr(r, "markdown", "") or str(r)
238
  for r in results]
239
 
240
- # ---- fallback: drive the chat template directly ----
241
  outs = []
242
  for im in images:
243
  msgs = [{"role": "user", "content": [
244
  {"type": "image", "image": im},
245
  {"type": "text", "text": prompt_type},
246
  ]}]
247
- inputs = processor.apply_chat_template(
248
  msgs, tokenize=True, add_generation_prompt=True,
249
  return_dict=True, return_tensors="pt",
250
- ).to(model.device)
251
  if "pixel_values" in inputs:
252
- inputs["pixel_values"] = inputs["pixel_values"].to(model.dtype)
253
- gen = model.generate(**inputs, max_new_tokens=int(max_new_tokens), do_sample=False)
 
254
  trimmed = [o[len(i):] for i, o in zip(inputs["input_ids"], gen)]
255
- outs.append(processor.batch_decode(trimmed, skip_special_tokens=True)[0].strip())
256
  return outs
257
 
258
 
@@ -266,39 +305,111 @@ def to_markdown(raw: str) -> str:
266
 
267
 
268
  # ---------------------------------------------------------------------------
269
- # Orchestration
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  # ---------------------------------------------------------------------------
271
 
272
- def run(files, prompt_type, dpi, max_side, max_new_tokens,
273
- progress=gr.Progress()):
274
 
275
- def msg(m):
276
- return "", "", "", m, None, None
 
 
 
 
 
 
277
 
278
  if not files:
279
- yield msg("Upload a PDF or some images first.")
280
  return
281
 
282
- pages, skipped = collect_pages(files, int(dpi))
283
- note = ("\nSkipped: " + "; ".join(skipped)) if skipped else ""
 
 
 
284
 
285
  if not pages:
286
- yield msg("Nothing readable in that upload." + note +
287
- "\n\nSupported: .pdf, .png, .jpg, .jpeg, .webp, .bmp, .tif, .tiff")
288
  return
289
 
290
- truncated = note
 
 
 
 
291
  if len(pages) > MAX_PAGES:
292
- truncated += f" (truncated from {len(pages)} pages)"
293
  pages = pages[:MAX_PAGES]
294
 
295
- md_parts, raw_parts, per_page = [], [], []
 
 
296
  t_start = time.time()
297
 
298
  for start in range(0, len(pages), PAGES_PER_GPU_CALL):
299
  chunk = pages[start:start + PAGES_PER_GPU_CALL]
300
  names = [n for n, _ in chunk]
301
- imgs = [fit(im, int(max_side)) for _, im in chunk]
 
302
 
303
  progress(start / len(pages),
304
  desc=f"{names[0]} … ({start + 1}-{start + len(chunk)}/{len(pages)})")
@@ -311,18 +422,25 @@ def run(files, prompt_type, dpi, max_side, max_new_tokens,
311
  except Exception as e: # noqa: BLE001
312
  raws = [f"[FAILED: {type(e).__name__}: {e}]"] * len(chunk)
313
 
314
- for name, raw in zip(names, raws):
315
  md = to_markdown(raw)
 
 
 
 
 
 
316
  md_parts.append(f"\n\n---\n\n## {name}\n\n{md}")
317
  raw_parts.append(f"===== {name} =====\n{raw}")
318
- per_page.append((name, md, raw))
319
 
320
  elapsed = time.time() - t_start
321
  joined = "\n".join(md_parts)
322
  yield (joined, joined,
323
  "\n\n".join(raw_parts),
324
  f"{len(per_page)}/{len(pages)} pages{truncated} Β· {elapsed:.0f}s "
325
- f"({elapsed / max(1, len(per_page)):.1f}s/page)",
 
326
  None, None)
327
 
328
  # ---- artefacts ----
@@ -332,16 +450,25 @@ def run(files, prompt_type, dpi, max_side, max_new_tokens,
332
 
333
  zip_path = OUT_DIR / f"chandra_{stamp}.zip"
334
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
335
- for name, md, raw in per_page:
336
  safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
337
  z.writestr(f"markdown/{safe}.md", md)
338
  z.writestr(f"raw/{safe}.txt", raw)
 
 
 
 
339
  z.writestr("manifest.json", json.dumps({
340
  "model": MODEL_ID,
341
  "prompt_type": prompt_type,
342
  "dpi": dpi,
343
  "max_side": max_side,
344
- "pages": [n for n, _, _ in per_page],
 
 
 
 
 
345
  }, indent=2))
346
 
347
  total = time.time() - t_start
@@ -349,12 +476,47 @@ def run(files, prompt_type, dpi, max_side, max_new_tokens,
349
  yield (joined, joined,
350
  "\n\n".join(raw_parts),
351
  f"Done β€” {len(per_page)} page(s){truncated} in {total:.0f}s "
352
- f"({total / max(1, len(per_page)):.1f}s/page)",
 
353
  str(md_path), str(zip_path))
354
 
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  # ---------------------------------------------------------------------------
357
- # UI
358
  # ---------------------------------------------------------------------------
359
 
360
  GR_MAJOR = int(gr.__version__.split(".")[0])
@@ -388,11 +550,16 @@ _LAUNCH_KW = _STYLE if GR_MAJOR >= 6 else {}
388
  with gr.Blocks(title="Chandra OCR 2", **_BLOCKS_KW) as demo:
389
  gr.Markdown(
390
  f"""
391
- # Chandra OCR 2 β€” document β†’ markdown / HTML / JSON
392
 
393
  Layout-aware OCR from [Datalab](https://datalab.to). Handles tables, math,
394
  forms, handwriting and 90+ languages, preserving reading order and structure.
395
 
 
 
 
 
 
396
  Upload **PDFs and/or images**; each page is processed separately and results
397
  stream in below. Capped at **{MAX_PAGES} pages** per run in this demo.
398
 
@@ -404,20 +571,19 @@ under $2M funding/revenue β€” **not** for building a competitor to Datalab's API
404
 
405
  with gr.Row():
406
  with gr.Column(scale=1):
407
- # No file_types filter on purpose: it silently rejects valid files
408
- # on some Gradio/browser combinations. Validation happens in
409
- # collect_pages() instead, which can give a useful message.
410
  files = C(gr.Files, label="PDFs / images", file_count="multiple")
411
  go = C(gr.Button, value="Run OCR", variant="primary")
412
 
413
  prompt_type = C(gr.Dropdown, label="Prompt type", choices=PROMPT_TYPES,
414
  value=DEFAULT_PROMPT_TYPE, allow_custom_value=True,
415
- info="'ocr_layout' is the documented default.")
 
416
 
417
  with gr.Accordion("Advanced", open=False):
418
  dpi = C(gr.Slider, minimum=100, maximum=400, value=200, step=25,
419
- label="PDF render DPI",
420
- info="200-300 suits most scans.")
421
  max_side = C(gr.Slider, minimum=768, maximum=2560, value=1540, step=64,
422
  label="Max image side (px)",
423
  info="Biggest lever on speed and VRAM.")
@@ -425,25 +591,28 @@ under $2M funding/revenue β€” **not** for building a competitor to Datalab's API
425
  label="Max new tokens",
426
  info="Layout output is verbose β€” keep this high.")
427
 
428
- status = C(gr.Textbox, label="Status", lines=3, elem_id="status",
429
- value=("" if model is not None else
430
- f"MODEL NOT LOADED β€” {MODEL_ERR}\n"
431
- f"Check Settings > Hardware (needs ZeroGPU or a GPU tier)."))
432
 
433
- with gr.Accordion("Diagnostics", open=model is None):
434
- C(gr.Textbox, label=None, lines=5, value=diagnostics(),
435
- interactive=False)
 
436
 
437
  with gr.Column(scale=2):
438
  with gr.Tabs():
439
  with gr.Tab("Rendered"):
440
  md_view = C(gr.Markdown, value="", elem_classes=["md_pane"])
441
  with gr.Tab("Markdown source"):
442
- md_src = C(gr.Textbox, label=None, lines=24,
443
- show_copy_button=True)
444
  with gr.Tab("Raw model output"):
445
  raw_view = C(gr.Textbox, label=None, lines=24,
446
  elem_id="raw_out", show_copy_button=True)
 
 
 
 
 
 
447
  with gr.Row():
448
  md_file = C(gr.File, label="Combined .md")
449
  zip_file = C(gr.File, label="All pages .zip")
@@ -451,9 +620,10 @@ under $2M funding/revenue β€” **not** for building a competitor to Datalab's API
451
  go.click(
452
  run,
453
  inputs=[files, prompt_type, dpi, max_side, max_new],
454
- outputs=[md_view, md_src, raw_view, status, md_file, zip_file],
 
455
  )
456
 
457
 
458
  if __name__ == "__main__":
459
- demo.queue(max_size=12).launch(show_error=True, **_LAUNCH_KW)
 
4
  Model: datalab-to/chandra-ocr-2 (~10B, bf16)
5
  Docs: https://huggingface.co/datalab-to/chandra-ocr-2
6
 
7
+ Two pipeline stages:
8
+ 1. Text & layout extraction β€” all text in reading order, with tables, math,
9
+ forms and headings preserved as markdown (chandra's parse_markdown).
10
+ 2. Visual understanding β€” locate figures/charts/diagrams/photos/stamps in the
11
+ layout output, crop them at full resolution, and surface the model's
12
+ caption + any structured data it read from them.
13
+
14
+ Hardware: needs ZeroGPU (H200 slice) or a paid A100/L40S/RTX PRO 6000.
15
  The model is ~20 GB in bf16 and will NOT run on the free CPU tier.
16
+
17
+ API facts verified against the installed `chandra` package (not guessed):
18
+ - generate_hf(batch, model, max_output_tokens=...) -> list[GenerationResult]
19
+ GenerationResult has .raw, .token_count, .error
20
+ - PROMPT_MAPPING contains exactly: "ocr_layout", "ocr"
21
+ - parse_markdown(raw, include_headers_footers=False, include_images=True)
22
+ - parse_chunks(raw, image, bbox_scale=1000) -> list of
23
+ {"bbox": [x0, y0, x1, y1] (pixels), "label": str, "content": str}
24
+ - settings.BBOX_SCALE == 1000 (bbox is normalised per-axis, so it is
25
+ resolution-independent and can be mapped onto the full-res source image)
26
  """
27
 
28
  import inspect
 
42
 
43
  MODEL_ID = "datalab-to/chandra-ocr-2"
44
 
 
 
 
 
 
 
 
45
  PAGES_PER_GPU_CALL = 3 # keep each ZeroGPU allocation inside its duration budget
46
  GPU_DURATION = 180 # seconds requested per allocation
47
  MAX_PAGES = 20 # guard against someone uploading a 500-page PDF
 
50
  OUT_DIR.mkdir(parents=True, exist_ok=True)
51
 
52
  IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
53
+ PDF_EXTS = {".pdf"}
54
+
55
+ # Labels the layout model can attach to blocks that count as "figures" for
56
+ # stage 2 (visual understanding). Kept lowercase for matching.
57
+ FIGURE_LABELS = {
58
+ "figure", "chart", "diagram", "photo", "image", "picture", "stamp",
59
+ "graph", "plot", "illustration", "logo", "icon", "drawing", "map",
60
+ }
61
+ CAPTION_LABELS = {"caption", "figure-caption", "figcaption", "figure_caption"}
62
 
63
  # ---------------------------------------------------------------------------
64
  # ZeroGPU shim β€” lets the same file run locally without the `spaces` package
65
  # ---------------------------------------------------------------------------
66
 
 
67
  ON_ZERO = os.environ.get("SPACES_ZERO_GPU") == "true"
68
 
69
  try:
 
87
  # ---------------------------------------------------------------------------
88
 
89
  CHANDRA_ERR = None
90
+ USE_CHANDRA = False
91
+ HAS_PARSE_CHUNKS = False
92
+ PROMPT_TYPES = ["ocr_layout"] # safe default; replaced below if package present
93
+
94
  try:
95
  from chandra.model import generate_hf
96
  from chandra.model.schema import BatchInputItem
97
+ from chandra.model import PROMPT_MAPPING # enumerate real prompt types
98
 
99
  try:
100
+ from chandra.output import parse_markdown, parse_chunks
101
  except ImportError:
102
+ from chandra.model.output import parse_markdown, parse_chunks
103
 
104
  USE_CHANDRA = True
105
+ HAS_PARSE_CHUNKS = True
106
+ PROMPT_TYPES = list(PROMPT_MAPPING.keys())
107
  except Exception as e: # noqa: BLE001
 
108
  CHANDRA_ERR = f"{type(e).__name__}: {e}"
109
+ USE_CHANDRA = False
110
+ HAS_PARSE_CHUNKS = False
111
+
112
+ DEFAULT_PROMPT_TYPE = "ocr_layout" if "ocr_layout" in PROMPT_TYPES else PROMPT_TYPES[0]
113
 
114
 
115
  # ---------------------------------------------------------------------------
116
+ # Model β€” loaded at module scope, guarded so a failure never kills the Space
117
  # ---------------------------------------------------------------------------
118
 
119
+ MODEL = None
120
+ PROCESSOR = None
121
+ MODEL_ERROR = None
122
+
123
+
124
  def _load():
125
  from transformers import AutoProcessor
126
 
 
132
  # On ZeroGPU there is no GPU visible at import time, so accelerate's
133
  # device_map="auto" would strand the model on CPU. ZeroGPU instead
134
  # intercepts .to("cuda") at global scope. Elsewhere, device_map is fine.
135
+ # Blackwell (sm_120) -> SDPA, never flash-attn (no prebuilt wheels).
136
+ kw = dict(low_cpu_mem_usage=True, attn_implementation="sdpa")
137
  if not ON_ZERO:
138
  kw["device_map"] = "auto"
139
 
140
  try:
141
  m = VLM.from_pretrained(MODEL_ID, dtype=torch.bfloat16, **kw)
142
  except TypeError: # transformers < 4.56 spelled it torch_dtype
143
+ kw.pop("attn_implementation", None)
144
  m = VLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, **kw)
145
 
146
  if ON_ZERO:
 
153
  return m, p
154
 
155
 
 
 
 
 
 
156
  try:
157
+ print(f"Loading {MODEL_ID} ...")
158
+ _t0 = time.time()
159
+ MODEL, PROCESSOR = _load()
160
+ print(f"Loaded in {time.time() - _t0:.0f}s | chandra pkg: {USE_CHANDRA} "
161
+ f"({CHANDRA_ERR or 'ok'})")
162
  except Exception as e: # noqa: BLE001
163
+ MODEL_ERROR = f"{type(e).__name__}: {e}"
164
+ print(f"MODEL LOAD FAILED: {MODEL_ERROR}")
165
+
166
+ MODEL_LOADED = MODEL is not None
 
 
167
 
 
168
 
169
+ # ---------------------------------------------------------------------------
170
+ # Diagnostics β€” surfaced in the UI, not just logs
171
+ # ---------------------------------------------------------------------------
172
 
173
+ def _transformers_version():
 
174
  try:
175
+ import transformers
176
+ return transformers.__version__
177
  except Exception: # noqa: BLE001
178
+ return "n/a"
179
+
180
+
181
+ def diagnostics_md() -> str:
182
+ cuda = torch.cuda.is_available()
183
+ gpu_name = torch.cuda.get_device_name(0) if cuda else "none visible"
184
+ rows = [
185
+ ("torch", torch.__version__),
186
+ ("transformers", _transformers_version()),
187
+ ("gradio", gr.__version__),
188
+ ("CUDA visible", str(cuda)),
189
+ ("GPU", gpu_name),
190
+ ("chandra package", "imported" if USE_CHANDRA else f"FAILED β€” {CHANDRA_ERR}"),
191
+ ("prompt types", ", ".join(PROMPT_TYPES)),
192
+ ("model loaded", "yes" if MODEL_LOADED else "NO"),
193
+ ("model error", MODEL_ERROR or "β€”"),
194
+ ]
195
+ return "\n".join(f"- **{k}**: `{v}`" for k, v in rows)
196
 
197
 
198
  # ---------------------------------------------------------------------------
 
200
  # ---------------------------------------------------------------------------
201
 
202
  def as_path(f) -> Path:
203
+ """Normalise str / os.PathLike / Gradio file object into a real Path.
204
 
205
+ pathlib.Path also has a .name attribute, but there it is the *basename* β€”
206
+ a hasattr(f, "name") check would silently drop the directory. Handle the
207
+ types distinctly instead.
208
  """
209
  if isinstance(f, (str, os.PathLike)):
210
  return Path(f)
211
+ # Gradio file object (or tempfile.NamedTemporaryFile) exposes .name as a path
212
  return Path(getattr(f, "name", str(f)))
213
 
214
 
 
229
 
230
 
231
  def collect_pages(files, dpi: int):
232
+ """Return (pages, rejected) where pages is [(name, full_res_PIL)] and
233
+ rejected is [(name, reason)]. One bad file never fails the run."""
234
+ pages, rejected = [], []
235
  for f in files:
236
  p = as_path(f)
237
  ext = p.suffix.lower()
238
  try:
239
+ if ext in PDF_EXTS:
240
  pages.extend(pdf_to_images(p, dpi))
241
  elif ext in IMAGE_EXTS:
242
  pages.append((p.stem, Image.open(p).convert("RGB")))
243
  else:
244
+ rejected.append((p.name, f"unsupported type '{ext or 'none'}'"))
245
  except Exception as e: # noqa: BLE001
246
+ rejected.append((p.name, f"{type(e).__name__}: {e}"))
247
+ return pages, rejected
248
 
249
 
250
  def fit(img: Image.Image, max_side: int) -> Image.Image:
 
266
  @torch.inference_mode()
267
  def _infer_chunk(images, prompt_type: str, max_new_tokens: int):
268
  """OCR a small batch of PIL images. Returns a list of raw model strings."""
269
+ if MODEL is None:
270
+ raise RuntimeError("model not loaded")
 
 
 
271
 
272
  if USE_CHANDRA:
273
  batch = [BatchInputItem(image=im, prompt_type=prompt_type) for im in images]
274
+ results = generate_hf(batch, MODEL, max_output_tokens=max_new_tokens)
 
 
 
275
  return [getattr(r, "raw", None) or getattr(r, "markdown", "") or str(r)
276
  for r in results]
277
 
278
+ # ---- fallback: drive the chat template directly (degraded) ----
279
  outs = []
280
  for im in images:
281
  msgs = [{"role": "user", "content": [
282
  {"type": "image", "image": im},
283
  {"type": "text", "text": prompt_type},
284
  ]}]
285
+ inputs = PROCESSOR.apply_chat_template(
286
  msgs, tokenize=True, add_generation_prompt=True,
287
  return_dict=True, return_tensors="pt",
288
+ ).to(MODEL.device)
289
  if "pixel_values" in inputs:
290
+ inputs["pixel_values"] = inputs["pixel_values"].to(MODEL.dtype)
291
+ gen = MODEL.generate(**inputs, max_new_tokens=int(max_new_tokens),
292
+ do_sample=False)
293
  trimmed = [o[len(i):] for i, o in zip(inputs["input_ids"], gen)]
294
+ outs.append(PROCESSOR.batch_decode(trimmed, skip_special_tokens=True)[0].strip())
295
  return outs
296
 
297
 
 
305
 
306
 
307
  # ---------------------------------------------------------------------------
308
+ # Stage 2 β€” figure extraction (visual understanding)
309
+ # ---------------------------------------------------------------------------
310
+
311
+ def _find_caption(chunks, fig_idx: int):
312
+ """Look for a caption chunk immediately after (or before) the figure."""
313
+ for j in (fig_idx + 1, fig_idx - 1):
314
+ if 0 <= j < len(chunks):
315
+ lbl = (chunks[j].get("label") or "").lower()
316
+ if lbl in CAPTION_LABELS:
317
+ return chunks[j].get("content") or ""
318
+ return ""
319
+
320
+
321
+ def extract_figures(raw: str, full_res: Image.Image):
322
+ """Locate figure-like blocks in the layout output and crop them from the
323
+ FULL-RESOLUTION source image (bbox is per-axis normalised, so it maps
324
+ cleanly onto any resolution with the same aspect ratio).
325
+
326
+ Returns (figures, degraded) where figures is a list of dicts and degraded
327
+ is True when we could not use the real chandra path (so captions/data are
328
+ NOT from the model's figure understanding).
329
+ """
330
+ if not USE_CHANDRA or not HAS_PARSE_CHUNKS:
331
+ return [], True
332
+
333
+ try:
334
+ chunks = parse_chunks(raw, full_res, bbox_scale=1000)
335
+ except Exception: # noqa: BLE001
336
+ return [], True
337
+
338
+ figures = []
339
+ for i, ch in enumerate(chunks):
340
+ label = (ch.get("label") or "").lower()
341
+ if label not in FIGURE_LABELS:
342
+ continue
343
+ bbox = ch.get("bbox")
344
+ if not bbox or len(bbox) != 4:
345
+ continue
346
+ x0, y0, x1, y1 = (int(v) for v in bbox)
347
+ w, h = full_res.size
348
+ x0, y0 = max(0, x0), max(0, y0)
349
+ x1, y1 = min(w, x1), min(h, y1)
350
+ if x1 <= x0 or y1 <= y0:
351
+ continue
352
+ crop = full_res.crop((x0, y0, x1, y1))
353
+ figures.append({
354
+ "page": None, # filled by caller
355
+ "label": label,
356
+ "bbox": [x0, y0, x1, y1],
357
+ "crop": crop,
358
+ "caption": _find_caption(chunks, i),
359
+ "structured": (ch.get("content") or "").strip(),
360
+ })
361
+ return figures, False
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # Orchestration β€” streaming generator
366
  # ---------------------------------------------------------------------------
367
 
368
+ FIG_HEADERS = ["Page", "Label", "BBox (x0,y0,x1,y1)", "Caption", "Structured data"]
369
+
370
 
371
+ def run(files, prompt_type, dpi, max_side, max_new_tokens, progress=gr.Progress()):
372
+
373
+ def empty(status):
374
+ return "", "", "", status, None, [], None, None
375
+
376
+ if not MODEL_LOADED:
377
+ yield empty(f"Model failed to load β€” see diagnostics.\n{MODEL_ERROR}")
378
+ return
379
 
380
  if not files:
381
+ yield empty("Upload a PDF or some images first.")
382
  return
383
 
384
+ try:
385
+ pages, rejected = collect_pages(files, int(dpi))
386
+ except Exception as e: # noqa: BLE001
387
+ yield empty(f"Could not read those files:\n{type(e).__name__}: {e}")
388
+ return
389
 
390
  if not pages:
391
+ yield empty("No readable PDF or image files found in that upload.")
 
392
  return
393
 
394
+ skip_note = ""
395
+ if rejected:
396
+ skip_note = "\nSkipped:\n" + "\n".join(f" - {n}: {r}" for n, r in rejected)
397
+
398
+ truncated = ""
399
  if len(pages) > MAX_PAGES:
400
+ truncated = f" (truncated from {len(pages)})"
401
  pages = pages[:MAX_PAGES]
402
 
403
+ md_parts, raw_parts = [], []
404
+ all_figs = [] # flattened figure records for the gallery/table
405
+ per_page = [] # (name, md, raw, figs)
406
  t_start = time.time()
407
 
408
  for start in range(0, len(pages), PAGES_PER_GPU_CALL):
409
  chunk = pages[start:start + PAGES_PER_GPU_CALL]
410
  names = [n for n, _ in chunk]
411
+ full_res = [im for _, im in chunk]
412
+ imgs = [fit(im, int(max_side)) for im in full_res]
413
 
414
  progress(start / len(pages),
415
  desc=f"{names[0]} … ({start + 1}-{start + len(chunk)}/{len(pages)})")
 
422
  except Exception as e: # noqa: BLE001
423
  raws = [f"[FAILED: {type(e).__name__}: {e}]"] * len(chunk)
424
 
425
+ for name, fimg, raw in zip(names, full_res, raws):
426
  md = to_markdown(raw)
427
+ figs, degraded = extract_figures(raw, fimg)
428
+ for fg in figs:
429
+ fg["page"] = name
430
+ fg["degraded"] = degraded
431
+ all_figs.append(fg)
432
+
433
  md_parts.append(f"\n\n---\n\n## {name}\n\n{md}")
434
  raw_parts.append(f"===== {name} =====\n{raw}")
435
+ per_page.append((name, md, raw, figs))
436
 
437
  elapsed = time.time() - t_start
438
  joined = "\n".join(md_parts)
439
  yield (joined, joined,
440
  "\n\n".join(raw_parts),
441
  f"{len(per_page)}/{len(pages)} pages{truncated} Β· {elapsed:.0f}s "
442
+ f"({elapsed / max(1, len(per_page)):.1f}s/page){skip_note}",
443
+ _gallery(all_figs), _fig_rows(all_figs),
444
  None, None)
445
 
446
  # ---- artefacts ----
 
450
 
451
  zip_path = OUT_DIR / f"chandra_{stamp}.zip"
452
  with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
453
+ for name, md, raw, figs in per_page:
454
  safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
455
  z.writestr(f"markdown/{safe}.md", md)
456
  z.writestr(f"raw/{safe}.txt", raw)
457
+ for k, fg in enumerate(figs):
458
+ buf = _to_png_bytes(fg["crop"])
459
+ if buf:
460
+ z.writestr(f"figures/{safe}_{k:02d}_{fg['label']}.png", buf)
461
  z.writestr("manifest.json", json.dumps({
462
  "model": MODEL_ID,
463
  "prompt_type": prompt_type,
464
  "dpi": dpi,
465
  "max_side": max_side,
466
+ "pages": [n for n, _, _, _ in per_page],
467
+ "figures": [
468
+ {"page": f["page"], "label": f["label"], "bbox": f["bbox"],
469
+ "caption": f["caption"], "structured": f["structured"]}
470
+ for f in all_figs
471
+ ],
472
  }, indent=2))
473
 
474
  total = time.time() - t_start
 
476
  yield (joined, joined,
477
  "\n\n".join(raw_parts),
478
  f"Done β€” {len(per_page)} page(s){truncated} in {total:.0f}s "
479
+ f"({total / max(1, len(per_page)):.1f}s/page){skip_note}",
480
+ _gallery(all_figs), _fig_rows(all_figs),
481
  str(md_path), str(zip_path))
482
 
483
 
484
+ def _to_png_bytes(img: Image.Image):
485
+ import io
486
+ buf = io.BytesIO()
487
+ try:
488
+ img.save(buf, format="PNG")
489
+ return buf.getvalue()
490
+ except Exception: # noqa: BLE001
491
+ return None
492
+
493
+
494
+ def _gallery(figs):
495
+ """Gallery entries: (crop, caption). Caption is clearly marked degraded when
496
+ it did not come from the model's figure-understanding path."""
497
+ out = []
498
+ for f in figs:
499
+ cap = f["caption"] or "(no caption from model)"
500
+ if f.get("degraded"):
501
+ cap = f"[degraded β€” not from model] {cap}"
502
+ out.append((f["crop"], f"{f['page']} Β· {f['label']} Β· {cap}"))
503
+ return out or None
504
+
505
+
506
+ def _fig_rows(figs):
507
+ rows = []
508
+ for f in figs:
509
+ cap = f["caption"] or ""
510
+ if f.get("degraded"):
511
+ cap = f"[degraded] {cap}".strip()
512
+ rows.append([f["page"], f["label"],
513
+ f"({f['bbox'][0]},{f['bbox'][1]},{f['bbox'][2]},{f['bbox'][3]})",
514
+ cap, f["structured"]])
515
+ return rows
516
+
517
+
518
  # ---------------------------------------------------------------------------
519
+ # UI β€” Gradio 4/5/6 tolerant
520
  # ---------------------------------------------------------------------------
521
 
522
  GR_MAJOR = int(gr.__version__.split(".")[0])
 
550
  with gr.Blocks(title="Chandra OCR 2", **_BLOCKS_KW) as demo:
551
  gr.Markdown(
552
  f"""
553
+ # Chandra OCR 2 β€” document β†’ markdown / HTML / JSON + figures
554
 
555
  Layout-aware OCR from [Datalab](https://datalab.to). Handles tables, math,
556
  forms, handwriting and 90+ languages, preserving reading order and structure.
557
 
558
+ Two outputs per page:
559
+ 1. **Text & layout** β€” markdown with tables/math/forms/headings preserved.
560
+ 2. **Figures** β€” charts, diagrams, photos and stamps cropped at full resolution,
561
+ with the model's caption and any structured data it read from them.
562
+
563
  Upload **PDFs and/or images**; each page is processed separately and results
564
  stream in below. Capped at **{MAX_PAGES} pages** per run in this demo.
565
 
 
571
 
572
  with gr.Row():
573
  with gr.Column(scale=1):
574
+ # NOTE: no file_types filter β€” it silently rejects valid files on
575
+ # some browser/version combinations. Validation happens in Python.
 
576
  files = C(gr.Files, label="PDFs / images", file_count="multiple")
577
  go = C(gr.Button, value="Run OCR", variant="primary")
578
 
579
  prompt_type = C(gr.Dropdown, label="Prompt type", choices=PROMPT_TYPES,
580
  value=DEFAULT_PROMPT_TYPE, allow_custom_value=True,
581
+ info="Enumerated from the chandra package; editable "
582
+ "so you can try undocumented values.")
583
 
584
  with gr.Accordion("Advanced", open=False):
585
  dpi = C(gr.Slider, minimum=100, maximum=400, value=200, step=25,
586
+ label="PDF render DPI", info="200-300 suits most scans.")
 
587
  max_side = C(gr.Slider, minimum=768, maximum=2560, value=1540, step=64,
588
  label="Max image side (px)",
589
  info="Biggest lever on speed and VRAM.")
 
591
  label="Max new tokens",
592
  info="Layout output is verbose β€” keep this high.")
593
 
594
+ status = C(gr.Textbox, label="Status", lines=3, elem_id="status")
 
 
 
595
 
596
+ # Diagnostics β€” auto-open when the model failed to load.
597
+ diag = gr.Accordion("Diagnostics", open=not MODEL_LOADED)
598
+ with diag:
599
+ gr.Markdown(diagnostics_md())
600
 
601
  with gr.Column(scale=2):
602
  with gr.Tabs():
603
  with gr.Tab("Rendered"):
604
  md_view = C(gr.Markdown, value="", elem_classes=["md_pane"])
605
  with gr.Tab("Markdown source"):
606
+ md_src = C(gr.Textbox, label=None, lines=24, show_copy_button=True)
 
607
  with gr.Tab("Raw model output"):
608
  raw_view = C(gr.Textbox, label=None, lines=24,
609
  elem_id="raw_out", show_copy_button=True)
610
+ with gr.Tab("Figures"):
611
+ fig_gallery = C(gr.Gallery, label="Extracted figures",
612
+ columns=3, height="auto",
613
+ object_fit="contain")
614
+ fig_table = C(gr.Dataframe, headers=FIG_HEADERS,
615
+ label="Figure metadata", interactive=False)
616
  with gr.Row():
617
  md_file = C(gr.File, label="Combined .md")
618
  zip_file = C(gr.File, label="All pages .zip")
 
620
  go.click(
621
  run,
622
  inputs=[files, prompt_type, dpi, max_side, max_new],
623
+ outputs=[md_view, md_src, raw_view, status, fig_gallery, fig_table,
624
+ md_file, zip_file],
625
  )
626
 
627
 
628
  if __name__ == "__main__":
629
+ demo.queue(max_size=12).launch(show_error=True, **_LAUNCH_KW)