ebinan92's picture
Fix ZeroGPU compat: torch 2.6.0 -> 2.8.0, update causal_conv1d wheel
9f1bf28 verified
Raw
History Blame Contribute Delete
31.2 kB
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'<div\s+data-bbox="([^"]+)"\s+data-label="([^"]+)">(.*?)</div>',
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"""<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
html,body{{height:100%}}
body{{font-family:-apple-system,"Helvetica Neue","Noto Sans JP",sans-serif;background:#1a1a2e;color:#e0e0e0;overflow:hidden}}
.app{{display:flex;flex-direction:column;height:100vh}}
.main{{display:flex;flex:1;overflow:hidden;min-height:0}}
.img-panel{{flex:1;overflow:auto;padding:12px;background:#0d1b36;min-width:280px;position:relative;cursor:grab}}
.img-panel.grabbing{{cursor:grabbing}}
.img-wrap{{position:relative;display:inline-block;line-height:0}}
.img-wrap img{{display:block;user-select:none;-webkit-user-drag:none;max-width:none}}
.bbox-ov{{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}}
.bb{{position:absolute;border:3px solid;opacity:.6;transition:opacity .15s,border-width .15s;pointer-events:auto;cursor:pointer}}
.bb:hover,.bb.on{{opacity:1}}
.bb.on{{border-width:5px;box-shadow:0 0 12px rgba(255,255,255,.3)}}
.v-resize{{width:4px;cursor:col-resize;background:#333;flex-shrink:0}}
.v-resize:hover{{background:#555}}
.cmp-panel{{display:flex;flex:0 0 360px;min-width:0}}
.blk-col{{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden;background:#0f3460;border-left:1px solid #333}}
.blk-col-head{{padding:8px 12px;font-size:12px;font-weight:600;background:#16213e;border-bottom:1px solid #333;display:flex;justify-content:space-between;align-items:center;gap:8px}}
.blk-col-head .tag{{font-size:10px;padding:2px 6px;border-radius:3px;background:#1565c0;color:#fff}}
.blk-col-head .live{{font-size:10px;padding:2px 8px;border-radius:10px;display:inline-flex;align-items:center;gap:4px;background:#333;color:#888}}
.blk-col-head .live.on{{background:#0d4d2a;color:#7ee29a}}
.blk-col-head .live.on::before{{content:"";width:6px;height:6px;border-radius:50%;background:#7ee29a;animation:pulse 1s ease-in-out infinite}}
.blk-col-head .live.done{{background:#0a3d6e;color:#9ec9ff}}
.blk-col-head .live.done::before{{content:"✓";font-size:11px;line-height:1}}
@keyframes pulse{{0%,100%{{opacity:1}}50%{{opacity:.35}}}}
.blk-list{{flex:1;overflow-y:auto}}
.blk-card{{padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.04);cursor:pointer;border-left:3px solid transparent}}
.blk-card:hover{{background:rgba(255,255,255,.03)}}
.blk-card.on{{background:rgba(33,150,243,.12)}}
.blk-card.fresh{{animation:flashIn .35s ease-out}}
@keyframes flashIn{{from{{background:rgba(126,226,154,.25)}}to{{background:transparent}}}}
.blk-hdr{{display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap}}
.blk-idx{{font-size:10px;color:#666;min-width:18px}}
.blk-tag{{font-size:10px;font-weight:600;padding:2px 7px;border-radius:3px;color:#fff}}
.blk-bb{{font-size:10px;color:#555;font-family:monospace}}
.blk-body{{font-size:12px;line-height:1.5;color:#bbb;max-height:220px;overflow-y:auto;word-break:break-word}}
.blk-body table{{border-collapse:collapse;width:100%;margin-top:4px;font-size:11px}}
.blk-body th,.blk-body td{{border:1px solid #444;padding:3px 6px;text-align:left}}
.blk-body th{{background:#1a2a4e;font-weight:600}}
.blk-body p{{margin:2px 0}}
.blk-body img{{max-width:100%;height:auto}}
.blk-empty{{padding:16px;color:#555;font-size:12px;text-align:center}}
.placeholder{{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#445;font-size:13px;pointer-events:none;text-align:center;line-height:1.6}}
</style>
</head>
<body>
<div class="app">
<div class="main">
<div class="img-panel" id="imgPanel">
<div class="img-wrap" id="imgWrap">
<img id="pgImg" alt="page" draggable="false" style="display:none">
<div class="bbox-ov" id="bboxOv"></div>
</div>
<div class="placeholder" id="placeholder">📄<br>Upload an image or PDF</div>
</div>
<div class="v-resize" id="vResize"></div>
<div class="cmp-panel">
<div class="blk-col">
<div class="blk-col-head">
<span>Prediction</span>
<span style="display:flex;align-items:center;gap:6px">
<span class="live" id="liveBadge">idle</span>
<span style="font-size:10px;color:#888" id="blkCount"></span>
</span>
</div>
<div class="blk-list" id="predList"><div class="blk-empty">(no blocks parsed)</div></div>
</div>
</div>
</div>
</div>
<script>
const colors = {_json_for_script(LABEL_COLORS)};
let blocks = [];
let activeIdx = -1;
const _img = document.getElementById('pgImg');
const _panel = document.getElementById('imgPanel');
const _placeholder = document.getElementById('placeholder');
const _badge = document.getElementById('liveBadge');
let _scale = 1, _fitScale = 1, _ready = false;
function setLive(state) {{
_badge.classList.remove('on', 'done');
if (state === 'streaming') {{ _badge.classList.add('on'); _badge.textContent = 'generating'; }}
else if (state === 'done') {{ _badge.classList.add('done'); _badge.textContent = 'done'; }}
else {{ _badge.textContent = 'idle'; }}
}}
function esc(s) {{
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}}
function formatContent(htmlStr) {{
if (!htmlStr) return '';
let out = htmlStr.replace(/<math([^>]*)>([\\s\\S]*?)<\\/math>/g, (_, attrs, tex) => {{
const isBlock = attrs.includes('display="block"');
if (typeof katex !== 'undefined') {{
try {{ return katex.renderToString(tex, {{ displayMode: isBlock, throwOnError: false }}); }} catch(e) {{}}
}}
const d = document.createElement('div');
d.textContent = tex;
return '<code style="color:#ce93d8;background:#1a1a2e;padding:1px 4px;border-radius:2px">' + d.innerHTML + '</code>';
}});
if (out.match(/^\\s*<tbody[\\s>]/i) && !out.match(/<table[\\s>]/i)) {{
out = '<table>' + out + '</table>';
}}
return out;
}}
function addBBox(b, i) {{
const ov = document.getElementById('bboxOv');
const parts = String(b.bbox || '').split(/\\s+/).map(Number);
if (parts.length !== 4 || parts.some(v => Number.isNaN(v))) return;
const [x0, y0, x1, y1] = parts;
const c = colors[b.label] || '#999';
const r = document.createElement('div');
r.className = 'bb' + (i === activeIdx ? ' on' : '');
r.dataset.i = i;
r.style.left = (x0/10)+'%'; r.style.top = (y0/10)+'%';
r.style.width = ((x1-x0)/10)+'%'; r.style.height = ((y1-y0)/10)+'%';
r.style.borderColor = c;
r.onclick = e => {{ e.stopPropagation(); setActive(i); }};
ov.appendChild(r);
}}
function appendBlockCard(b, i, fresh) {{
const c = colors[b.label] || '#999';
const card = document.createElement('div');
card.className = 'blk-card' + (i === activeIdx ? ' on' : '') + (fresh ? ' fresh' : '');
card.dataset.i = i;
if (i === activeIdx) card.style.borderLeftColor = c;
card.innerHTML = `
<div class="blk-hdr">
<span class="blk-idx">#${{i}}</span>
<span class="blk-tag" style="background:${{c}}">${{esc(b.label)}}</span>
<span class="blk-bb">${{esc(b.bbox)}}</span>
</div>
<div class="blk-body">${{formatContent(b.content) || '<em style="color:#444">(empty)</em>'}}</div>`;
card.onclick = () => setActive(i);
document.getElementById('predList').appendChild(card);
}}
function clearAllBlocks() {{
blocks = [];
activeIdx = -1;
document.getElementById('bboxOv').innerHTML = '';
document.getElementById('predList').innerHTML = '<div class="blk-empty">(no blocks parsed)</div>';
document.getElementById('blkCount').textContent = '';
}}
function applyBlocksUpdate(newBlocks) {{
if (!Array.isArray(newBlocks)) return;
if (newBlocks.length < blocks.length) {{
clearAllBlocks();
}}
if (newBlocks.length > 0) {{
const empty = document.querySelector('#predList .blk-empty');
if (empty) empty.remove();
}}
for (let i = blocks.length; i < newBlocks.length; i++) {{
const b = newBlocks[i];
blocks.push(b);
addBBox(b, i);
appendBlockCard(b, i, true);
}}
document.getElementById('blkCount').textContent = blocks.length ? blocks.length + ' blocks' : '';
}}
function setActive(idx) {{
const same = activeIdx === idx;
activeIdx = same ? -1 : idx;
document.querySelectorAll('.bb').forEach(el => {{
el.classList.toggle('on', +el.dataset.i === activeIdx);
}});
document.querySelectorAll('.blk-card').forEach(el => {{
const on = +el.dataset.i === activeIdx;
el.classList.toggle('on', on);
if (on) {{
el.style.borderLeftColor = colors[blocks[+el.dataset.i].label] || '#999';
el.scrollIntoView({{ block: 'nearest' }});
}} else {{
el.style.borderLeftColor = 'transparent';
}}
}});
}}
document.addEventListener('keydown', e => {{
if (blocks.length === 0) return;
if (e.key === 'j' || e.key === 'ArrowDown') {{
e.preventDefault();
const nxt = activeIdx < 0 ? 0 : Math.min(activeIdx + 1, blocks.length - 1);
activeIdx = -1; setActive(nxt);
}} else if (e.key === 'k' || e.key === 'ArrowUp') {{
e.preventDefault();
const prv = activeIdx < 0 ? 0 : Math.max(activeIdx - 1, 0);
activeIdx = -1; setActive(prv);
}} else if (e.key === 'Escape') {{
if (activeIdx >= 0) setActive(activeIdx);
}}
}});
document.addEventListener('mousedown', e => {{
const h = e.target.closest('#vResize');
if (!h) return;
const cmp = document.querySelector('.cmp-panel');
if (!cmp) return;
document.body.style.cursor = 'col-resize';
const onMove = ev => {{
const w = Math.max(280, Math.min(document.body.clientWidth - ev.clientX, 1400));
cmp.style.flex = '0 0 ' + w + 'px';
}};
const onUp = () => {{
document.body.style.cursor = '';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}});
// --- Zoom & pan ---
function _applyScale() {{
if (!_ready) return;
_img.style.width = Math.round(_img.naturalWidth * _scale) + 'px';
_img.style.height = 'auto';
}}
function _computeFit() {{
const aw = Math.max(50, _panel.clientWidth - 24);
return aw / Math.max(1, _img.naturalWidth);
}}
function _fitZoom() {{
_fitScale = _computeFit();
_scale = _fitScale;
_applyScale();
_panel.scrollLeft = 0; _panel.scrollTop = 0;
}}
function _zoomAt(factor, cx, cy) {{
if (!_ready) return;
const rect = _panel.getBoundingClientRect();
const px = cx - rect.left, py = cy - rect.top;
const oldScale = _scale;
const maxScale = Math.max(10, _fitScale * 2);
_scale = Math.max(_fitScale, Math.min(maxScale, _scale * factor));
if (_scale === oldScale) return;
_applyScale();
const r = _scale / oldScale;
_panel.scrollLeft = (_panel.scrollLeft + px) * r - px;
_panel.scrollTop = (_panel.scrollTop + py) * r - py;
}}
function applySetImage(url) {{
if (!url) return;
_ready = false;
clearAllBlocks();
_img.style.display = '';
_placeholder.style.display = 'none';
const onLoad = () => {{ _ready = true; _fitZoom(); }};
_img.addEventListener('load', onLoad, {{ once: true }});
_img.addEventListener('error', () => {{ _ready = true; }}, {{ once: true }});
_img.src = url;
}}
window.addEventListener('message', (e) => {{
const d = e.data || {{}};
if (d.type === 'oc-set-image') applySetImage(d.url);
else if (d.type === 'oc-update') applyBlocksUpdate(d.blocks || []);
else if (d.type === 'oc-state') setLive(d.state);
}});
_panel.addEventListener('wheel', e => {{
if (!(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1/1.15;
_zoomAt(factor, e.clientX, e.clientY);
}}, {{ passive: false }});
let _dragging = false, _dragX = 0, _dragY = 0, _startSL = 0, _startST = 0;
_panel.addEventListener('mousedown', e => {{
if (e.target.closest('.bb')) return;
_dragging = true;
_dragX = e.clientX; _dragY = e.clientY;
_startSL = _panel.scrollLeft; _startST = _panel.scrollTop;
_panel.classList.add('grabbing');
e.preventDefault();
}});
window.addEventListener('mousemove', e => {{
if (!_dragging) return;
_panel.scrollLeft = _startSL - (e.clientX - _dragX);
_panel.scrollTop = _startST - (e.clientY - _dragY);
}});
window.addEventListener('mouseup', () => {{
if (!_dragging) return;
_dragging = false;
_panel.classList.remove('grabbing');
}});
window.addEventListener('resize', () => {{
if (Math.abs(_scale - _fitScale) < 1e-3) _fitZoom();
else _fitScale = _computeFit();
}});
document.addEventListener('keydown', e => {{
if (e.key === '+' || e.key === '=') {{
e.preventDefault();
const r = _panel.getBoundingClientRect();
_zoomAt(1.2, r.left + r.width/2, r.top + r.height/2);
}} else if (e.key === '-' || e.key === '_') {{
e.preventDefault();
const r = _panel.getBoundingClientRect();
_zoomAt(1/1.2, r.left + r.width/2, r.top + r.height/2);
}} else if (e.key === '0') {{
e.preventDefault();
_fitZoom();
}}
}});
// Tell the parent we're ready for state replays.
try {{ window.parent.postMessage({{type: 'oc-ready'}}, '*'); }} catch(_) {{}}
</script>
</body>
</html>"""
return (
f'<iframe class="oc-viewer" style="width:100%;height:{VIEWER_HEIGHT_PX}px;'
'border:0;border-radius:8px;background:#1a1a2e;display:block" '
f'sandbox="allow-scripts" srcdoc="{html.escape(srcdoc, quote=True)}"></iframe>'
)
_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"<!-- markdown conversion failed: {e} -->"
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 = """
<script>
(function() {
if (window._ocBus) return;
window._ocBus = { latestImage: null, latestBlocks: null, latestState: null };
function _iframes() { return document.querySelectorAll('iframe.oc-viewer'); }
function _post(msg, target) {
const targets = target ? [target] : Array.from(_iframes()).map(f => f.contentWindow);
targets.forEach(w => { if (!w) return; try { w.postMessage(msg, '*'); } catch(_) {} });
}
window._ocSetImage = function(url) {
if (!url) return;
window._ocBus.latestImage = url;
// New image: reset block cache too — sender is expected to follow up with []
window._ocBus.latestBlocks = [];
_post({type:'oc-set-image', url});
};
window._ocSetBlocks = function(blocks) {
if (!Array.isArray(blocks)) return;
window._ocBus.latestBlocks = blocks;
_post({type:'oc-update', blocks});
};
window._ocSetState = function(state) {
window._ocBus.latestState = state;
_post({type:'oc-state', state});
};
window.addEventListener('message', (e) => {
if (!e.data || e.data.type !== 'oc-ready') return;
const b = window._ocBus;
if (b.latestImage) _post({type:'oc-set-image', url: b.latestImage}, e.source);
if (b.latestBlocks) _post({type:'oc-update', blocks: b.latestBlocks}, e.source);
if (b.latestState) _post({type:'oc-state', state: b.latestState}, e.source);
});
})();
</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()