| 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.") |
|
|
|
|
| |
| try: |
| import fitz |
| except ImportError: |
| st.error("**PyMuPDF is not installed.**\n\n```bash\npip install pymupdf\n```") |
| st.stop() |
|
|
|
|
| |
| @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"}, |
| ) |
|
|
|
|
| |
| 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) |
|
|
| |
| render_q: queue.Queue = queue.Queue(maxsize=2) |
|
|
| 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) |
|
|
| 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) |
|
|
|
|
| |
| 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", |
| ) |
|
|
|
|
| |
| 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): |
| |
| if all(isinstance(v, list) for v in data.values()): |
| try: |
| tables.append(pd.DataFrame(data)) |
| except ValueError: |
| |
| 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) |
| |
| 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: |
| |
| tables.append(pd.json_normalize(data if isinstance(data, list) else [data])) |
|
|
| return [df for df in tables if not df.empty] |
|
|
|
|
| |
| 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)") |
|
|
| |
| 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 |
|
|
| |
| 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, |
| ) |
| |
| 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", |
| ) |
|
|
| |
| 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", |
| ) |
|
|
| |
| 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() |
|
|
| |
| |
| 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) |
|
|