Files
wehub-resource-sync 925e56bb5f
Unit tests / build (t4_gpu) (push) Has been cancelled
Unit tests / build (ubuntu-latest) (push) Has been cancelled
Unit tests / build (windows-latest) (push) Has been cancelled
Test CLI scripts / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:56 +08:00

507 lines
18 KiB
Python

"""Surya2 streamlit app — exercise layout, recognition, table_rec via the
inference manager. Detection + OCR-error stay in their own torch paths."""
from __future__ import annotations
import io
import re
import tempfile
import time
from typing import List
import pypdfium2
import streamlit as st
import streamlit.components.v1 as components
from PIL import Image, ImageDraw
from surya.debug.draw import draw_polys_on_image, draw_bboxes_on_image
from surya.detection import TextDetectionResult
from surya.inference import SuryaInferenceManager
from surya.layout import LayoutPredictor
from surya.layout.schema import LayoutResult
from surya.recognition import RecognitionPredictor
from surya.recognition.schema import PageOCRResult
from surya.settings import settings
from surya.table_rec import TableRecPredictor
from surya.table_rec.schema import TableResult
# KaTeX-enabled HTML wrapper. The OCR HTML wraps math in <math>...</math>
# (KaTeX-compatible LaTeX inside), which a browser would otherwise show as
# raw text. We convert those tags to \( \) / \[ \] delimiters and let KaTeX
# auto-render typeset them inside an iframe component.
_KATEX_HEAD = r"""<!doctype html><html><head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
<style>
/* White "paper" card so the text stays readable in both light and dark
Streamlit themes (the iframe is otherwise transparent and our text is dark). */
html,body{background:#ffffff;}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px;line-height:1.55;color:#111111;margin:0;padding:14px;}
table{border-collapse:collapse;margin:6px 0;} td,th{border:1px solid #bbb;padding:3px 6px;color:#111111;}
[data-label="SectionHeader"],[data-label="PageHeader"]{font-weight:600;}
</style></head><body>
"""
_KATEX_TAIL = r"""
<script>
renderMathInElement(document.body, {
delimiters: [
{left: "\\[", right: "\\]", display: true},
{left: "\\(", right: "\\)", display: false}
],
throwOnError: false
});
</script></body></html>
"""
_MATH_RE = re.compile(r"<math\b([^>]*)>(.*?)</math>", re.DOTALL | re.IGNORECASE)
def _math_to_katex(html_str: str) -> str:
"""Rewrite <math>...</math> tags into KaTeX \\( \\) / \\[ \\] delimiters."""
def repl(m: "re.Match") -> str:
attrs, inner = m.group(1), m.group(2)
if re.search(r"""display\s*=\s*["']block["']""", attrs):
return "\\[" + inner + "\\]"
return "\\(" + inner + "\\)"
return _MATH_RE.sub(repl, html_str or "")
def render_ocr_html(html_str: str, height: int = 400) -> None:
"""Render OCR HTML with math typeset by KaTeX (iframe component)."""
components.html(
_KATEX_HEAD + _math_to_katex(html_str) + _KATEX_TAIL,
height=height,
scrolling=True,
)
def _assemble_page_html(page: PageOCRResult) -> str:
"""Reconstruct a div-block whole-page HTML from a PageOCRResult."""
parts: List[str] = []
for blk in page.blocks:
if blk.skipped:
continue
x0, y0, x1, y1 = (int(c) for c in blk.bbox)
body = blk.html or ""
parts.append(
f'<div data-bbox="{x0} {y0} {x1} {y1}" data-label="{blk.label}">{body}</div>'
)
return "\n".join(parts)
def _show_timing(label: str, elapsed_s: float, extra: str = "") -> None:
"""Render a small caption with wall-clock + optional extra detail."""
detail = f" — {extra}" if extra else ""
st.caption(f"⏱ {label}: {elapsed_s * 1000:.0f} ms ({elapsed_s:.2f}s){detail}")
@st.cache_resource()
def load_predictors_cached():
manager = SuryaInferenceManager()
layout_predictor = LayoutPredictor(manager)
rec_predictor = RecognitionPredictor(manager)
table_rec_predictor = TableRecPredictor(manager)
# Lazy-import detection / ocr_error to keep startup snappy when the user
# only wants VLM modes
from surya.detection import DetectionPredictor
from surya.ocr_error import OCRErrorPredictor
return {
"manager": manager,
"layout": layout_predictor,
"recognition": rec_predictor,
"table_rec": table_rec_predictor,
"detection": DetectionPredictor(),
"ocr_error": OCRErrorPredictor(),
}
@st.cache_resource()
def load_fast_layout():
from surya.fast_layout import FastLayoutPredictor
return FastLayoutPredictor()
def _layout_predictor(use_fast: bool):
return load_fast_layout() if use_fast else predictors["layout"]
def text_detection(img) -> tuple[Image.Image, TextDetectionResult, float]:
t = time.perf_counter()
text_pred = predictors["detection"]([img])[0]
elapsed = time.perf_counter() - t
text_polygons = [p.polygon for p in text_pred.bboxes]
det_img = draw_polys_on_image(text_polygons, img.copy())
return det_img, text_pred, elapsed
def layout_detection(
img, use_fast: bool = False
) -> tuple[Image.Image, LayoutResult, float]:
t = time.perf_counter()
pred = _layout_predictor(use_fast)([img])[0]
elapsed = time.perf_counter() - t
polygons = [p.polygon for p in pred.bboxes]
labels = [
f"{p.label}-{p.position}-c{p.count}-{round(p.confidence or 0, 2)}"
for p in pred.bboxes
]
annotated = draw_polys_on_image(
polygons, img.copy(), labels=labels, label_font_size=14
)
return annotated, pred, elapsed
def block_ocr(img) -> tuple[Image.Image, PageOCRResult, LayoutResult, float, float]:
"""Layout → block crops → BLOCK_PROMPT. Returns layout + block-OCR timings."""
t_layout = time.perf_counter()
layout = predictors["layout"]([img])[0]
layout_elapsed = time.perf_counter() - t_layout
t_blocks = time.perf_counter()
page_results = predictors["recognition"]([img], [layout])
blocks_elapsed = time.perf_counter() - t_blocks
page = page_results[0]
annotated = img.copy()
draw = ImageDraw.Draw(annotated)
for blk in page.blocks:
x0, y0, x1, y1 = blk.bbox
color = "red" if blk.error else ("orange" if blk.skipped else "green")
draw.rectangle((x0, y0, x1, y1), outline=color, width=3)
draw.text((x0 + 4, y0 + 4), f"{blk.reading_order} {blk.label}", fill=color)
return annotated, page, layout, layout_elapsed, blocks_elapsed
def full_page_ocr(img) -> tuple[Image.Image, PageOCRResult, float]:
"""Single HIGH_ACCURACY_BBOX_PROMPT call on the whole page."""
t = time.perf_counter()
page_results = predictors["recognition"]([img], full_page=True)
elapsed = time.perf_counter() - t
page = page_results[0]
annotated = img.copy()
draw = ImageDraw.Draw(annotated)
for blk in page.blocks:
x0, y0, x1, y1 = blk.bbox
color = "red" if blk.error else ("orange" if blk.skipped else "green")
draw.rectangle((x0, y0, x1, y1), outline=color, width=3)
draw.text((x0 + 4, y0 + 4), f"{blk.reading_order} {blk.label}", fill=color)
return annotated, page, elapsed
def table_recognition(
img: Image.Image,
mode: str,
skip_table_detection: bool,
use_fast_layout: bool = False,
) -> tuple[Image.Image, List[TableResult], float, float]:
"""Returns (annotated_img, table_preds, layout_elapsed, table_rec_elapsed)."""
layout_elapsed = 0.0
if skip_table_detection:
table_imgs = [img]
table_bboxes = [(0, 0, img.size[0], img.size[1])]
else:
t = time.perf_counter()
layout = _layout_predictor(use_fast_layout)([img])[0]
layout_elapsed = time.perf_counter() - t
tables = [b for b in layout.bboxes if b.label in ("Table", "TableOfContents")]
if not tables:
return img.copy(), [], layout_elapsed, 0.0
table_bboxes = [tuple(int(c) for c in b.bbox) for b in tables]
table_imgs = [img.crop(b) for b in table_bboxes]
t = time.perf_counter()
if mode == "full":
table_preds = predictors["table_rec"].predict_full(table_imgs)
else:
table_preds = predictors["table_rec"].predict_simple(table_imgs)
table_rec_elapsed = time.perf_counter() - t
out_img = img.copy()
for pred, table_img, tbbox in zip(table_preds, table_imgs, table_bboxes):
if pred.error or pred.mode != "simple" or not pred.rows:
continue
row_bboxes = [r.bbox for r in pred.rows]
col_bboxes = [c.bbox for c in pred.cols]
row_labels = [r.label for r in pred.rows]
col_labels = [c.label for c in pred.cols]
annot = table_img.copy()
annot = draw_bboxes_on_image(
row_bboxes, annot, labels=row_labels, label_font_size=14, color="blue"
)
annot = draw_bboxes_on_image(
col_bboxes, annot, labels=col_labels, label_font_size=14, color="red"
)
# Paste annotated crop back at the table's position in the page.
out_img.paste(annot, (tbbox[0], tbbox[1]))
return out_img, table_preds, layout_elapsed, table_rec_elapsed
def ocr_errors(pdf_file, page_count, sample_len=512, max_samples=10, max_pages=15):
from pdftext.extraction import plain_text_output
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
f.write(pdf_file.getvalue())
f.seek(0)
page_middle = page_count // 2
page_range = range(
max(page_middle - max_pages, 0), min(page_middle + max_pages, page_count)
)
text = plain_text_output(f.name, page_range=page_range)
sample_gap = len(text) // max_samples
if len(text) == 0 or sample_gap == 0:
return "This PDF has no text or very little text", ["no text"]
if sample_gap < sample_len:
sample_gap = sample_len
samples = []
for i in range(0, len(text), sample_gap):
samples.append(text[i : i + sample_len])
results = predictors["ocr_error"](samples)
label = "This PDF has good text."
if results.labels.count("bad") / len(results.labels) > 0.2:
label = "This PDF may have garbled or bad OCR text."
return label, results.labels
def open_pdf(pdf_file):
stream = io.BytesIO(pdf_file.getvalue())
return pypdfium2.PdfDocument(stream)
@st.cache_data()
def get_page_image(pdf_file, page_num, dpi=settings.IMAGE_DPI):
doc = open_pdf(pdf_file)
renderer = doc.render(
pypdfium2.PdfBitmap.to_pil,
page_indices=[page_num - 1],
scale=dpi / 72,
)
png = list(renderer)[0]
png_image = png.convert("RGB")
doc.close()
return png_image
@st.cache_data()
def page_counter(pdf_file):
doc = open_pdf(pdf_file)
doc_len = len(doc)
doc.close()
return doc_len
st.set_page_config(layout="wide")
col1, col2 = st.columns([0.55, 0.45])
predictors = load_predictors_cached()
st.markdown(
"""
# Surya 2 Demo
VLM-backed layout, OCR, and table recognition. The model runs in a local
`llama-server` (or vllm) process, started on first use.
Modes:
- **Layout**: page → list of blocks with label + bbox + token count
- **Block OCR**: layout + per-block HTML
- **Table Rec (simple)**: row + column bboxes only
- **Table Rec (full)**: full HTML for each detected table
"""
)
in_file = st.sidebar.file_uploader(
"PDF file or image:", type=["pdf", "png", "jpg", "jpeg", "gif", "webp"]
)
if in_file is None:
st.stop()
filetype = in_file.type
page_count = None
if "pdf" in filetype:
page_count = page_counter(in_file)
page_number = st.sidebar.number_input(
f"Page number out of {page_count}:", min_value=1, value=1, max_value=page_count
)
# Render at high DPI so the OCR / table-rec demos see fine glyphs.
# Layout + detection internally downsample (or accept the small perf hit
# at demo scale); we always render and display the high-DPI page here.
pil_image = get_page_image(in_file, page_number, settings.IMAGE_DPI_HIGHRES)
else:
pil_image = Image.open(in_file).convert("RGB")
page_number = None
run_full_page_ocr = st.sidebar.button("Run Full-Page OCR")
run_text_det = st.sidebar.button("Run Text Detection")
run_layout = st.sidebar.button("Run Layout Analysis")
run_table_rec = st.sidebar.button("Run Table Rec")
run_block_ocr = st.sidebar.button("Run Block OCR")
run_ocr_errors = st.sidebar.button("Run bad-PDF-text detection")
use_fast_layout = st.sidebar.checkbox(
"Fast layout",
value=True,
help="Use the fast layout detector.",
)
table_mode = st.sidebar.radio(
"Table mode",
options=["simple", "full"],
index=0,
help="simple: rows+cols only. full: full HTML.",
)
skip_table_detection = st.sidebar.checkbox(
"Skip table detection",
value=False,
help="Treat the entire page/image as a single table.",
)
if pil_image is None:
st.stop()
if run_text_det:
det_img, text_pred, elapsed = text_detection(pil_image)
with col1:
_show_timing("Text detection", elapsed, f"{len(text_pred.bboxes)} polys")
st.image(det_img, caption="Detected Text", use_container_width=True)
st.json(
text_pred.model_dump(exclude=["heatmap", "affinity_map"]), expanded=False
)
if run_layout:
annotated, pred, elapsed = layout_detection(pil_image, use_fast=use_fast_layout)
with col1:
label = "Layout (fast)" if use_fast_layout else "Layout"
_show_timing(label, elapsed, f"{len(pred.bboxes)} blocks")
st.image(annotated, caption="Detected Layout", use_container_width=True)
st.json(pred.model_dump(), expanded=False)
if run_block_ocr:
annotated, page, layout, t_layout, t_blocks = block_ocr(pil_image)
with col1:
n_blocks = len(page.blocks)
n_ok = sum(1 for b in page.blocks if not b.skipped and not b.error)
_show_timing("Block OCR — layout", t_layout, f"{n_blocks} blocks")
_show_timing("Block OCR — per-block OCR", t_blocks, f"{n_ok} OCR'd")
_show_timing("Block OCR — total", t_layout + t_blocks)
st.image(
annotated,
caption="Block OCR (green=ok, orange=skipped, red=error)",
use_container_width=True,
)
full_html = _assemble_page_html(page)
with st.expander("Full page HTML (rendered)", expanded=False):
render_ocr_html(full_html, height=600)
with st.expander("Full page HTML (source)", expanded=False):
st.code(full_html, language="html")
for blk in page.blocks:
with st.expander(
f"#{blk.reading_order} {blk.label} (conf {blk.confidence:.2f})"
):
# Diagnostics: show numeric bbox + polygon + a thumbnail with the
# drawn rectangle highlighted, then the actual crop fed to OCR.
xs = [p[0] for p in blk.polygon]
ys = [p[1] for p in blk.polygon]
bbox_drawn = [int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))]
cx0 = max(0, int(min(xs)) - 4)
cy0 = max(0, int(min(ys)) - 4)
cx1 = min(pil_image.size[0], int(max(xs)) + 4)
cy1 = min(pil_image.size[1], int(max(ys)) + 4)
st.text(
f"bbox(drawn) = {bbox_drawn}\n"
f"crop(ocr) = {(cx0, cy0, cx1, cy1)} (= bbox ± 4px pad)"
)
# Thumbnail with this block's rectangle highlighted in red.
thumb = pil_image.copy()
ImageDraw.Draw(thumb).rectangle(bbox_drawn, outline="red", width=4)
st.image(thumb, caption="this block's drawn rect (red)", width=300)
# The actual crop fed to OCR
if cx1 > cx0 and cy1 > cy0:
st.image(pil_image.crop((cx0, cy0, cx1, cy1)), caption="OCR crop")
if blk.skipped:
st.info("Block skipped (visual label)")
elif blk.error:
st.error("Block OCR errored")
else:
render_ocr_html(blk.html, height=160)
st.code(blk.html, language="html")
if run_full_page_ocr:
annotated, page, elapsed = full_page_ocr(pil_image)
with col1:
n_blocks = len(page.blocks)
n_ok = sum(1 for b in page.blocks if not b.skipped and not b.error)
_show_timing("Full-Page OCR", elapsed, f"{n_blocks} blocks parsed, {n_ok} OK")
st.image(
annotated,
caption="Full-Page OCR (green=ok, orange=skipped, red=error)",
use_container_width=True,
)
full_html = _assemble_page_html(page)
with st.expander("Full page HTML (rendered)", expanded=False):
render_ocr_html(full_html, height=600)
with st.expander("Full page HTML (source)", expanded=False):
st.code(full_html, language="html")
for blk in page.blocks:
with st.expander(
f"#{blk.reading_order} {blk.label} (conf {blk.confidence:.2f})"
):
if blk.skipped:
st.info("Block skipped (visual label)")
elif blk.error:
st.error("Block OCR errored")
else:
render_ocr_html(blk.html, height=160)
st.code(blk.html, language="html")
if run_table_rec:
table_img, preds, t_layout, t_table = table_recognition(
pil_image, table_mode, skip_table_detection, use_fast_layout=use_fast_layout
)
with col1:
if not skip_table_detection:
_show_timing("Table Rec — layout", t_layout, f"{len(preds)} tables found")
_show_timing(f"Table Rec — {table_mode}", t_table)
if not skip_table_detection:
_show_timing("Table Rec — total", t_layout + t_table)
st.image(table_img, caption="Table Recognition", use_container_width=True)
for pred in preds:
if pred.mode == "full" and pred.html:
with st.expander("Table HTML"):
render_ocr_html(pred.html, height=400)
st.code(pred.html, language="html")
else:
st.json(pred.model_dump(), expanded=False)
if run_ocr_errors:
if "pdf" not in filetype:
st.error("This feature only works with PDFs.")
else:
label, results = ocr_errors(in_file, page_count)
with col1:
st.write(label)
st.json(results)
with col2:
st.image(pil_image, caption="Uploaded Image", use_container_width=True)