Spaces:
Running on Zero
Running on Zero
Upload 2 files (#3)
Browse files- Upload 2 files (a40745ae5e8b7d5f88811ca025fde17740102a6d)
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
| 129 |
except Exception as e: # noqa: BLE001
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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
|
| 141 |
-
import platform
|
| 142 |
try:
|
| 143 |
-
import transformers
|
| 144 |
-
|
| 145 |
except Exception: # noqa: BLE001
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
# ---------------------------------------------------------------------------
|
|
@@ -159,13 +200,15 @@ def diagnostics() -> str:
|
|
| 159 |
# ---------------------------------------------------------------------------
|
| 160 |
|
| 161 |
def as_path(f) -> Path:
|
| 162 |
-
"""Normalise str /
|
| 163 |
|
| 164 |
-
|
| 165 |
-
hasattr
|
|
|
|
| 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 |
-
"""
|
| 190 |
-
|
|
|
|
| 191 |
for f in files:
|
| 192 |
p = as_path(f)
|
| 193 |
ext = p.suffix.lower()
|
| 194 |
try:
|
| 195 |
-
if ext
|
| 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 |
-
|
| 201 |
except Exception as e: # noqa: BLE001
|
| 202 |
-
|
| 203 |
-
return pages,
|
| 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
|
| 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 |
-
|
| 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 =
|
| 248 |
msgs, tokenize=True, add_generation_prompt=True,
|
| 249 |
return_dict=True, return_tensors="pt",
|
| 250 |
-
).to(
|
| 251 |
if "pixel_values" in inputs:
|
| 252 |
-
inputs["pixel_values"] = inputs["pixel_values"].to(
|
| 253 |
-
gen =
|
|
|
|
| 254 |
trimmed = [o[len(i):] for i, o in zip(inputs["input_ids"], gen)]
|
| 255 |
-
outs.append(
|
| 256 |
return outs
|
| 257 |
|
| 258 |
|
|
@@ -266,39 +305,111 @@ def to_markdown(raw: str) -> str:
|
|
| 266 |
|
| 267 |
|
| 268 |
# ---------------------------------------------------------------------------
|
| 269 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
# ---------------------------------------------------------------------------
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
if not files:
|
| 279 |
-
yield
|
| 280 |
return
|
| 281 |
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
if not pages:
|
| 286 |
-
yield
|
| 287 |
-
"\n\nSupported: .pdf, .png, .jpg, .jpeg, .webp, .bmp, .tif, .tiff")
|
| 288 |
return
|
| 289 |
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
if len(pages) > MAX_PAGES:
|
| 292 |
-
truncated
|
| 293 |
pages = pages[:MAX_PAGES]
|
| 294 |
|
| 295 |
-
md_parts, raw_parts
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
#
|
| 408 |
-
#
|
| 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="
|
|
|
|
| 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 |
-
|
| 434 |
-
|
| 435 |
-
|
|
|
|
| 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,
|
|
|
|
| 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)
|