indra-ocr / app.py
vikp's picture
Indra OCR 1.1: indra-ocr 1.1.1, raw example pages (handwriting, Korean, Thai, tilted memo added), 1.1 text
61541f6 verified
Raw History Blame Contribute Delete
11.2 kB
"""Gradio demo for Indra OCR (datalab-to/indra-ocr).
A 1M-parameter ONNX OCR model that turns PDFs and images into markdown, plain
text, or JSON — with a bounding box for every line and every layout block, and
reading order — entirely on CPU.
"""
import json
import threading
import time
from pathlib import Path
import gradio as gr
import indra_ocr
from indra_ocr.document import MAX_PAGE_PIXELS
from PIL import Image, ImageDraw
CSS = """
#col-container { max-width: 1280px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
TAG_COLORS = {
"Text": (9, 105, 218), # blue
"Section-Header": (221, 74, 104), # red
"Page-Header": (160, 160, 160), # gray
"Page-Footer": (160, 160, 160), # gray
"List-Group": (63, 185, 80), # green
"Table": (255, 140, 26), # orange
"Caption": (188, 141, 224), # purple
"Footnote": (130, 170, 255), # light blue
"Table-Of-Contents": (255, 214, 10), # yellow
"Form": (46, 160, 170), # teal
}
MAX_PAGES = 5 # demo cap: fine for a look; the library reads whole files too
_converters = {}
_lock = threading.Lock()
def _converter(mode: str, furniture: bool):
"""One Converter per (mode, furniture) combination, built once and reused."""
key = (mode, furniture)
with _lock:
if key not in _converters:
_converters[key] = indra_ocr.load(
threads=4, mode=mode, furniture=furniture
)
return _converters[key]
print("[indra] loading models (first boot downloads ~76 MB from datalab-to/indra-ocr)...")
_t0 = time.perf_counter()
_converter("auto", False) # eager: the default pipeline is ready before the UI
print(f"[indra] models ready in {time.perf_counter() - _t0:.1f}s")
def _is_pdf(path: str) -> bool:
try:
with open(path, "rb") as f:
return b"%PDF-" in f.read(1024)
except OSError:
return False
def _pdf_page_scale(dpi: float, page) -> float:
"""The same render scale Indra's Converter uses (dpi, lowered for huge pages)."""
w, h = page.get_size()
k = dpi / 72.0
return min(k, (MAX_PAGE_PIXELS / max(w * h, 1.0)) ** 0.5)
def _rotate(img: Image.Image, degrees_ccw: int) -> Image.Image:
if degrees_ccw % 360 == 0:
return img
turns = {90: Image.Transpose.ROTATE_270, 180: Image.Transpose.ROTATE_180}
turns[270] = Image.Transpose.ROTATE_90
key = degrees_ccw % 360
return img.transpose(turns[key]) if key in turns else img
def _draw_preview(img: Image.Image, page) -> Image.Image:
"""Page rendered upright with layout blocks (colored, labeled) and lines."""
img = _rotate(img, page.rotation)
draw = ImageDraw.Draw(img)
for ln in page.lines: # faint blue boxes: every detected line
draw.rectangle(ln.box, outline=(185, 210, 255), width=2)
for b in page.blocks: # colored boxes with labels: layout blocks
color = TAG_COLORS.get(b["label"], (110, 110, 110))
box = b["bbox"]
draw.rectangle(box, outline=color, width=4)
label = f" {b['label']} "
lx, ly = box[0], max(0.0, box[1] - 18)
tw = draw.textlength(label)
draw.rectangle((lx, ly, lx + tw, ly + 17), fill=color)
draw.text((lx, ly + 1), label, fill=(255, 255, 255))
return img
def _page_dict(page) -> dict:
"""A Page in the same shape as the CLI's `--format json` output."""
return {
"markdown": page.markdown,
"text": page.text,
"source": page.source,
"rotation": page.rotation,
"skew": round(page.skew, 2),
"note": page.note,
"timings": {k: round(v, 1) for k, v in page.timings.items()},
"lines": [
{"box": [round(c, 1) for c in ln.box], "text": ln.text,
"conf": round(ln.conf, 3)}
for ln in page.lines
],
"blocks": [
{"label": b["label"], "bbox": [round(c, 1) for c in b["bbox"]],
"text": b["text"]}
for b in page.blocks
],
}
def _status_line(i: int, page) -> str:
bits = [f"page {i}", f"source: {page.source}",
f"{round(page.timings.get('total_ms', 0))} ms",
f"{len(page.lines)} lines · {len(page.blocks)} blocks"]
if page.rotation:
bits.append(f"turned {page.rotation}°")
if page.skew:
bits.append(f"deskewed {page.skew:.1f}°")
if page.note:
bits.append(f"({page.note})")
return " · ".join(bits)
def convert_file(
file_path: str,
mode: str = "auto",
output_format: str = "markdown",
dpi: float = 200.0,
keep_furniture: bool = False,
draw_boxes: bool = True,
):
"""Read a PDF or image document: markdown / plain text / JSON, with layout.
Indra OCR detects every text line and every layout block (headings, lists,
tables, captions, footnotes...), orders them for reading, and writes the
page as markdown. Digital PDFs are read through their own text layer where
it passes Indra's checks; scans and images go through the OCR models.
Args:
file_path: the uploaded PDF or image file.
mode: "auto" reads the PDF text layer where it passes the checks and
OCRs the rest; "ocr" always OCRs; "text" trusts the text layer.
output_format: "markdown", "plain text", or "json" (lines, boxes,
layout blocks, confidences, timings).
dpi: render resolution for PDFs.
keep_furniture: keep page headers and footers in the output.
draw_boxes: overlay layout blocks and line boxes on the page preview.
"""
if not file_path:
raise gr.Error("Please upload a PDF or image file first.")
conv = _converter(mode, bool(keep_furniture))
try:
pages = list(conv.convert(file_path, max_pages=MAX_PAGES))
except indra_ocr.InputError as e:
raise gr.Error(str(e))
if not pages:
raise gr.Error("No pages could be read from that file.")
# ---- preview of page 0, drawn from the page's own boxes -------------
first_i, first_page = pages[0]
if _is_pdf(file_path):
import pypdfium2 as pdfium
doc = pdfium.PdfDocument(file_path)
try:
pg = doc[first_i]
preview = pg.render(
scale=_pdf_page_scale(dpi, pg), grayscale=False
).to_pil()
finally:
doc.close()
else:
preview = Image.open(file_path).convert("RGB")
if draw_boxes:
preview = _draw_preview(preview, first_page)
# ---- result in the chosen format -----------------------------------
if output_format == "json":
result = json.dumps(
{"pages": [_page_dict(p) for _, p in pages]}, ensure_ascii=False, indent=1
)
elif output_format == "plain text":
result = "\n\n".join(p.text for _, p in pages)
else:
result = "\n\n---\n\n".join(p.markdown or "" for _, p in pages)
rendered = "\n\n---\n\n".join(p.markdown or "" for _, p in pages)
status = "\n".join(_status_line(i, p) for i, p in pages)
if len(pages) >= MAX_PAGES:
status += f"\n(first {MAX_PAGES} pages only — the library reads whole files)"
return preview, result, rendered, status
with gr.Blocks() as demo:
gr.Markdown(
"""
# 📄 Indra OCR
**[datalab-to/indra-ocr](https://huggingface.co/datalab-to/indra-ocr)** — a 1M-parameter ONNX OCR model that reads PDFs and images
and turns them into **markdown**, plain text, or **JSON with layout blocks and line boxes** — all on CPU. It reads handwriting, 80+ languages, and pages at any angle.
Drop in a document below, or click one of the example pages.
[Model card](https://huggingface.co/datalab-to/indra-ocr) · [code](https://github.com/datalab-to/indra) (Apache-2.0; weights under a modified OpenRAIL-M license)
"""
)
with gr.Row():
with gr.Column(scale=5):
file_in = gr.File(
label="PDF or image",
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp", ".bmp",
".tif", ".tiff"],
type="filepath",
)
run_btn = gr.Button("Convert", variant="primary")
with gr.Accordion("Advanced settings", open=False):
mode = gr.Radio(
["auto", "ocr", "text"],
value="auto",
label="Page reading mode",
info="auto: PDF text layer where it passes the checks, OCR the rest · ocr: never read the text layer · text: trust the text layer",
)
output_format = gr.Radio(
["markdown", "plain text", "json"],
value="markdown",
label="Output format",
)
dpi = gr.Slider(100, 300, value=200, step=10, label="PDF render DPI")
keep_furniture = gr.Checkbox(
value=False, label="Keep page headers and footers (furniture)"
)
draw_boxes = gr.Checkbox(
value=True,
label="Draw layout blocks and line boxes on the preview",
)
with gr.Column(scale=4):
page_preview = gr.Image(
label="Detected layout (faint blue: lines · colored: blocks)",
type="pil",
interactive=False,
)
with gr.Tabs():
with gr.Tab("Raw output"):
result_out = gr.Textbox(
label="Result",
lines=20,
max_lines=45,
buttons=["copy"],
)
with gr.Tab("Rendered markdown"):
rendered_out = gr.Markdown(label="Rendered markdown", value="")
status_out = gr.Textbox(
label="Per-page status and timing", lines=4, interactive=False
)
run_btn.click(
convert_file,
inputs=[file_in, mode, output_format, dpi, keep_furniture, draw_boxes],
outputs=[page_preview, result_out, rendered_out, status_out],
api_name="convert",
concurrency_limit=1,
)
file_in.upload(
convert_file,
inputs=[file_in, mode, output_format, dpi, keep_furniture, draw_boxes],
outputs=[page_preview, result_out, rendered_out, status_out],
api_name="convert_upload",
concurrency_limit=1,
)
gr.Examples(
examples=[
["examples/handwriting_letter.png"],
["examples/korean_letter.png"],
["examples/thai_calendar.png"],
["examples/tilted_memo.png"],
["examples/table_pmc.png"],
["examples/multi_column.png"],
["examples/chinese_essay.png"],
["examples/rotated_letter.png"],
],
inputs=[file_in],
fn=convert_file,
outputs=[page_preview, result_out, rendered_out, status_out],
cache_examples=True,
cache_mode="lazy",
label="Example documents (the OmniParseBench pages used in the model card)",
)
demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)