Spaces:
Sleeping
Sleeping
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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) | |