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("", "<\\/")
VIEWER_HEIGHT_PX = int(os.getenv("VIEWER_HEIGHT_PX", "900"))
def _make_viewer_iframe() -> 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"""
"""
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()