bobo-dada commited on
Commit
1bbd3a6
Β·
verified Β·
1 Parent(s): e7bba3b

Upload 4 files

Browse files
Files changed (4) hide show
  1. .gitignore +4 -0
  2. README.md +72 -7
  3. app.py +397 -54
  4. requirements.txt +11 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .gradio/
4
+ /tmp/
README.md CHANGED
@@ -1,16 +1,81 @@
1
  ---
2
  title: Chandra OCR 2
3
- emoji: πŸ’¬
4
- colorFrom: yellow
5
  colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.5.1
8
  app_file: app.py
9
  pinned: false
10
- hf_oauth: true
11
- hf_oauth_scopes:
12
- - inference-api
13
  license: apache-2.0
 
 
 
 
 
 
 
 
14
  ---
15
 
16
- An example chatbot using [Gradio](https://gradio.app), [`huggingface_hub`](https://huggingface.co/docs/huggingface_hub/v0.22.2/en/index), and the [Hugging Face Inference API](https://huggingface.co/docs/api-inference/index).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Chandra OCR 2
3
+ emoji: πŸ“„
4
+ colorFrom: indigo
5
  colorTo: purple
6
  sdk: gradio
 
7
  app_file: app.py
8
  pinned: false
 
 
 
9
  license: apache-2.0
10
+ short_description: Layout-aware document OCR to markdown, HTML, or JSON
11
+ models:
12
+ - datalab-to/chandra-ocr-2
13
+ tags:
14
+ - ocr
15
+ - document-ai
16
+ - vision-language
17
+ suggested_hardware: zero-a10g
18
  ---
19
 
20
+ # Chandra OCR 2 β€” Space demo
21
+
22
+ Gradio demo for [`datalab-to/chandra-ocr-2`](https://huggingface.co/datalab-to/chandra-ocr-2),
23
+ Datalab's layout-aware document OCR model. Upload PDFs or images and get back
24
+ markdown with tables, math, forms, and reading order preserved.
25
+
26
+ ## Hardware
27
+
28
+ **This will not run on the free CPU tier.** The model is ~10B parameters,
29
+ roughly 20 GB in bf16.
30
+
31
+ | Hardware | Works? |
32
+ |---|---|
33
+ | CPU basic (free) | No β€” OOM at load |
34
+ | ZeroGPU (H200 slice) | Yes β€” recommended |
35
+ | L40S / A100 | Yes |
36
+ | T4 (16 GB) | No, unless you add 4-bit quantisation |
37
+
38
+ Set this under **Settings β†’ Hardware** after creating the Space. `suggested_hardware`
39
+ in the frontmatter is only a hint to visitors; it does not provision anything.
40
+
41
+ ## How it works
42
+
43
+ - PDFs are rasterised page by page with PyMuPDF (no poppler/apt needed).
44
+ - Each page is capped on its long edge before inference β€” visual token count
45
+ scales with area, so this is the main lever on latency and memory.
46
+ - Pages are batched `PAGES_PER_GPU_CALL` at a time so each ZeroGPU allocation
47
+ finishes inside its duration budget.
48
+ - Output is offered three ways: rendered markdown, markdown source, and the raw
49
+ model string (useful when a prompt type returns HTML or JSON instead).
50
+
51
+ The app prefers the official `chandra` package (`generate_hf` + `BatchInputItem`
52
+ + `parse_markdown`). If that import fails, it falls back to driving the chat
53
+ template through plain `transformers` so the Space still boots.
54
+
55
+ ## Configuration
56
+
57
+ Edit the constants at the top of `app.py`:
58
+
59
+ | Constant | Default | Purpose |
60
+ |---|---|---|
61
+ | `PROMPT_TYPES` | `ocr_layout`, … | Prompt types offered in the dropdown |
62
+ | `MAX_PAGES` | 20 | Per-run page cap |
63
+ | `PAGES_PER_GPU_CALL` | 3 | Pages per ZeroGPU allocation |
64
+ | `GPU_DURATION` | 180 | Seconds requested per allocation |
65
+
66
+ Only `ocr_layout` is confirmed from the model card Quickstart. The others are
67
+ exposed on the assumption that the card's markdown/HTML/JSON output modes map to
68
+ prompt types; if one errors, remove it or check the `chandra` package docs.
69
+
70
+ ## Licence β€” read before making this public
71
+
72
+ The Space **code** here is Apache-2.0. The **model weights** are not:
73
+
74
+ > Code is Apache 2.0. Model weights are under a modified OpenRAIL-M license.
75
+ > Free for research, personal use, and startups under $2M funding/revenue.
76
+ > Cannot be used competitively with our API.
77
+
78
+ A public, free, hosted OCR endpoint is plausibly "competitive with our API."
79
+ If you are past the revenue threshold, or intend this as a product rather than a
80
+ demo, check with Datalab first. Setting the Space to **private** avoids the
81
+ question entirely.
app.py CHANGED
@@ -1,69 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
- from huggingface_hub import InferenceClient
3
-
4
-
5
- def respond(
6
- message,
7
- history: list[dict[str, str]],
8
- system_message,
9
- max_tokens,
10
- temperature,
11
- top_p,
12
- hf_token: gr.OAuthToken,
13
- ):
14
- """
15
- For more information on `huggingface_hub` Inference API support, please check the docs: https://huggingface.co/docs/huggingface_hub/v0.22.2/en/guides/inference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  """
17
- client = InferenceClient(token=hf_token.token, model="openai/gpt-oss-20b")
 
 
 
 
 
 
 
 
 
18
 
19
- messages = [{"role": "system", "content": system_message}]
 
 
 
 
 
 
 
20
 
21
- messages.extend(history)
22
 
23
- messages.append({"role": "user", "content": message})
 
 
 
 
 
 
 
 
 
24
 
25
- response = ""
26
 
27
- for message in client.chat_completion(
28
- messages,
29
- max_tokens=max_tokens,
30
- stream=True,
31
- temperature=temperature,
32
- top_p=top_p,
33
- ):
34
- choices = message.choices
35
- token = ""
36
- if len(choices) and choices[0].delta.content:
37
- token = choices[0].delta.content
38
 
39
- response += token
40
- yield response
41
 
 
 
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  """
44
- For information on how to customize the ChatInterface, peruse the gradio docs: https://www.gradio.app/docs/chatinterface
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  """
46
- chatbot = gr.ChatInterface(
47
- respond,
48
- additional_inputs=[
49
- gr.Textbox(value="You are a friendly Chatbot.", label="System message"),
50
- gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="Max new tokens"),
51
- gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature"),
52
- gr.Slider(
53
- minimum=0.1,
54
- maximum=1.0,
55
- value=0.95,
56
- step=0.05,
57
- label="Top-p (nucleus sampling)",
58
- ),
59
- ],
60
- )
61
-
62
- with gr.Blocks() as demo:
63
- with gr.Sidebar():
64
- gr.LoginButton()
65
- chatbot.render()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
 
68
  if __name__ == "__main__":
69
- demo.launch()
 
1
+ """
2
+ Chandra OCR 2 β€” Hugging Face Space demo.
3
+
4
+ Model: datalab-to/chandra-ocr-2 (~10B, bf16)
5
+ Docs: https://huggingface.co/datalab-to/chandra-ocr-2
6
+
7
+ Hardware: needs ZeroGPU (H200 slice) or a paid A100/L40S.
8
+ The model is ~20 GB in bf16 and will NOT run on the free CPU tier.
9
+ """
10
+
11
+ import inspect
12
+ import json
13
+ import os
14
+ import time
15
+ import zipfile
16
+ from pathlib import Path
17
+
18
  import gradio as gr
19
+ import torch
20
+ from PIL import Image
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Config
24
+ # ---------------------------------------------------------------------------
25
+
26
+ MODEL_ID = "datalab-to/chandra-ocr-2"
27
+
28
+ # Chandra is prompt-type driven rather than free-text prompted. 'ocr_layout' is
29
+ # the one shown in the model card Quickstart; the others are exposed because the
30
+ # card advertises markdown / HTML / JSON output. If one errors, the package
31
+ # doesn't support that name in your installed version β€” check `chandra` docs.
32
+ PROMPT_TYPES = ["ocr_layout", "ocr", "ocr_html", "ocr_json", "layout"]
33
+ DEFAULT_PROMPT_TYPE = "ocr_layout"
34
+
35
+ PAGES_PER_GPU_CALL = 3 # keep each ZeroGPU allocation inside its duration budget
36
+ GPU_DURATION = 180 # seconds requested per allocation
37
+ MAX_PAGES = 20 # guard against someone uploading a 500-page PDF
38
+
39
+ OUT_DIR = Path(os.environ.get("CHANDRA_OUT_DIR", "/tmp/chandra_out"))
40
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
41
+
42
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # ZeroGPU shim β€” lets the same file run locally without the `spaces` package
46
+ # ---------------------------------------------------------------------------
47
+
48
+ # Set by the platform on ZeroGPU hardware.
49
+ ON_ZERO = os.environ.get("SPACES_ZERO_GPU") == "true"
50
+
51
+ try:
52
+ import spaces
53
+
54
+ gpu = spaces.GPU
55
+ except ImportError: # local / non-ZeroGPU deploy
56
+
57
+ def gpu(*args, **kwargs):
58
+ if args and callable(args[0]):
59
+ return args[0]
60
+
61
+ def deco(fn):
62
+ return fn
63
+
64
+ return deco
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Chandra package β€” preferred path. Falls back to plain transformers.
69
+ # ---------------------------------------------------------------------------
70
+
71
+ CHANDRA_ERR = None
72
+ try:
73
+ from chandra.model import generate_hf
74
+ from chandra.model.schema import BatchInputItem
75
+
76
+ try:
77
+ from chandra.output import parse_markdown
78
+ except ImportError:
79
+ from chandra.model.output import parse_markdown
80
+
81
+ USE_CHANDRA = True
82
+ except Exception as e: # noqa: BLE001
83
+ USE_CHANDRA = False
84
+ CHANDRA_ERR = f"{type(e).__name__}: {e}"
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Model
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def _load():
92
+ from transformers import AutoProcessor
93
+
94
+ try:
95
+ from transformers import AutoModelForImageTextToText as VLM
96
+ except ImportError:
97
+ from transformers import AutoModelForVision2Seq as VLM
98
+
99
+ # On ZeroGPU there is no GPU visible at import time, so accelerate's
100
+ # device_map="auto" would strand the model on CPU. ZeroGPU instead
101
+ # intercepts .to("cuda") at global scope. Elsewhere, device_map is fine.
102
+ kw = dict(low_cpu_mem_usage=True)
103
+ if not ON_ZERO:
104
+ kw["device_map"] = "auto"
105
+
106
+ try:
107
+ m = VLM.from_pretrained(MODEL_ID, dtype=torch.bfloat16, **kw)
108
+ except TypeError: # transformers < 4.56 spelled it torch_dtype
109
+ m = VLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16, **kw)
110
+
111
+ if ON_ZERO:
112
+ m = m.to("cuda")
113
+
114
+ m.eval()
115
+ p = AutoProcessor.from_pretrained(MODEL_ID)
116
+ p.tokenizer.padding_side = "left" # required by chandra's batched generate
117
+ m.processor = p
118
+ return m, p
119
+
120
+
121
+ print(f"Loading {MODEL_ID} ...")
122
+ _t0 = time.time()
123
+ model, processor = _load()
124
+ print(f"Loaded in {time.time() - _t0:.0f}s | chandra pkg: {USE_CHANDRA} ({CHANDRA_ERR or 'ok'})")
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Page extraction
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def as_path(f) -> Path:
132
+ """Normalise str / Path / Gradio file object into a real Path.
133
+
134
+ Note pathlib.Path also has .name, but there it's the basename β€” checking
135
+ hasattr first would silently drop the directory.
136
  """
137
+ if isinstance(f, (str, os.PathLike)):
138
+ return Path(f)
139
+ return Path(getattr(f, "name", str(f)))
140
+
141
+
142
+ def pdf_to_images(path: Path, dpi: int):
143
+ try:
144
+ import pymupdf
145
+ except ImportError:
146
+ import fitz as pymupdf
147
 
148
+ doc = pymupdf.open(str(path))
149
+ pages = []
150
+ for i, page in enumerate(doc):
151
+ pix = page.get_pixmap(dpi=dpi)
152
+ pages.append((f"{path.stem}_p{i + 1:03d}",
153
+ Image.frombytes("RGB", (pix.width, pix.height), pix.samples)))
154
+ doc.close()
155
+ return pages
156
 
 
157
 
158
+ def collect_pages(files, dpi: int):
159
+ pages = []
160
+ for f in files:
161
+ p = as_path(f)
162
+ ext = p.suffix.lower()
163
+ if ext == ".pdf":
164
+ pages.extend(pdf_to_images(p, dpi))
165
+ elif ext in IMAGE_EXTS:
166
+ pages.append((p.stem, Image.open(p).convert("RGB")))
167
+ return pages
168
 
 
169
 
170
+ def fit(img: Image.Image, max_side: int) -> Image.Image:
171
+ """Cap the long edge β€” visual token count scales with area, so this is the
172
+ single biggest lever on VRAM and latency."""
173
+ img = img.convert("RGB")
174
+ if max(img.size) > max_side:
175
+ s = max_side / max(img.size)
176
+ img = img.resize((max(1, int(img.width * s)), max(1, int(img.height * s))),
177
+ Image.LANCZOS)
178
+ return img
 
 
179
 
 
 
180
 
181
+ # ---------------------------------------------------------------------------
182
+ # Inference
183
+ # ---------------------------------------------------------------------------
184
 
185
+ @gpu(duration=GPU_DURATION)
186
+ @torch.inference_mode()
187
+ def _infer_chunk(images, prompt_type: str, max_new_tokens: int):
188
+ """OCR a small batch of PIL images. Returns a list of raw model strings."""
189
+ if USE_CHANDRA:
190
+ batch = [BatchInputItem(image=im, prompt_type=prompt_type) for im in images]
191
+ try:
192
+ results = generate_hf(batch, model, max_tokens=max_new_tokens)
193
+ except TypeError:
194
+ results = generate_hf(batch, model)
195
+ return [getattr(r, "raw", None) or getattr(r, "markdown", "") or str(r)
196
+ for r in results]
197
+
198
+ # ---- fallback: drive the chat template directly ----
199
+ outs = []
200
+ for im in images:
201
+ msgs = [{"role": "user", "content": [
202
+ {"type": "image", "image": im},
203
+ {"type": "text", "text": prompt_type},
204
+ ]}]
205
+ inputs = processor.apply_chat_template(
206
+ msgs, tokenize=True, add_generation_prompt=True,
207
+ return_dict=True, return_tensors="pt",
208
+ ).to(model.device)
209
+ if "pixel_values" in inputs:
210
+ inputs["pixel_values"] = inputs["pixel_values"].to(model.dtype)
211
+ gen = model.generate(**inputs, max_new_tokens=int(max_new_tokens), do_sample=False)
212
+ trimmed = [o[len(i):] for i, o in zip(inputs["input_ids"], gen)]
213
+ outs.append(processor.batch_decode(trimmed, skip_special_tokens=True)[0].strip())
214
+ return outs
215
+
216
+
217
+ def to_markdown(raw: str) -> str:
218
+ if USE_CHANDRA:
219
+ try:
220
+ return parse_markdown(raw)
221
+ except Exception: # noqa: BLE001
222
+ pass
223
+ return raw
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Orchestration
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def run(files, prompt_type, dpi, max_side, max_new_tokens,
231
+ progress=gr.Progress()):
232
+
233
+ def msg(m):
234
+ return "", "", "", m, None, None
235
+
236
+ if not files:
237
+ yield msg("Upload a PDF or some images first.")
238
+ return
239
+
240
+ try:
241
+ pages = collect_pages(files, int(dpi))
242
+ except Exception as e: # noqa: BLE001
243
+ yield msg(f"Could not read those files:\n{type(e).__name__}: {e}")
244
+ return
245
+
246
+ if not pages:
247
+ yield msg("No PDF or image files found in that upload.")
248
+ return
249
+
250
+ truncated = ""
251
+ if len(pages) > MAX_PAGES:
252
+ truncated = f" (truncated from {len(pages)})"
253
+ pages = pages[:MAX_PAGES]
254
+
255
+ md_parts, raw_parts, per_page = [], [], []
256
+ t_start = time.time()
257
+
258
+ for start in range(0, len(pages), PAGES_PER_GPU_CALL):
259
+ chunk = pages[start:start + PAGES_PER_GPU_CALL]
260
+ names = [n for n, _ in chunk]
261
+ imgs = [fit(im, int(max_side)) for _, im in chunk]
262
+
263
+ progress(start / len(pages),
264
+ desc=f"{names[0]} … ({start + 1}-{start + len(chunk)}/{len(pages)})")
265
+
266
+ try:
267
+ raws = _infer_chunk(imgs, prompt_type, int(max_new_tokens))
268
+ except torch.cuda.OutOfMemoryError:
269
+ torch.cuda.empty_cache()
270
+ raws = ["[OUT OF MEMORY β€” lower 'Max image side']"] * len(chunk)
271
+ except Exception as e: # noqa: BLE001
272
+ raws = [f"[FAILED: {type(e).__name__}: {e}]"] * len(chunk)
273
+
274
+ for name, raw in zip(names, raws):
275
+ md = to_markdown(raw)
276
+ md_parts.append(f"\n\n---\n\n## {name}\n\n{md}")
277
+ raw_parts.append(f"===== {name} =====\n{raw}")
278
+ per_page.append((name, md, raw))
279
+
280
+ elapsed = time.time() - t_start
281
+ joined = "\n".join(md_parts)
282
+ yield (joined, joined,
283
+ "\n\n".join(raw_parts),
284
+ f"{len(per_page)}/{len(pages)} pages{truncated} Β· {elapsed:.0f}s "
285
+ f"({elapsed / max(1, len(per_page)):.1f}s/page)",
286
+ None, None)
287
+
288
+ # ---- artefacts ----
289
+ stamp = time.strftime("%Y%m%d_%H%M%S")
290
+ md_path = OUT_DIR / f"chandra_{stamp}.md"
291
+ md_path.write_text("\n".join(md_parts), encoding="utf-8")
292
+
293
+ zip_path = OUT_DIR / f"chandra_{stamp}.zip"
294
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as z:
295
+ for name, md, raw in per_page:
296
+ safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in name)
297
+ z.writestr(f"markdown/{safe}.md", md)
298
+ z.writestr(f"raw/{safe}.txt", raw)
299
+ z.writestr("manifest.json", json.dumps({
300
+ "model": MODEL_ID,
301
+ "prompt_type": prompt_type,
302
+ "dpi": dpi,
303
+ "max_side": max_side,
304
+ "pages": [n for n, _, _ in per_page],
305
+ }, indent=2))
306
+
307
+ total = time.time() - t_start
308
+ joined = "\n".join(md_parts)
309
+ yield (joined, joined,
310
+ "\n\n".join(raw_parts),
311
+ f"Done β€” {len(per_page)} page(s){truncated} in {total:.0f}s "
312
+ f"({total / max(1, len(per_page)):.1f}s/page)",
313
+ str(md_path), str(zip_path))
314
+
315
+
316
+ # ---------------------------------------------------------------------------
317
+ # UI
318
+ # ---------------------------------------------------------------------------
319
+
320
+ GR_MAJOR = int(gr.__version__.split(".")[0])
321
+
322
+
323
+ def C(cls, **kw):
324
+ """Build a component, dropping kwargs this Gradio version rejects.
325
+
326
+ Gradio 6 removed Textbox.show_copy_button and moved theme/css from
327
+ Blocks() to launch(). This keeps one file working across 4/5/6.
328
+ """
329
+ try:
330
+ allowed = set(inspect.signature(cls.__init__).parameters)
331
+ if "kwargs" not in allowed:
332
+ kw = {k: v for k, v in kw.items() if k in allowed}
333
+ except (TypeError, ValueError):
334
+ pass
335
+ return cls(**kw)
336
+
337
+
338
+ CSS = """
339
+ #raw_out textarea { font-family: ui-monospace, monospace; font-size: 12px; }
340
+ #status textarea { font-family: ui-monospace, monospace; font-size: 12px; }
341
+ .md_pane { max-height: 640px; overflow-y: auto; }
342
  """
343
+
344
+ _STYLE = dict(theme=gr.themes.Soft(), css=CSS)
345
+ _BLOCKS_KW = {} if GR_MAJOR >= 6 else _STYLE
346
+ _LAUNCH_KW = _STYLE if GR_MAJOR >= 6 else {}
347
+
348
+ with gr.Blocks(title="Chandra OCR 2", **_BLOCKS_KW) as demo:
349
+ gr.Markdown(
350
+ f"""
351
+ # Chandra OCR 2 β€” document β†’ markdown / HTML / JSON
352
+
353
+ Layout-aware OCR from [Datalab](https://datalab.to). Handles tables, math,
354
+ forms, handwriting and 90+ languages, preserving reading order and structure.
355
+
356
+ Upload **PDFs and/or images**; each page is processed separately and results
357
+ stream in below. Capped at **{MAX_PAGES} pages** per run in this demo.
358
+
359
+ Model: [`{MODEL_ID}`](https://huggingface.co/{MODEL_ID}) Β· weights are under a
360
+ modified OpenRAIL-M licence (free for research, personal use, and companies
361
+ under $2M funding/revenue β€” **not** for building a competitor to Datalab's API).
362
  """
363
+ )
364
+
365
+ with gr.Row():
366
+ with gr.Column(scale=1):
367
+ files = C(gr.Files, label="PDFs / images",
368
+ file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp",
369
+ ".bmp", ".tif", ".tiff"],
370
+ file_count="multiple")
371
+ go = C(gr.Button, value="Run OCR", variant="primary")
372
+
373
+ prompt_type = C(gr.Dropdown, label="Prompt type", choices=PROMPT_TYPES,
374
+ value=DEFAULT_PROMPT_TYPE, allow_custom_value=True,
375
+ info="'ocr_layout' is the documented default.")
376
+
377
+ with gr.Accordion("Advanced", open=False):
378
+ dpi = C(gr.Slider, minimum=100, maximum=400, value=200, step=25,
379
+ label="PDF render DPI",
380
+ info="200-300 suits most scans.")
381
+ max_side = C(gr.Slider, minimum=768, maximum=2560, value=1540, step=64,
382
+ label="Max image side (px)",
383
+ info="Biggest lever on speed and VRAM.")
384
+ max_new = C(gr.Slider, minimum=512, maximum=8192, value=4096, step=256,
385
+ label="Max new tokens",
386
+ info="Layout output is verbose β€” keep this high.")
387
+
388
+ status = C(gr.Textbox, label="Status", lines=3, elem_id="status")
389
+
390
+ with gr.Column(scale=2):
391
+ with gr.Tabs():
392
+ with gr.Tab("Rendered"):
393
+ md_view = C(gr.Markdown, value="", elem_classes=["md_pane"])
394
+ with gr.Tab("Markdown source"):
395
+ md_src = C(gr.Textbox, label=None, lines=24,
396
+ show_copy_button=True)
397
+ with gr.Tab("Raw model output"):
398
+ raw_view = C(gr.Textbox, label=None, lines=24,
399
+ elem_id="raw_out", show_copy_button=True)
400
+ with gr.Row():
401
+ md_file = C(gr.File, label="Combined .md")
402
+ zip_file = C(gr.File, label="All pages .zip")
403
+
404
+ go.click(
405
+ run,
406
+ inputs=[files, prompt_type, dpi, max_side, max_new],
407
+ outputs=[md_view, md_src, raw_view, status, md_file, zip_file],
408
+ )
409
 
410
 
411
  if __name__ == "__main__":
412
+ demo.queue(max_size=12).launch(show_error=True, **_LAUNCH_KW)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chandra OCR 2 Space
2
+ # torch is preinstalled on ZeroGPU/GPU Spaces β€” do not pin it here.
3
+
4
+ chandra-ocr
5
+ transformers>=4.57.0
6
+ accelerate>=0.34.0
7
+ huggingface_hub>=0.26.0
8
+ pymupdf>=1.24.0
9
+ pillow>=10.0.0
10
+ gradio>=5.0.0
11
+ spaces