| import subprocess, sys, os, tempfile, base64 |
| from io import BytesIO |
| from threading import Thread |
| from typing import Iterator |
| import queue |
| import threading |
|
|
|
|
| |
| |
| |
| |
| |
| _RUNTIME_PKGS = [ |
| "torch==2.10.0", |
| "torchvision==0.25.0", |
| "transformers==4.57.1", |
| ] |
|
|
| print("Installing pinned runtime dependencies...") |
| subprocess.run( |
| [sys.executable, "-m", "pip", "install", "--quiet", "--no-cache-dir"] + _RUNTIME_PKGS, |
| check=True, |
| ) |
| print("Runtime deps installed.") |
|
|
| |
| import torch |
| from transformers import AutoModel, AutoTokenizer, TextIteratorStreamer |
| from gradio import Server |
| from gradio.data_classes import FileData |
| from fastapi.responses import HTMLResponse |
| import spaces |
|
|
| |
| |
| |
| |
| |
| MODEL_NAME = "baidu/Unlimited-OCR" |
|
|
| print("Loading tokenizer...") |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True) |
| print("Loading model...") |
| model = AutoModel.from_pretrained( |
| MODEL_NAME, |
| trust_remote_code=True, |
| use_safetensors=True, |
| torch_dtype=torch.bfloat16, |
| ).eval().cuda() |
| print("Model ready.") |
|
|
| app = Server() |
|
|
|
|
| |
| def pdf_to_images(pdf_path: str, dpi: int = 200) -> list[str]: |
| """Convert every page of a PDF to a PNG. Returns list of file paths.""" |
| import fitz |
| doc = fitz.open(pdf_path) |
| tmp_dir = tempfile.mkdtemp(prefix="pdf_ocr_") |
| mat = fitz.Matrix(dpi / 72, dpi / 72) |
| paths = [] |
| for i, page in enumerate(doc): |
| out = os.path.join(tmp_dir, f"page_{i + 1:04d}.png") |
| page.get_pixmap(matrix=mat).save(out) |
| paths.append(out) |
| doc.close() |
| return paths |
|
|
|
|
| def _image_data_url(path: str, max_side: int = 1600, quality: int = 85) -> str | None: |
| """Return a browser-displayable JPEG data URL for an output image.""" |
| try: |
| from PIL import Image |
|
|
| with Image.open(path) as img: |
| img = img.convert("RGB") |
| img.thumbnail((max_side, max_side)) |
| buf = BytesIO() |
| img.save(buf, format="JPEG", quality=quality, optimize=True) |
| return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode("ascii") |
| except Exception as e: |
| print(f"Failed to encode image artifact {path}: {e}") |
| return None |
|
|
|
|
| def _collect_artifacts(out_dir: str) -> dict: |
| """Collect annotated result images and embedded images cropped by the model.""" |
| image_exts = (".jpg", ".jpeg", ".png", ".webp") |
|
|
| def artifact(path: str, kind: str) -> dict | None: |
| data_url = _image_data_url(path, max_side=1600 if kind == "annotated" else 1000) |
| if not data_url: |
| return None |
| return {"name": os.path.basename(path), "data_url": data_url} |
|
|
| annotated = [] |
| for fname in sorted(os.listdir(out_dir)): |
| if fname.lower().endswith(image_exts): |
| item = artifact(os.path.join(out_dir, fname), "annotated") |
| if item: |
| annotated.append(item) |
|
|
| extracted = [] |
| images_dir = os.path.join(out_dir, "images") |
| if os.path.isdir(images_dir): |
| for fname in sorted(os.listdir(images_dir)): |
| if fname.lower().endswith(image_exts): |
| item = artifact(os.path.join(images_dir, fname), "extracted") |
| if item: |
| extracted.append(item) |
|
|
| return {"annotated": annotated, "extracted": extracted} |
|
|
|
|
| def _collect_output(out_dir: str) -> str: |
| """Read all text/markdown files written by model.infer().""" |
| result = "" |
| for fname in sorted(os.listdir(out_dir)): |
| if fname.endswith((".txt", ".md")): |
| with open(os.path.join(out_dir, fname), "r", encoding="utf-8") as f: |
| result += f.read() + "\n" |
| if not result: |
| for fname in sorted(os.listdir(out_dir)): |
| fpath = os.path.join(out_dir, fname) |
| if os.path.isfile(fpath): |
| try: |
| with open(fpath, "r", encoding="utf-8") as f: |
| result += f.read() + "\n" |
| except Exception: |
| pass |
| return result.strip() |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| class ThreadTargetedStdout: |
| def __init__(self, target_thread, q, original_stdout): |
| self.target_thread = target_thread |
| self.q = q |
| self.original_stdout = original_stdout |
|
|
| def write(self, data): |
| self.original_stdout.write(data) |
| self.original_stdout.flush() |
| if threading.current_thread() == self.target_thread: |
| if data: |
| lower_data = data.lower() |
| if "tps:" in lower_data or "tokens/s" in lower_data: |
| return len(data) |
| self.q.put(data) |
| return len(data) |
|
|
| def flush(self): |
| self.original_stdout.flush() |
|
|
| def __getattr__(self, name): |
| return getattr(self.original_stdout, name) |
|
|
|
|
| @app.api(stream_every=0.1) |
| @spaces.GPU(duration=60) |
| def run_ocr( |
| image_path: FileData, |
| mode: str = "gundam", |
| prompt: str = "document parsing.", |
| ) -> Iterator[dict]: |
| """ |
| Stream OCR output for one image page token-by-token. |
| |
| Yields dicts: {"text": str, "done": bool} |
| |
| mode: 'gundam' β fast (640 px crop) β ZeroGPU-friendly default |
| 'base' β accurate (1024 px) |
| """ |
| path = image_path["path"] |
| out_dir = tempfile.mkdtemp(prefix="ocr_out_") |
|
|
| if mode == "gundam": |
| base_size, image_size, crop_mode, ngram_window = 1024, 640, True, 128 |
| else: |
| base_size, image_size, crop_mode, ngram_window = 1024, 1024, False, 128 |
|
|
| |
| _infer_kwargs = dict( |
| prompt=f"<image>{prompt}", |
| image_file=path, |
| output_path=out_dir, |
| base_size=base_size, |
| image_size=image_size, |
| crop_mode=crop_mode, |
| max_length=8192, |
| no_repeat_ngram_size=35, |
| ngram_window=ngram_window, |
| save_results=True, |
| ) |
|
|
| q = queue.Queue() |
| errors = [] |
|
|
| def _infer_thread(): |
| try: |
| model.infer(tokenizer, **_infer_kwargs) |
| except Exception as e: |
| errors.append(str(e)) |
|
|
| thread = Thread(target=_infer_thread, daemon=True) |
|
|
| original_stdout = sys.stdout |
| targeted_stdout = ThreadTargetedStdout(thread, q, original_stdout) |
| sys.stdout = targeted_stdout |
|
|
| accumulated = "" |
| try: |
| thread.start() |
| while thread.is_alive() or not q.empty(): |
| try: |
| chunk = q.get(timeout=0.02) |
| accumulated += chunk |
| yield {"text": accumulated, "done": False} |
| except queue.Empty: |
| continue |
| finally: |
| sys.stdout = original_stdout |
| thread.join() |
|
|
| |
| full_text = _collect_output(out_dir) |
| artifacts = _collect_artifacts(out_dir) |
|
|
| if accumulated: |
| if full_text: |
| yield {"text": full_text, "done": True, "artifacts": artifacts} |
| else: |
| yield {"text": accumulated, "done": True, "artifacts": artifacts} |
| else: |
| if full_text: |
| words = full_text.split() |
| acc = "" |
| for i, word in enumerate(words): |
| acc += ("" if i == 0 else " ") + word |
| if i % 5 == 0: |
| yield {"text": acc, "done": False} |
| yield {"text": full_text, "done": True, "artifacts": artifacts} |
| else: |
| if errors: |
| raise RuntimeError(f"Inference failed: {', '.join(errors)}") |
| yield {"text": "", "done": True, "artifacts": artifacts} |
|
|
|
|
| |
| @app.api() |
| def explode_pdf(pdf_file: FileData) -> dict: |
| """ |
| Convert a PDF into per-page image paths (CPU only β no GPU wasted on I/O). |
| The frontend then calls run_ocr once per page, keeping each GPU slot to 60 s. |
| """ |
| pages = pdf_to_images(pdf_file["path"], dpi=200) |
| return {"pages": [{"path": p, "orig_name": os.path.basename(p)} for p in pages]} |
|
|
|
|
| |
| @app.get("/") |
| async def homepage(): |
| html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html") |
| with open(html_path, "r", encoding="utf-8") as f: |
| return HTMLResponse(content=f.read()) |
|
|
|
|
| app.launch(show_error=True) |
|
|