Ashoka74's picture
Deploy current work to HF Space (slim)
a1aef88
Raw
History Blame Contribute Delete
17.9 kB
import json
import queue
import re
import threading
import numpy as np
import pandas as pd
import streamlit as st
st.title("PDF OCR β€” Table Extractor")
st.caption("Powered by LightOnOCR-1B + Microsoft Harrier embeddings.")
# ── Dependency check ───────────────────────────────────────────────────────────
try:
import fitz # noqa: F401
except ImportError:
st.error("**PyMuPDF is not installed.**\n\n```bash\npip install pymupdf\n```")
st.stop()
# ── Cached models ──────────────────────────────────────────────────────────────
@st.cache_resource(show_spinner="Loading OCR model (one-time, ~2 GB)…")
def load_ocr_model():
import torch
from transformers import AutoProcessor, LightOnOcrForConditionalGeneration
processor = AutoProcessor.from_pretrained("lightonai/LightOnOCR-1B-1025")
model = LightOnOcrForConditionalGeneration.from_pretrained(
"lightonai/LightOnOCR-1B-1025",
torch_dtype=torch.bfloat16,
device_map="auto",
)
return processor, model
@st.cache_resource(show_spinner="Loading embedding model (one-time)…")
def load_embedding_model():
from sentence_transformers import SentenceTransformer
return SentenceTransformer(
"microsoft/harrier-oss-v1-0.6b",
model_kwargs={"dtype": "auto"},
)
# ── Core helpers ───────────────────────────────────────────────────────────────
def pdf_page_to_image(pdf_bytes: bytes, page_num: int, dpi: int):
from PIL import Image
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
pix = doc.load_page(page_num).get_pixmap(matrix=fitz.Matrix(dpi / 72, dpi / 72), alpha=False)
image = Image.fromarray(
np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
)
n_pages = doc.page_count
doc.close()
return image, n_pages
def run_ocr(processor, model, image) -> str:
import torch
prompt = (
"Extract all text from this page in natural reading order, "
"using Markdown for tables and LaTeX for equations."
)
messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": prompt}]}]
inputs = processor.apply_chat_template(messages, tokenize=True, return_tensors="pt")
inputs = {k: v.to(model.device) for k, v in inputs.items()}
with torch.no_grad():
output = model.generate(**inputs, max_new_tokens=2048, temperature=0.1)
return processor.decode(output[0], skip_special_tokens=True)
def parse_markdown_tables(text: str) -> list[pd.DataFrame]:
tables = []
for match in re.finditer(r"((?:\|.+\|\n?)+)", text, re.MULTILINE):
lines = match.group().strip().split("\n")
data_lines = [l for l in lines if not re.fullmatch(r"\|[\s:\-|]+\|", l.strip())]
if len(data_lines) < 2:
continue
rows = [[c.strip() for c in ln.split("|")[1:-1]] for ln in data_lines]
n_cols = len(rows[0])
rows = [r for r in rows if len(r) == n_cols]
if len(rows) < 2:
continue
tables.append(pd.DataFrame(rows[1:], columns=rows[0]))
return tables
def ocr_pdf(pdf_bytes: bytes, page_start: int, page_end: int, dpi: int,
processor, model, label: str) -> tuple[list[pd.DataFrame], list[str]]:
"""
Pipeline: a background thread renders PDF pages (CPU) while the main
thread runs GPU inference, so rendering and inference overlap.
render page N+1 ──────┐
β”œβ”€ overlapped
OCR page N β”€β”€β”€β”€β”€β”€β”€β”˜
"""
_, n_pages = pdf_page_to_image(pdf_bytes, 0, dpi)
page_end = min(page_end, n_pages - 1)
pages = list(range(page_start, page_end + 1))
n = len(pages)
# Queue holds (page_num, PIL.Image) tuples; sentinel = None
render_q: queue.Queue = queue.Queue(maxsize=2) # max 2 pre-rendered ahead
def _render_worker():
for p in pages:
image, _ = pdf_page_to_image(pdf_bytes, p, dpi)
render_q.put((p, image))
render_q.put(None) # sentinel
threading.Thread(target=_render_worker, daemon=True).start()
tables, texts = [], []
bar = st.progress(0, text=f"{label}: starting…")
for i in range(n):
item = render_q.get()
if item is None:
break
p, image = item
bar.progress(i / n, text=f"{label}: page {p + 1}/{n_pages}…")
with st.expander(f"{label} β€” page {p + 1}", expanded=False):
st.image(image, use_column_width=True)
text = run_ocr(processor, model, image)
texts.append(f"#### Page {p + 1}\n\n{text}")
for t in parse_markdown_tables(text):
t.insert(0, "_page", p + 1)
tables.append(t)
bar.progress(1.0, text=f"{label}: done.")
return tables, texts
def show_tables(tables: list[pd.DataFrame], key_prefix: str):
if not tables:
st.info("No markdown tables found in OCR output.")
return
for idx, df in enumerate(tables):
page_label = df["_page"].iloc[0] if "_page" in df.columns else "?"
st.write(f"**Table {idx + 1}** β€” page {page_label}")
display = df.drop(columns=[c for c in ("_page", "_file") if c in df.columns])
st.dataframe(display, use_container_width=True)
# ── Embedding section (shared, shown after any OCR run) ───────────────────────
def embedding_section(combined: pd.DataFrame):
st.markdown("---")
st.subheader("Calculate Embeddings")
text_cols = [c for c in combined.columns if c not in ("_page", "_file", "embeddings")]
if not text_cols:
st.warning("No text columns available.")
return
selected_cols = st.multiselect(
"Columns to embed (values are concatenated per row)",
options=text_cols,
default=[text_cols[0]],
key="embed_cols",
)
prompt_name = st.selectbox(
"Encoding prompt",
options=["none (document)", "web_search_query", "sts_query", "bitext_query"],
help="Use 'none' for document/passage content. Use a query prompt only for query-side encoding.",
key="embed_prompt",
)
if not st.button("Calculate Embeddings", key="btn_embed"):
return
if not selected_cols:
st.warning("Select at least one column.")
return
texts = combined[selected_cols].fillna("").astype(str).agg(" ".join, axis=1).tolist()
embed_model = load_embedding_model()
with st.spinner(f"Encoding {len(texts)} rows…"):
kwargs = {} if prompt_name == "none (document)" else {"prompt_name": prompt_name}
embeddings = embed_model.encode(texts, show_progress_bar=False, **kwargs)
combined = combined.copy()
combined["embeddings"] = [json.dumps(e.tolist()) for e in embeddings]
st.session_state["ocr_combined_with_embeddings"] = combined
st.success(f"Embeddings added: {embeddings.shape[1]}-dim vectors for {len(texts)} rows.")
st.dataframe(
combined.assign(embeddings=combined["embeddings"].str[:60] + "…"),
use_container_width=True,
)
st.download_button(
label="Download table + embeddings as CSV",
data=combined.to_csv(index=False).encode(),
file_name="ocr_tables_with_embeddings.csv",
mime="text/csv",
key="dl_embed",
)
# ── JSON helper ────────────────────────────────────────────────────────────────
def json_to_dataframes(raw: bytes) -> list[pd.DataFrame]:
"""
Convert any JSON structure into one or more DataFrames.
Handles:
- list of dicts β†’ single flat DataFrame
- dict of lists β†’ single DataFrame (pd.DataFrame(data))
- nested dict β†’ pd.json_normalize, one DataFrame
- dict of dicts β†’ one DataFrame per top-level key, or normalise whole thing
- list of non-dict items β†’ single-column DataFrame
"""
data = json.loads(raw)
tables = []
if isinstance(data, list):
if data and isinstance(data[0], dict):
tables.append(pd.json_normalize(data))
else:
tables.append(pd.DataFrame({"value": data}))
elif isinstance(data, dict):
# Check if values are all lists of equal length β†’ columnar format
if all(isinstance(v, list) for v in data.values()):
try:
tables.append(pd.DataFrame(data))
except ValueError:
# Unequal lengths β€” normalise each key separately
for key, val in data.items():
df = pd.json_normalize(val) if val and isinstance(val[0], dict) else pd.DataFrame({"value": val})
df.insert(0, "_key", key)
tables.append(df)
# Dict of dicts β†’ one table per key
elif all(isinstance(v, dict) for v in data.values()):
for key, val in data.items():
df = pd.json_normalize([val])
df.insert(0, "_key", key)
tables.append(df)
else:
# Mixed / deeply nested β€” normalise whole thing
tables.append(pd.json_normalize(data if isinstance(data, list) else [data]))
return [df for df in tables if not df.empty]
# ── UI ─────────────────────────────────────────────────────────────────────────
mode = st.radio("Input mode", ["Single PDF upload", "Multiple PDF upload", "JSON file"], horizontal=True)
dpi = st.slider("Render DPI", 100, 300, 200, step=50,
help="~1540 px longest side is optimal for LightOnOCR (PDF modes only)")
# Clear stale results when mode changes
if st.session_state.get("_ocr_mode") != mode:
for key in ("ocr_tables", "ocr_texts", "ocr_combined_with_embeddings"):
st.session_state.pop(key, None)
st.session_state["_ocr_mode"] = mode
# ── Single PDF ─────────────────────────────────────────────────────────────────
if mode == "Single PDF upload":
uploaded = st.file_uploader("Upload a PDF", type=["pdf"])
if not uploaded:
st.info("Upload a PDF to get started.")
st.stop()
pdf_bytes = uploaded.read()
_, n_pages = pdf_page_to_image(pdf_bytes, 0, dpi)
st.write(f"**Pages detected:** {n_pages}")
c1, c2 = st.columns(2)
page_start = c1.number_input("First page (0-indexed)", 0, n_pages - 1, 0)
page_end = c2.number_input("Last page (inclusive)", int(page_start), n_pages - 1, n_pages - 1)
if st.button("Run OCR"):
processor, ocr_model = load_ocr_model()
tables, texts = ocr_pdf(
pdf_bytes, int(page_start), int(page_end), dpi,
processor, ocr_model, label=uploaded.name,
)
st.session_state["ocr_tables"] = tables
st.session_state["ocr_texts"] = texts
st.session_state.pop("ocr_combined_with_embeddings", None)
if "ocr_tables" in st.session_state:
tables = st.session_state["ocr_tables"]
texts = st.session_state["ocr_texts"]
st.subheader("Extracted Tables")
show_tables(tables, key_prefix="single")
with st.expander("Full OCR text", expanded=not bool(tables)):
st.markdown("\n\n---\n\n".join(texts))
if tables:
combined = pd.concat(
[df.drop(columns=[c for c in ("_page",) if c in df.columns]) for df in tables],
ignore_index=True,
)
# Restore _page for reference
combined_with_meta = pd.concat(tables, ignore_index=True)
embedding_section(combined_with_meta)
if "ocr_combined_with_embeddings" not in st.session_state:
st.download_button(
"Download tables as CSV",
data=combined_with_meta.to_csv(index=False).encode(),
file_name=f"{uploaded.name}_tables.csv",
mime="text/csv",
key="dl_single",
)
# ── Multiple PDF upload ────────────────────────────────────────────────────────
elif mode == "Multiple PDF upload":
uploaded_files = st.file_uploader(
"Select PDF files (Ctrl/Shift-click to pick multiple)",
type=["pdf"],
accept_multiple_files=True,
)
if not uploaded_files:
st.info("Select one or more PDF files to get started.")
st.stop()
st.write(f"**{len(uploaded_files)} PDF(s) selected:**")
st.dataframe(
pd.DataFrame({
"File": [f.name for f in uploaded_files],
"Size (KB)": [round(f.size / 1024, 1) for f in uploaded_files],
}),
use_container_width=True, hide_index=True,
)
c1, c2 = st.columns(2)
page_start = c1.number_input("First page per PDF (0-indexed)", min_value=0, value=0)
page_end_input = c2.number_input("Last page per PDF (-1 = all)", min_value=-1, value=-1)
if st.button("Run OCR on all PDFs"):
processor, ocr_model = load_ocr_model()
all_tables: list[pd.DataFrame] = []
all_texts: list[str] = []
overall = st.progress(0, text="Starting batch OCR…")
for f_idx, uploaded_file in enumerate(uploaded_files):
overall.progress(f_idx / len(uploaded_files),
text=f"{uploaded_file.name} ({f_idx + 1}/{len(uploaded_files)})…")
st.markdown(f"---\n### {uploaded_file.name}")
pdf_bytes = uploaded_file.read()
_, n_pages = pdf_page_to_image(pdf_bytes, 0, dpi)
p_end = n_pages - 1 if page_end_input == -1 else min(int(page_end_input), n_pages - 1)
tables, texts = ocr_pdf(
pdf_bytes, int(page_start), p_end, dpi,
processor, ocr_model, label=uploaded_file.name,
)
for t in tables:
t.insert(0, "_file", uploaded_file.name)
all_tables.extend(tables)
all_texts.extend(texts)
show_tables(tables, key_prefix=f"f{f_idx}")
with st.expander(f"Full OCR text β€” {uploaded_file.name}", expanded=False):
st.markdown("\n\n---\n\n".join(texts))
overall.progress(1.0, text=f"All {len(uploaded_files)} PDFs processed.")
st.session_state["ocr_tables"] = all_tables
st.session_state["ocr_texts"] = all_texts
st.session_state.pop("ocr_combined_with_embeddings", None)
if "ocr_tables" in st.session_state:
all_tables = st.session_state["ocr_tables"]
if all_tables:
st.markdown("---")
st.subheader("Combined export")
combined = pd.concat(all_tables, ignore_index=True)
st.dataframe(combined, use_container_width=True)
embedding_section(combined)
if "ocr_combined_with_embeddings" not in st.session_state:
st.download_button(
"Download all tables as CSV",
data=combined.to_csv(index=False).encode(),
file_name="ocr_all_tables.csv",
mime="text/csv",
key="dl_folder_combined",
)
# ── JSON file ──────────────────────────────────────────────────────────────────
elif mode == "JSON file":
json_file = st.file_uploader(
"Upload a JSON file",
type=["json"],
help="Accepts any JSON structure: list of dicts, dict of lists, nested objects, etc.",
)
if not json_file:
st.info("Upload a JSON file to get started.")
st.stop()
try:
tables = json_to_dataframes(json_file.read())
except Exception as e:
st.error(f"Could not parse JSON: {e}")
st.stop()
if not tables:
st.warning("No non-empty tables could be extracted from this JSON.")
st.stop()
# Store as ocr_tables so the shared embedding section works unchanged
# Attach a dummy _page col so show_tables doesn't break
tagged = []
for i, df in enumerate(tables):
df = df.copy()
if "_page" not in df.columns:
df.insert(0, "_page", i + 1)
tagged.append(df)
st.session_state["ocr_tables"] = tagged
st.session_state["ocr_texts"] = []
st.subheader("Extracted Tables")
for idx, df in enumerate(tagged):
label = f"Table {idx + 1}"
if "_key" in df.columns:
label += f" β€” {df['_key'].iloc[0]}"
st.write(f"**{label}** ({len(df)} rows Γ— {len(df.columns)} cols)")
display = df.drop(columns=[c for c in ("_page", "_key") if c in df.columns])
st.dataframe(display, use_container_width=True)
st.download_button(
f"Download table {idx + 1} as CSV",
data=display.to_csv(index=False).encode(),
file_name=f"{json_file.name}_table_{idx + 1}.csv",
mime="text/csv",
key=f"dl_json_{idx}",
)
combined = pd.concat(tagged, ignore_index=True)
embedding_section(combined)