Chandra_OCR_2 / app.py
bobo-dada's picture
Update app.py (#6)
53f8e3d
Raw
History Blame Contribute Delete
23.6 kB
"""
Chandra OCR 2 β€” Hugging Face Space demo.
Model: datalab-to/chandra-ocr-2 (~10B, bf16)
Docs: https://huggingface.co/datalab-to/chandra-ocr-2
Two pipeline stages:
1. Text & layout extraction β€” all text in reading order, with tables, math,
forms and headings preserved as markdown (chandra's parse_markdown).
2. Visual understanding β€” locate figures/charts/diagrams/photos/stamps in the
layout output, crop them at full resolution, and surface the model's
caption + any structured data it read from them.
Hardware: needs ZeroGPU (H200 slice) or a paid A100/L40S/RTX PRO 6000.
The model is ~20 GB in bf16 and will NOT run on the free CPU tier.
API facts verified against the installed `chandra` package (not guessed):
- generate_hf(batch, model, max_output_tokens=...) -> list[GenerationResult]
GenerationResult has .raw, .token_count, .error
- PROMPT_MAPPING contains exactly: "ocr_layout", "ocr"
- parse_markdown(raw, include_headers_footers=False, include_images=True)
- parse_chunks(raw, image, bbox_scale=1000) -> list of
{"bbox": [x0, y0, x1, y1] (pixels), "label": str, "content": str}
- settings.BBOX_SCALE == 1000 (bbox is normalised per-axis, so it is
resolution-independent and can be mapped onto the full-res source image)
"""
import inspect
import json
import os
import time
import zipfile
from pathlib import Path
import gradio as gr
import torch
from PIL import Image
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
#MODEL_ID = "datalab-to/chandra-ocr-2"
MODEL_ID = "datalab-to/surya-ocr-2"
PAGES_PER_GPU_CALL = 3 # keep each ZeroGPU allocation inside its duration budget
GPU_DURATION = 180 # seconds requested per allocation
MAX_PAGES = 20 # guard against someone uploading a 500-page PDF
OUT_DIR = Path(os.environ.get("CHANDRA_OUT_DIR", "/tmp/chandra_out"))
OUT_DIR.mkdir(parents=True, exist_ok=True)
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
PDF_EXTS = {".pdf"}
# Labels the layout model can attach to blocks that count as "figures" for
# stage 2 (visual understanding). Kept lowercase for matching.
FIGURE_LABELS = {
"figure", "chart", "diagram", "photo", "image", "picture", "stamp",
"graph", "plot", "illustration", "logo", "icon", "drawing", "map",
}
CAPTION_LABELS = {"caption", "figure-caption", "figcaption", "figure_caption"}
# ---------------------------------------------------------------------------
# ZeroGPU shim β€” lets the same file run locally without the `spaces` package
# ---------------------------------------------------------------------------
ON_ZERO = os.environ.get("SPACES_ZERO_GPU") == "true"
try:
import spaces
gpu = spaces.GPU
except ImportError: # local / non-ZeroGPU deploy
def gpu(*args, **kwargs):
if args and callable(args[0]):
return args[0]
def deco(fn):
return fn
return deco
# ---------------------------------------------------------------------------
# Chandra package β€” preferred path. Falls back to plain transformers.
# ---------------------------------------------------------------------------
CHANDRA_ERR = None
USE_CHANDRA = False
HAS_PARSE_CHUNKS = False
PROMPT_TYPES = ["ocr_layout"] # safe default; replaced below if package present
try:
from chandra.model import generate_hf
from chandra.model.schema import BatchInputItem
from chandra.model import PROMPT_MAPPING # enumerate real prompt types
try:
from chandra.output import parse_markdown, parse_chunks
except ImportError:
from chandra.model.output import parse_markdown, parse_chunks
USE_CHANDRA = True
HAS_PARSE_CHUNKS = True
PROMPT_TYPES = list(PROMPT_MAPPING.keys())
except Exception as e: # noqa: BLE001
CHANDRA_ERR = f"{type(e).__name__}: {e}"
USE_CHANDRA = False
HAS_PARSE_CHUNKS = False
DEFAULT_PROMPT_TYPE = "ocr_layout" if "ocr_layout" in PROMPT_TYPES else PROMPT_TYPES[0]
# ---------------------------------------------------------------------------
# Model β€” loaded at module scope, guarded so a failure never kills the Space
# ---------------------------------------------------------------------------
MODEL = None
PROCESSOR = None
MODEL_ERROR = None
def _load():
from transformers import AutoProcessor
try:
from transformers import AutoModelForImageTextToText as VLM
except ImportError:
from transformers import AutoModelForVision2Seq as VLM
# On ZeroGPU there is no GPU visible at import time, so accelerate's
# device_map="auto" would strand the model on CPU. ZeroGPU instead
# intercepts .to("cuda") at global scope. Elsewhere, device_map is fine.
# Blackwell (sm_120) -> SDPA, never flash-attn (no prebuilt wheels).
kw = dict(low_cpu_mem_usage=True, attn_implementation="sdpa")
if not ON_ZERO:
kw["device_map"] = "auto"
try:
m = VLM.from_pretrained(MODEL_ID, dtype=torch.bfloat16, **kw)
except TypeError: # transformers < 4.56 spelled it torch_dtype
kw.pop("attn_implementation", None)
m = VLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, **kw)
if ON_ZERO:
m = m.to("cuda")
m.eval()
p = AutoProcessor.from_pretrained(MODEL_ID)
p.tokenizer.padding_side = "left" # required by chandra's batched generate
m.processor = p
return m, p
try:
print(f"Loading {MODEL_ID} ...")
_t0 = time.time()
MODEL, PROCESSOR = _load()
print(f"Loaded in {time.time() - _t0:.0f}s | chandra pkg: {USE_CHANDRA} "
f"({CHANDRA_ERR or 'ok'})")
except Exception as e: # noqa: BLE001
MODEL_ERROR = f"{type(e).__name__}: {e}"
print(f"MODEL LOAD FAILED: {MODEL_ERROR}")
MODEL_LOADED = MODEL is not None
# ---------------------------------------------------------------------------
# Diagnostics β€” surfaced in the UI, not just logs
# ---------------------------------------------------------------------------
def _transformers_version():
try:
import transformers
return transformers.__version__
except Exception: # noqa: BLE001
return "n/a"
def diagnostics_md() -> str:
cuda = torch.cuda.is_available()
gpu_name = torch.cuda.get_device_name(0) if cuda else "none visible"
rows = [
("torch", torch.__version__),
("transformers", _transformers_version()),
("gradio", gr.__version__),
("CUDA visible", str(cuda)),
("GPU", gpu_name),
("chandra package", "imported" if USE_CHANDRA else f"FAILED β€” {CHANDRA_ERR}"),
("prompt types", ", ".join(PROMPT_TYPES)),
("model loaded", "yes" if MODEL_LOADED else "NO"),
("model error", MODEL_ERROR or "β€”"),
]
return "\n".join(f"- **{k}**: `{v}`" for k, v in rows)
# ---------------------------------------------------------------------------
# Page extraction
# ---------------------------------------------------------------------------
def as_path(f) -> Path:
"""Normalise str / os.PathLike / Gradio file object into a real Path.
pathlib.Path also has a .name attribute, but there it is the *basename* β€”
a hasattr(f, "name") check would silently drop the directory. Handle the
types distinctly instead.
"""
if isinstance(f, (str, os.PathLike)):
return Path(f)
# Gradio file object (or tempfile.NamedTemporaryFile) exposes .name as a path
return Path(getattr(f, "name", str(f)))
def pdf_to_images(path: Path, dpi: int):
try:
import pymupdf
except ImportError:
import fitz as pymupdf
doc = pymupdf.open(str(path))
pages = []
for i, page in enumerate(doc):
pix = page.get_pixmap(dpi=dpi)
pages.append((f"{path.stem}_p{i + 1:03d}",
Image.frombytes("RGB", (pix.width, pix.height), pix.samples)))
doc.close()
return pages
def collect_pages(files, dpi: int):
"""Return (pages, rejected) where pages is [(name, full_res_PIL)] and
rejected is [(name, reason)]. One bad file never fails the run."""
pages, rejected = [], []
for f in files:
p = as_path(f)
ext = p.suffix.lower()
try:
if ext in PDF_EXTS:
pages.extend(pdf_to_images(p, dpi))
elif ext in IMAGE_EXTS:
pages.append((p.stem, Image.open(p).convert("RGB")))
else:
rejected.append((p.name, f"unsupported type '{ext or 'none'}'"))
except Exception as e: # noqa: BLE001
rejected.append((p.name, f"{type(e).__name__}: {e}"))
return pages, rejected
def fit(img: Image.Image, max_side: int) -> Image.Image:
"""Cap the long edge β€” visual token count scales with area, so this is the
single biggest lever on VRAM and latency."""
img = img.convert("RGB")
if max(img.size) > max_side:
s = max_side / max(img.size)
img = img.resize((max(1, int(img.width * s)), max(1, int(img.height * s))),
Image.LANCZOS)
return img
# ---------------------------------------------------------------------------
# Inference
# ---------------------------------------------------------------------------
@gpu(duration=GPU_DURATION)
@torch.inference_mode()
def _infer_chunk(images, prompt_type: str, max_new_tokens: int):
"""OCR a small batch of PIL images. Returns a list of raw model strings."""
if MODEL is None:
raise RuntimeError("model not loaded")
if USE_CHANDRA:
batch = [BatchInputItem(image=im, prompt_type=prompt_type) for im in images]
results = generate_hf(batch, MODEL, max_output_tokens=max_new_tokens)
return [getattr(r, "raw", None) or getattr(r, "markdown", "") or str(r)
for r in results]
# ---- fallback: drive the chat template directly (degraded) ----
outs = []
for im in images:
msgs = [{"role": "user", "content": [
{"type": "image", "image": im},
{"type": "text", "text": prompt_type},
]}]
inputs = PROCESSOR.apply_chat_template(
msgs, tokenize=True, add_generation_prompt=True,
return_dict=True, return_tensors="pt",
).to(MODEL.device)
if "pixel_values" in inputs:
inputs["pixel_values"] = inputs["pixel_values"].to(MODEL.dtype)
gen = MODEL.generate(**inputs, max_new_tokens=int(max_new_tokens),
do_sample=False)
trimmed = [o[len(i):] for i, o in zip(inputs["input_ids"], gen)]
outs.append(PROCESSOR.batch_decode(trimmed, skip_special_tokens=True)[0].strip())
return outs
def to_markdown(raw: str) -> str:
if USE_CHANDRA:
try:
return parse_markdown(raw)
except Exception: # noqa: BLE001
pass
return raw
# ---------------------------------------------------------------------------
# Stage 2 β€” figure extraction (visual understanding)
# ---------------------------------------------------------------------------
def _find_caption(chunks, fig_idx: int):
"""Look for a caption chunk immediately after (or before) the figure."""
for j in (fig_idx + 1, fig_idx - 1):
if 0 <= j < len(chunks):
lbl = (chunks[j].get("label") or "").lower()
if lbl in CAPTION_LABELS:
return chunks[j].get("content") or ""
return ""
def extract_figures(raw: str, full_res: Image.Image):
"""Locate figure-like blocks in the layout output and crop them from the
FULL-RESOLUTION source image (bbox is per-axis normalised, so it maps
cleanly onto any resolution with the same aspect ratio).
Returns (figures, degraded) where figures is a list of dicts and degraded
is True when we could not use the real chandra path (so captions/data are
NOT from the model's figure understanding).
"""
if not USE_CHANDRA or not HAS_PARSE_CHUNKS:
return [], True
try:
chunks = parse_chunks(raw, full_res, bbox_scale=1000)
except Exception: # noqa: BLE001
return [], True
figures = []
for i, ch in enumerate(chunks):
label = (ch.get("label") or "").lower()
if label not in FIGURE_LABELS:
continue
bbox = ch.get("bbox")
if not bbox or len(bbox) != 4:
continue
x0, y0, x1, y1 = (int(v) for v in bbox)
w, h = full_res.size
x0, y0 = max(0, x0), max(0, y0)
x1, y1 = min(w, x1), min(h, y1)
if x1 <= x0 or y1 <= y0:
continue
crop = full_res.crop((x0, y0, x1, y1))
figures.append({
"page": None, # filled by caller
"label": label,
"bbox": [x0, y0, x1, y1],
"crop": crop,
"caption": _find_caption(chunks, i),
"structured": (ch.get("content") or "").strip(),
})
return figures, False
# ---------------------------------------------------------------------------
# Orchestration β€” streaming generator
# ---------------------------------------------------------------------------
FIG_HEADERS = ["Page", "Label", "BBox (x0,y0,x1,y1)", "Caption", "Structured data"]
def run(files, prompt_type, dpi, max_side, max_new_tokens, progress=gr.Progress()):
def empty(status):
return "", "", "", status, None, [], None, None
if not MODEL_LOADED:
yield empty(f"Model failed to load β€” see diagnostics.\n{MODEL_ERROR}")
return
if not files:
yield empty("Upload a PDF or some images first.")
return
try:
pages, rejected = collect_pages(files, int(dpi))
except Exception as e: # noqa: BLE001
yield empty(f"Could not read those files:\n{type(e).__name__}: {e}")
return
if not pages:
yield empty("No readable PDF or image files found in that upload.")
return
skip_note = ""
if rejected:
skip_note = "\nSkipped:\n" + "\n".join(f" - {n}: {r}" for n, r in rejected)
truncated = ""
if len(pages) > MAX_PAGES:
truncated = f" (truncated from {len(pages)})"
pages = pages[:MAX_PAGES]
md_parts, raw_parts = [], []
all_figs = [] # flattened figure records for the gallery/table
per_page = [] # (name, md, raw, figs)
t_start = time.time()
for start in range(0, len(pages), PAGES_PER_GPU_CALL):
chunk = pages[start:start + PAGES_PER_GPU_CALL]
names = [n for n, _ in chunk]
full_res = [im for _, im in chunk]
imgs = [fit(im, int(max_side)) for im in full_res]
progress(start / len(pages),
desc=f"{names[0]} … ({start + 1}-{start + len(chunk)}/{len(pages)})")
try:
raws = _infer_chunk(imgs, prompt_type, int(max_new_tokens))
except torch.cuda.OutOfMemoryError:
torch.cuda.empty_cache()
raws = ["[OUT OF MEMORY β€” lower 'Max image side']"] * len(chunk)
except Exception as e: # noqa: BLE001
raws = [f"[FAILED: {type(e).__name__}: {e}]"] * len(chunk)
for name, fimg, raw in zip(names, full_res, raws):
md = to_markdown(raw)
figs, degraded = extract_figures(raw, fimg)
for fg in figs:
fg["page"] = name
fg["degraded"] = degraded
all_figs.append(fg)
md_parts.append(f"\n\n---\n\n## {name}\n\n{md}")
raw_parts.append(f"===== {name} =====\n{raw}")
per_page.append((name, md, raw, figs))
elapsed = time.time() - t_start
joined = "\n".join(md_parts)
yield (joined, joined,
"\n\n".join(raw_parts),
f"{len(per_page)}/{len(pages)} pages{truncated} Β· {elapsed:.0f}s "
f"({elapsed / max(1, len(per_page)):.1f}s/page){skip_note}",
_gallery(all_figs), _fig_rows(all_figs),
None, None)
# ---- artefacts ----
stamp = time.strftime("%Y%m%d_%H%M%S")
md_path = OUT_DIR / f"chandra_{stamp}.md"
md_path.write_text("\n".join(md_parts), encoding="utf-8")
zip_path = OUT_DIR / f"chandra_{stamp}.zip"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
for name, md, raw, figs in per_page:
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
z.writestr(f"markdown/{safe}.md", md)
z.writestr(f"raw/{safe}.txt", raw)
for k, fg in enumerate(figs):
buf = _to_png_bytes(fg["crop"])
if buf:
z.writestr(f"figures/{safe}_{k:02d}_{fg['label']}.png", buf)
z.writestr("manifest.json", json.dumps({
"model": MODEL_ID,
"prompt_type": prompt_type,
"dpi": dpi,
"max_side": max_side,
"pages": [n for n, _, _, _ in per_page],
"figures": [
{"page": f["page"], "label": f["label"], "bbox": f["bbox"],
"caption": f["caption"], "structured": f["structured"]}
for f in all_figs
],
}, indent=2))
total = time.time() - t_start
joined = "\n".join(md_parts)
yield (joined, joined,
"\n\n".join(raw_parts),
f"Done β€” {len(per_page)} page(s){truncated} in {total:.0f}s "
f"({total / max(1, len(per_page)):.1f}s/page){skip_note}",
_gallery(all_figs), _fig_rows(all_figs),
str(md_path), str(zip_path))
def _to_png_bytes(img: Image.Image):
import io
buf = io.BytesIO()
try:
img.save(buf, format="PNG")
return buf.getvalue()
except Exception: # noqa: BLE001
return None
def _gallery(figs):
"""Gallery entries: (crop, caption). Caption is clearly marked degraded when
it did not come from the model's figure-understanding path."""
out = []
for f in figs:
cap = f["caption"] or "(no caption from model)"
if f.get("degraded"):
cap = f"[degraded β€” not from model] {cap}"
out.append((f["crop"], f"{f['page']} Β· {f['label']} Β· {cap}"))
return out or None
def _fig_rows(figs):
rows = []
for f in figs:
cap = f["caption"] or ""
if f.get("degraded"):
cap = f"[degraded] {cap}".strip()
rows.append([f["page"], f["label"],
f"({f['bbox'][0]},{f['bbox'][1]},{f['bbox'][2]},{f['bbox'][3]})",
cap, f["structured"]])
return rows
# ---------------------------------------------------------------------------
# UI β€” Gradio 4/5/6 tolerant
# ---------------------------------------------------------------------------
GR_MAJOR = int(gr.__version__.split(".")[0])
def C(cls, **kw):
"""Build a component, dropping kwargs this Gradio version rejects.
Gradio 6 removed Textbox.show_copy_button and moved theme/css from
Blocks() to launch(). This keeps one file working across 4/5/6.
"""
try:
allowed = set(inspect.signature(cls.__init__).parameters)
if "kwargs" not in allowed:
kw = {k: v for k, v in kw.items() if k in allowed}
except (TypeError, ValueError):
pass
return cls(**kw)
CSS = """
#raw_out textarea { font-family: ui-monospace, monospace; font-size: 12px; }
#status textarea { font-family: ui-monospace, monospace; font-size: 12px; }
.md_pane { max-height: 640px; overflow-y: auto; }
"""
_STYLE = dict(theme=gr.themes.Soft(), css=CSS)
_BLOCKS_KW = {} if GR_MAJOR >= 6 else _STYLE
_LAUNCH_KW = _STYLE if GR_MAJOR >= 6 else {}
with gr.Blocks(title="Chandra OCR 2", **_BLOCKS_KW) as demo:
gr.Markdown(
f"""
# Chandra OCR 2 β€” document β†’ markdown / HTML / JSON + figures
Layout-aware OCR from [Datalab](https://datalab.to). Handles tables, math,
forms, handwriting and 90+ languages, preserving reading order and structure.
Two outputs per page:
1. **Text & layout** β€” markdown with tables/math/forms/headings preserved.
2. **Figures** β€” charts, diagrams, photos and stamps cropped at full resolution,
with the model's caption and any structured data it read from them.
Upload **PDFs and/or images**; each page is processed separately and results
stream in below. Capped at **{MAX_PAGES} pages** per run in this demo.
Model: [`{MODEL_ID}`](https://huggingface.co/{MODEL_ID}) Β· weights are under a
modified OpenRAIL-M licence (free for research, personal use, and companies
under $2M funding/revenue β€” **not** for building a competitor to Datalab's API).
"""
)
with gr.Row():
with gr.Column(scale=1):
# NOTE: no file_types filter β€” it silently rejects valid files on
# some browser/version combinations. Validation happens in Python.
files = C(gr.Files, label="PDFs / images", file_count="multiple")
go = C(gr.Button, value="Run OCR", variant="primary")
prompt_type = C(gr.Dropdown, label="Prompt type", choices=PROMPT_TYPES,
value=DEFAULT_PROMPT_TYPE, allow_custom_value=True,
info="Enumerated from the chandra package; editable "
"so you can try undocumented values.")
with gr.Accordion("Advanced", open=False):
dpi = C(gr.Slider, minimum=100, maximum=400, value=200, step=25,
label="PDF render DPI", info="200-300 suits most scans.")
max_side = C(gr.Slider, minimum=768, maximum=2560, value=1540, step=64,
label="Max image side (px)",
info="Biggest lever on speed and VRAM.")
max_new = C(gr.Slider, minimum=512, maximum=8192, value=4096, step=256,
label="Max new tokens",
info="Layout output is verbose β€” keep this high.")
status = C(gr.Textbox, label="Status", lines=3, elem_id="status")
# Diagnostics β€” auto-open when the model failed to load.
diag = gr.Accordion("Diagnostics", open=not MODEL_LOADED)
with diag:
gr.Markdown(diagnostics_md())
with gr.Column(scale=2):
with gr.Tabs():
with gr.Tab("Rendered"):
md_view = C(gr.Markdown, value="", elem_classes=["md_pane"])
with gr.Tab("Markdown source"):
md_src = C(gr.Textbox, label=None, lines=24, show_copy_button=True)
with gr.Tab("Raw model output"):
raw_view = C(gr.Textbox, label=None, lines=24,
elem_id="raw_out", show_copy_button=True)
with gr.Tab("Figures"):
fig_gallery = C(gr.Gallery, label="Extracted figures",
columns=3, height="auto",
object_fit="contain")
fig_table = C(gr.Dataframe, headers=FIG_HEADERS,
label="Figure metadata", interactive=False)
with gr.Row():
md_file = C(gr.File, label="Combined .md")
zip_file = C(gr.File, label="All pages .zip")
go.click(
run,
inputs=[files, prompt_type, dpi, max_side, max_new],
outputs=[md_view, md_src, raw_view, status, fig_gallery, fig_table,
md_file, zip_file],
)
if __name__ == "__main__":
demo.queue(max_size=12).launch(show_error=True, **_LAUNCH_KW)