from __future__ import annotations import base64 import html import io import json import os import re import subprocess import sys import time from pathlib import Path from threading import Thread # Pre-built causal_conv1d wheel (linear-attention dependency, can't compile in Space build). # Pulled from Dao-AILab official GitHub release. Auto-selects the cpXY wheel matching the # running Python version (HF Space = cp310, local dev py3.12 = cp312, etc.). # Soft-fails on unsupported combos — flash-linear-attention falls back to its torch impl. _CAUSAL_CONV1D_PY = f"cp{sys.version_info.major}{sys.version_info.minor}" _CAUSAL_CONV1D_WHEEL = ( "https://github.com/Dao-AILab/causal-conv1d/releases/download/v1.6.2.post1/" f"causal_conv1d-1.6.2.post1+cu12torch2.8cxx11abiTRUE-" f"{_CAUSAL_CONV1D_PY}-{_CAUSAL_CONV1D_PY}-linux_x86_64.whl" ) try: import causal_conv1d # noqa: F401 except ImportError: try: subprocess.run( [sys.executable, "-m", "pip", "install", _CAUSAL_CONV1D_WHEEL, "--no-deps", "-q"], check=True, ) import causal_conv1d # noqa: F401 except Exception as e: print(f"[warn] causal_conv1d unavailable ({e}); slower linear-attention conv1d path will be used") import gradio as gr import spaces from chandra.output import parse_markdown from PIL import Image MODEL_ID = os.getenv("MODEL_ID", "ebinan92/Qwen3.5-ocr-jp-2b") PROMPT = os.getenv( "OCR_PROMPT", "OCR this image as HTML layout blocks with bbox and label." ) MAX_NEW_TOKENS = 12288 # Area-based image cap (matches chandra-ocr-2 reference Space). 1536² px² ≈ 2300 visual tokens. MAX_IMAGE_PIXELS = int(os.getenv("MAX_IMAGE_PIXELS", str(1536 * 1536))) PDF_DPI = int(os.getenv("PDF_DPI", "180")) HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_HUB_TOKEN") LABEL_COLORS = { "Text": "#2196F3", "Section-Header": "#F44336", "Equation-Block": "#9C27B0", "Table": "#FF9800", "Image": "#4CAF50", "Figure": "#009688", "Caption": "#795548", "Footnote": "#607D8B", "List-Group": "#3F51B5", "Page-Header": "#CDDC39", "Page-Footer": "#FFC107", "Code-Block": "#00BCD4", "Bibliography": "#E91E63", "Complex-Block": "#8BC34A", "Form": "#FF5722", "Table-Of-Contents": "#673AB7", "Diagram": "#FFEB3B", } _BLOCK_RE = re.compile( r'(.*?)', re.DOTALL, ) def _load_model_eager(): """Module-level eager load for ZeroGPU. Uses device_map="auto" + flash-linear-attention CUDA kernels (via the `fla` package pulled in by requirements). Without flash-linear-attention, the 18 linear_attention layers in Qwen3.5 fall back to a slow Python implementation — that's the 5–10x slowdown we hit before. """ import torch from transformers import AutoModelForImageTextToText, AutoProcessor print(f"[startup] Loading model: {MODEL_ID}") kwargs = {"trust_remote_code": True, "device_map": "auto"} if HF_TOKEN: kwargs["token"] = HF_TOKEN try: model = AutoModelForImageTextToText.from_pretrained( MODEL_ID, dtype=torch.bfloat16, **kwargs, ) except TypeError: model = AutoModelForImageTextToText.from_pretrained( MODEL_ID, torch_dtype=torch.bfloat16, **kwargs, ) model.eval() processor = AutoProcessor.from_pretrained( MODEL_ID, trust_remote_code=True, token=HF_TOKEN, ) if hasattr(processor, "tokenizer"): processor.tokenizer.padding_side = "left" print(f"[startup] Model ready") return model, processor MODEL, PROCESSOR = _load_model_eager() def _resize_if_needed(image: Image.Image) -> Image.Image: """Area-based scale-to-fit (matches chandra-ocr-2 Space).""" if image.mode != "RGB": image = image.convert("RGB") if MAX_IMAGE_PIXELS <= 0: return image w, h = image.size total = w * h if total <= MAX_IMAGE_PIXELS: return image scale = (MAX_IMAGE_PIXELS / total) ** 0.5 return image.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS) def _render_pdf_page(path: Path, page_number: int) -> tuple[int, Image.Image]: import pypdfium2 as pdfium doc = pdfium.PdfDocument(str(path)) try: if page_number < 1 or page_number > len(doc): raise gr.Error( f"Invalid page number ({page_number}). Must be between 1 and {len(doc)}." ) page = doc[page_number - 1] image = page.render(scale=PDF_DPI / 72).to_pil() page.close() return page_number, _resize_if_needed(image) finally: doc.close() def _load_image(file_path: str, page_number: int) -> tuple[int, Image.Image]: path = Path(file_path) if path.suffix.lower() == ".pdf": return _render_pdf_page(path, page_number) img = Image.open(path) img.load() # eagerly read pixel data so the fp can be released return 1, _resize_if_needed(img) @spaces.GPU(duration=180) def _generate_page_stream( image: Image.Image, temperature: float, ): """Streaming inference. Yields (partial_text, token_count, elapsed, done).""" from transformers import TextIteratorStreamer conversation = [ { "role": "user", "content": [ {"type": "image", "image": image}, {"type": "text", "text": PROMPT}, ], } ] inputs = PROCESSOR.apply_chat_template( [conversation], tokenize=True, add_generation_prompt=True, return_dict=True, return_tensors="pt", padding=True, ) inputs = inputs.to(MODEL.device) eos_token_id = MODEL.generation_config.eos_token_id im_end_id = PROCESSOR.tokenizer.convert_tokens_to_ids("<|im_end|>") if isinstance(eos_token_id, int): eos_token_id = [eos_token_id] elif eos_token_id is None: eos_token_id = [] if im_end_id is not None and im_end_id not in eos_token_id: eos_token_id.append(im_end_id) streamer = TextIteratorStreamer( PROCESSOR.tokenizer, skip_prompt=True, skip_special_tokens=True, clean_up_tokenization_spaces=False, ) gen_kwargs = dict( **inputs, max_new_tokens=MAX_NEW_TOKENS, eos_token_id=eos_token_id, streamer=streamer, ) result_holder: dict = {} def _run(): try: result_holder["ids"] = MODEL.generate(**gen_kwargs) except Exception as e: result_holder["error"] = e start = time.perf_counter() thread = Thread(target=_run) thread.start() text = "" chunk_count = 0 try: for chunk in streamer: if not chunk: continue text += chunk chunk_count += 1 yield text, chunk_count, time.perf_counter() - start, False finally: thread.join() if "error" in result_holder: raise result_holder["error"] elapsed = time.perf_counter() - start ids = result_holder.get("ids") if ids is not None: token_count = int(ids.shape[1] - inputs.input_ids.shape[1]) else: token_count = chunk_count yield text, token_count, elapsed, True def parse_chandra_html(html_text: str) -> list[dict]: blocks = [] for match in _BLOCK_RE.finditer(html_text): bbox, label, content = match.groups() blocks.append({"bbox": bbox, "label": label, "content": content}) return blocks def _image_data_url(image: Image.Image) -> str: buf = io.BytesIO() image.save(buf, format="PNG") b64 = base64.b64encode(buf.getvalue()).decode("ascii") return f"data:image/png;base64,{b64}" def _json_for_script(data) -> str: return json.dumps(data, ensure_ascii=False).replace(" str: """Static viewer iframe, rendered ONCE at UI build time. Image and blocks are pushed in via ``postMessage`` from the parent page (see HEAD_SCRIPT and the change-handlers on image_signal / blocks_signal). Keeping the iframe stable across updates preserves zoom/scroll state and eliminates flicker. """ srcdoc = f"""
📄
Upload an image or PDF
Prediction idle
(no blocks parsed)
""" return ( f'' ) _EMPTY_BLOCKS_JSON = "[]" def preview_uploaded(file_path: str | None, page_number: int): """Render the uploaded image (or selected PDF page) without running the model. Outputs: gallery, html_code, markdown_code, image_signal, blocks_signal, state_signal, metadata, status """ if not file_path: return [], "", "", "", _EMPTY_BLOCKS_JSON, "idle", [], "" try: page_num, image = _load_image(file_path, int(page_number or 1)) except gr.Error: raise except Exception as e: raise gr.Error(f"Failed to load file: {e}") from e status = ( f"Loaded `{Path(file_path).name}` · page `{page_num}` · " f"`{image.width}x{image.height}` — click **Run OCR** to predict." ) meta = [ { "file": Path(file_path).name, "page": page_num, "image_size": f"{image.width}x{image.height}", } ] return ( [image], "", "", _image_data_url(image), _EMPTY_BLOCKS_JSON, "idle", meta, status, ) def run_ocr( file_path: str | None, page_number: int, temperature: float, ): """Streaming OCR. Yields: gallery, html_code, markdown_code, image_signal, blocks_signal, state_signal, metadata, status """ if not file_path: raise gr.Error("Please upload an image or PDF.") page_num, image = _load_image(file_path, int(page_number)) image_url = _image_data_url(image) # First yield: clear any previous blocks, mark "streaming", ensure image set. yield ( [image], "", gr.skip(), image_url, _EMPTY_BLOCKS_JSON, "streaming", gr.skip(), f"⏳ Generating… page `{page_num}` · `{image.width}x{image.height}`", ) last_block_count = 0 text = "" token_count = 0 elapsed = 0.0 for text, token_count, elapsed, done in _generate_page_stream( image, temperature ): if done: break blocks = parse_chandra_html(text) cur_count = len(blocks) if cur_count != last_block_count: blocks_out = json.dumps(blocks, ensure_ascii=False) last_block_count = cur_count else: blocks_out = gr.skip() status = ( f"⏳ Generating… `{cur_count}` blocks · `{token_count}` chunks · " f"`{round(elapsed, 1)}s`" ) yield ( [image], text, gr.skip(), gr.skip(), blocks_out, gr.skip(), gr.skip(), status, ) try: markdown = parse_markdown(text) except Exception as e: markdown = f"" blocks = parse_chandra_html(text) blocks_json = json.dumps(blocks, ensure_ascii=False) metadata = [ { "page": page_num, "tokens": token_count, "seconds": round(elapsed, 2), "image_size": f"{image.width}x{image.height}", "blocks": len(blocks), } ] status = ( f"### ✅ Done — `{len(blocks)}` blocks · `{token_count}` tokens · " f"`{round(elapsed, 2)}s`\n" f"Model: `{MODEL_ID}` · Page: `{page_num}`" ) yield ( [image], text, markdown, gr.skip(), blocks_json, "done", metadata, status, ) CSS = """ .gradio-container { max-width: 100% !important; padding: 8px 16px !important; } .viewer-host > .label-wrap { display: none !important; } .viewer-host .gradio-html { padding: 0 !important; } .input-pane { border-right: 1px solid #d0d7de; padding-right: 12px !important; } .section-h { margin: 4px 0 4px !important; font-size: 13px !important; font-weight: 600 !important; color: #444 !important; } .model-link { margin: 0 0 8px !important; font-size: 12px !important; color: #555 !important; } """ HEAD_SCRIPT = """ """ with gr.Blocks(title="Qwen3.5-OCR-JP-2B", css=CSS, head=HEAD_SCRIPT) as demo: gr.Markdown( "Model: [`ebinan92/Qwen3.5-ocr-jp-2b`](https://huggingface.co/ebinan92/Qwen3.5-ocr-jp-2b)", elem_classes=["model-link"], ) with gr.Row(): with gr.Column(scale=1, min_width=300, elem_classes=["input-pane"]): input_file = gr.File( label="Image / PDF", file_types=["image", ".pdf"], type="filepath", ) page_number = gr.Number( label="PDF page number", value=1, precision=0, minimum=1, info="Used only for PDF input (ignored for images).", ) temperature = gr.Slider( 0.0, 1.0, value=0.0, step=0.05, label="Temperature" ) run_button = gr.Button("Run OCR", variant="primary") status = gr.Markdown() with gr.Accordion("Generated HTML / Markdown", open=False): with gr.Tabs(): with gr.Tab("Generated HTML"): html_code = gr.Code( label="Generated Chandra HTML", language="html", lines=18, ) with gr.Tab("Markdown"): markdown_code = gr.Code( label="Markdown (chandra parse_markdown)", language="markdown", lines=18, ) with gr.Tab("Input pages"): gallery = gr.Gallery( label="Input pages", columns=1, height=260, object_fit="contain", ) with gr.Tab("Metadata"): metadata = gr.JSON(label="Run metadata") with gr.Column(scale=4): preview = gr.HTML( value=_make_viewer_iframe(), elem_classes=["viewer-host"] ) # Hidden signals: changes are forwarded to the iframe via postMessage so the # iframe is never re-rendered (preserves zoom/scroll, kills flicker). image_signal = gr.Textbox(visible=False) blocks_signal = gr.Textbox(visible=False, value=_EMPTY_BLOCKS_JSON) state_signal = gr.Textbox(visible=False, value="idle") image_signal.change( None, inputs=[image_signal], outputs=[], js="(url) => { if (url && window._ocSetImage) window._ocSetImage(url); }", ) blocks_signal.change( None, inputs=[blocks_signal], outputs=[], js=( "(s) => { if (s == null) return;" " let b; try { b = JSON.parse(s); } catch(_) { return; }" " if (window._ocSetBlocks) window._ocSetBlocks(b); }" ), ) state_signal.change( None, inputs=[state_signal], outputs=[], js="(s) => { if (window._ocSetState) window._ocSetState(s); }", ) common_outputs = [ gallery, html_code, markdown_code, image_signal, blocks_signal, state_signal, metadata, status, ] input_file.change( preview_uploaded, inputs=[input_file, page_number], outputs=common_outputs, ) page_number.change( preview_uploaded, inputs=[input_file, page_number], outputs=common_outputs, ) run_button.click( run_ocr, inputs=[input_file, page_number, temperature], outputs=common_outputs, show_progress="full", ) if __name__ == "__main__": demo.queue(default_concurrency_limit=int(os.getenv("CONCURRENCY_LIMIT", "1"))) demo.launch()