This view is limited to 50 files because it contains too many changes. See the raw diff here.
AGENTS.md DELETED
@@ -1,109 +0,0 @@
1
- # For coding agents
2
-
3
- This repo is a curated collection of ready-to-run OCR scripts — each one self-contained
4
- via UV inline metadata, runnable over the network via `hf jobs uv run`. No clone, no
5
- install, no setup.
6
-
7
- ## Don't rely on this doc — discover the current state
8
-
9
- This file will go stale. Prefer these sources of truth:
10
-
11
- - `hf jobs uv run --help` — job submission flags (volumes, secrets, flavors, timeouts)
12
- - `hf jobs hardware` — current GPU flavors and pricing
13
- - `hf auth whoami` — check HF token is set
14
- - `hf jobs ps` / `hf jobs logs <id>` — monitor running jobs
15
- - `ls` the repo to see which scripts actually exist (bucket variants especially)
16
- - [README.md](./README.md) — the table of scripts with model sizes and notes
17
-
18
- ## Picking a script
19
-
20
- The [README.md](./README.md) table lists every script with model size, backend, and
21
- a short note. Axes that matter:
22
-
23
- - **Model size** vs accuracy vs GPU cost. Smaller = cheaper per doc.
24
- - **Backend**: vLLM scripts are usually fastest at scale. `transformers` and
25
- `falcon-perception` are alternatives for specific models.
26
- - **Task support**: most scripts do plain text; some expose `--task-mode`
27
- (table, formula, layout, etc.) — check the script's own docstring.
28
- - **Language coverage**: [`models.json`](./models.json) maps every script to its model,
29
- params, backend, required image pins, and what the model card claims about languages —
30
- with an `evidence` level (`per-language-benchmark` beats a bare `multilingual` tag).
31
- Human-readable version: [LANGUAGES.md](./LANGUAGES.md).
32
-
33
- For the authoritative benchmark numbers on any model in the table, query the model
34
- card programmatically — every OCR model publishes eval results on its card:
35
-
36
- from huggingface_hub import HfApi
37
- info = HfApi().model_info("tiiuae/Falcon-OCR", expand=["evalResults"])
38
- for r in info.eval_results:
39
- print(r.dataset_id, r.value)
40
-
41
- See the [leaderboard data guide](https://huggingface.co/docs/hub/en/leaderboard-data-guide)
42
- for the full API. This is more reliable than any markdown table that might drift.
43
-
44
- ## Getting help from a specific script
45
-
46
- Each script has a docstring at the top with a description and usage examples. To read it
47
- without downloading:
48
-
49
- curl -s https://huggingface.co/datasets/uv-scripts/ocr/raw/main/<script>.py | head -100
50
-
51
- Or open the URL in a browser. Running `uv run <url> --help` locally may fail if the
52
- script has GPU-only dependencies — reading the docstring is more reliable.
53
-
54
- ## The main pattern: dataset → dataset
55
-
56
- Most scripts take an input HF dataset ID and push results to an output HF dataset ID:
57
-
58
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \
59
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/<script>.py \
60
- <input-dataset-id> <output-dataset-id> [--max-samples N] [--shuffle]
61
-
62
- The script adds a `markdown` column to the input dataset and pushes the merged result
63
- to the output dataset ID on the Hub.
64
-
65
- ## Alternative: directory → directory (bucket variants)
66
-
67
- A couple of scripts have `-bucket.py` variants (currently `falcon-ocr-bucket.py` and
68
- `glm-ocr-bucket.py`) that read from a mounted directory and write one `.md` per image
69
- (or per PDF page). Useful with HF Buckets via `-v`:
70
-
71
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \
72
- -v hf://buckets/<user>/<input>:/input:ro \
73
- -v hf://buckets/<user>/<output>:/output \
74
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/<script>-bucket.py \
75
- /input /output
76
-
77
- `ls` the repo to check whether a `-bucket.py` variant exists for the model you want
78
- before assuming it's available.
79
-
80
- The `-v` source can also be a **local directory** (`huggingface_hub` ≥ 1.22) — the CLI
81
- syncs it to a private bucket and mounts it, so local images/PDFs need no upload step:
82
-
83
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \
84
- -v ./pages:/input \
85
- -v hf://buckets/<user>/<output>:/output \
86
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/<script>-bucket.py \
87
- /input /output
88
-
89
- Re-runs only sync new/changed files. Local mounts are read-only by default; a local
90
- *output* dir works too with `:rw` — pull results back with the `hf buckets sync`
91
- command the CLI prints at launch.
92
-
93
- ## Common flags across dataset-mode scripts
94
-
95
- Most scripts support: `--max-samples`, `--shuffle`, `--seed`, `--split`, `--image-column`,
96
- `--output-column`, `--private`, `--config`, `--create-pr`, `--verbose`. Read the script's
97
- docstring for the authoritative list — individual scripts may add model-specific options
98
- like `--task-mode`.
99
-
100
- ## Gotchas
101
-
102
- - **Secrets**: pass `-s HF_TOKEN` to forward the user's token into the job.
103
- - **GPU required**: all scripts exit if CUDA isn't available. `l4x1` is the cheapest
104
- GPU flavor and works for models up to ~3B. Check `hf jobs hardware` for current options.
105
- - **First run is slow**: model download + `torch.compile` / vLLM warmup dominates small
106
- runs. Cost per doc drops sharply past a few hundred images — test with `--max-samples 10`
107
- first, then scale.
108
- - **Don't poll jobs**: jobs run async. Submit once, check status later with
109
- `hf jobs ps` or `hf jobs logs <id>`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CLAUDE.md CHANGED
@@ -1,288 +1,213 @@
1
- # OCR Scripts Development Notes
2
 
3
- Dev notes for the `ocr/` recipes: the **conventions** to follow, the **per-script gotchas** (the
4
- "why" behind each script's quirks), and the **internal tooling**. Runnable examples live in each
5
- script's docstring and `README.md`; benchmark result tables live in `OCR-BENCHMARK.md`.
6
 
7
- - [Conventions & invariants](#conventions--invariants) — read before adding/changing a recipe
8
- - [Script status](#script-status) · [Per-script gotchas](#per-script-gotchas)
9
- - [Internal tooling](#internal-tooling) · [Deferred / tracked](#deferred--tracked) · [Change log](#change-log)
 
 
10
 
11
- ---
12
-
13
- ## Conventions & invariants
14
-
15
- Read this before adding or changing a recipe. Each rule maps to a failure we've actually hit; the
16
- planned **self-review skill** (see [Deferred](#deferred--tracked)) just enforces this list.
17
-
18
- - **Catalog entry.** Adding or changing a recipe means updating [`models.json`](./models.json)
19
- (script → model, params, backend, image pins, language claim) and, if the language claim
20
- changed, [LANGUAGES.md](./LANGUAGES.md). Language fields record what the **model card claims**
21
- (with an evidence level), never inferred coverage. Hand-maintained for now; if drift becomes a
22
- problem, the follow-up is generating the README table from the JSON.
23
- - **Self-contained single file.** Each recipe is one PEP 723 UV script runnable from a raw URL
24
- (`hf jobs uv run <url>`). No shared *importable local* module (the job env only gets the one file).
25
- Extra pip deps are fine — **pin them**. A heavy/stable/shared subsystem may become an opt-in *package*
26
- dep (e.g. bucket I/O → `bucketbag`, [#67](https://github.com/davanstrien/uv-scripts-for-ai/issues/67)),
27
- never a local import. Recipes = inline; internal tooling may share freely.
28
- - **GPU + output.** Check `torch.cuda.is_available()` and exit clearly if absent. Write to the Hub
29
- (`push_to_hub`) or a bucket (`-v hf://…`), never bare local paths (Jobs disk is ephemeral; local *input*
30
- dirs mount via `-v ./dir:/mnt`, synced to a bucket automatically).
31
- - **vLLM image + fail-fast preflight.** If the model's arch isn't in a stable vLLM wheel, **omit
32
- `vllm`/`torch` from deps** and run on the pinned `vllm/vllm-openai` image via
33
- `--image … --python … -e PYTHONPATH=…`; add a preflight that `sys.exit(1)`s naming the exact flags
34
- (surya-class — see gotchas). Pinned-image scripts: `surya-ocr` (`:v0.20.1`, **site-packages**),
35
- `nanonets-ocr2` (`:v0.10.2`), `unlimited-ocr` (`:unlimited-ocr`), `deepseek-ocr2`/`glm-ocr` (nightly).
36
- - **Pins are temporary.** An image/version pin (`:v0.10.2`, `:v0.20.1`, a nightly, `surya-ocr==0.20.0`,
37
- …) is a workaround for a *current* ecosystem gap — a decode regression, an arch not yet in a stable
38
- wheel, a resolver backtrack. In the recipe, record **why** the pin exists and **what would loosen it**
39
- (e.g. "move back to the default image when a newer vLLM ships a Qwen2.5-VL decode fix"; "drop the
40
- nightly once the arch lands in a stable release"). Re-test periodically and relax when the gap closes —
41
- that's what the `bump-vllm-pins` skill is for; prefer floors over exact pins once you can.
42
- - **Env guards on the bare image.** Set `VLLM_USE_FLASHINFER_SAMPLER=0` (and `VLLM_USE_DEEP_GEMM=0`
43
- for nightly vLLM) **before** importing `vllm` — both JIT paths need `nvcc`, which the bare uv image lacks.
44
- - **Context-length invariant.** `--max-tokens` ≤ `--max-model-len` ≤ the model's real max context
45
- (`config.json` `max_position_embeddings`; VLMs → `text_config`, mind `rope_scaling`). vLLM refuses to
46
- start if `max_model_len` is over (we don't set `VLLM_ALLOW_LONG_MAX_MODEL_LEN`); output can't fit if
47
- `max_tokens` > `max_model_len`. Check:
48
- `curl -s https://huggingface.co/<model>/raw/main/config.json | python -c "import json,sys;c=json.load(sys.stdin);t=c.get('text_config',c);print(t.get('max_position_embeddings'),t.get('rope_scaling'))"`.
49
- - **Output-column collision guard.** Every recipe that adds an output column calls
50
- `ensure_output_columns_free(dataset, [cols], overwrite)` (or the inline sink guard for pp-*) so it
51
- fails fast instead of duplicating/clobbering an input column; `--overwrite` opts into replacing it.
52
- Default `--output-column` is `markdown` (never a bare `text`). ([#66](https://github.com/davanstrien/uv-scripts-for-ai/pull/66))
53
- - **Bound large images.** Full-page recipes cap input pixels / resize, or size `max_model_len` to fit —
54
- a 7–9 MP page is ~14k image tokens. Prefer bounding the input (deterministic) over a giant context;
55
- don't auto-size `max_model_len` from images (it's fixed at engine init, before images are seen).
56
- - **Dep sanity.** No stale version *caps* that drag a transitive lib back — e.g. `pyarrow<18` forced an
57
- old `datasets` lacking the `Json` feature → `load_dataset` crashed (the glm-ocr bug). Use floors, not ceilings.
58
- - **Error signalling (known gap).** Scripts currently write sentinels (`[OCR ERROR]`, `[SURYA GENERATE
59
- ERROR]`) *into* the output column, so partial failures are silent. A companion `ocr_error` status
60
- column is the deferred fix (see [Deferred](#deferred--tracked)).
61
 
62
- ---
63
 
64
- ## Script status
65
-
66
- Legend: ✅ production-ready · ⚠️ works only with a required pinned image · 🧪 experimental/on-hold.
67
- "+image" = needs a `--image vllm/vllm-openai:<tag>` override (not the default uv image).
68
-
69
- | Script | | Backend | Flavor | Note |
70
- |--------|--|---------|--------|------|
71
- | `deepseek-ocr-vllm.py` | ✅ | vLLM (stable) | l4x1 | `NGramPerReqLogitsProcessor` anti-repeat |
72
- | `deepseek-ocr2-vllm.py` | ✅ | vLLM (nightly) | l4x1 | arch needs nightly; `addict`+`matplotlib` deps |
73
- | `lighton-ocr2.py` | ✅ | vLLM | a100-large / l4x1 | resize 1540px; `--max-model-len` 16384 |
74
- | `paddleocr-vl-1.5.py` | ✅ | transformers | l4x1 | not vLLM (server-only upstream); single-image |
75
- | `paddleocr-vl-1.6.py` | ✅ | vLLM | l4x1 | smart-resize ~1M px; SOTA OmniDocBench |
76
- | `paddleocr-vl.py` | ✅ | vLLM | l4x1 | |
77
- | `dots-ocr.py` | ✅ | vLLM (stable) | l4x1 | `--max-model-len` 32768; no internal resize |
78
- | `dots-ocr-1.5.py` | ✅ | vLLM 0.17.1 | l4x1 | see gotcha (`content_format`, mirror, bbox space) |
79
- | `glm-ocr.py` | ✅ | vLLM (nightly) | l4x1 | `VLLM_USE_DEEP_GEMM=0`; no pyarrow cap |
80
- | `ovis-ocr2.py` | ✅ | vLLM (stable ≥0.22.1) | l4x1 / a10g-small | `gdn_prefill_backend="triton"` (card); card-exact prompt via `enable_thinking=False` template + `llm.generate`; `<img>` region tags filtered by default (`--keep-image-tags`) |
81
- | `glm-ocr-v2.py` | 🧪 | vLLM (nightly) | l4x1 | CommitScheduler incremental — on hold (see Deferred) |
82
- | `nanonets-ocr.py` | ✅ | vLLM | a10g-small | `--max-model-len` 32768 (`--max-tokens` 15000) |
83
- | `nanonets-ocr2.py` | ⚠️+image | vLLM `:v0.10.2` | a10g-small | Qwen2.5-VL ≥0.11 regression → pure `!` |
84
- | `unlimited-ocr-vllm.py` | ✅+image | vLLM `:unlimited-ocr` | l4x1 | single-image; multi-page → serve |
85
- | `surya-ocr.py` | ✅+image | vLLM `:v0.20.1` | l4x1 | offline backend inject; site-packages PYTHONPATH |
86
- | `surya-ocr-bucket.py` | ✅+image | vLLM `:v0.20.1` | l4x1 | bucket I/O; pin `surya-ocr==0.20.0` |
87
- | `lift-extract.py` | ✅ | hf / vLLM | a100-large | schema-constrained extraction; naming gotcha |
88
- | `nuextract3.py`, `lfm2-extract.py`, `lfm2-vl-extract.py` | ✅ | vLLM | l4x1 | structured extraction |
89
- | `hunyuan-ocr.py` | ✅ | vLLM | l4x1 | **1.0, revision-pinned** (root repo became 1.5 in-place); `transformers<5.13` cap — see gotcha |
90
- | `hunyuan-ocr-1.5.py` | ✅ | vLLM | l4x1 | tracks repo root (=1.5); task-locked prompts; `transformers<5.13` cap — see gotcha |
91
- | `rolm-ocr.py`, `smoldocling-ocr.py`, `numarkdown-ocr.py`, `qianfan-ocr.py`, `firered-ocr.py`, `abot-ocr.py`, `falcon-ocr.py`, `olmocr2-vllm.py`, `dots-mocr.py` | ✅ | vLLM | varies | see `README.md` for flags |
92
- | `pp-ocrv6.py`, `pp-doclayout.py` | ✅ | PaddleOCR / PaddleX | l4x1 | classical det+rec; dataset **or** bucket I/O |
93
-
94
- **License note:** Surya and `lift` ship code as Apache-2.0 but **weights under a modified OpenRAIL-M**
95
- (research/personal/<$5M, no competitive use vs Datalab's API) — surfaced in each docstring + card.
96
- HunyuanOCR (1.0 + 1.5) is under the [Tencent Hunyuan Community License](https://huggingface.co/tencent/HunyuanOCR/blob/main/LICENSE)
97
- (territory excludes EU/UK/South Korea; standard across Tencent's Hunyuan releases) — surfaced in each docstring + card.
98
 
99
- ---
 
 
 
100
 
101
- ## Per-script gotchas
102
-
103
- Only the scripts with load-bearing quirks; the rest are unremarkable (`README.md` covers flags).
104
-
105
- ### `surya-ocr.py` / `surya-ocr-bucket.py` — pinned image + site-packages path
106
- Surya-2 (`datalab-to/surya-ocr-2`, 650M, `qwen3_5`) needs its **known-good** vLLM build, and the
107
- `:v0.20.1` image puts python at `/usr/local/bin/python3` and libs at
108
- **`/usr/local/lib/python3.12/site-packages`** (not the usual `dist-packages`) — wrong path →
109
- `No module named 'vllm'` → 0/5. It can't Docker-in-Docker Surya's normal server, so it **injects an
110
- in-process `OfflineVLLMBackend`** into `SuryaInferenceManager` (subclassing Surya's `Backend` ABC) and
111
- reuses Surya's own prompts/`scale_to_fit`/HTML+bbox parsing so the offline path matches the server.
112
- `mm_processor_kwargs={min_pixels:3136, max_pixels:6291456}`, `max_model_len=18000`, `logprobs=1` →
113
- per-block `confidence`. Writes two columns (`--output-column` markdown + `surya_blocks` JSON). Never
114
- name the file `surya.py` (shadows the package). The recipe now **fails fast** if `vllm` isn't importable,
115
- naming the required flags. **Bucket variant:** pin `surya-ocr==0.20.0` (loosening it, or adding
116
- `huggingface-hub>=1.6.0`, lets uv backtrack to a surya without `surya.inference`); **copy beats mount**
117
- for bucket reads (FUSE `rglob` is ~26× slower on a 38k-file bucket; mount also hit a transient CSI flake);
118
- `.jp2` via an `imagecodecs` fallback (Pillow lacks OpenJPEG); resume-by-skip on the output `.json`.
119
-
120
- ### `nanonets-ocr2.py` — pinned `:v0.10.2` image
121
- Nanonets-OCR2-3B is **Qwen2.5-VL**, which has a vLLM **≥0.11 decode regression** (outputs pure `!` on
122
- every page) — [vllm#27775](https://github.com/vllm-project/vllm/issues/27775). 0.9.2/0.10.1/**0.10.2**
123
- are known-good. Not context length (still `!` at 32768) and not torch.compile. Pip-pinning `vllm==0.10.2`
124
- clashes with modern `transformers` (old tokenizer API), so run on the **`:v0.10.2` image** (ships a
125
- consistent vLLM 0.10.2 + transformers 4.56.1); `vllm`/`torch` omitted from deps. `--max-model-len` 32768
126
- (the 15000 `--max-tokens` can't fit 8192). Re-test the default image when a newer vLLM ships a Qwen2.5-VL
127
- decode fix.
128
-
129
- ### `dots-ocr-1.5.py` — `content_format="string"` + resized-bbox space
130
- Must pass `chat_template_content_format="string"` to `llm.chat()` — the model's `tokenizer_config.json`
131
- template expects string content; without it you get ~1 token then EOS (empty output). The v1.5 weights
132
- aren't on HF from the authors — **mirrored to `davanstrien/dots.ocr-1.5`** from ModelScope (MIT-based).
133
- Layout bboxes are in the **resized** image space (`Qwen2VLImageProcessor.smart_resize`,
134
- `max_pixels=11,289,600`, `factor=28`); map back with:
135
- ```python
136
- import math
137
- def smart_resize(h, w, factor=28, min_pixels=3136, max_pixels=11289600):
138
- h_bar, w_bar = max(factor, round(h/factor)*factor), max(factor, round(w/factor)*factor)
139
- if h_bar*w_bar > max_pixels:
140
- beta = math.sqrt((h*w)/max_pixels); h_bar, w_bar = math.floor(h/beta/factor)*factor, math.floor(w/beta/factor)*factor
141
- elif h_bar*w_bar < min_pixels:
142
- beta = math.sqrt(min_pixels/(h*w)); h_bar, w_bar = math.ceil(h*beta/factor)*factor, math.ceil(w*beta/factor)*factor
143
- return h_bar, w_bar
144
- # orig_x = bbox_x * (orig_w / w_bar); orig_y = bbox_y * (orig_h / h_bar)
145
  ```
146
- (Same `smart_resize`/`max_pixels=11.29M` applies to `dots-ocr.py` v1's processor cap — but the
147
- `content_format="string"` fix does **not**: v1 works with the auto-detected `openai` chat format.)
148
-
149
- ### `unlimited-ocr-vllm.py` — dedicated image, single-image batch
150
- Baidu `baidu/Unlimited-OCR` (3.3B, DeepSeek-OCR descendant); arch is in **no stable vLLM wheel**, so it
151
- runs on **`vllm/vllm-openai:unlimited-ocr`** (`:unlimited-ocr-cu129` on Hopper), standard `/usr/bin/python3`
152
- + `dist-packages`. `NGramPerReqLogitsProcessor` (re-exported via `unlimited_ocr`), prompt
153
- `<image>document parsing.`, `limit_mm_per_prompt={"image":1}`, `--strip-grounding` drops `<|det|>`/`<|ref|>`.
154
- **Batch recipe stays single-image**; multi-page is finicky offline (one `<image>` per page; degrades on
155
- hard scans) and belongs to **serving** — both engines read clean multi-page docs, but **SGLang is more
156
- robust on hard/degraded scans**. Serving setup (SGLang pin `lmsysorg/sglang:v0.5.10.post1`, a100+flashinfer
157
- — HF `h200` nodes fail with `CUDA error 802`) is in `serving-unlimited-ocr.md`.
158
-
159
- ### `lift-extract.py` — naming + backends
160
- Datalab `lift` (9B, Qwen3.5), schema-constrained image/PDF → JSON; the only recipe ingesting PDFs directly.
161
- **Must not be named `lift.py`** (shadows the installed `lift` package → ImportError). Two in-process backends
162
- via `--method`: `hf` (default image, plain `model.generate`) and `vllm` (needs the `vllm/vllm-openai` image;
163
- reproduces lift's own recipe: `mm_processor_kwargs={min_pixels:3136,max_pixels:861696}`, guided JSON schema,
164
- `temperature=0.0,top_p=0.1,max_tokens=12384`). Pin `--model datalab-to/lift` via the `MODEL_CHECKPOINT` env
165
- (settings read env at import).
166
-
167
- ### `deepseek-ocr-vllm.py` / `deepseek-ocr2-vllm.py`
168
- v1 uses the official offline pattern (`llm.generate()` + `NGramPerReqLogitsProcessor` for repetition).
169
- **Known bug** (hit on vLLM *nightly*, 2026-02-12; unverified on the stable wheels v1 now resolves):
170
- some aspect ratios trip `images_crop dim[2] expected 1024, got 640` (gundam-mode
171
- default vs a validator expecting 1024²) — hit 2/10 on `ufo-ColPali`, aspect-ratio dependent, no upstream
172
- issue filed ([vllm#28160](https://github.com/vllm-project/vllm/issues/28160) is the related request). v2
173
- needs **nightly** vLLM (`DeepseekOCR2ForCausalLM` not in stable) + `addict`/`matplotlib` (its HF custom
174
- code), plus `limit_mm_per_prompt={"image":1}`.
175
-
176
- ### `hunyuan-ocr.py` / `hunyuan-ocr-1.5.py` — upstream replaced the repo root in-place
177
- On 2026-07-06 Tencent pushed HunyuanOCR-1.5 **into the same repo** `tencent/HunyuanOCR` (1.5 at root,
178
- 1.0 archived under `v1.0/`, DFlash draft under `dflash/`, **no 1.0 tag or branch**). vLLM can't load a
179
- repo subfolder, so `hunyuan-ocr.py` pins the last 1.0 commit by `revision` (`f6af82ee…`) — that pin is
180
- the 1.0 *identity*, never loosen it to `main`; 1.5 is its own recipe (`hunyuan-ocr-1.5.py`, tracks root,
181
- has `--revision` as insurance against the next in-place swap). Separate breakage, both scripts: stable
182
- vLLM ≤0.24.0's `hunyuan_vl_image.py` does a string-key `AutoImageProcessor.register(...)` which
183
- transformers 5.13 rejects (`'str' object has no attribute '__module__'` → "architectures failed to be
184
- inspected" at engine init) — hence the `transformers<5.13` cap in both; drop it when the vllm#47872 fix
185
- ships in a stable wheel. 1.5 prompts are task-locked (12 types, Chinese wording, from the official
186
- client's `hunyuan_tasks.py`) and sampling is card-locked (temp 0.0, rep-penalty 1.08) — don't
187
- "improve" either; upstream observed hand-tweaked prompts silently degrade quality.
188
-
189
- ### `glm-ocr.py`
190
- Chatty on blank pages / can emit degenerate repeats — that's **model quality, not a crash**; don't
191
- re-debug it as a recipe bug. (The actual historical crash was the `pyarrow<18` cap — see Conventions.)
192
-
193
- ### `lighton-ocr2.py`
194
- The original breakage was **not** vLLM — it was a dead `HF_HUB_ENABLE_HF_TRANSFER=1` (the `hf_transfer`
195
- package is gone), which surfaced as "Can't load image processor". Removed. Pixtral ViT + Qwen3, RLVR-trained,
196
- resize 1540px @200 DPI, `--max-model-len` 16384. `paddleocr-vl-1.5.py` uses the **transformers** backend
197
- (single-image) because PaddleOCR-VL only supports vLLM in server mode.
198
 
199
- ---
 
 
 
 
 
 
 
 
 
200
 
201
- ## Internal tooling
202
 
203
- Not user recipes — benchmark/eval infra. **Result tables + validation history → `OCR-BENCHMARK.md`.**
 
 
 
204
 
205
- ### `ocr-bench-run.py` — coordinator
206
- Launches N OCR models on the same dataset, each pushing to a shared repo as a separate config via
207
- `--config/--create-pr`. Eval separately with `ocr-vllm-judge.py` / `ocr-elo-bench.py`. Registry (4 models):
208
- `glm-ocr`, `deepseek-ocr` (auto `--prompt-mode free`), `lighton-ocr-2`, `dots-ocr`; each has a `default_args`.
209
  ```bash
210
- uv run ocr-bench-run.py source-dataset --output my-bench --max-samples 50
211
- uv run ocr-bench-run.py --list-models # registry table
212
- uv run ocr-bench-run.py ... --models glm-ocr dots-ocr --dry-run
213
- uv run ocr-elo-bench.py my-bench --from-prs --mode both # eval from PRs, no merge
 
214
  ```
215
 
216
- ### `ocr-vllm-judge.py` — offline vLLM jury judge
217
- Pairwise OCR-quality comparisons via vLLM offline `LLM()`; jury mode (multiple models vote, majority
218
- aggregation) with **0 parse failures** (structured output via the `StructuredOutputsParams` → `GuidedDecodingParams`
219
- prompt shim). Prefer over the API judge (`ocr-jury-bench.py`) for batch eval no rate limits, reproducible.
220
- `--from-prs` loads configs from open PRs without merging; `--save-results REPO` persists comparisons/leaderboard/metadata.
221
- A100 recommended for jury mode; L4 works for a single 7B judge.
222
- ```bash
223
- hf jobs uv run --flavor a100-large -s HF_TOKEN ocr-vllm-judge.py davanstrien/ocr-bench-nls-50 --from-prs \
224
- --judge-model Qwen/Qwen2.5-VL-7B-Instruct --judge-model Qwen/Qwen3-VL-8B-Instruct --max-samples 50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  ```
226
 
227
- ### `ocr-human-eval.py` blind human A/B
228
- Gradio app for blind A/B with ELO + optional agreement-vs-judge analysis (split-jury comparisons shown
229
- first; round-robin image variety). Resume-safe (atomic JSON per vote).
230
- ```bash
231
- uv run ocr-human-eval.py davanstrien/ocr-bench-rubenstein --from-prs \
232
- --judge-results davanstrien/ocr-bench-rubenstein-judge --max-samples 5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  ```
234
 
235
- **Validation headline** (full tables in `OCR-BENCHMARK.md`): DeepSeek-OCR is consistently #1 across
236
- datasets and eval methods; middle-pack rankings are dataset-dependent; a jury of small models gives 0
237
- parse failures; **Kimi K2.5 (170B)** is the only judge matching the human's #1 (small judges overrate
238
- LightOnOCR-2's commentary style).
 
 
 
239
 
240
  ---
241
 
242
- ## Deferred / tracked
243
-
244
- - **bucketbag adoption** evaluate adopting `bucketbag` for the bucket recipes (slim recipes / harden
245
- bucketbag / find other beneficiaries) → [#67](https://github.com/davanstrien/uv-scripts-for-ai/issues/67).
246
- - **Self-review skill** (spark) a dev-only skill (sibling to `bump-vllm-pins`) that reviews a recipe/diff
247
- against the [Conventions](#conventions--invariants) block: context-length, collision guard, vLLM-image +
248
- preflight, env guards, dep sanity, image bounding, optional Jobs smoke. Enforces that list; build after
249
- the current work.
250
- - **Error-signalling** companion `ocr_error` status column (null cell + truncated exception) instead of
251
- sentinels in the output column, so "read nothing" "run errored". Touches ~all recipes; deferred.
252
- - **OCR smoke-test dataset** — a tiny curated set (~20–30 images across doc-type/quality/language/layout,
253
- ground truth where possible) for fast CI-style regression checks after dep bumps. Pairs with the skill.
254
- - **Multi-page batch (Unlimited-OCR)** — an SGLang-server-in-job recipe for robust multi-page at scale
255
- (single-image vLLM stays the batch default). Gated on a real corpus-scale need + the `h200`/`fa3` infra
256
- fix; see `serving-unlimited-ocr.md`.
257
- - **ALTO XML export** — from `surya_blocks` (block-level bbox→`HPOS/VPOS/…`, label→`TextBlock`/`Illustration`);
258
- the surya-ocr-bucket test bucket ships CA's own ALTO `.xml` as a diff target.
259
- - **Incremental uploads** superseded by HF Buckets / `bucketbag` ([#67](https://github.com/davanstrien/uv-scripts-for-ai/issues/67));
260
- `glm-ocr-v2.py` keeps the older CommitScheduler resume path for very large jobs today (do not port it on hold).
261
- - **Leaderboard Space** public ELO/pointwise view fed by the benchmark datasets. Idea only.
262
- - **MonkeyOCRv2 evaluated, not added** (2026-07-14). `zenosai/MonkeyOCRv2-S-Parsing` (0.6B) needs the
263
- [GitHub repo's](https://github.com/Yuliang-Liu/MonkeyOCRv2) custom multi-stage pipeline (`parsing/parse.py`,
264
- structure detection) — not pip-packaged, pins `vllm==0.11.2`, and the card text says "academic research and
265
- non-commercial use only" while the metadata tag says `apache-2.0` (conflicting license signals). Revisit if
266
- it gets packaged / vLLM-native support or the license is clarified.
267
-
268
- **Watch:** `deepseek-ocr2` / `glm-ocr` stay on nightly vLLM until their arch lands in a stable release.
269
- The nightly index (`https://wheels.vllm.ai/nightly`) occasionally has transient build issues (e.g. only
270
- ARM wheels) — if a nightly-recipe install fails on resolution, wait and retry before debugging the recipe.
 
 
 
 
 
 
271
 
272
  ---
273
 
274
- ## Change log
275
-
276
- - **2026-07-14** — added `ovis-ocr2.py` (`ATH-MaaS/OvisOCR2`, 0.9B Qwen3.5, 96.58 OmniDocBench v1.6,
277
- Apache-2.0; stable vLLM ≥0.22.1, `gdn_prefill_backend="triton"`, card-exact prompt/postprocessing).
278
- Smoke-tested green on the default uv image, a10g-small, resolved vLLM 0.25.1 (5/5 pages, tags filtered,
279
- stamp OK). Evaluated MonkeyOCRv2 alongside it — deferred (see Deferred / tracked).
280
- - **2026-07-08** — HunyuanOCR upstream repo swap: pinned `hunyuan-ocr.py` to the last 1.0 revision +
281
- added `hunyuan-ocr-1.5.py` (12 task types, locked sampling); `transformers<5.13` cap in both for the
282
- stable-vLLM HunyuanVL register breakage (vllm#47872). See the hunyuan gotcha above.
283
- - **2026-07-01** — large full-page scan fixes ([#65](https://github.com/davanstrien/uv-scripts-for-ai/pull/65)):
284
- surya vLLM-missing preflight; `dots` 8192→32768 + `--max-pixels`; `lighton-ocr2` 8192→16384; `glm` dropped
285
- `pyarrow<18` (→ `datasets` `Json` load crash) + `VLLM_USE_DEEP_GEMM=0` + `--max-pixels`; `pp-ocrv6`
286
- `--output-column` + collision guard. Then the output-column collision-guard + `--overwrite` sweep across
287
- the recipes ([#66](https://github.com/davanstrien/uv-scripts-for-ai/pull/66)); `nanonets-ocr` 8192→32768.
288
- - **Earlier** — per-script fixes are recorded in git history + the gotchas above; benchmark runs in `OCR-BENCHMARK.md`.
 
1
+ # OCR Scripts - Development Notes
2
 
3
+ ## Active Scripts
 
 
4
 
5
+ ### DeepSeek-OCR v1 (`deepseek-ocr-vllm.py`)
6
+ **Production Ready**
7
+ - Fully supported by vLLM
8
+ - Fast batch processing
9
+ - Tested and working on HF Jobs
10
 
11
+ ### LightOnOCR-2-1B (`lighton-ocr2.py`)
12
+ ✅ **Production Ready** (Fixed 2026-01-29)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
+ **Status:** Working with vLLM nightly
15
 
16
+ **What was fixed:**
17
+ - Root cause was NOT vLLM - it was the deprecated `HF_HUB_ENABLE_HF_TRANSFER=1` env var
18
+ - The script was setting this env var but `hf_transfer` package no longer exists
19
+ - This caused download failures that manifested as "Can't load image processor" errors
20
+ - Fix: Removed the `HF_HUB_ENABLE_HF_TRANSFER=1` setting from the script
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ **Test results (2026-01-29):**
23
+ - 10/10 samples processed successfully
24
+ - Clean markdown output with proper headers and paragraphs
25
+ - Output dataset: `davanstrien/lighton-ocr2-test-v4`
26
 
27
+ **Example usage:**
28
+ ```bash
29
+ hf jobs uv run --flavor a100-large \
30
+ -s HF_TOKEN \
31
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr2.py \
32
+ davanstrien/ufo-ColPali output-dataset \
33
+ --max-samples 10 --shuffle --seed 42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ **Model Info:**
37
+ - Model: `lightonai/LightOnOCR-2-1B`
38
+ - Architecture: Pixtral ViT encoder + Qwen3 LLM
39
+ - Training: RLVR (Reinforcement Learning with Verifiable Rewards)
40
+ - Performance: 83.2% on OlmOCR-Bench, 42.8 pages/sec on H100
41
+
42
+ ### PaddleOCR-VL-1.5 (`paddleocr-vl-1.5.py`)
43
+ ✅ **Production Ready** (Added 2026-01-30)
44
+
45
+ **Status:** Working with transformers
46
 
47
+ **Note:** Uses transformers backend (not vLLM) because PaddleOCR-VL only supports vLLM in server mode, which doesn't fit the single-command UV script pattern. Images are processed one at a time for stability.
48
 
49
+ **Test results (2026-01-30):**
50
+ - 10/10 samples processed successfully
51
+ - Processing time: ~50s per image on L4 GPU
52
+ - Output dataset: `davanstrien/paddleocr-vl15-final-test`
53
 
54
+ **Example usage:**
 
 
 
55
  ```bash
56
+ hf jobs uv run --flavor l4x1 \
57
+ -s HF_TOKEN \
58
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.5.py \
59
+ davanstrien/ufo-ColPali output-dataset \
60
+ --max-samples 10 --shuffle --seed 42
61
  ```
62
 
63
+ **Task modes:**
64
+ - `ocr` (default): General text extraction to markdown
65
+ - `table`: Table extraction to HTML format
66
+ - `formula`: Mathematical formula recognition to LaTeX
67
+ - `chart`: Chart and diagram analysis
68
+ - `spotting`: Text spotting with localization (uses higher resolution)
69
+ - `seal`: Seal and stamp recognition
70
+
71
+ **Model Info:**
72
+ - Model: `PaddlePaddle/PaddleOCR-VL-1.5`
73
+ - Size: 0.9B parameters (ultra-compact)
74
+ - Performance: 94.5% SOTA on OmniDocBench v1.5
75
+ - Backend: Transformers (single image processing)
76
+ - Requires: `transformers>=5.0.0`
77
+
78
+ ## Pending Development
79
+
80
+ ### DeepSeek-OCR-2 (Visual Causal Flow Architecture)
81
+
82
+ **Status:** ⏳ Waiting for vLLM upstream support
83
+
84
+ **Context:**
85
+ DeepSeek-OCR-2 is the next generation OCR model (3B parameters) with Visual Causal Flow architecture offering improved quality. We attempted to create a UV script (`deepseek-ocr2-vllm.py`) but encountered a blocker.
86
+
87
+ **Blocker:**
88
+ vLLM does not yet support `DeepseekOCR2ForCausalLM` architecture in the official release.
89
+
90
+ **PR to Watch:**
91
+ 🔗 https://github.com/vllm-project/vllm/pull/33165
92
+
93
+ This PR adds DeepSeek-OCR-2 support but is currently:
94
+ - ⚠️ **Open** (not merged)
95
+ - Has unresolved review comments
96
+ - Pre-commit checks failing
97
+ - Issues: hardcoded parameters, device mismatch bugs, missing error handling
98
+
99
+ **What's Needed:**
100
+ 1. PR #33165 needs to be reviewed, fixed, and merged
101
+ 2. vLLM needs to release a version including the merge
102
+ 3. Then we can add these dependencies to our script:
103
+ ```python
104
+ # dependencies = [
105
+ # "datasets>=4.0.0",
106
+ # "huggingface-hub",
107
+ # "pillow",
108
+ # "vllm",
109
+ # "tqdm",
110
+ # "toolz",
111
+ # "torch",
112
+ # "addict",
113
+ # "matplotlib",
114
+ # ]
115
+ ```
116
+
117
+ **Implementation Progress:**
118
+ - ✅ Created `deepseek-ocr2-vllm.py` script
119
+ - ✅ Fixed dependency issues (pyarrow, datasets>=4.0.0)
120
+ - ✅ Tested script structure on HF Jobs
121
+ - ❌ Blocked: vLLM doesn't recognize architecture
122
+
123
+ **Partial Implementation:**
124
+ The file `deepseek-ocr2-vllm.py` exists in this repo but is **not functional** until vLLM support lands. Consider it a draft.
125
+
126
+ **Testing Evidence:**
127
+ When we ran on HF Jobs, we got:
128
+ ```
129
+ ValidationError: Model architectures ['DeepseekOCR2ForCausalLM'] are not supported for now.
130
+ Supported architectures: [...'DeepseekOCRForCausalLM'...]
131
  ```
132
 
133
+ **Next Steps (when PR merges):**
134
+ 1. Update `deepseek-ocr2-vllm.py` dependencies to include `addict` and `matplotlib`
135
+ 2. Test on HF Jobs with small dataset (10 samples)
136
+ 3. Verify output quality
137
+ 4. Update README.md with DeepSeek-OCR-2 section
138
+ 5. Document v1 vs v2 differences
139
+
140
+ **Alternative Approaches (if urgent):**
141
+ - Create transformers-based script (slower, no vLLM batching)
142
+ - Use DeepSeek's official repo setup (complex, not UV-script compatible)
143
+
144
+ **Model Information:**
145
+ - Model ID: `deepseek-ai/DeepSeek-OCR-2`
146
+ - Model Card: https://huggingface.co/deepseek-ai/DeepSeek-OCR-2
147
+ - GitHub: https://github.com/deepseek-ai/DeepSeek-OCR-2
148
+ - Parameters: 3B
149
+ - Resolution: (0-6)×768×768 + 1×1024×1024 patches
150
+ - Key improvement: Visual Causal Flow architecture
151
+
152
+ **Resolution Modes (for v2):**
153
+ ```python
154
+ RESOLUTION_MODES = {
155
+ "tiny": {"base_size": 512, "image_size": 512, "crop_mode": False},
156
+ "small": {"base_size": 640, "image_size": 640, "crop_mode": False},
157
+ "base": {"base_size": 1024, "image_size": 768, "crop_mode": False}, # v2 optimized
158
+ "large": {"base_size": 1280, "image_size": 1024, "crop_mode": False},
159
+ "gundam": {"base_size": 1024, "image_size": 768, "crop_mode": True}, # v2 optimized
160
+ }
161
  ```
162
 
163
+ ## Other OCR Scripts
164
+
165
+ ### Nanonets OCR (`nanonets-ocr.py`, `nanonets-ocr2.py`)
166
+ Both versions working
167
+
168
+ ### PaddleOCR-VL (`paddleocr-vl.py`)
169
+ ✅ Working
170
 
171
  ---
172
 
173
+ ## Future: OCR Smoke Test Dataset
174
+
175
+ **Status:** Idea (noted 2026-02-12)
176
+
177
+ Build a small curated dataset (`uv-scripts/ocr-smoke-test`?) with ~2-5 samples from diverse sources. Purpose: fast CI-style verification that scripts still work after dep updates, without downloading full datasets.
178
+
179
+ **Design goals:**
180
+ - Tiny (~20-30 images total) so download is seconds not minutes
181
+ - Covers the axes that break things: document type, image quality, language, layout complexity
182
+ - Has ground truth text where possible for quality regression checks
183
+ - All permissively licensed (CC0/CC-BY preferred)
184
+
185
+ **Candidate sources:**
186
+
187
+ | Source | What it covers | Why |
188
+ |--------|---------------|-----|
189
+ | `NationalLibraryOfScotland/medical-history-of-british-india` | Historical English, degraded scans | Has hand-corrected `text` column for comparison. CC0. Already tested with GLM-OCR. |
190
+ | `davanstrien/ufo-ColPali` | Mixed modern documents | Already used as our go-to test set. Varied layouts. |
191
+ | Something with **tables** | Structured data extraction | Tests `--task table` modes. Maybe a financial report or census page. |
192
+ | Something with **formulas/LaTeX** | Math notation | Tests `--task formula`. arXiv pages or textbook scans. |
193
+ | Something **multilingual** (CJK, Arabic, etc.) | Non-Latin scripts | GLM-OCR claims zh/ja/ko support. Good to verify. |
194
+ | Something **handwritten** | Handwriting recognition | Edge case that reveals model limits. |
195
+
196
+ **How it would work:**
197
+ ```bash
198
+ # Quick smoke test for any script
199
+ uv run glm-ocr.py uv-scripts/ocr-smoke-test smoke-out --max-samples 5
200
+ # Or a dedicated test runner that checks all scripts against it
201
+ ```
202
+
203
+ **Open questions:**
204
+ - Build as a proper HF dataset, or just a folder of images in the repo?
205
+ - Should we include expected output for regression testing (fragile if models change)?
206
+ - Could we add a `--smoke-test` flag to each script that auto-uses this dataset?
207
+ - Worth adding to HF Jobs scheduled runs for ongoing monitoring?
208
 
209
  ---
210
 
211
+ **Last Updated:** 2026-02-12
212
+ **Watch PRs:**
213
+ - DeepSeek-OCR-2: https://github.com/vllm-project/vllm/pull/33165
 
 
 
 
 
 
 
 
 
 
 
 
LANGUAGES.md DELETED
@@ -1,38 +0,0 @@
1
- # Language support
2
-
3
- What each model's own card claims about language coverage — compiled from the model cards as of **2026-07-15**. Treat these as **claims, not guarantees**: only one model (Surya) publishes per-language scores, and a "multilingual" tag tells you very little. Machine-readable version (plus params, backend, image pins): [`models.json`](models.json).
4
-
5
- The fastest way to find out whether a model handles *your* language is to run it on a few of your own pages — `--max-samples 10` costs a few cents on a T4/L4:
6
-
7
- ```bash
8
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \
9
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/surya-ocr.py \
10
- your-dataset your-output --max-samples 10
11
- ```
12
-
13
- _Sorted by strength of evidence, then coverage:_
14
-
15
- | Model | Card claim | Evidence |
16
- |-------|-----------|----------|
17
- | [Surya OCR 2](https://huggingface.co/datalab-to/surya-ocr-2) | **91 languages** | The only **per-language benchmark** published ([full table](https://github.com/datalab-to/surya/blob/master/static/docs/multilingual.md)): 38/91 score ≥90%, 76/91 ≥80%. Weakest of the top-15: Arabic 72.7%, Vietnamese 73.2% |
18
- | [dots.ocr](https://huggingface.co/rednote-hilab/dots.ocr) | 100 languages | Explicit **low-resource** claim, backed by an in-house benchmark (1,493 PDFs across 100 languages) — aggregate scores only, not per-language |
19
- | [Tesseract 5](https://github.com/tesseract-ocr/tesseract) | 125 traineddata packs (~100+ languages + script models) | Fully **enumerable** ([tessdata_best](https://github.com/tesseract-ocr/tessdata_best)) — the broadest *named* coverage here, incl. many low-resource languages; script models (Latin, Cyrillic, Devanagari, …) cover languages without a dedicated pack |
20
- | [Qianfan-OCR](https://huggingface.co/baidu/Qianfan-OCR) | 192 languages | Headline claim; no list or per-language numbers |
21
- | [PaddleOCR-VL](https://huggingface.co/PaddlePaddle/PaddleOCR-VL) / [1.5](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5) / [1.6](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6) | 109 languages; 1.5 adds Tibetan + Bengali | Names scripts (Cyrillic, Arabic, Devanagari, Thai) and claims handwriting + historical documents; no per-language numbers |
22
- | [PP-OCRv6](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6) | 48 languages | Official card claim |
23
- | [Nanonets-OCR2-3B](https://huggingface.co/nanonets/Nanonets-OCR2-3B) | 11 named + "many more" (incl. Arabic + CJK) | Illustrative list, no benchmark — but the only card claiming multilingual **handwriting** |
24
- | [LightOnOCR-2-1B](https://huggingface.co/lightonai/LightOnOCR-2-1B) | 11 (9 European + zh/ja) | Declared, not benchmarked. [v1](https://huggingface.co/lightonai/LightOnOCR-1B-1025) is explicitly European/Latin-script only (9) |
25
- | [GLM-OCR](https://huggingface.co/zai-org/GLM-OCR) | 8 (zh en fr es ru de ja ko) | Declared in card metadata only; no per-language evidence in the card body |
26
- | [HunyuanOCR-1.5](https://huggingface.co/tencent/HunyuanOCR) | "Multilingual" | Nothing enumerated, but explicitly targets **low-resource + ancient-script OCR** as a design goal (new in 1.5; the pinned 1.0 revision doesn't claim this) |
27
- | [dots.mocr](https://huggingface.co/rednote-hilab/dots.mocr) · [DeepSeek-OCR](https://huggingface.co/deepseek-ai/DeepSeek-OCR) / [-2](https://huggingface.co/deepseek-ai/DeepSeek-OCR-2) · [Unlimited-OCR](https://huggingface.co/baidu/Unlimited-OCR) · [NuExtract3](https://huggingface.co/numind/NuExtract3) | "Multilingual", unspecified | A tag or one-liner only — no count, list, or benchmark |
28
- | [olmOCR-2](https://huggingface.co/allenai/olmOCR-2-7B-1025-FP8) · [Nanonets-OCR-s](https://huggingface.co/nanonets/Nanonets-OCR-s) · [SmolDocling](https://huggingface.co/ds4sd/SmolDocling-256M-preview) · [LFM2.5-VL-Extract](https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-Extract) | English only | Stated on the card |
29
- | [Falcon-OCR](https://huggingface.co/tiiuae/Falcon-OCR) · [OvisOCR2](https://huggingface.co/ATH-MaaS/OvisOCR2) · [FireRed-OCR](https://huggingface.co/FireRedTeam/FireRed-OCR) · [ABot-OCR](https://huggingface.co/acvlab/ABot-OCR) · [RolmOCR](https://huggingface.co/reducto/RolmOCR) · [NuMarkdown-8B](https://huggingface.co/numind/NuMarkdown-8B-Thinking) · [lift](https://huggingface.co/datalab-to/lift) | Not stated | No language information on the card at all |
30
-
31
- Text-only extraction: [LFM2-1.2B-Extract](https://huggingface.co/LiquidAI/LFM2-1.2B-Extract) (chains after OCR) names 9 languages: English, Arabic, Chinese, French, German, Japanese, Korean, Portuguese, Spanish.
32
-
33
- ## Reading the table for low-resource work
34
-
35
- - **Only Surya lets you check your language before running anything** — its [benchmark table](https://github.com/datalab-to/surya/blob/master/static/docs/multilingual.md) has a row per language.
36
- - **Tesseract's coverage is enumerable** — if your language has a [traineddata pack](https://github.com/tesseract-ocr/tessdata_best), it's supported (quality varies; it's the legacy baseline, not the quality leader).
37
- - **dots.ocr and HunyuanOCR-1.5 are the VLMs that talk about low-resource languages at all**; PaddleOCR-VL-1.5 names Tibetan and Bengali specifically.
38
- - A big claimed number (192, 109) without a list or per-language scores means you're testing it yourself either way — which is cheap (see the command above).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
OCR-BENCHMARK.md DELETED
@@ -1,93 +0,0 @@
1
- # OCR Benchmark — results & history
2
-
3
- Result tables and validation history for the OCR benchmark tooling. **How to *run* the tools**
4
- (`ocr-bench-run.py`, `ocr-vllm-judge.py`, `ocr-human-eval.py`) lives in `CLAUDE.md` → "Internal
5
- tooling"; this file is the accumulated *evidence*.
6
-
7
- ## Model registry (as benchmarked)
8
-
9
- | Slug | Model | Size | GPU | Notes |
10
- |------|-------|------|-----|-------|
11
- | `glm-ocr` | `zai-org/GLM-OCR` | 0.9B | l4x1 | |
12
- | `deepseek-ocr` | `deepseek-ai/DeepSeek-OCR` | 4B | l4x1 | auto `--prompt-mode free` (no grounding tags) |
13
- | `lighton-ocr-2` | `lightonai/LightOnOCR-2-1B` | 1B | a100-large | |
14
- | `dots-ocr` | `rednote-hilab/dots.ocr` | 1.7B | l4x1 | stable vLLM (>=0.9.1) |
15
-
16
- ## Run 1 — NLS Medical History (2026-02-14, pilot)
17
-
18
- `NationalLibraryOfScotland/medical-history-of-british-india`, 10 samples, seed 42. Judge:
19
- `Qwen2.5-VL-72B` via Inference Providers. Historical English, degraded scans.
20
-
21
- - **ELO (pairwise, 5 samples):** DoTS 1540 (67%) · DeepSeek 1539 (57%) · LightOnOCR-2 1486 (50%) · GLM 1436 (29%)
22
- - **Pointwise (5):** DeepSeek 5.0 · GLM 4.6 · LightOnOCR-2 4.4 · DoTS 4.2
23
- - **Key finding:** DeepSeek's `--prompt-mode document` emits grounding tags (`<|ref|>`/`<|det|>`) the
24
- judge penalises heavily; switching to `--prompt-mode free` moved it last→top-2 (now the registry default).
25
- - **Caveat:** 5 samples is far too few for stable rankings.
26
-
27
- ## Run 2 — Rubenstein Manuscript Catalog (2026-02-15, first full run)
28
-
29
- `biglam/rubenstein-manuscript-catalog`, 50 samples, seed 42. Judge: jury of `Qwen2.5-VL-7B` +
30
- `Qwen3-VL-8B` on A100 (`ocr-vllm-judge.py`). ~48K typewritten + handwritten cards (Duke, CC0).
31
-
32
- **ELO (50 samples, 300 comparisons, 0 parse failures):**
33
-
34
- | Rank | Model | ELO | W | L | T | Win% |
35
- |------|-------|-----|---|---|---|------|
36
- | 1 | LightOnOCR-2-1B | 1595 | 100 | 50 | 0 | 67% |
37
- | 2 | DeepSeek-OCR | 1497 | 73 | 77 | 0 | 49% |
38
- | 3 | GLM-OCR | 1471 | 57 | 93 | 0 | 38% |
39
- | 4 | dots.ocr | 1437 | 70 | 80 | 0 | 47% |
40
-
41
- Job times (50 samples): dots 5.3 min (L4) · deepseek 5.6 (L4) · glm 5.7 (L4) · lighton 6.4 (A100).
42
-
43
- **Findings:** LightOnOCR-2 dominates on manuscript cards (very different from the NLS pilot) — rankings
44
- are **dataset-dependent**; a jury of small models works well (0 parse failures via vLLM structured output);
45
- 50 samples gives meaningful separation.
46
-
47
- ## Run 3 — UFO-ColPali (2026-02-15, cross-dataset validation)
48
-
49
- `davanstrien/ufo-ColPali`, 50 samples, seed 42. Judge: `Qwen3-VL-30B-A3B` on A100 (updated prompt).
50
- Mixed modern documents.
51
-
52
- **ELO (50 samples, 294 comparisons):**
53
-
54
- | Rank | Model | ELO | W | L | T | Win% |
55
- |------|-------|-----|---|---|---|------|
56
- | 1 | DeepSeek-OCR | 1827 | 130 | 17 | 0 | 88% |
57
- | 2 | dots.ocr | 1510 | 64 | 83 | 0 | 44% |
58
- | 3 | LightOnOCR-2-1B | 1368 | 77 | 70 | 0 | 52% |
59
- | 4 | GLM-OCR | 1294 | 23 | 124 | 0 | 16% |
60
-
61
- **Human validation (30 comparisons):** DeepSeek #1 (matches judge), LightOnOCR-2 #3 (matches). Middle
62
- pack (GLM, dots) shuffled between human and judge.
63
-
64
- ## Cross-dataset comparison (human-validated)
65
-
66
- | Model | Rubenstein Human | Rubenstein Kimi | UFO Human | UFO 30B |
67
- |-------|:---:|:---:|:---:|:---:|
68
- | DeepSeek-OCR | **#1** | **#1** | **#1** | **#1** |
69
- | GLM-OCR | #2 | #3 | #2 | #4 |
70
- | LightOnOCR-2 | #4 | #2 | #3 | #3 |
71
- | dots.ocr | #3 | #4 | #4 | #2 |
72
-
73
- **Conclusion:** DeepSeek-OCR is consistently #1 across datasets and eval methods; middle-pack rankings
74
- are dataset-dependent. (NLS pilot omitted — 5 samples / 72B API judge, not comparable with the newer
75
- methodology.)
76
-
77
- ## Judge validation — `ocr-vllm-judge.py` (2026-02-15)
78
-
79
- - **Test 1** (single judge, 1 sample, L4): `Qwen2.5-VL-7B`, 6/6 comparisons, 0 parse failures, ~3 min.
80
- - **Test 2** (jury of 2, 3 samples, A100): `Qwen2.5-VL-7B` + `Qwen3-VL-8B`, 15/15, 0 failures; GPU cleanup
81
- between models OK; majority-vote aggregation working (`[2/2]` unanimous, `[1/2]` split).
82
- - **Test 3** (full, 50 samples, A100, Rubenstein): 300/300 comparisons, 0 parse failures; clear ELO
83
- separation. First saved dataset: `davanstrien/ocr-bench-rubenstein-judge`.
84
-
85
- Structured output via a compatibility shim: `StructuredOutputsParams` (vLLM ≥0.12) → `GuidedDecodingParams`
86
- (older) → prompt-based fallback. Position bias mitigated by A/B randomisation. A100 recommended for jury mode.
87
-
88
- ## Human eval — `ocr-human-eval.py` first validation (Rubenstein, 30 annotations)
89
-
90
- Tested 3 judge configs against 30 human annotations. **Kimi K2.5 (170B) via Novita + the updated prompt**
91
- is the only judge to match the human's #1 (DeepSeek-OCR); it's now the default in `ocr-jury-bench.py`.
92
- Small models (7B/8B/30B) overrate LightOnOCR-2 (bias toward its commentary style); the updated prompt
93
- (faithfulness > completeness > accuracy) helps, but model size is the bigger factor.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,17 +1,15 @@
1
  ---
2
  viewer: false
3
- tags: [uv-script, ocr, extraction, vision-language-model, document-processing, hf-jobs]
4
  ---
5
 
6
  # OCR UV Scripts
7
 
8
- <a href="https://huggingface.co/uv-scripts"><picture><source media="(prefers-color-scheme: dark)" srcset="https://huggingface.co/datasets/huggingface/badges/resolve/main/follow-us-on-hf-md-dark.svg"><img src="https://huggingface.co/datasets/huggingface/badges/resolve/main/follow-us-on-hf-md.svg" alt="Follow uv-scripts on Hugging Face"></picture></a>
9
 
10
- > Part of [uv-scripts](https://huggingface.co/uv-scripts) self-contained UV scripts you run on Hugging Face Jobs in one command.
11
 
12
- A model zoo of OCR scripts — one per model — that add a `markdown` column to an image dataset. Pick a model from the table below, point it at your dataset, and run it on a GPU with one command. A few recipes do **structured extraction** instead — image *or* text → JSON given a schema (see [Structured extraction](#structured-extraction-image-or-text--json) below). Two more companions sit alongside: `pp-doclayout.py` detects layout regions (bboxes for text/title/table/figure/…) instead of text, and `ocr-vllm-judge.py` compares model outputs head-to-head.
13
-
14
- ## Quick Start
15
 
16
  Run OCR on any dataset without needing your own GPU:
17
 
@@ -24,243 +22,589 @@ hf jobs uv run --flavor l4x1 \
24
  --max-samples 10
25
  ```
26
 
27
- This will:
28
 
29
- - Process the first 10 images from your dataset
30
  - Add OCR results as a new `markdown` column
31
  - Push the results to a new dataset
32
  - View results at: `https://huggingface.co/datasets/[your-output-dataset]`
33
 
34
- ## Serve a model as a live endpoint
35
 
36
- The recipes here run as batch jobs. Some models also have a **`-server.py` sibling recipe** that runs the same dataset→dataset batch job through an in-job `vllm serve` + concurrent driver — measurably faster (continuous batching stays fed) and more robust (one bad image fails one request, not a whole batch); see [SERVING.md](SERVING.md) for the architecture, A/B numbers, and which models officially document server mode. To call a model interactively, from an agent, or with concurrent ad-hoc requests, you can instead run it as a temporary endpoint: [HF Jobs serving](https://huggingface.co/docs/hub/jobs-serving) exposes a port on a GPU Job, giving an OpenAI-compatible endpoint that runs until the job is cancelled or its `--timeout` is reached. See [serving-unlimited-ocr.md](serving-unlimited-ocr.md) for a worked example serving Baidu's [Unlimited-OCR](https://huggingface.co/baidu/Unlimited-OCR) — with vLLM (official image) or SGLang. To OCR a whole corpus of single-page images instead, the batch recipe `unlimited-ocr-vllm.py` is the better fit (it's single-image only). **Multi-page** documents need a server: both vLLM and SGLang read clean multi-page docs, but **SGLang is the more robust** — on hard/degraded scans vLLM multi-page hallucinated in our tests while SGLang held up.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
- ## Models at a glance
39
 
40
- **Start here:** for a quick first run, try **`lighton-ocr2.py`** (1B, very fast), **`paddleocr-vl-1.6.py`** (0.9B, 96.33 OmniDocBench) or **`ovis-ocr2.py`** (0.9B, 96.58 OmniDocBench — current SOTA); for the smallest footprint, **`falcon-ocr.py`** (0.3B, strong on tables). Reach for a 7–8B model only when quality demands it. Several of these models sit on the public [olmOCR-Bench](https://huggingface.co/datasets/allenai/olmOCR-bench) pull the live ranking from your terminal in one command:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  ```bash
43
- hf datasets leaderboard allenai/olmOCR-bench
44
  ```
45
 
46
- But which model wins on *your* documents is still document-dependent — so [ocr-bench](https://github.com/davanstrien/ocr-bench) builds a **per-collection leaderboard** for your own data (pairwise VLM-as-judge, optionally human-validated), using these scripts under the hood.
47
 
48
- **Language coverage:** [LANGUAGES.md](LANGUAGES.md) lists what each model's card claims (and how much evidence backs it). Machine-readable catalog for agents — script → model, params, backend, image pins, languages: [`models.json`](models.json).
49
 
50
- _Sorted by model size:_
 
 
 
 
51
 
52
- | Script | Model | Size | Backend | Notes |
53
- |--------|-------|------|---------|-------|
54
- | [`tesseract-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/tesseract-ocr.py) | [Tesseract 5](https://github.com/tesseract-ocr/tesseract) | n/a (classical) | pytesseract (CPU) | **The legacy baseline** — no GPU at all, runs on `cpu-upgrade`. Plain-text output, `--lang`/`--psm`/`--oem` exposed, 100+ language packs via apt. Apache 2.0 |
55
- | [`pp-ocrv6.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/pp-ocrv6.py) | [PP-OCRv6](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6) | 1.5–34.5M | PaddleOCR (paddle) | **Smallest neural** — classical det+rec pipeline, not a VLM. Three tiers (`--model-tier tiny\|small\|medium`), plain-text output (not markdown). 48 langs. Runs on `t4-small`. Apache 2.0 |
56
- | [`falcon-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/falcon-ocr.py) | [Falcon-OCR](https://huggingface.co/tiiuae/Falcon-OCR) | 0.3B | falcon-perception | Smallest VLM in collection. #1 on multi-column docs and tables (olmOCR), Apache 2.0 |
57
- | [`smoldocling-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/smoldocling-ocr.py) | [SmolDocling](https://huggingface.co/ds4sd/SmolDocling-256M-preview) | 256M | Transformers | DocTags structured output |
58
- | [`surya-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/surya-ocr.py) | [Surya OCR 2](https://huggingface.co/datalab-to/surya-ocr-2) | 0.65B | vLLM | **Structured** OCR + `--task layout\|table`: per-block HTML with bboxes & reading order in an extra `surya_blocks` column. 91 langs, top-under-3B on olmOCR-Bench. Modified OpenRAIL-M license. Needs the **pinned** `vllm/vllm-openai:v0.20.1` image |
59
- | [`glm-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/glm-ocr.py) | [GLM-OCR](https://huggingface.co/zai-org/GLM-OCR) | 0.9B | vLLM | 94.62% OmniDocBench V1.5 |
60
- | [`paddleocr-vl.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/paddleocr-vl.py) | [PaddleOCR-VL](https://huggingface.co/PaddlePaddle/PaddleOCR-VL) | 0.9B | vLLM | 4 task modes (ocr/table/formula/chart) |
61
- | [`paddleocr-vl-1.5.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/paddleocr-vl-1.5.py) | [PaddleOCR-VL-1.5](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5) | 0.9B | Transformers | 94.5% OmniDocBench, 6 task modes |
62
- | [`paddleocr-vl-1.6.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/paddleocr-vl-1.6.py) | [PaddleOCR-VL-1.6](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.6) | 0.9B | vLLM | **96.33% OmniDocBench v1.6**, drop-in upgrade of 1.5 |
63
- | [`ovis-ocr2.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/ovis-ocr2.py) | [OvisOCR2](https://huggingface.co/ATH-MaaS/OvisOCR2) | 0.9B | vLLM | **96.58 OmniDocBench v1.6** (SOTA; first end-to-end model to top it). Qwen3.5 base; markdown + LaTeX + HTML tables. Apache 2.0 |
64
- | [`ovis-ocr2-server.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/ovis-ocr2-server.py) | [OvisOCR2](https://huggingface.co/ATH-MaaS/OvisOCR2) | 0.9B | vLLM server | **Server-mode sibling** of `ovis-ocr2.py`: in-job `vllm serve` + concurrent driver — ~1.7× its throughput, per-image failure isolation. See [SERVING.md](SERVING.md) |
65
- | [`lighton-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/lighton-ocr.py) | [LightOnOCR-1B](https://huggingface.co/lightonai/LightOnOCR-1B-1025) | 1B | vLLM | Fast, 3 vocab sizes |
66
- | [`lighton-ocr2.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/lighton-ocr2.py) | [LightOnOCR-2-1B](https://huggingface.co/lightonai/LightOnOCR-2-1B) | 1B | vLLM | 7× faster than v1, RLVR trained |
67
- | [`lighton-ocr2-server.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/lighton-ocr2-server.py) | [LightOnOCR-2-1B](https://huggingface.co/lightonai/LightOnOCR-2-1B) | 1B | vLLM server | **Server-mode sibling** of `lighton-ocr2.py` (the card's own documented path) — ~1.8× its throughput. See [SERVING.md](SERVING.md) |
68
- | [`hunyuan-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/hunyuan-ocr.py) | [HunyuanOCR 1.0](https://huggingface.co/tencent/HunyuanOCR/tree/f6af82ee007fe6091b29fb3bb287b491ead41c82) | 1B | vLLM | Lightweight VLM. Pinned to the last 1.0 revision (repo root became 1.5 in-place on 2026-07-06). [Hunyuan Community License](https://huggingface.co/tencent/HunyuanOCR/blob/main/LICENSE) (excludes EU/UK/KR) |
69
- | [`hunyuan-ocr-1.5.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/hunyuan-ocr-1.5.py) | [HunyuanOCR-1.5](https://huggingface.co/tencent/HunyuanOCR) | 1B | vLLM | 128K context, 4K images, 12 task types, ancient scripts. ~4-5× faster/page than dots.ocr & DeepSeek-OCR-2 (tech report). [Hunyuan Community License](https://huggingface.co/tencent/HunyuanOCR/blob/main/LICENSE) (excludes EU/UK/KR) |
70
- | [`dots-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/dots-ocr.py) | [dots.ocr](https://huggingface.co/rednote-hilab/dots.ocr) | 1.7B | vLLM | 100 languages (in-house bench), explicit low-resource claim |
71
- | [`firered-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/firered-ocr.py) | [FireRed-OCR](https://huggingface.co/FireRedTeam/FireRed-OCR) | 2.1B | vLLM | Qwen3-VL fine-tune, Apache 2.0 |
72
- | [`abot-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/abot-ocr.py) | [ABot-OCR](https://huggingface.co/acvlab/ABot-OCR) | 2B | vLLM | Qwen3-VL based, doc→Markdown (text/LaTeX/HTML tables). Needs `vllm/vllm-openai` image. [paper](https://arxiv.org/abs/2605.27978) |
73
- | [`nanonets-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/nanonets-ocr.py) | [Nanonets-OCR-s](https://huggingface.co/nanonets/Nanonets-OCR-s) | 2B | vLLM | LaTeX, tables, forms |
74
- | [`dots-mocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/dots-mocr.py) | [dots.mocr](https://huggingface.co/rednote-hilab/dots.mocr) | 3B | vLLM | 8 prompt modes incl. SVG generation, layout + bbox, 100+ languages |
75
- | [`nanonets-ocr2.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/nanonets-ocr2.py) | [Nanonets-OCR2-3B](https://huggingface.co/nanonets/Nanonets-OCR2-3B) | 3B | vLLM | Next-gen, Qwen2.5-VL base. **Pin `--image vllm/vllm-openai:v0.10.2`** (vLLM ≥0.11 breaks Qwen2.5-VL → all `!`) |
76
- | [`deepseek-ocr-vllm.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/deepseek-ocr-vllm.py) | [DeepSeek-OCR](https://huggingface.co/deepseek-ai/DeepSeek-OCR) | 4B | vLLM | 5 resolution + 5 prompt modes |
77
- | [`deepseek-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/deepseek-ocr.py) | [DeepSeek-OCR](https://huggingface.co/deepseek-ai/DeepSeek-OCR) | 4B | Transformers | Same model, Transformers backend |
78
- | [`deepseek-ocr2-vllm.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/deepseek-ocr2-vllm.py) | [DeepSeek-OCR-2](https://huggingface.co/deepseek-ai/DeepSeek-OCR-2) | 3B | vLLM | Newer; needs nightly vLLM **+ the `vllm/vllm-openai` image** ([why](#if-a-vllm-script-crashes-at-startup-the-nvcc--nvrtc-error)) |
79
- | [`unlimited-ocr-vllm.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/unlimited-ocr-vllm.py) | [Unlimited-OCR](https://huggingface.co/baidu/Unlimited-OCR) | 3.3B | vLLM | DeepSeek-OCR-based; layout-grounded markdown (`--strip-grounding` for clean text). Single-image batch — needs Baidu's **dedicated `vllm/vllm-openai:unlimited-ocr` image** (`-cu129` on Hopper). Multi-page "long-horizon" parsing → serve it ([doc](serving-unlimited-ocr.md)); both engines do clean docs, **SGLang more robust** on hard scans. MIT |
80
- | [`nuextract3.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/nuextract3.py) | [NuExtract3](https://huggingface.co/numind/NuExtract3) | 4B | vLLM | Markdown OCR **+ schema-guided JSON extraction** (template/Pydantic). Needs `vllm/vllm-openai` image |
81
- | [`qianfan-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/qianfan-ocr.py) | [Qianfan-OCR](https://huggingface.co/baidu/Qianfan-OCR) | 4.7B | vLLM | #1 OmniDocBench v1.5 (93.12), Layout-as-Thought, 192 languages |
82
- | [`olmocr2-vllm.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/olmocr2-vllm.py) | [olmOCR-2-7B](https://huggingface.co/allenai/olmOCR-2-7B-1025-FP8) | 7B | vLLM | 82.4% olmOCR-Bench |
83
- | [`rolm-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/rolm-ocr.py) | [RolmOCR](https://huggingface.co/reducto/RolmOCR) | 7B | vLLM | Qwen2.5-VL based, general-purpose |
84
- | [`numarkdown-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/numarkdown-ocr.py) | [NuMarkdown-8B](https://huggingface.co/numind/NuMarkdown-8B-Thinking) | 8B | vLLM | Reasoning-based OCR |
85
-
86
- **Variants & tools** (same models, different I/O): `glm-ocr-v2.py` adds checkpoint/resume for very large jobs · `glm-ocr-bucket.py` and `falcon-ocr-bucket.py` read images/PDFs from a mounted bucket and write one `.md` per page · `surya-ocr-bucket.py` is the structured bucket recipe — OCR a bucket of files (no dataset round-trip) via either a FUSE mount **or** `huggingface_hub` batch-copy (`--io-mode mount|copy`), writing per-page `.md` + `.json` (`surya_blocks`) back to a bucket (resumable) and/or a pushed dataset · `ocr-vllm-judge.py` runs pairwise OCR-quality comparisons.
87
-
88
- `surya-ocr.py` is the structured outlier: besides the flattened text column it writes a `surya_blocks` JSON column (per-block HTML + bounding boxes + reading order), and `--task` switches between OCR, `layout`, and `table`. It runs as **offline vLLM batch** (no server) and must use the **pinned** `vllm/vllm-openai:v0.20.1` image — its `qwen3_5` architecture is recent and version-sensitive, and that image puts vLLM at `/usr/local/lib/python3.12/site-packages` (use `--python /usr/local/bin/python3`; the exact command is in the script's docstring). Weights are **modified OpenRAIL-M**.
89
-
90
- ## Structured extraction (image or text → JSON)
91
-
92
- Most scripts here output markdown. These take a **schema** and return **structured data** instead — give them the fields you want, they fill them in:
93
-
94
- | Script | Model | Size | Input | Output |
95
- |--------|-------|------|-------|--------|
96
- | [`lfm2-vl-extract.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/lfm2-vl-extract.py) | [LFM2.5-VL-1.6B-Extract](https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-Extract) | 1.6B | image | JSON |
97
- | [`nuextract3.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/nuextract3.py) | [NuExtract3](https://huggingface.co/numind/NuExtract3) | 4B | image | markdown **or** JSON |
98
- | [`lfm2-extract.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/lfm2-extract.py) | [LFM2-1.2B-Extract](https://huggingface.co/LiquidAI/LFM2-1.2B-Extract) | 1.2B | **text** | JSON / XML / YAML |
99
- | [`lift-extract.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/lift-extract.py) | [lift](https://huggingface.co/datalab-to/lift) | 9B | image **or** PDF | JSON |
100
-
101
- Pass `--schema` (inline JSON, a URL, or a file path). The LFM models are small and fast; run them on the `vllm/vllm-openai` image so the CUDA toolkit is present (each script's docstring has the exact command). Because `lfm2-extract.py` works on a **text** column, you can **chain it after OCR**: a recipe above turns a page into `markdown`, then `lfm2-extract.py` turns that markdown into fields.
102
-
103
- `lift-extract.py` is the one outlier: a 9B model that also reads **multi-page PDFs** (`--pdf-column`, `--page-range`) and runs on either Transformers (`--method hf`) or vLLM (`--method vllm`). Its weights are **modified OpenRAIL-M** (free for research, personal use, and startups under $5M; no competitive use against Datalab's API) — the only non-permissive license here, so check the terms.
104
 
105
- ```bash
106
- # image JSON directly
107
- hf jobs uv run --flavor l4x1 --secrets HF_TOKEN \
108
- --image vllm/vllm-openai --python /usr/bin/python3 \
109
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \
110
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lfm2-vl-extract.py \
111
- my-images my-fields --schema '{"title": "the document title", "date": "any date shown"}'
112
  ```
113
 
114
- ## Layout detection (not OCR)
115
 
116
- `pp-doclayout.py` runs PaddleOCR's [PP-DocLayout-L](https://huggingface.co/PaddlePaddle/PP-DocLayout-L) (or M / S / plus-L) and emits per-image **bounding boxes + region classes** (text, title, table, figure, formula, list, header, footer, ...) — it does NOT extract text. Useful for filtering pages, cropping regions for downstream OCR, dataset analysis, and training-data prep.
117
 
118
- | Script | Model | Size | Backend | Notes |
119
- |--------|-------|------|---------|-------|
120
- | [`pp-doclayout.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/pp-doclayout.py) | [PP-DocLayout-L](https://huggingface.co/PaddlePaddle/PP-DocLayout-L) | 123M | paddleocr | Layout bboxes (no text). Bucket support: incremental parquet shards, resumable. |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
  ```bash
123
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \
124
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-doclayout.py \
125
- your-dataset your-layout-output --max-samples 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  ```
127
 
128
- Source/sink can be either an HF dataset repo OR an `hf://buckets/...` URL (auto-detected). Bucket output writes incremental zstd parquet shards via the buckets API — resumable across runs (snapshot-backed source listing) and no git/commit overhead. See the script's `--help` for all flags.
129
 
130
- ## If a vLLM script crashes at startup (the `nvcc` / `nvrtc` error)
131
 
132
- The vLLM recipes run on the **default** Jobs image and carry a guard (`VLLM_USE_FLASHINFER_SAMPLER=0`) so they work there with the plain command. But some especially nightly-vLLM ones — JIT-compile a CUDA kernel at engine init and crash on the default image with one of:
 
 
 
 
 
 
 
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  ```
135
- RuntimeError: Could not find nvcc and default cuda_home='/usr/local/cuda' doesn't exist
136
- nvrtc: error: failed to open libnvrtc-builtins.so...
137
- ```
138
 
139
- Run those on the **`vllm/vllm-openai` image**, which ships the full CUDA toolkit. Add these flags to any recipe — they point `import vllm` at the image's CUDA-matched build:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  ```bash
142
- hf jobs uv run --flavor l4x1 --secrets HF_TOKEN \
143
- --image vllm/vllm-openai --python /usr/bin/python3 \
144
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \
145
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/<script>.py \
146
- INPUT OUTPUT --max-samples 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  ```
148
 
149
- This is **required** for a few scripts (e.g. `deepseek-ocr2-vllm.py`, `abot-ocr.py`, `nuextract3.py`) and a safe fallback for any vLLM recipe that crashes at startup. (It's also the more robust way to run any vLLM recipe — full CUDA toolkit, ABI-matched build. It isn't a speed-up: uv still reinstalls the script's deps either way.)
150
 
151
- ## Common Options
152
 
153
- The scripts aim to expose a **consistent interface**: every OCR model script takes `input-dataset output-dataset` as positional arguments, accepts the shared core flags below, and writes a `markdown` column — so switching models is usually just swapping the script URL. Models differ where they need to, though: some add their own flags (task modes, resolution presets, `--think`, vocab sizes), a few need a specific Docker image, and per-model defaults (batch size, context length, temperature) are tuned to each model card. Always check a script's `--help` for its specifics.
 
 
 
 
 
 
154
 
155
- | Option | Description |
156
- |--------|-------------|
157
- | `--image-column` | Column containing images (default: `image`) |
158
- | `--output-column` | Output column name (default: `markdown`) |
159
- | `--split` | Dataset split (default: `train`) |
160
- | `--max-samples` | Limit number of samples (useful for testing) |
161
- | `--private` | Make output dataset private |
162
- | `--shuffle` | Shuffle dataset before processing |
163
- | `--seed` | Random seed for shuffling (default: `42`) |
164
- | `--batch-size` | Images per batch (default varies per model) |
165
- | `--max-model-len` | Max context length (default varies per model) |
166
- | `--max-tokens` | Max output tokens (default varies per model) |
167
- | `--gpu-memory-utilization` | GPU memory fraction (default: `0.8`) |
168
- | `--config` | Config name for Hub push (for benchmarking) |
169
- | `--create-pr` | Push as PR instead of direct commit |
170
- | `--verbose` | Log resolved package versions after run |
171
 
172
- Every script supports `--help` to see all available options:
 
 
 
 
173
 
174
  ```bash
175
- uv run glm-ocr.py --help
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  ```
177
 
178
- ## NuExtract3: markdown OCR + structured extraction
 
 
179
 
180
- [NuExtract3](https://huggingface.co/numind/NuExtract3) (4B, Apache-2.0) is the one script here that does both document-to-markdown OCR *and* schema-guided JSON extraction. Give it a template (or a JSON Schema / Pydantic model) and it returns JSON shaped to match.
 
 
 
 
 
 
 
181
 
182
- > **Run it with the `vllm/vllm-openai` image.** NuExtract3's Qwen3.5 architecture needs the image's prebuilt CUDA kernels — the default uv-script image lacks `nvcc`, so flashinfer's JIT compile fails at engine warmup. Use `--image vllm/vllm-openai:latest --python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages` on `a100-large`.
183
 
184
  ```bash
185
- # Markdown OCR (default mode)
186
  hf jobs uv run --flavor a100-large \
187
- --image vllm/vllm-openai:latest \
188
- --python /usr/bin/python3 \
189
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \
190
  -s HF_TOKEN \
191
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nuextract3.py \
192
- my-documents my-markdown --max-samples 10
 
 
193
 
194
- # Structured extraction with an inline template
195
  hf jobs uv run --flavor a100-large \
196
- --image vllm/vllm-openai:latest \
197
- --python /usr/bin/python3 \
198
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \
199
  -s HF_TOKEN \
200
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nuextract3.py \
201
- receipts extracted \
202
- --template '{"store": "verbatim-string", "date": "date", "total": "number"}'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  ```
204
 
205
- **Templates** (`--template`) and **JSON Schemas** (`--schema`) each accept **inline JSON, a URL, or a file path**, so a schema can be hosted once and reused. Add `--enable-thinking` for harder layouts (slower; reasoning trace stored in a `<output-column>_reasoning` column). Template field names act as the model's extraction instructions, so name them descriptively — overly leading names can prompt over-generation, so verify against a few examples.
206
-
207
- ## Model-specific modes & flags
208
-
209
- Beyond the shared flags, some models add their own. Run `--help` on any script for the full list; the common ones:
210
-
211
- | Script | Extra options |
212
- |--------|---------------|
213
- | [`surya-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/surya-ocr.py) | `--task ocr\|layout\|table`, `--table-mode full\|simple`, `--pdf-column`/`--page-range`, `--blocks-column` |
214
- | [`pp-ocrv6.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/pp-ocrv6.py) | `--model-tier tiny\|small\|medium` (1.5M–34.5M params) |
215
- | [`glm-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/glm-ocr.py) | `--task ocr\|formula\|table` |
216
- | [`ovis-ocr2.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/ovis-ocr2.py) | `--keep-image-tags` (retain visual-region `<img>` bbox tags, filtered by default), `--min-pixels`/`--max-pixels` (processor bounds, card defaults 448²/2880²) |
217
- | [`paddleocr-vl.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/paddleocr-vl.py) | `--task-mode ocr\|table\|formula\|chart` |
218
- | [`paddleocr-vl-1.5.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/paddleocr-vl-1.5.py) | `--task-mode ocr\|table\|formula\|chart\|spotting\|seal` |
219
- | [`paddleocr-vl-1.6.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/paddleocr-vl-1.6.py) | `--task-mode ocr\|table\|formula` |
220
- | [`lighton-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/lighton-ocr.py) | `--vocab-size 151k\|32k\|16k` (smaller = faster on European languages) |
221
- | [`deepseek-ocr-vllm.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/deepseek-ocr-vllm.py) | `--resolution-mode tiny\|small\|base\|large\|gundam`, `--prompt-mode document\|image\|free\|figure\|describe`; pass `-e UV_TORCH_BACKEND=auto` |
222
- | [`dots-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/dots-ocr.py) | `--prompt-mode ocr\|layout-all\|layout-only` |
223
- | [`dots-mocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/dots-mocr.py) | `--prompt-mode` (8: ocr, layout-all, layout-only, web-parsing, scene-spotting, grounding-ocr, svg, general); SVG: `--model rednote-hilab/dots.mocr-svg --prompt-mode svg` |
224
- | [`qianfan-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/qianfan-ocr.py) | `--prompt-mode ocr\|table\|formula\|chart\|scene\|kie`, `--think` (Layout-as-Thought); `kie` needs `--custom-prompt` |
225
- | [`unlimited-ocr-vllm.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/unlimited-ocr-vllm.py) | `--strip-grounding` (drop `<\|det\|>`/`<\|ref\|>` grounding tags); needs the **`vllm/vllm-openai:unlimited-ocr`** image |
226
- | [`numarkdown-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/numarkdown-ocr.py) | `--include-thinking` (store the reasoning trace) |
227
- | [`nuextract3.py`](https://huggingface.co/datasets/uv-scripts/ocr/blob/main/nuextract3.py) | `--template` / `--schema` / `--enable-thinking` — see the NuExtract3 section above |
228
-
229
- **Image-mode models** — `abot-ocr.py` and `nuextract3.py` (Qwen3.5 architecture) need the `vllm/vllm-openai` image because the default uv-script image lacks `nvcc`. Add `--image vllm/vllm-openai:latest --python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages` (see the NuExtract3 example above for the full command). `unlimited-ocr-vllm.py` is a special case — its architecture isn't in any stable vLLM wheel, so it needs Baidu's **dedicated** `vllm/vllm-openai:unlimited-ocr` image (tag `:unlimited-ocr-cu129` on Hopper), e.g. `--image vllm/vllm-openai:unlimited-ocr --python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages` (its docstring has the full command).
230
-
231
- ## Output & features
232
-
233
- - **Markdown column** — each run adds an `--output-column` (default `markdown`) with the OCR result.
234
- - **Multi-model comparison** — every script records `inference_info`, so you can run several models into the *same* dataset and compare. Point a second model at the same output repo:
235
- ```bash
236
- uv run rolm-ocr.py my-dataset my-dataset --max-samples 100
237
- uv run nanonets-ocr.py my-dataset my-dataset --max-samples 100 # appends
238
- ```
239
- - **Reproducible sampling** — `--shuffle` (with `--seed`, default 42) draws a representative sample instead of the first N rows.
240
- - **Automatic dataset cards** — every run writes a card with the model config, processing stats, column descriptions, and a reproduction command.
241
-
242
- ## More examples
243
 
244
  ```bash
245
- # DeepSeek-OCR on historical scans, large resolution mode
246
- hf jobs uv run --flavor a100-large -s HF_TOKEN -e UV_TORCH_BACKEND=auto \
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  https://huggingface.co/datasets/uv-scripts/ocr/raw/main/deepseek-ocr-vllm.py \
248
- NationalLibraryOfScotland/Britain-and-UK-Handbooks-Dataset out \
249
- --max-samples 100 --shuffle --resolution-mode large
 
 
 
250
 
251
- # dots.mocr SVG generation from charts/figures
252
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \
253
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-mocr.py \
254
- your-charts svg-output --prompt-mode svg --model rednote-hilab/dots.mocr-svg
 
 
 
 
255
 
256
- # Qianfan — key-information extraction
257
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \
258
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/qianfan-ocr.py \
259
- invoices extracted-fields \
260
- --prompt-mode kie --custom-prompt "Extract: name, date, total. Output as JSON."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  ```
262
 
263
- **Python API:**
264
 
265
  ```python
266
  from huggingface_hub import run_uv_job
@@ -268,17 +612,36 @@ from huggingface_hub import run_uv_job
268
  job = run_uv_job(
269
  "https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py",
270
  args=["input-dataset", "output-dataset", "--batch-size", "16"],
271
- flavor="l4x1",
272
  )
273
  ```
274
 
275
- **Run locally** (needs your own GPU) — same scripts, run directly from the URL:
276
 
277
  ```bash
278
- uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \
 
 
 
 
 
 
279
  input-dataset output-dataset
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  ```
281
 
282
- ---
283
 
284
- Works with any Hugging Face dataset containing images — documents, forms, receipts, books, handwriting.
 
1
  ---
2
  viewer: false
3
+ tags: [uv-script, ocr, vision-language-model, document-processing, hf-jobs]
4
  ---
5
 
6
  # OCR UV Scripts
7
 
8
+ > Part of [uv-scripts](https://huggingface.co/uv-scripts) - ready-to-run ML tools powered by UV and HuggingFace Jobs.
9
 
10
+ 14 OCR models from 0.9B to 8B parameters. Pick a model, point at your dataset, get markdown — no setup required.
11
 
12
+ ## 🚀 Quick Start
 
 
13
 
14
  Run OCR on any dataset without needing your own GPU:
15
 
 
22
  --max-samples 10
23
  ```
24
 
25
+ That's it! The script will:
26
 
27
+ - Process first 10 images from your dataset
28
  - Add OCR results as a new `markdown` column
29
  - Push the results to a new dataset
30
  - View results at: `https://huggingface.co/datasets/[your-output-dataset]`
31
 
32
+ <details><summary>All scripts at a glance (sorted by model size)</summary>
33
 
34
+ | Script | Model | Size | Backend | Notes |
35
+ |--------|-------|------|---------|-------|
36
+ | `smoldocling-ocr.py` | [SmolDocling](https://huggingface.co/ds4sd/SmolDocling-256M-preview) | 256M | Transformers | DocTags structured output |
37
+ | `glm-ocr.py` | [GLM-OCR](https://huggingface.co/zai-org/GLM-OCR) | 0.9B | vLLM | 94.62% OmniDocBench V1.5 |
38
+ | `paddleocr-vl.py` | [PaddleOCR-VL](https://huggingface.co/PaddlePaddle/PaddleOCR-VL) | 0.9B | Transformers | 4 task modes (ocr/table/formula/chart) |
39
+ | `paddleocr-vl-1.5.py` | [PaddleOCR-VL-1.5](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5) | 0.9B | Transformers | 94.5% OmniDocBench, 6 task modes |
40
+ | `lighton-ocr.py` | [LightOnOCR-1B](https://huggingface.co/lightonai/LightOnOCR-1B-1025) | 1B | vLLM | Fast, 3 vocab sizes |
41
+ | `lighton-ocr2.py` | [LightOnOCR-2-1B](https://huggingface.co/lightonai/LightOnOCR-2-1B) | 1B | vLLM | 7× faster than v1, RLVR trained |
42
+ | `hunyuan-ocr.py` | [HunyuanOCR](https://huggingface.co/tencent/HunyuanOCR) | 1B | vLLM | Lightweight VLM |
43
+ | `dots-ocr.py` | [DoTS.ocr](https://huggingface.co/Tencent/DoTS.ocr) | 1.7B | vLLM | 100+ languages |
44
+ | `firered-ocr.py` | [FireRed-OCR](https://huggingface.co/FireRedTeam/FireRed-OCR) | 2.1B | vLLM | Qwen3-VL fine-tune, Apache 2.0 |
45
+ | `nanonets-ocr.py` | [Nanonets-OCR-s](https://huggingface.co/nanonets/Nanonets-OCR-s) | 2B | vLLM | LaTeX, tables, forms |
46
+ | `dots-ocr-1.5.py` | [DoTS.ocr-1.5](https://huggingface.co/Tencent/DoTS.ocr-1.5) | 3B | vLLM | Updated multilingual model |
47
+ | `nanonets-ocr2.py` | [Nanonets-OCR2-3B](https://huggingface.co/nanonets/Nanonets-OCR2-s) | 3B | vLLM | Next-gen, Qwen2.5-VL base |
48
+ | `deepseek-ocr-vllm.py` | [DeepSeek-OCR](https://huggingface.co/deepseek-ai/DeepSeek-OCR) | 4B | vLLM | 5 resolution + 5 prompt modes |
49
+ | `deepseek-ocr.py` | [DeepSeek-OCR](https://huggingface.co/deepseek-ai/DeepSeek-OCR) | 4B | Transformers | Same model, Transformers backend |
50
+ | `deepseek-ocr2-vllm.py` | [DeepSeek-OCR-2](https://huggingface.co/deepseek-ai/DeepSeek-OCR-2) | 3B | vLLM | Newer, requires nightly vLLM |
51
+ | `olmocr2-vllm.py` | [olmOCR-2-7B](https://huggingface.co/allenai/olmOCR-2-7B-1025-FP8) | 7B | vLLM | 82.4% olmOCR-Bench |
52
+ | `rolm-ocr.py` | [RolmOCR](https://huggingface.co/reducto/RolmOCR) | 7B | vLLM | Qwen2.5-VL based, general-purpose |
53
+ | `numarkdown-ocr.py` | [NuMarkdown-8B](https://huggingface.co/numind/NuMarkdown-8B-Thinking) | 8B | vLLM | Reasoning-based OCR |
54
+
55
+ </details>
56
 
57
+ ## Common Options
58
 
59
+ All scripts accept the same core flags. Model-specific defaults (batch size, context length, temperature) are tuned per model based on model card recommendations and can be overridden.
60
+
61
+ | Option | Description |
62
+ |--------|-------------|
63
+ | `--image-column` | Column containing images (default: `image`) |
64
+ | `--output-column` | Output column name (default: `markdown`) |
65
+ | `--split` | Dataset split (default: `train`) |
66
+ | `--max-samples` | Limit number of samples (useful for testing) |
67
+ | `--private` | Make output dataset private |
68
+ | `--shuffle` | Shuffle dataset before processing |
69
+ | `--seed` | Random seed for shuffling (default: `42`) |
70
+ | `--batch-size` | Images per batch (default varies per model) |
71
+ | `--max-model-len` | Max context length (default varies per model) |
72
+ | `--max-tokens` | Max output tokens (default varies per model) |
73
+ | `--gpu-memory-utilization` | GPU memory fraction (default: `0.8`) |
74
+ | `--config` | Config name for Hub push (for benchmarking) |
75
+ | `--create-pr` | Push as PR instead of direct commit |
76
+ | `--verbose` | Log resolved package versions after run |
77
+
78
+ Every script supports `--help` to see all available options:
79
 
80
  ```bash
81
+ uv run glm-ocr.py --help
82
  ```
83
 
84
+ ## Example: GLM-OCR
85
 
86
+ [GLM-OCR](https://huggingface.co/zai-org/GLM-OCR) (0.9B) scores 94.62% on OmniDocBench V1.5 and supports OCR, formula, and table extraction:
87
 
88
+ ```bash
89
+ # Basic OCR
90
+ hf jobs uv run --flavor l4x1 -s HF_TOKEN \
91
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \
92
+ my-documents my-ocr-output
93
 
94
+ # Table extraction
95
+ hf jobs uv run --flavor l4x1 -s HF_TOKEN \
96
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \
97
+ my-documents my-tables --task table
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
+ # Test on 10 samples first
100
+ hf jobs uv run --flavor l4x1 -s HF_TOKEN \
101
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \
102
+ my-documents my-test --max-samples 10
 
 
 
103
  ```
104
 
105
+ <details><summary>Detailed per-model documentation</summary>
106
 
107
+ ### PaddleOCR-VL-1.5 (`paddleocr-vl-1.5.py`) — 6 task modes
108
 
109
+ OCR using [PaddlePaddle/PaddleOCR-VL-1.5](https://huggingface.co/PaddlePaddle/PaddleOCR-VL-1.5) with 94.5% accuracy:
110
+
111
+ - **94.5% on OmniDocBench v1.5** (0.9B parameters)
112
+ - 🧩 **Ultra-compact** - Only 0.9B parameters
113
+ - 📝 **OCR mode** - General text extraction to markdown
114
+ - 📊 **Table mode** - HTML table recognition
115
+ - 📐 **Formula mode** - LaTeX mathematical notation
116
+ - 📈 **Chart mode** - Chart and diagram analysis
117
+ - 🔍 **Spotting mode** - Text spotting with localization (higher resolution)
118
+ - 🔖 **Seal mode** - Seal and stamp recognition
119
+ - 🌍 **Multilingual** - Support for multiple languages
120
+
121
+ **Task Modes:**
122
+
123
+ - `ocr`: General text extraction (default)
124
+ - `table`: Table extraction to HTML
125
+ - `formula`: Mathematical formula to LaTeX
126
+ - `chart`: Chart and diagram analysis
127
+ - `spotting`: Text spotting with localization
128
+ - `seal`: Seal and stamp recognition
129
+
130
+ **Quick start:**
131
 
132
  ```bash
133
+ # Basic OCR mode
134
+ hf jobs uv run --flavor l4x1 \
135
+ -s HF_TOKEN \
136
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.5.py \
137
+ your-input-dataset your-output-dataset \
138
+ --max-samples 100
139
+
140
+ # Table extraction
141
+ hf jobs uv run --flavor l4x1 \
142
+ -s HF_TOKEN \
143
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.5.py \
144
+ documents tables-extracted \
145
+ --task-mode table
146
+
147
+ # Seal recognition
148
+ hf jobs uv run --flavor l4x1 \
149
+ -s HF_TOKEN \
150
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.5.py \
151
+ documents seals-extracted \
152
+ --task-mode seal
153
  ```
154
 
155
+ ### PaddleOCR-VL (`paddleocr-vl.py`) 🎯 Smallest model with task-specific modes!
156
 
157
+ Ultra-compact OCR using [PaddlePaddle/PaddleOCR-VL](https://huggingface.co/PaddlePaddle/PaddleOCR-VL) with only 0.9B parameters:
158
 
159
+ - 🎯 **Smallest model** - Only 0.9B parameters (even smaller than LightOnOCR!)
160
+ - 📝 **OCR mode** - General text extraction to markdown
161
+ - 📊 **Table mode** - HTML table recognition and extraction
162
+ - 📐 **Formula mode** - LaTeX mathematical notation
163
+ - 📈 **Chart mode** - Structured chart and diagram analysis
164
+ - 🌍 **Multilingual** - Support for multiple languages
165
+ - ⚡ **Fast initialization** - Tiny model size for quick startup
166
+ - 🔧 **ERNIE-4.5 based** - Different architecture from Qwen models
167
 
168
+ **Task Modes:**
169
+
170
+ - `ocr`: General text extraction (default)
171
+ - `table`: Table extraction to HTML
172
+ - `formula`: Mathematical formula to LaTeX
173
+ - `chart`: Chart and diagram analysis
174
+
175
+ **Quick start:**
176
+
177
+ ```bash
178
+ # Basic OCR mode
179
+ hf jobs uv run --flavor l4x1 \
180
+ -s HF_TOKEN \
181
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl.py \
182
+ your-input-dataset your-output-dataset \
183
+ --max-samples 100
184
+
185
+ # Table extraction
186
+ hf jobs uv run --flavor l4x1 \
187
+ -s HF_TOKEN \
188
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl.py \
189
+ documents tables-extracted \
190
+ --task-mode table \
191
+ --batch-size 32
192
  ```
 
 
 
193
 
194
+ ### GLM-OCR (`glm-ocr.py`) 🏆 SOTA on OmniDocBench V1.5!
195
+
196
+ Compact high-performance OCR using [zai-org/GLM-OCR](https://huggingface.co/zai-org/GLM-OCR) with 0.9B parameters:
197
+
198
+ - 🏆 **94.62% on OmniDocBench V1.5** - #1 overall ranking
199
+ - 🧠 **Multi-Token Prediction** - MTP loss + stable full-task RL for quality
200
+ - 📝 **Text recognition** - Clean markdown output
201
+ - 📐 **Formula recognition** - LaTeX mathematical notation
202
+ - 📊 **Table recognition** - Structured table extraction
203
+ - 🌍 **Multilingual** - zh, en, fr, es, ru, de, ja, ko
204
+ - ⚡ **Compact** - Only 0.9B parameters, MIT licensed
205
+ - 🔧 **CogViT + GLM** - Visual encoder with efficient token downsampling
206
+
207
+ **Task Modes:**
208
+
209
+ - `ocr`: Text recognition (default)
210
+ - `formula`: LaTeX formula recognition
211
+ - `table`: Table extraction
212
+
213
+ **Quick start:**
214
 
215
  ```bash
216
+ # Basic OCR
217
+ hf jobs uv run --flavor l4x1 \
218
+ -s HF_TOKEN \
219
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \
220
+ your-input-dataset your-output-dataset \
221
+ --max-samples 100
222
+
223
+ # Formula recognition
224
+ hf jobs uv run --flavor l4x1 \
225
+ -s HF_TOKEN \
226
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \
227
+ scientific-papers formulas-extracted \
228
+ --task formula
229
+
230
+ # Table extraction
231
+ hf jobs uv run --flavor l4x1 \
232
+ -s HF_TOKEN \
233
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \
234
+ documents tables-extracted \
235
+ --task table
236
  ```
237
 
238
+ ### LightOnOCR (`lighton-ocr.py`) Good one to test first since it's small and fast!
239
 
240
+ Fast and compact OCR using [lightonai/LightOnOCR-1B-1025](https://huggingface.co/lightonai/LightOnOCR-1B-1025):
241
 
242
+ - **Fastest**: 5.71 pages/sec on H100, ~6.25 images/sec on A100 with batch_size=4096
243
+ - 🎯 **Compact**: Only 1B parameters - quick to download and initialize
244
+ - 🌍 **Multilingual**: 3 vocabulary sizes for different use cases
245
+ - 📐 **LaTeX formulas**: Mathematical notation in LaTeX format
246
+ - 📊 **Table extraction**: Markdown table format
247
+ - 📝 **Document structure**: Preserves hierarchy and layout
248
+ - 🚀 **Production-ready**: 76.1% benchmark score, used in production
249
 
250
+ **Vocabulary sizes:**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
+ - `151k`: Full vocabulary, all languages (default)
253
+ - `32k`: European languages, ~12% faster decoding
254
+ - `16k`: European languages, ~12% faster decoding
255
+
256
+ **Quick start:**
257
 
258
  ```bash
259
+ # Test on 100 samples with English text (32k vocab is fastest for European languages)
260
+ hf jobs uv run --flavor l4x1 \
261
+ -s HF_TOKEN \
262
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr.py \
263
+ your-input-dataset your-output-dataset \
264
+ --vocab-size 32k \
265
+ --batch-size 32 \
266
+ --max-samples 100
267
+
268
+ # Full production run on A100 (can handle huge batches!)
269
+ hf jobs uv run --flavor a100-large \
270
+ -s HF_TOKEN \
271
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr.py \
272
+ your-input-dataset your-output-dataset \
273
+ --vocab-size 32k \
274
+ --batch-size 4096 \
275
+ --temperature 0.0
276
  ```
277
 
278
+ ### LightOnOCR-2 (`lighton-ocr2.py`) Fastest OCR model!
279
+
280
+ Next-generation fast OCR using [lightonai/LightOnOCR-2-1B](https://huggingface.co/lightonai/LightOnOCR-2-1B) with RLVR training:
281
 
282
+ - **7× faster than v1**: 42.8 pages/sec on H100 (vs 5.71 for v1)
283
+ - 🎯 **Higher accuracy**: 83.2% on OlmOCR-Bench (+7.1% vs v1)
284
+ - 🧠 **RLVR trained**: Eliminates repetition loops and formatting errors
285
+ - 📚 **Better dataset**: 2.5× larger training data with cleaner annotations
286
+ - 🌍 **Multilingual**: Optimized for European languages
287
+ - 📐 **LaTeX formulas**: Mathematical notation support
288
+ - 📊 **Table extraction**: Markdown table format
289
+ - 💪 **Production-ready**: Outperforms models 9× larger
290
 
291
+ **Quick start:**
292
 
293
  ```bash
294
+ # Test on 100 samples
295
  hf jobs uv run --flavor a100-large \
 
 
 
296
  -s HF_TOKEN \
297
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr2.py \
298
+ your-input-dataset your-output-dataset \
299
+ --batch-size 32 \
300
+ --max-samples 100
301
 
302
+ # Full production run
303
  hf jobs uv run --flavor a100-large \
 
 
 
304
  -s HF_TOKEN \
305
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr2.py \
306
+ your-input-dataset your-output-dataset \
307
+ --batch-size 32
308
+ ```
309
+
310
+ ### DeepSeek-OCR (`deepseek-ocr-vllm.py`)
311
+
312
+ Advanced document OCR using [deepseek-ai/DeepSeek-OCR](https://huggingface.co/deepseek-ai/DeepSeek-OCR) with visual-text compression:
313
+
314
+ - 📐 **LaTeX equations** - Mathematical formulas in LaTeX format
315
+ - 📊 **Tables** - Extracted as HTML/markdown
316
+ - 📝 **Document structure** - Headers, lists, formatting preserved
317
+ - 🖼️ **Image grounding** - Spatial layout with bounding boxes
318
+ - 🔍 **Complex layouts** - Multi-column and hierarchical structures
319
+ - 🌍 **Multilingual** - Multiple language support
320
+ - 🎚️ **Resolution modes** - 5 presets for speed/quality trade-offs
321
+ - 💬 **Prompt modes** - 5 presets for different OCR tasks
322
+ - ⚡ **Fast batch processing** - vLLM acceleration
323
+
324
+ **Resolution Modes:**
325
+
326
+ - `tiny` (512×512): Fast, 64 vision tokens
327
+ - `small` (640×640): Balanced, 100 vision tokens
328
+ - `base` (1024×1024): High quality, 256 vision tokens
329
+ - `large` (1280×1280): Maximum quality, 400 vision tokens
330
+ - `gundam` (dynamic): Adaptive multi-tile (default)
331
+
332
+ **Prompt Modes:**
333
+
334
+ - `document`: Convert to markdown with grounding (default)
335
+ - `image`: OCR any image with grounding
336
+ - `free`: Fast OCR without layout
337
+ - `figure`: Parse figures from documents
338
+ - `describe`: Detailed image descriptions
339
+
340
+ ### RolmOCR (`rolm-ocr.py`)
341
+
342
+ Fast general-purpose OCR using [reducto/RolmOCR](https://huggingface.co/reducto/RolmOCR) based on Qwen2.5-VL-7B:
343
+
344
+ - 🚀 **Fast extraction** - Optimized for speed and efficiency
345
+ - 📄 **Plain text output** - Clean, natural text representation
346
+ - 💪 **General-purpose** - Works well on various document types
347
+ - 🔥 **Large context** - Handles up to 16K tokens
348
+ - ⚡ **Batch optimized** - Efficient processing with vLLM
349
+
350
+ ### Nanonets OCR (`nanonets-ocr.py`)
351
+
352
+ State-of-the-art document OCR using [nanonets/Nanonets-OCR-s](https://huggingface.co/nanonets/Nanonets-OCR-s) that handles:
353
+
354
+ - 📐 **LaTeX equations** - Mathematical formulas preserved
355
+ - 📊 **Tables** - Extracted as HTML format
356
+ - 📝 **Document structure** - Headers, lists, formatting maintained
357
+ - 🖼️ **Images** - Captions and descriptions included
358
+ - ☑️ **Forms** - Checkboxes rendered as ☐/☑
359
+
360
+ ### Nanonets OCR2 (`nanonets-ocr2.py`)
361
+
362
+ Next-generation Nanonets OCR using [nanonets/Nanonets-OCR2-3B](https://huggingface.co/nanonets/Nanonets-OCR2-3B) with improved accuracy:
363
+
364
+ - 🎯 **Enhanced quality** - 3.75B parameters for superior OCR accuracy
365
+ - 📐 **LaTeX equations** - Mathematical formulas preserved in LaTeX format
366
+ - 📊 **Advanced tables** - Improved HTML table extraction
367
+ - 📝 **Document structure** - Headers, lists, formatting maintained
368
+ - 🖼️ **Smart image captions** - Intelligent descriptions and captions
369
+ - ☑️ **Forms** - Checkboxes rendered as ☐/☑
370
+ - 🌍 **Multilingual** - Enhanced language support
371
+ - 🔧 **Based on Qwen2.5-VL** - Built on state-of-the-art vision-language model
372
+
373
+ ### SmolDocling (`smoldocling-ocr.py`)
374
+
375
+ Ultra-compact document understanding using [ds4sd/SmolDocling-256M-preview](https://huggingface.co/ds4sd/SmolDocling-256M-preview) with only 256M parameters:
376
+
377
+ - 🏷️ **DocTags format** - Efficient XML-like representation
378
+ - 💻 **Code blocks** - Preserves indentation and syntax
379
+ - 🔢 **Formulas** - Mathematical expressions with layout
380
+ - 📊 **Tables & charts** - Structured data extraction
381
+ - 📐 **Layout preservation** - Bounding boxes and spatial info
382
+ - ⚡ **Ultra-fast** - Tiny model size for quick inference
383
+
384
+ ### NuMarkdown (`numarkdown-ocr.py`)
385
+
386
+ Advanced reasoning-based OCR using [numind/NuMarkdown-8B-Thinking](https://huggingface.co/numind/NuMarkdown-8B-Thinking) that analyzes documents before converting to markdown:
387
+
388
+ - 🧠 **Reasoning Process** - Thinks through document layout before generation
389
+ - 📊 **Complex Tables** - Superior table extraction and formatting
390
+ - 📐 **Mathematical Formulas** - Accurate LaTeX/math notation preservation
391
+ - 🔍 **Multi-column Layouts** - Handles complex document structures
392
+ - ✨ **Thinking Traces** - Optional inclusion of reasoning process with `--include-thinking`
393
+
394
+ ### DoTS.ocr (`dots-ocr.py`)
395
+
396
+ Compact multilingual OCR using [rednote-hilab/dots.ocr](https://huggingface.co/rednote-hilab/dots.ocr) with only 1.7B parameters:
397
+
398
+ - 🌍 **100+ Languages** - Extensive multilingual support
399
+ - 📝 **Simple OCR** - Clean text extraction (default mode)
400
+ - 📊 **Layout Analysis** - Optional structured output with bboxes and categories
401
+ - 📐 **Formula recognition** - LaTeX format support
402
+ - 🎯 **Compact** - Only 1.7B parameters, efficient on smaller GPUs
403
+ - 🔀 **Flexible prompts** - Switch between OCR, layout-all, and layout-only modes
404
+
405
+ ### FireRed-OCR (`firered-ocr.py`)
406
+
407
+ Document OCR using [FireRedTeam/FireRed-OCR](https://huggingface.co/FireRedTeam/FireRed-OCR), a 2.1B model fine-tuned from Qwen3-VL-2B-Instruct:
408
+
409
+ - 📝 **Structured Markdown** - Preserves headings, paragraphs, lists
410
+ - 📐 **LaTeX formulas** - Inline and block math support
411
+ - 📊 **HTML tables** - Table extraction with `<table>` tags
412
+ - 🪶 **Lightweight** - 2.1B parameters, runs on L4 GPU
413
+ - 📜 **Apache 2.0** - Permissive license
414
+
415
+ **Quick start:**
416
+
417
+ ```bash
418
+ hf jobs uv run --flavor l4x1 \
419
+ -s HF_TOKEN \
420
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/firered-ocr.py \
421
+ your-input-dataset your-output-dataset \
422
+ --max-samples 100
423
+ ```
424
+
425
+ ### olmOCR2 (`olmocr2-vllm.py`)
426
+
427
+ High-quality document OCR using [allenai/olmOCR-2-7B-1025-FP8](https://huggingface.co/allenai/olmOCR-2-7B-1025-FP8) optimized with GRPO reinforcement learning:
428
+
429
+ - 🎯 **High accuracy** - 82.4 ± 1.1 on olmOCR-Bench (84.9% on math)
430
+ - 📐 **LaTeX equations** - Mathematical formulas in LaTeX format
431
+ - 📊 **Table extraction** - Structured table recognition
432
+ - 📑 **Multi-column layouts** - Complex document structures
433
+ - 🗜️ **FP8 quantized** - Efficient 8B model for faster inference
434
+ - 📜 **Degraded scans** - Works well on old/historical documents
435
+ - 📝 **Long text extraction** - Headers, footers, and full document content
436
+ - 🧩 **YAML metadata** - Structured front matter (language, rotation, content type)
437
+ - 🚀 **Based on Qwen2.5-VL-7B** - Fine-tuned with reinforcement learning
438
+
439
+ ## 🆕 New Features
440
+
441
+ ### Multi-Model Comparison Support
442
+
443
+ All scripts now include `inference_info` tracking for comparing multiple OCR models:
444
+
445
+ ```bash
446
+ # First model
447
+ uv run rolm-ocr.py my-dataset my-dataset --max-samples 100
448
+
449
+ # Second model (appends to same dataset)
450
+ uv run nanonets-ocr.py my-dataset my-dataset --max-samples 100
451
+
452
+ # View all models used
453
+ python -c "import json; from datasets import load_dataset; ds = load_dataset('my-dataset'); print(json.loads(ds[0]['inference_info']))"
454
+ ```
455
+
456
+ ### Random Sampling
457
+
458
+ Get representative samples with the new `--shuffle` flag:
459
+
460
+ ```bash
461
+ # Random 50 samples instead of first 50
462
+ uv run rolm-ocr.py ordered-dataset output --max-samples 50 --shuffle
463
+
464
+ # Reproducible random sampling
465
+ uv run nanonets-ocr.py dataset output --max-samples 100 --shuffle --seed 42
466
  ```
467
 
468
+ ### Automatic Dataset Cards
469
+
470
+ Every OCR run now generates comprehensive dataset documentation including:
471
+
472
+ - Model configuration and parameters
473
+ - Processing statistics
474
+ - Column descriptions
475
+ - Reproduction instructions
476
+
477
+ ## 💻 Usage Examples
478
+
479
+ ### Run on HuggingFace Jobs (Recommended)
480
+
481
+ No GPU? No problem! Run on HF infrastructure:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
  ```bash
484
+ # PaddleOCR-VL - Smallest model (0.9B) with task modes
485
+ hf jobs uv run --flavor l4x1 \
486
+ --secrets HF_TOKEN \
487
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl.py \
488
+ your-input-dataset your-output-dataset \
489
+ --task-mode ocr \
490
+ --max-samples 100
491
+
492
+ # PaddleOCR-VL - Extract tables from documents
493
+ hf jobs uv run --flavor l4x1 \
494
+ --secrets HF_TOKEN \
495
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl.py \
496
+ documents tables-dataset \
497
+ --task-mode table
498
+
499
+ # PaddleOCR-VL - Formula recognition
500
+ hf jobs uv run --flavor l4x1 \
501
+ --secrets HF_TOKEN \
502
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl.py \
503
+ scientific-papers formulas-extracted \
504
+ --task-mode formula \
505
+ --batch-size 32
506
+
507
+ # GLM-OCR - SOTA 0.9B model (94.62% OmniDocBench)
508
+ hf jobs uv run --flavor l4x1 \
509
+ -s HF_TOKEN \
510
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \
511
+ your-input-dataset your-output-dataset \
512
+ --batch-size 16 \
513
+ --max-samples 100
514
+
515
+ # DeepSeek-OCR - Real-world example (National Library of Scotland handbooks)
516
+ hf jobs uv run --flavor a100-large \
517
+ -s HF_TOKEN \
518
+ -e UV_TORCH_BACKEND=auto \
519
  https://huggingface.co/datasets/uv-scripts/ocr/raw/main/deepseek-ocr-vllm.py \
520
+ NationalLibraryOfScotland/Britain-and-UK-Handbooks-Dataset \
521
+ davanstrien/handbooks-deep-ocr \
522
+ --max-samples 100 \
523
+ --shuffle \
524
+ --resolution-mode large
525
 
526
+ # DeepSeek-OCR - Fast testing with tiny mode
527
+ hf jobs uv run --flavor l4x1 \
528
+ -s HF_TOKEN \
529
+ -e UV_TORCH_BACKEND=auto \
530
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/deepseek-ocr-vllm.py \
531
+ your-input-dataset your-output-dataset \
532
+ --max-samples 10 \
533
+ --resolution-mode tiny
534
 
535
+ # DeepSeek-OCR - Parse figures from scientific papers
536
+ hf jobs uv run --flavor a100-large \
537
+ -s HF_TOKEN \
538
+ -e UV_TORCH_BACKEND=auto \
539
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/deepseek-ocr-vllm.py \
540
+ scientific-papers figures-extracted \
541
+ --prompt-mode figure
542
+
543
+ # Basic OCR job with Nanonets
544
+ hf jobs uv run --flavor l4x1 \
545
+ --secrets HF_TOKEN \
546
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py \
547
+ your-input-dataset your-output-dataset
548
+
549
+ # DoTS.ocr - Multilingual OCR with compact 1.7B model
550
+ hf jobs uv run --flavor a100-large \
551
+ --secrets HF_TOKEN \
552
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-ocr.py \
553
+ davanstrien/ufo-ColPali \
554
+ your-username/ufo-ocr \
555
+ --batch-size 256 \
556
+ --max-samples 1000 \
557
+ --shuffle
558
+
559
+ # Real example with UFO dataset 🛸
560
+ hf jobs uv run \
561
+ --flavor a10g-large \
562
+ --secrets HF_TOKEN \
563
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py \
564
+ davanstrien/ufo-ColPali \
565
+ your-username/ufo-ocr \
566
+ --image-column image \
567
+ --max-model-len 16384 \
568
+ --batch-size 128
569
+
570
+ # Nanonets OCR2 - Next-gen quality with 3B model
571
+ hf jobs uv run \
572
+ --flavor l4x1 \
573
+ --secrets HF_TOKEN \
574
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr2.py \
575
+ your-input-dataset \
576
+ your-output-dataset \
577
+ --batch-size 16
578
+
579
+ # NuMarkdown with reasoning traces for complex documents
580
+ hf jobs uv run \
581
+ --flavor l4x4 \
582
+ --secrets HF_TOKEN \
583
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/numarkdown-ocr.py \
584
+ your-input-dataset your-output-dataset \
585
+ --max-samples 50 \
586
+ --include-thinking \
587
+ --shuffle
588
+
589
+ # olmOCR2 - High-quality OCR with YAML metadata
590
+ hf jobs uv run \
591
+ --flavor a100-large \
592
+ --secrets HF_TOKEN \
593
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/olmocr2-vllm.py \
594
+ your-input-dataset your-output-dataset \
595
+ --batch-size 16 \
596
+ --max-samples 100
597
+
598
+ # Private dataset with custom settings
599
+ hf jobs uv run --flavor l40sx1 \
600
+ --secrets HF_TOKEN \
601
+ https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py \
602
+ private-input private-output \
603
+ --private \
604
+ --batch-size 32
605
  ```
606
 
607
+ ### Python API
608
 
609
  ```python
610
  from huggingface_hub import run_uv_job
 
612
  job = run_uv_job(
613
  "https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py",
614
  args=["input-dataset", "output-dataset", "--batch-size", "16"],
615
+ flavor="l4x1"
616
  )
617
  ```
618
 
619
+ ### Run Locally (Requires GPU)
620
 
621
  ```bash
622
+ # Clone and run
623
+ git clone https://huggingface.co/datasets/uv-scripts/ocr
624
+ cd ocr
625
+ uv run nanonets-ocr.py input-dataset output-dataset
626
+
627
+ # Or run directly from URL
628
+ uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py \
629
  input-dataset output-dataset
630
+
631
+ # PaddleOCR-VL for task-specific OCR (smallest model!)
632
+ uv run paddleocr-vl.py documents extracted --task-mode ocr
633
+ uv run paddleocr-vl.py papers tables --task-mode table # Extract tables
634
+ uv run paddleocr-vl.py textbooks formulas --task-mode formula # LaTeX formulas
635
+
636
+ # RolmOCR for fast text extraction
637
+ uv run rolm-ocr.py documents extracted-text
638
+ uv run rolm-ocr.py images texts --shuffle --max-samples 100 # Random sample
639
+
640
+ # Nanonets OCR2 for highest quality
641
+ uv run nanonets-ocr2.py documents ocr-results
642
+
643
  ```
644
 
645
+ </details>
646
 
647
+ Works with any HuggingFace dataset containing images — documents, forms, receipts, books, handwriting.
SERVING.md DELETED
@@ -1,93 +0,0 @@
1
- # Server-mode OCR: official support per model + the `-server.py` recipes
2
-
3
- The recipes in this folder come in two shapes:
4
-
5
- - **Offline batch** (`<model>.py`): the script owns the vLLM engine (`LLM.generate`)
6
- and processes the dataset in fixed-size batches. One `hf jobs uv run` command.
7
- - **Server + driver** (`<model>-server.py`): one job starts `vllm serve` in the
8
- background, then a lightweight driver (no torch/vllm deps — installs in seconds)
9
- posts images concurrently to the OpenAI-compatible endpoint on localhost.
10
-
11
- Why server mode? Two measured reasons (100-page historical-scan A/B on `l4x1`,
12
- same data, same sampling, driver concurrency 32):
13
-
14
- | Model | Offline (inference-only) | Server | Speedup | Output parity |
15
- |---|---|---|---|---|
16
- | OvisOCR2 0.9B | 0.83 img/s | 1.44 img/s | ~1.7× | 94/100 byte-identical, rest ≥0.978 similar |
17
- | LightOnOCR-2 1B | 0.33 img/s | 0.59 img/s | ~1.8× | 64/100 byte-identical at temp 0.2, rest median 0.998 |
18
- | Nanonets-OCR2 3B | 0.31 img/s (steady-state) | 0.36 img/s | ~1.2× | 52/100 byte-identical, median 0.999; 3 hard plate pages fork under greedy (worst case was the *offline* arm degenerating into a 22k-char repetition loop) |
19
-
20
- 1. **Throughput**: offline `llm.generate` drains at every batch boundary (GPU idles
21
- while the CPU decodes the next batch of images); a concurrent driver keeps vLLM's
22
- continuous batching fed. The speedup is a floor, not a ceiling — at concurrency 32
23
- the server's KV cache was <10% used.
24
- 2. **Failure isolation**: offline, one bad image (e.g. a None cell) fails its whole
25
- batch of 16; server mode fails that one request. On a real dataset where ~half the
26
- image cells were empty, the offline recipe produced 0 usable outputs and the server
27
- recipe produced all of the valid ones.
28
-
29
- The job command stays the standard `hf jobs uv run` shape: the driver spawns
30
- `vllm serve` itself as a subprocess when no server is reachable (flags live in the
31
- script's `SERVE_ARGS`, taken from the model's own card where they exist), so the only
32
- thing to get right is `--image vllm/vllm-openai:<tag>` — which provides the `vllm`
33
- binary. Without it the script fails fast, printing the exact correct command. Pass
34
- `--server URL` to use an already-running or remote endpoint instead (nothing is
35
- spawned when the server is reachable, so the moss-style explicit `bash -c` serve
36
- command keeps working too).
37
-
38
- For **interactive/agent** use (a live endpoint instead of a batch run), see
39
- [serving-unlimited-ocr.md](serving-unlimited-ocr.md) — `hf jobs run --expose` gives an
40
- OpenAI-compatible URL that outlives a single script.
41
-
42
- ## The recurring official serve pattern for OCR
43
-
44
- Three flags recur across independent vendors' official serve commands (DeepSeek's vLLM
45
- recipe, LightOn's card, Paddle's vLLM recipe, Unlimited-OCR's recipe):
46
-
47
- ```
48
- --no-enable-prefix-caching --mm-processor-cache-gb 0 --limit-mm-per-prompt '{"image": 1}'
49
- ```
50
-
51
- OCR workloads never reuse images, so prefix/multimodal caches only cost memory.
52
-
53
- ## Version pins carry over — via the image tag
54
-
55
- Where an offline recipe pins an engine version, the server variant needs the same pin
56
- as a `vllm/vllm-openai:<tag>` image (e.g. Nanonets-OCR2-3B uses `v0.10.2`, matching its
57
- offline recipe). [`models.json`](models.json) records the required image per script.
58
- When trying a new model/image combination, sanity-check the first few outputs before
59
- scaling: a mismatched combination can produce degenerate output while looking like
60
- normal load from the outside (full GPU utilisation, no errors).
61
-
62
- ## Which models document server mode themselves? (surveyed 2026-07-16)
63
-
64
- Server mode is the officially documented path for most models in this collection —
65
- for several of them it's the *only* documented vLLM path. Summary of each model's own
66
- card/docs (verbatim commands live in the linked sources):
67
-
68
- | Model | Official server example | Notes |
69
- |---|---|---|
70
- | lightonai/LightOnOCR-1B / 2-1B | ✅ vLLM | Card ships the serve command + client; ≥0.11.1 for v1; images longest-dim 1540px |
71
- | nanonets/OCR-s / OCR2-3B | ✅ vLLM | Bare `vllm serve` + client in card; **pin v0.10.2 for OCR2** (see above) |
72
- | rednote-hilab/dots.mocr | ✅ vLLM ≥0.11 | Direct from Hub id on `vllm/vllm-openai:v0.11.0`+ |
73
- | rednote-hilab/dots.ocr | ✅ (GitHub) | HF card shows a legacy pre-0.11 hack; GitHub README: integrated upstream since 0.11 |
74
- | tencent/HunyuanOCR | ✅ vLLM 0.18.1 | serve.sh in repo; nightly adds DFlash speculative decoding |
75
- | zai-org/GLM-OCR | ✅ vLLM + SGLang + Ollama | Only card here with an SGLang serve example; needs vLLM nightly |
76
- | numind/NuExtract3 | ✅ vLLM | Production-grade recipe: MTP speculative decoding, per-request `chat_template_kwargs` |
77
- | numind/NuMarkdown-8B-Thinking | ✅ vLLM | Thinking always on — parse `<think>`/`<answer>` |
78
- | baidu/Unlimited-OCR | ✅ vLLM + SGLang | Custom image / dev wheel; see [serving-unlimited-ocr.md](serving-unlimited-ocr.md) |
79
- | baidu/Qianfan-OCR | ✅ vLLM (minimal) | Needs `--hf-overrides '{"architectures": ["InternVLChatModel"]}'` |
80
- | PaddlePaddle/PaddleOCR-VL 1.x | ✅ paddle `genai_server` + vLLM recipe | Serves the 0.9B VLM only; raw serve skips the layout stage (official quality warning) |
81
- | deepseek-ai/DeepSeek-OCR / -2 | ⚠️ vLLM recipe pages only | docs.vllm.ai recipes; needs the DeepSeek n-gram logits processor flags |
82
- | allenai/olmOCR-2-7B-FP8 | ✅ (GitHub) | olmocr toolkit spawns `vllm serve` itself; YAML-front-matter prompt required |
83
- | reducto/RolmOCR | ✅ vLLM | Card serve + client (client's model string has a typo — pass `--served-model-name`) |
84
- | tiiuae/Falcon-OCR | ✅ vLLM in official Docker | `ghcr.io/tiiuae/falcon-ocr`; task-token prompts |
85
- | datalab-to/surya-ocr-2, lift | ⚠️ wrapper-mediated | Server-native but driven via their own managers (`SURYA_INFERENCE_URL`, `lift_vllm`) |
86
- | LiquidAI LFM2.5-VL-Extract | ⚠️ family docs | vLLM ≥0.23 + SGLang cookbooks, not Extract-specific |
87
- | ATH-MaaS/OvisOCR2 | ❌ offline vLLM only | `ovis-ocr2-server.py` is our translation of the card's offline args (parity-validated, table above) |
88
- | FireRedTeam/FireRed-OCR | ❌ transformers only | |
89
- | acvlab/ABot-OCR | ❌ offline script only | pinned vllm 0.18.0 |
90
-
91
- SGLang reality check: only GLM-OCR and Unlimited-OCR ship SGLang serve paths (olmOCR
92
- dropped SGLang for vLLM in v0.1.75). For this collection, "server mode" effectively
93
- means a vLLM OpenAI-compatible endpoint.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
abot-ocr.py DELETED
@@ -1,600 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "vllm>=0.15.1",
8
- # "tqdm",
9
- # "toolz",
10
- # "torch",
11
- # ]
12
- #
13
- # ///
14
-
15
- """
16
- Convert document images to Markdown using ABot-OCR with vLLM.
17
-
18
- ABot-OCR (acvlab/ABot-OCR) is a compact Qwen3-VL-based document OCR model that
19
- converts PDF/document page images into structured Markdown, including:
20
- - Text with original document structure (headings, paragraphs, lists)
21
- - Mathematical formulas in LaTeX (inline \\( \\) and block \\[ \\])
22
- - Tables in HTML (<table>…</table>)
23
-
24
- Model: https://huggingface.co/acvlab/ABot-OCR
25
- Paper: https://arxiv.org/abs/2605.27978
26
- Code: https://github.com/amap-cvlab/ABot-OCR
27
-
28
- HF Jobs note: ABot-OCR (Qwen3-VL) uses vLLM's flashinfer sampler, which needs CUDA
29
- kernels the default uv-script image can't build ("Could not find nvcc"). Run on Jobs
30
- with the pre-built vLLM image so the kernels are reused (same image-mode pattern as
31
- nuextract3.py / paddleocr-vl-1.6.py):
32
- --image vllm/vllm-openai:latest
33
- --python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages
34
- """
35
-
36
- import argparse
37
- import base64
38
- import io
39
- import json
40
- import logging
41
- import os
42
- import sys
43
- from typing import Any, Dict, List, Union
44
- from datetime import datetime
45
-
46
- import torch
47
- from datasets import load_dataset
48
- from huggingface_hub import DatasetCard, login
49
- from PIL import Image
50
- from toolz import partition_all
51
- from tqdm.auto import tqdm
52
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
53
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
54
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
55
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
56
- from vllm import LLM, SamplingParams
57
-
58
- logging.basicConfig(level=logging.INFO)
59
- logger = logging.getLogger(__name__)
60
-
61
- # ABot-OCR's recommended document→Markdown prompt (from the model's reference
62
- # inference script; LaTeX delimiters de-mangled to proper \( \) and \[ \]).
63
- DEFAULT_PROMPT = r"""You are an AI assistant specialized in converting PDF images to Markdown format. Please follow these instructions for the conversion:
64
-
65
- 1. Text Processing:
66
- - Accurately recognize all text content in the PDF image without guessing or inferring.
67
- - Convert the recognized text into Markdown format.
68
- - Maintain the original document structure, including headings, paragraphs, lists, etc.
69
-
70
- 2. Mathematical Formula Processing:
71
- - Convert all mathematical formulas to LaTeX format.
72
- - Enclose inline formulas with \( \). For example: This is an inline formula \( E = mc^2 \)
73
- - Enclose block formulas with \[ \]. For example: \[ \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} \]
74
-
75
- 3. Table Processing:
76
- - Convert tables to HTML format.
77
- - Wrap the entire table with <table> and </table>.
78
-
79
- 4. Figure Handling:
80
- - Ignore figures content in the PDF image. Do not attempt to describe or convert images.
81
-
82
- 5. Output Format:
83
- - Ensure the output Markdown document has a clear structure with appropriate line breaks between elements.
84
- - For complex layouts, try to maintain the original document's structure and format as closely as possible.
85
-
86
- Please strictly follow these guidelines to ensure accuracy and consistency in the conversion. Your task is to accurately convert the content of the PDF image into Markdown format without adding any extra explanations or comments."""
87
-
88
-
89
- def check_cuda_availability():
90
- """Check if CUDA is available and exit if not."""
91
- if not torch.cuda.is_available():
92
- logger.error("CUDA is not available. This script requires a GPU.")
93
- logger.error("Please run on a machine with a CUDA-capable GPU.")
94
- sys.exit(1)
95
- else:
96
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
97
-
98
-
99
- def ensure_output_columns_free(dataset, columns, overwrite=False):
100
- """Fail fast if an output column would collide with an existing input column.
101
-
102
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
103
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
104
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
105
- column(s) here instead (logged) so the later add_column is clean.
106
- """
107
- clash = [c for c in columns if c in dataset.column_names]
108
- if not clash:
109
- return dataset
110
- if overwrite:
111
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
112
- return dataset.remove_columns(clash)
113
- logger.error(
114
- f"Output column(s) {clash} already exist in the input dataset "
115
- f"(columns: {dataset.column_names})."
116
- )
117
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
118
- sys.exit(1)
119
-
120
-
121
- def post_process_text(text: str, threshold: int = 8000) -> str:
122
- """Trim runaway repetition loops that VLM OCR can fall into on dense pages.
123
-
124
- Mirrors ABot-OCR's reference post-processing: if the tail of a long output
125
- repeats the same short substring many times, collapse it to a single copy.
126
- """
127
- n = len(text)
128
- if n < threshold:
129
- return text
130
- for length in range(2, n // 10 + 1):
131
- candidate = text[-length:]
132
- count = 0
133
- i = n - length
134
- while i >= 0 and text[i:i + length] == candidate:
135
- count += 1
136
- i -= length
137
- if count >= 10:
138
- return text[: n - length * (count - 1)]
139
- return text
140
-
141
-
142
- def make_ocr_message(
143
- image: Union[Image.Image, Dict[str, Any], str],
144
- prompt: str = DEFAULT_PROMPT,
145
- ) -> List[Dict]:
146
- """Create a vLLM chat message for OCR processing."""
147
- # Convert to PIL Image if needed
148
- if isinstance(image, Image.Image):
149
- pil_img = image
150
- elif isinstance(image, dict) and "bytes" in image:
151
- pil_img = Image.open(io.BytesIO(image["bytes"]))
152
- elif isinstance(image, str):
153
- pil_img = Image.open(image)
154
- else:
155
- raise ValueError(f"Unsupported image type: {type(image)}")
156
-
157
- if pil_img.mode != "RGB":
158
- pil_img = pil_img.convert("RGB")
159
-
160
- # Convert to base64 data URI
161
- buf = io.BytesIO()
162
- pil_img.save(buf, format="PNG")
163
- data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
164
-
165
- return [
166
- {
167
- "role": "user",
168
- "content": [
169
- {"type": "image_url", "image_url": {"url": data_uri}},
170
- {"type": "text", "text": prompt},
171
- ],
172
- }
173
- ]
174
-
175
-
176
- def create_dataset_card(
177
- source_dataset: str,
178
- model: str,
179
- num_samples: int,
180
- processing_time: str,
181
- batch_size: int,
182
- max_model_len: int,
183
- max_tokens: int,
184
- gpu_memory_utilization: float,
185
- image_column: str = "image",
186
- split: str = "train",
187
- ) -> str:
188
- """Create a dataset card documenting the OCR process."""
189
- model_name = model.split("/")[-1]
190
-
191
- return f"""---
192
- tags:
193
- - ocr
194
- - document-processing
195
- - abot
196
- - abot-ocr
197
- - markdown
198
- - uv-script
199
- - generated
200
- ---
201
-
202
- # Document OCR using {model_name}
203
-
204
- This dataset contains Markdown-formatted OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using [ABot-OCR](https://huggingface.co/{model}).
205
-
206
- ## Processing Details
207
-
208
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
209
- - **Model**: [{model}](https://huggingface.co/{model})
210
- - **Paper**: [arxiv.org/abs/2605.27978](https://arxiv.org/abs/2605.27978)
211
- - **Number of Samples**: {num_samples:,}
212
- - **Processing Time**: {processing_time}
213
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
214
-
215
- ### Configuration
216
-
217
- - **Image Column**: `{image_column}`
218
- - **Output Column**: `markdown`
219
- - **Dataset Split**: `{split}`
220
- - **Batch Size**: {batch_size}
221
- - **Max Model Length**: {max_model_len:,} tokens
222
- - **Max Output Tokens**: {max_tokens:,}
223
- - **GPU Memory Utilization**: {gpu_memory_utilization:.1%}
224
-
225
- ## Model Information
226
-
227
- ABot-OCR is a compact Qwen3-VL-based document OCR model that converts page images to Markdown:
228
- - 📐 **LaTeX equations** — inline `\\( \\)` and block `\\[ \\]`
229
- - 📊 **Tables** — extracted as HTML (`<table>…</table>`)
230
- - 📝 **Document structure** — headings, paragraphs, and lists preserved
231
-
232
- ## Dataset Structure
233
-
234
- The dataset contains all original columns plus:
235
- - `markdown`: The extracted text in Markdown format with preserved structure
236
- - `inference_info`: JSON list tracking all OCR models applied to this dataset
237
-
238
- ## Usage
239
-
240
- ```python
241
- from datasets import load_dataset
242
-
243
- dataset = load_dataset("{{{{output_dataset_id}}}}", split="{split}")
244
- for example in dataset:
245
- print(example["markdown"])
246
- break
247
- ```
248
-
249
- ## Reproduction
250
-
251
- This dataset was generated using the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) ABot-OCR script:
252
-
253
- ```bash
254
- uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/abot-ocr.py \\
255
- {source_dataset} \\
256
- <output-dataset> \\
257
- --image-column {image_column} \\
258
- --batch-size {batch_size} \\
259
- --max-model-len {max_model_len} \\
260
- --max-tokens {max_tokens} \\
261
- --gpu-memory-utilization {gpu_memory_utilization}
262
- ```
263
-
264
- Generated with 🤖 [UV Scripts](https://huggingface.co/uv-scripts)
265
- """
266
-
267
-
268
- def main(
269
- input_dataset: str,
270
- output_dataset: str,
271
- image_column: str = "image",
272
- batch_size: int = 16,
273
- model: str = "acvlab/ABot-OCR",
274
- max_model_len: int = 16384,
275
- max_tokens: int = 8192,
276
- gpu_memory_utilization: float = 0.8,
277
- hf_token: str = None,
278
- split: str = "train",
279
- max_samples: int = None,
280
- private: bool = False,
281
- shuffle: bool = False,
282
- seed: int = 42,
283
- output_column: str = "markdown",
284
- overwrite: bool = False,
285
- verbose: bool = False,
286
- ):
287
- """Process images from a HF dataset through the ABot-OCR model."""
288
-
289
- # Check CUDA availability first
290
- check_cuda_availability()
291
-
292
- # Track processing start time
293
- start_time = datetime.now()
294
-
295
- # Enable high-performance Xet downloads
296
- os.environ["HF_XET_HIGH_PERFORMANCE"] = "1"
297
-
298
- # Login to HF if token provided
299
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
300
- if HF_TOKEN:
301
- login(token=HF_TOKEN)
302
-
303
- # Load dataset
304
- logger.info(f"Loading dataset: {input_dataset}")
305
- dataset = load_dataset(input_dataset, split=split)
306
-
307
- # Validate image column
308
- if image_column not in dataset.column_names:
309
- raise ValueError(
310
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
311
- )
312
-
313
- # Fail fast if the output column would collide with an existing input column
314
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
315
-
316
- # Shuffle if requested
317
- if shuffle:
318
- logger.info(f"Shuffling dataset with seed {seed}")
319
- dataset = dataset.shuffle(seed=seed)
320
-
321
- # Limit samples if requested
322
- if max_samples:
323
- dataset = dataset.select(range(min(max_samples, len(dataset))))
324
- logger.info(f"Limited to {len(dataset)} samples")
325
-
326
- # Initialize vLLM
327
- logger.info(f"Initializing vLLM with model: {model}")
328
- llm = LLM(
329
- model=model,
330
- trust_remote_code=True,
331
- max_model_len=max_model_len,
332
- gpu_memory_utilization=gpu_memory_utilization,
333
- limit_mm_per_prompt={"image": 1},
334
- enforce_eager=True, # avoid warmup kernel compilation (no nvcc in the image)
335
- )
336
-
337
- sampling_params = SamplingParams(
338
- temperature=0.0, # Deterministic for OCR
339
- max_tokens=max_tokens,
340
- )
341
-
342
- # Process images in batches
343
- all_markdown = []
344
-
345
- logger.info(f"Processing {len(dataset)} images in batches of {batch_size}")
346
-
347
- for batch_indices in tqdm(
348
- partition_all(batch_size, range(len(dataset))),
349
- total=(len(dataset) + batch_size - 1) // batch_size,
350
- desc="OCR processing",
351
- ):
352
- batch_indices = list(batch_indices)
353
- batch_images = [dataset[i][image_column] for i in batch_indices]
354
-
355
- try:
356
- batch_messages = [make_ocr_message(img) for img in batch_images]
357
- outputs = llm.chat(batch_messages, sampling_params)
358
- for output in outputs:
359
- markdown_text = post_process_text(output.outputs[0].text.strip())
360
- all_markdown.append(markdown_text)
361
- except Exception as e:
362
- logger.error(f"Error processing batch: {e}")
363
- all_markdown.extend(["[OCR FAILED]"] * len(batch_images))
364
-
365
- # Add output column to dataset
366
- logger.info(f"Adding '{output_column}' column to dataset")
367
- dataset = dataset.add_column(output_column, all_markdown)
368
-
369
- # Handle inference_info tracking
370
- logger.info("Updating inference_info...")
371
-
372
- inference_entry = {
373
- "model_id": model,
374
- "model_name": "ABot-OCR",
375
- "column_name": output_column,
376
- "timestamp": datetime.now().isoformat(),
377
- "batch_size": batch_size,
378
- "max_tokens": max_tokens,
379
- "gpu_memory_utilization": gpu_memory_utilization,
380
- "max_model_len": max_model_len,
381
- "script": "abot-ocr.py",
382
- "script_url": "https://huggingface.co/datasets/uv-scripts/ocr/raw/main/abot-ocr.py",
383
- }
384
-
385
- if "inference_info" in dataset.column_names:
386
- logger.info("Updating existing inference_info column")
387
-
388
- def update_inference_info(example):
389
- try:
390
- existing_info = (
391
- json.loads(example["inference_info"])
392
- if example["inference_info"]
393
- else []
394
- )
395
- except (json.JSONDecodeError, TypeError):
396
- existing_info = []
397
- existing_info.append(inference_entry)
398
- return {"inference_info": json.dumps(existing_info)}
399
-
400
- dataset = dataset.map(update_inference_info)
401
- else:
402
- logger.info("Creating new inference_info column")
403
- inference_list = [json.dumps([inference_entry])] * len(dataset)
404
- dataset = dataset.add_column("inference_info", inference_list)
405
-
406
- # Push to hub
407
- logger.info(f"Pushing to {output_dataset}")
408
- dataset.push_to_hub(output_dataset, private=private, token=HF_TOKEN)
409
-
410
- # Calculate processing time
411
- end_time = datetime.now()
412
- processing_duration = end_time - start_time
413
- processing_time = f"{processing_duration.total_seconds() / 60:.1f} minutes"
414
-
415
- # Create and push dataset card
416
- logger.info("Creating dataset card...")
417
- card_content = create_dataset_card(
418
- source_dataset=input_dataset,
419
- model=model,
420
- num_samples=len(dataset),
421
- processing_time=processing_time,
422
- batch_size=batch_size,
423
- max_model_len=max_model_len,
424
- max_tokens=max_tokens,
425
- gpu_memory_utilization=gpu_memory_utilization,
426
- image_column=image_column,
427
- split=split,
428
- )
429
-
430
- card = DatasetCard(card_content)
431
- card.push_to_hub(output_dataset, token=HF_TOKEN)
432
- logger.info("✅ Dataset card created and pushed!")
433
-
434
- logger.info("✅ OCR conversion complete!")
435
- logger.info(
436
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
437
- )
438
-
439
- if verbose:
440
- import importlib.metadata
441
-
442
- logger.info("--- Resolved package versions ---")
443
- for pkg in ["vllm", "transformers", "torch", "datasets", "pyarrow", "pillow"]:
444
- try:
445
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
446
- except importlib.metadata.PackageNotFoundError:
447
- logger.info(f" {pkg}: not installed")
448
- logger.info("--- End versions ---")
449
-
450
-
451
- if __name__ == "__main__":
452
- # Show example usage if no arguments
453
- if len(sys.argv) == 1:
454
- print("=" * 80)
455
- print("ABot-OCR to Markdown Converter")
456
- print("=" * 80)
457
- print("\nConverts document images to structured Markdown using the")
458
- print("ABot-OCR model (Qwen3-VL based) with vLLM acceleration.")
459
- print("\nFeatures:")
460
- print("- Document structure preserved (headings, paragraphs, lists)")
461
- print("- LaTeX equation recognition (inline \\( \\) and block \\[ \\])")
462
- print("- Table extraction as HTML")
463
- print("\nExample usage:")
464
- print("\n1. Basic OCR conversion:")
465
- print(" uv run abot-ocr.py document-images markdown-docs")
466
- print("\n2. Process a subset for testing:")
467
- print(" uv run abot-ocr.py large-dataset test-output --max-samples 10")
468
- print("\n3. Running on HF Jobs (use the pre-built vLLM image for flashinfer kernels):")
469
- print(" hf jobs uv run --flavor l4x1 \\")
470
- print(" --image vllm/vllm-openai:latest \\")
471
- print(
472
- " --python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\"
473
- )
474
- print(" -s HF_TOKEN \\")
475
- print(
476
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/abot-ocr.py \\"
477
- )
478
- print(" your-document-dataset \\")
479
- print(" your-markdown-output \\")
480
- print(" --max-samples 10")
481
- print("\n" + "=" * 80)
482
- print("\nFor full help, run: uv run abot-ocr.py --help")
483
- sys.exit(0)
484
-
485
- parser = argparse.ArgumentParser(
486
- description="OCR images to Markdown using ABot-OCR (Qwen3-VL)",
487
- formatter_class=argparse.RawDescriptionHelpFormatter,
488
- epilog="""
489
- Examples:
490
- # Basic usage
491
- uv run abot-ocr.py my-images-dataset ocr-results
492
-
493
- # With specific image column
494
- uv run abot-ocr.py documents extracted-text --image-column scan
495
-
496
- # Process subset for testing
497
- uv run abot-ocr.py large-dataset test-output --max-samples 100
498
-
499
- # Random sample from ordered dataset
500
- uv run abot-ocr.py ordered-dataset random-sample --max-samples 50 --shuffle
501
- """,
502
- )
503
-
504
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
505
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
506
- parser.add_argument(
507
- "--image-column",
508
- default="image",
509
- help="Column containing images (default: image)",
510
- )
511
- parser.add_argument(
512
- "--batch-size",
513
- type=int,
514
- default=16,
515
- help="Batch size for processing (default: 16)",
516
- )
517
- parser.add_argument(
518
- "--model",
519
- default="acvlab/ABot-OCR",
520
- help="Model to use (default: acvlab/ABot-OCR)",
521
- )
522
- parser.add_argument(
523
- "--max-model-len",
524
- type=int,
525
- default=16384,
526
- help="Maximum model context length (default: 16384)",
527
- )
528
- parser.add_argument(
529
- "--max-tokens",
530
- type=int,
531
- default=8192,
532
- help="Maximum tokens to generate (default: 8192)",
533
- )
534
- parser.add_argument(
535
- "--gpu-memory-utilization",
536
- type=float,
537
- default=0.8,
538
- help="GPU memory utilization (default: 0.8)",
539
- )
540
- parser.add_argument("--hf-token", help="Hugging Face API token")
541
- parser.add_argument(
542
- "--split", default="train", help="Dataset split to use (default: train)"
543
- )
544
- parser.add_argument(
545
- "--max-samples",
546
- type=int,
547
- help="Maximum number of samples to process (for testing)",
548
- )
549
- parser.add_argument(
550
- "--private", action="store_true", help="Make output dataset private"
551
- )
552
- parser.add_argument(
553
- "--shuffle",
554
- action="store_true",
555
- help="Shuffle the dataset before processing (useful for random sampling)",
556
- )
557
- parser.add_argument(
558
- "--seed",
559
- type=int,
560
- default=42,
561
- help="Random seed for shuffling (default: 42)",
562
- )
563
- parser.add_argument(
564
- "--output-column",
565
- default="markdown",
566
- help="Column name for the OCR output text (default: markdown)",
567
- )
568
- parser.add_argument(
569
- "--overwrite",
570
- action="store_true",
571
- help="Replace the output column if it already exists in the input dataset "
572
- "(default: error out to avoid clobbering an existing column).",
573
- )
574
- parser.add_argument(
575
- "--verbose",
576
- action="store_true",
577
- help="Log resolved package versions after processing (useful for pinning deps)",
578
- )
579
-
580
- args = parser.parse_args()
581
-
582
- main(
583
- input_dataset=args.input_dataset,
584
- output_dataset=args.output_dataset,
585
- image_column=args.image_column,
586
- batch_size=args.batch_size,
587
- model=args.model,
588
- max_model_len=args.max_model_len,
589
- max_tokens=args.max_tokens,
590
- gpu_memory_utilization=args.gpu_memory_utilization,
591
- hf_token=args.hf_token,
592
- split=args.split,
593
- max_samples=args.max_samples,
594
- private=args.private,
595
- shuffle=args.shuffle,
596
- seed=args.seed,
597
- output_column=args.output_column,
598
- overwrite=args.overwrite,
599
- verbose=args.verbose,
600
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
deepseek-ocr-vllm.py CHANGED
@@ -48,10 +48,6 @@ from huggingface_hub import DatasetCard, login
48
  from PIL import Image
49
  from toolz import partition_all
50
  from tqdm.auto import tqdm
51
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
52
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
53
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
54
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
55
  from vllm import LLM, SamplingParams
56
  from vllm.model_executor.models.deepseek_ocr import NGramPerReqLogitsProcessor
57
 
@@ -78,28 +74,6 @@ def check_cuda_availability():
78
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
79
 
80
 
81
- def ensure_output_columns_free(dataset, columns, overwrite=False):
82
- """Fail fast if an output column would collide with an existing input column.
83
-
84
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
85
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
86
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
87
- column(s) here instead (logged) so the later add_column is clean.
88
- """
89
- clash = [c for c in columns if c in dataset.column_names]
90
- if not clash:
91
- return dataset
92
- if overwrite:
93
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
94
- return dataset.remove_columns(clash)
95
- logger.error(
96
- f"Output column(s) {clash} already exist in the input dataset "
97
- f"(columns: {dataset.column_names})."
98
- )
99
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
100
- sys.exit(1)
101
-
102
-
103
  def to_pil(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
104
  """Convert various image formats to PIL Image."""
105
  if isinstance(image, Image.Image):
@@ -233,8 +207,6 @@ def main(
233
  private: bool = False,
234
  shuffle: bool = False,
235
  seed: int = 42,
236
- output_column: str = "markdown",
237
- overwrite: bool = False,
238
  config: str = None,
239
  create_pr: bool = False,
240
  verbose: bool = False,
@@ -277,9 +249,6 @@ def main(
277
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
278
  )
279
 
280
- # Fail fast if the output column would collide with an existing input column
281
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
282
-
283
  # Shuffle if requested
284
  if shuffle:
285
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -353,9 +322,9 @@ def main(
353
  processing_duration = datetime.now() - start_time
354
  processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
355
 
356
- # Add output column to dataset
357
- logger.info(f"Adding '{output_column}' column to dataset")
358
- dataset = dataset.add_column(output_column, all_markdown)
359
 
360
  # Handle inference_info tracking
361
  logger.info("Updating inference_info...")
@@ -363,7 +332,7 @@ def main(
363
  inference_entry = {
364
  "model_id": model,
365
  "model_name": "DeepSeek-OCR",
366
- "column_name": output_column,
367
  "timestamp": datetime.now().isoformat(),
368
  "prompt_mode": prompt_mode if prompt is None else "custom",
369
  "batch_size": batch_size,
@@ -596,17 +565,6 @@ Examples:
596
  default=42,
597
  help="Random seed for shuffling (default: 42)",
598
  )
599
- parser.add_argument(
600
- "--output-column",
601
- default="markdown",
602
- help="Column name for the OCR output text (default: markdown)",
603
- )
604
- parser.add_argument(
605
- "--overwrite",
606
- action="store_true",
607
- help="Replace the output column if it already exists in the input dataset "
608
- "(default: error out to avoid clobbering an existing column).",
609
- )
610
  parser.add_argument(
611
  "--verbose",
612
  action="store_true",
@@ -632,8 +590,6 @@ Examples:
632
  private=args.private,
633
  shuffle=args.shuffle,
634
  seed=args.seed,
635
- output_column=args.output_column,
636
- overwrite=args.overwrite,
637
  config=args.config,
638
  create_pr=args.create_pr,
639
  verbose=args.verbose,
 
48
  from PIL import Image
49
  from toolz import partition_all
50
  from tqdm.auto import tqdm
 
 
 
 
51
  from vllm import LLM, SamplingParams
52
  from vllm.model_executor.models.deepseek_ocr import NGramPerReqLogitsProcessor
53
 
 
74
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  def to_pil(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
78
  """Convert various image formats to PIL Image."""
79
  if isinstance(image, Image.Image):
 
207
  private: bool = False,
208
  shuffle: bool = False,
209
  seed: int = 42,
 
 
210
  config: str = None,
211
  create_pr: bool = False,
212
  verbose: bool = False,
 
249
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
250
  )
251
 
 
 
 
252
  # Shuffle if requested
253
  if shuffle:
254
  logger.info(f"Shuffling dataset with seed {seed}")
 
322
  processing_duration = datetime.now() - start_time
323
  processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
324
 
325
+ # Add markdown column to dataset
326
+ logger.info("Adding markdown column to dataset")
327
+ dataset = dataset.add_column("markdown", all_markdown)
328
 
329
  # Handle inference_info tracking
330
  logger.info("Updating inference_info...")
 
332
  inference_entry = {
333
  "model_id": model,
334
  "model_name": "DeepSeek-OCR",
335
+ "column_name": "markdown",
336
  "timestamp": datetime.now().isoformat(),
337
  "prompt_mode": prompt_mode if prompt is None else "custom",
338
  "batch_size": batch_size,
 
565
  default=42,
566
  help="Random seed for shuffling (default: 42)",
567
  )
 
 
 
 
 
 
 
 
 
 
 
568
  parser.add_argument(
569
  "--verbose",
570
  action="store_true",
 
590
  private=args.private,
591
  shuffle=args.shuffle,
592
  seed=args.seed,
 
 
593
  config=args.config,
594
  create_pr=args.create_pr,
595
  verbose=args.verbose,
deepseek-ocr.py CHANGED
@@ -1,8 +1,8 @@
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
  # "pillow",
7
  # "torch",
8
  # "torchvision",
@@ -76,28 +76,6 @@ def check_cuda_availability():
76
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
77
 
78
 
79
- def ensure_output_columns_free(dataset, columns, overwrite=False):
80
- """Fail fast if an output column would collide with an existing input column.
81
-
82
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
83
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
84
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
85
- column(s) here instead (logged) so the later add_column is clean.
86
- """
87
- clash = [c for c in columns if c in dataset.column_names]
88
- if not clash:
89
- return dataset
90
- if overwrite:
91
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
92
- return dataset.remove_columns(clash)
93
- logger.error(
94
- f"Output column(s) {clash} already exist in the input dataset "
95
- f"(columns: {dataset.column_names})."
96
- )
97
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
98
- sys.exit(1)
99
-
100
-
101
  def create_dataset_card(
102
  source_dataset: str,
103
  model: str,
@@ -265,8 +243,6 @@ def main(
265
  private: bool = False,
266
  shuffle: bool = False,
267
  seed: int = 42,
268
- output_column: str = "markdown",
269
- overwrite: bool = False,
270
  ):
271
  """Process images from HF dataset through DeepSeek-OCR model."""
272
 
@@ -276,7 +252,8 @@ def main(
276
  # Track processing start time
277
  start_time = datetime.now()
278
 
279
-
 
280
 
281
  # Login to HF if token provided
282
  HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
@@ -318,9 +295,6 @@ def main(
318
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
319
  )
320
 
321
- # Fail fast if the output column would collide with an existing input column
322
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
323
-
324
  # Shuffle if requested
325
  if shuffle:
326
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -406,12 +380,12 @@ def main(
406
  try:
407
  shutil.rmtree(temp_dir)
408
  shutil.rmtree(temp_output_dir)
409
- except Exception:
410
  pass
411
 
412
- # Add output column to dataset
413
- logger.info(f"Adding '{output_column}' column to dataset")
414
- dataset = dataset.add_column(output_column, all_markdown)
415
 
416
  # Handle inference_info tracking
417
  logger.info("Updating inference_info...")
@@ -430,7 +404,7 @@ def main(
430
 
431
  # Add new inference info
432
  new_info = {
433
- "column_name": output_column,
434
  "model_id": model,
435
  "processing_date": datetime.now().isoformat(),
436
  "resolution_mode": resolution_mode,
@@ -608,17 +582,6 @@ Examples:
608
  default=42,
609
  help="Random seed for shuffling (default: 42)",
610
  )
611
- parser.add_argument(
612
- "--output-column",
613
- default="markdown",
614
- help="Column name for the OCR output text (default: markdown)",
615
- )
616
- parser.add_argument(
617
- "--overwrite",
618
- action="store_true",
619
- help="Replace the output column if it already exists in the input dataset "
620
- "(default: error out to avoid clobbering an existing column).",
621
- )
622
 
623
  args = parser.parse_args()
624
 
@@ -638,6 +601,4 @@ Examples:
638
  private=args.private,
639
  shuffle=args.shuffle,
640
  seed=args.seed,
641
- output_column=args.output_column,
642
- overwrite=args.overwrite,
643
  )
 
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
+ # "datasets",
5
+ # "huggingface-hub[hf_transfer]",
6
  # "pillow",
7
  # "torch",
8
  # "torchvision",
 
76
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  def create_dataset_card(
80
  source_dataset: str,
81
  model: str,
 
243
  private: bool = False,
244
  shuffle: bool = False,
245
  seed: int = 42,
 
 
246
  ):
247
  """Process images from HF dataset through DeepSeek-OCR model."""
248
 
 
252
  # Track processing start time
253
  start_time = datetime.now()
254
 
255
+ # Enable HF_TRANSFER for faster downloads
256
+ os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1"
257
 
258
  # Login to HF if token provided
259
  HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
 
295
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
296
  )
297
 
 
 
 
298
  # Shuffle if requested
299
  if shuffle:
300
  logger.info(f"Shuffling dataset with seed {seed}")
 
380
  try:
381
  shutil.rmtree(temp_dir)
382
  shutil.rmtree(temp_output_dir)
383
+ except:
384
  pass
385
 
386
+ # Add markdown column to dataset
387
+ logger.info("Adding markdown column to dataset")
388
+ dataset = dataset.add_column("markdown", all_markdown)
389
 
390
  # Handle inference_info tracking
391
  logger.info("Updating inference_info...")
 
404
 
405
  # Add new inference info
406
  new_info = {
407
+ "column_name": "markdown",
408
  "model_id": model,
409
  "processing_date": datetime.now().isoformat(),
410
  "resolution_mode": resolution_mode,
 
582
  default=42,
583
  help="Random seed for shuffling (default: 42)",
584
  )
 
 
 
 
 
 
 
 
 
 
 
585
 
586
  args = parser.parse_args()
587
 
 
601
  private=args.private,
602
  shuffle=args.shuffle,
603
  seed=args.seed,
 
 
604
  )
deepseek-ocr2-vllm.py CHANGED
@@ -59,10 +59,6 @@ from huggingface_hub import DatasetCard, login
59
  from PIL import Image
60
  from toolz import partition_all
61
  from tqdm.auto import tqdm
62
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
63
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
64
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
65
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
66
  from vllm import LLM, SamplingParams
67
  from vllm.model_executor.models.deepseek_ocr import NGramPerReqLogitsProcessor
68
 
@@ -86,28 +82,6 @@ def check_cuda_availability():
86
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
87
 
88
 
89
- def ensure_output_columns_free(dataset, columns, overwrite=False):
90
- """Fail fast if an output column would collide with an existing input column.
91
-
92
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
93
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
94
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
95
- column(s) here instead (logged) so the later add_column is clean.
96
- """
97
- clash = [c for c in columns if c in dataset.column_names]
98
- if not clash:
99
- return dataset
100
- if overwrite:
101
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
102
- return dataset.remove_columns(clash)
103
- logger.error(
104
- f"Output column(s) {clash} already exist in the input dataset "
105
- f"(columns: {dataset.column_names})."
106
- )
107
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
108
- sys.exit(1)
109
-
110
-
111
  def to_pil(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
112
  """Convert various image formats to PIL Image."""
113
  if isinstance(image, Image.Image):
@@ -247,7 +221,6 @@ def main(
247
  shuffle: bool = False,
248
  seed: int = 42,
249
  output_column: str = "markdown",
250
- overwrite: bool = False,
251
  config: str = None,
252
  create_pr: bool = False,
253
  verbose: bool = False,
@@ -290,9 +263,6 @@ def main(
290
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
291
  )
292
 
293
- # Fail fast if the output column would collide with an existing input column
294
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
295
-
296
  # Shuffle if requested
297
  if shuffle:
298
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -600,12 +570,6 @@ Examples:
600
  default="markdown",
601
  help="Column name for OCR output (default: markdown). Use a different name to add alongside existing OCR.",
602
  )
603
- parser.add_argument(
604
- "--overwrite",
605
- action="store_true",
606
- help="Replace the output column if it already exists in the input dataset "
607
- "(default: error out to avoid clobbering an existing column).",
608
- )
609
  parser.add_argument(
610
  "--shuffle",
611
  action="store_true",
@@ -652,7 +616,6 @@ Examples:
652
  shuffle=args.shuffle,
653
  seed=args.seed,
654
  output_column=args.output_column,
655
- overwrite=args.overwrite,
656
  config=args.config,
657
  create_pr=args.create_pr,
658
  verbose=args.verbose,
 
59
  from PIL import Image
60
  from toolz import partition_all
61
  from tqdm.auto import tqdm
 
 
 
 
62
  from vllm import LLM, SamplingParams
63
  from vllm.model_executor.models.deepseek_ocr import NGramPerReqLogitsProcessor
64
 
 
82
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def to_pil(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
86
  """Convert various image formats to PIL Image."""
87
  if isinstance(image, Image.Image):
 
221
  shuffle: bool = False,
222
  seed: int = 42,
223
  output_column: str = "markdown",
 
224
  config: str = None,
225
  create_pr: bool = False,
226
  verbose: bool = False,
 
263
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
264
  )
265
 
 
 
 
266
  # Shuffle if requested
267
  if shuffle:
268
  logger.info(f"Shuffling dataset with seed {seed}")
 
570
  default="markdown",
571
  help="Column name for OCR output (default: markdown). Use a different name to add alongside existing OCR.",
572
  )
 
 
 
 
 
 
573
  parser.add_argument(
574
  "--shuffle",
575
  action="store_true",
 
616
  shuffle=args.shuffle,
617
  seed=args.seed,
618
  output_column=args.output_column,
 
619
  config=args.config,
620
  create_pr=args.create_pr,
621
  verbose=args.verbose,
demo.gif DELETED

Git LFS Details

  • SHA256: aaa256a45c4d81d5fa2784666c45c5e1276faae923aca26c93c85cbfd920774a
  • Pointer size: 132 Bytes
  • Size of remote file: 4.92 MB
dots-mocr.py → dots-ocr-1.5.py RENAMED
@@ -1,42 +1,35 @@
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
- # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm>=0.15.1",
8
  # "tqdm",
9
  # "toolz",
10
  # "torch",
11
- # # dots.mocr's remote code (AutoProcessor.register with a string key)
12
- # # breaks on transformers v5; vllm pulls transformers unpinned.
13
- # "transformers<5",
14
  # ]
15
  #
16
  # ///
17
 
18
  """
19
- Convert document images to markdown using dots.mocr with vLLM.
20
 
21
- dots.mocr is a 3B multilingual document parsing model with SOTA performance
22
- on 100+ languages. It excels at converting structured graphics (charts, UI
23
- layouts, scientific figures) directly into SVG code. Core capabilities include
24
- grounding, recognition, semantic understanding, and interactive dialogue.
25
 
26
  Features:
27
  - Multilingual support (100+ languages)
28
  - Table extraction and formatting
29
  - Formula recognition
30
  - Layout-aware text extraction
31
- - Web screen parsing
32
- - Scene text spotting
33
- - SVG code generation (use --prompt-mode svg, or --model rednote-hilab/dots.mocr-svg for best results)
34
-
35
- Model: rednote-hilab/dots.mocr
36
- SVG variant: rednote-hilab/dots.mocr-svg
37
- vLLM: Officially integrated since v0.11.0
38
- GitHub: https://github.com/rednote-hilab/dots.mocr
39
- Paper: https://arxiv.org/abs/2603.13032
40
  """
41
 
42
  import argparse
@@ -53,13 +46,9 @@ from typing import Any, Dict, List, Union
53
  import torch
54
  from datasets import load_dataset
55
  from huggingface_hub import DatasetCard, login
56
- from PIL import Image, UnidentifiedImageError
57
  from toolz import partition_all
58
  from tqdm.auto import tqdm
59
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
60
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
61
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
62
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
63
  from vllm import LLM, SamplingParams
64
 
65
  logging.basicConfig(level=logging.INFO)
@@ -67,8 +56,8 @@ logger = logging.getLogger(__name__)
67
 
68
 
69
  # ────────────────────────────────────────────────────────────────
70
- # dots.mocr Prompt Templates
71
- # Source: https://github.com/rednote-hilab/dots.mocr/blob/master/dots_mocr/utils/prompts.py
72
  # ────────────────────────────────────────────────────────────────
73
 
74
  PROMPT_TEMPLATES = {
@@ -91,19 +80,11 @@ PROMPT_TEMPLATES = {
91
 
92
  5. Final Output: The entire output must be a single JSON object.
93
  """,
94
- # NOTE: Bboxes from layout-all/layout-only are in the resized image coordinate
95
- # space (Qwen2VLImageProcessor smart_resize: max_pixels=11289600, factor=28),
96
- # NOT original image coordinates. To map back, compute:
97
- # resized_h, resized_w = smart_resize(orig_h, orig_w)
98
- # scale_x, scale_y = orig_w / resized_w, orig_h / resized_h
99
  "layout-only": """Please output the layout information from this PDF image, including each layout's bbox and its category. The bbox should be in the format [x1, y1, x2, y2]. The layout categories for the PDF document include ['Caption', 'Footnote', 'Formula', 'List-item', 'Page-footer', 'Page-header', 'Picture', 'Section-header', 'Table', 'Text', 'Title']. Do not output the corresponding text. The layout result should be in JSON format.""",
 
100
  "web-parsing": """Parsing the layout info of this webpage image with format json:\n""",
101
  "scene-spotting": """Detect and recognize the text in the image.""",
102
  "grounding-ocr": """Extract text from the given bounding box on the image (format: [x1, y1, x2, y2]).\nBounding Box:\n""",
103
- # SVG code generation — {width} and {height} are replaced with actual image dimensions.
104
- # For best results, use --model rednote-hilab/dots.mocr-svg
105
- # Uses higher temperature (0.9) and top_p (1.0) per official recommendation.
106
- "svg": """Please generate the SVG code based on the image. viewBox="0 0 {width} {height}" """,
107
  "general": """ """,
108
  }
109
 
@@ -118,28 +99,6 @@ def check_cuda_availability():
118
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
119
 
120
 
121
- def ensure_output_columns_free(dataset, columns, overwrite=False):
122
- """Fail fast if an output column would collide with an existing input column.
123
-
124
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
125
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
126
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
127
- column(s) here instead (logged) so the later add_column is clean.
128
- """
129
- clash = [c for c in columns if c in dataset.column_names]
130
- if not clash:
131
- return dataset
132
- if overwrite:
133
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
134
- return dataset.remove_columns(clash)
135
- logger.error(
136
- f"Output column(s) {clash} already exist in the input dataset "
137
- f"(columns: {dataset.column_names})."
138
- )
139
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
140
- sys.exit(1)
141
-
142
-
143
  def make_ocr_message(
144
  image: Union[Image.Image, Dict[str, Any], str],
145
  prompt: str = PROMPT_TEMPLATES["ocr"],
@@ -158,12 +117,6 @@ def make_ocr_message(
158
  # Convert to RGB
159
  pil_img = pil_img.convert("RGB")
160
 
161
- # For SVG mode, inject actual image dimensions into the prompt
162
- if "{width}" in prompt and "{height}" in prompt:
163
- prompt = prompt.replace("{width}", str(pil_img.width)).replace(
164
- "{height}", str(pil_img.height)
165
- )
166
-
167
  # Convert to base64 data URI
168
  buf = io.BytesIO()
169
  pil_img.save(buf, format="PNG")
@@ -201,7 +154,7 @@ def create_dataset_card(
201
  tags:
202
  - ocr
203
  - document-processing
204
- - dots-mocr
205
  - multilingual
206
  - markdown
207
  - uv-script
@@ -210,7 +163,7 @@ tags:
210
 
211
  # Document OCR using {model_name}
212
 
213
- This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using dots.mocr, a 3B multilingual model with SOTA document parsing and SVG generation.
214
 
215
  ## Processing Details
216
 
@@ -233,14 +186,13 @@ This dataset contains OCR results from images in [{source_dataset}](https://hugg
233
 
234
  ## Model Information
235
 
236
- dots.mocr is a 3B multilingual document parsing model that excels at:
237
  - 100+ Languages — Multilingual document support
238
  - Table extraction — Structured data recognition
239
  - Formulas — Mathematical notation preservation
240
  - Layout-aware — Reading order and structure preservation
241
  - Web screen parsing — Webpage layout analysis
242
  - Scene text spotting — Text detection in natural scenes
243
- - SVG code generation — Charts, UI layouts, scientific figures to SVG
244
 
245
  ## Dataset Structure
246
 
@@ -270,10 +222,10 @@ for info in inference_info:
270
 
271
  ## Reproduction
272
 
273
- This dataset was generated using the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) dots.mocr script:
274
 
275
  ```bash
276
- uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-mocr.py \\
277
  {source_dataset} \\
278
  <output-dataset> \\
279
  --image-column {image_column} \\
@@ -293,7 +245,7 @@ def main(
293
  output_dataset: str,
294
  image_column: str = "image",
295
  batch_size: int = 16,
296
- model: str = "rednote-hilab/dots.mocr",
297
  max_model_len: int = 24000,
298
  max_tokens: int = 24000,
299
  gpu_memory_utilization: float = 0.9,
@@ -306,14 +258,13 @@ def main(
306
  prompt_mode: str = "ocr",
307
  custom_prompt: str = None,
308
  output_column: str = "markdown",
309
- overwrite: bool = False,
310
  config: str = None,
311
  create_pr: bool = False,
312
  temperature: float = 0.1,
313
  top_p: float = 0.9,
314
  verbose: bool = False,
315
  ):
316
- """Process images from HF dataset through dots.mocr model."""
317
 
318
  # Check CUDA availability first
319
  check_cuda_availability()
@@ -344,9 +295,6 @@ def main(
344
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
345
  )
346
 
347
- # Fail fast if the output column would collide with an existing input column
348
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
349
-
350
  # Shuffle if requested
351
  if shuffle:
352
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -367,12 +315,6 @@ def main(
367
  gpu_memory_utilization=gpu_memory_utilization,
368
  )
369
 
370
- # SVG mode uses higher temperature/top_p per official recommendation
371
- if prompt_mode == "svg" and temperature == 0.1 and top_p == 0.9:
372
- logger.info("SVG mode: using recommended temperature=0.9, top_p=1.0")
373
- temperature = 0.9
374
- top_p = 1.0
375
-
376
  sampling_params = SamplingParams(
377
  temperature=temperature,
378
  top_p=top_p,
@@ -388,35 +330,17 @@ def main(
388
  for batch_indices in tqdm(
389
  partition_all(batch_size, range(len(dataset))),
390
  total=(len(dataset) + batch_size - 1) // batch_size,
391
- desc="dots.mocr processing",
392
  ):
393
  batch_indices = list(batch_indices)
394
-
395
- # Fetch images first, with per-batch fallback for unreadable files.
396
- # One corrupt image used to take down the entire run via the list
397
- # comprehension; now we mark the whole batch as skipped and continue.
398
- try:
399
- batch_images = [dataset[i][image_column] for i in batch_indices]
400
- except (UnidentifiedImageError, OSError) as e:
401
- logger.warning(
402
- f"Skipping batch of {len(batch_indices)} — unreadable image "
403
- f"in batch: {type(e).__name__}: {e}"
404
- )
405
- all_outputs.extend(
406
- ["[OCR SKIPPED — UNREADABLE IMAGE]"] * len(batch_indices)
407
- )
408
- continue
409
 
410
  try:
411
  # Create messages for batch
412
  batch_messages = [make_ocr_message(img, prompt) for img in batch_images]
413
 
414
- # Process with vLLM (dots.mocr needs "string" content format)
415
- outputs = llm.chat(
416
- batch_messages,
417
- sampling_params,
418
- chat_template_content_format="string",
419
- )
420
 
421
  # Extract outputs
422
  for output in outputs:
@@ -439,7 +363,7 @@ def main(
439
  # Handle inference_info tracking (for multi-model comparisons)
440
  inference_entry = {
441
  "model_id": model,
442
- "model_name": "dots.mocr",
443
  "column_name": output_column,
444
  "timestamp": datetime.now().isoformat(),
445
  "prompt_mode": prompt_mode if not custom_prompt else "custom",
@@ -520,7 +444,7 @@ def main(
520
  card = DatasetCard(card_content)
521
  card.push_to_hub(output_dataset, token=HF_TOKEN)
522
 
523
- logger.info("dots.mocr processing complete!")
524
  logger.info(
525
  f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
526
  )
@@ -542,83 +466,77 @@ if __name__ == "__main__":
542
  # Show example usage if no arguments
543
  if len(sys.argv) == 1:
544
  print("=" * 80)
545
- print("dots.mocr Document Processing")
546
  print("=" * 80)
547
- print("\n3B multilingual OCR model with SVG generation")
548
  print("\nFeatures:")
549
  print("- Multilingual support (100+ languages)")
550
  print("- Fast processing with vLLM")
551
  print("- Table extraction and formatting")
552
  print("- Formula recognition")
553
  print("- Layout-aware text extraction")
554
- print("- Web screen parsing")
555
- print("- Scene text spotting")
556
- print("- SVG code generation (charts, UI, figures)")
557
  print("\nPrompt modes:")
558
- print(" ocr - Text extraction (default)")
559
- print(" layout-all - Layout + bboxes + text (JSON)")
560
- print(" layout-only - Layout + bboxes only (JSON)")
561
- print(" web-parsing - Webpage layout analysis (JSON)")
562
  print(" scene-spotting - Scene text detection")
563
- print(" grounding-ocr - Text from bounding box region")
564
- print(" svg - SVG code generation")
565
- print(" general - Free-form (use with --custom-prompt)")
566
  print("\nExample usage:")
567
  print("\n1. Basic OCR:")
568
- print(" uv run dots-mocr.py input-dataset output-dataset")
569
- print("\n2. SVG generation:")
570
- print(
571
- " uv run dots-mocr.py charts svg-output --prompt-mode svg --model rednote-hilab/dots.mocr-svg"
572
- )
573
- print("\n3. Web screen parsing:")
574
- print(" uv run dots-mocr.py screenshots parsed --prompt-mode web-parsing")
575
  print("\n4. Layout analysis with structure:")
576
- print(" uv run dots-mocr.py papers analyzed --prompt-mode layout-all")
577
  print("\n5. Running on HF Jobs:")
578
  print(" hf jobs uv run --flavor l4x1 \\")
579
  print(" -s HF_TOKEN \\")
580
  print(
581
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-mocr.py \\"
582
  )
583
  print(" input-dataset output-dataset")
584
  print("\n" + "=" * 80)
585
- print("\nFor full help, run: uv run dots-mocr.py --help")
586
  sys.exit(0)
587
 
588
  parser = argparse.ArgumentParser(
589
- description="Document OCR using dots.mocr (3B multilingual model with SVG generation)",
590
  formatter_class=argparse.RawDescriptionHelpFormatter,
591
  epilog="""
592
- Prompt Modes (official dots.mocr prompts):
593
  ocr - Simple text extraction (default)
594
  layout-all - Layout analysis with bboxes, categories, and text (JSON output)
595
  layout-only - Layout detection with bboxes and categories only (JSON output)
596
- web-parsing - Webpage layout analysis (JSON output)
597
- scene-spotting - Scene text detection and recognition
598
- grounding-ocr - Extract text from bounding box region
599
- svg - SVG code generation (auto-injects image dimensions into viewBox)
600
- general - Free-form QA (use with --custom-prompt)
601
 
602
  SVG Code Generation:
603
- Use --prompt-mode svg for SVG output. For best results, combine with
604
- --model rednote-hilab/dots.mocr-svg (the SVG-optimized variant).
605
- SVG mode automatically uses temperature=0.9, top_p=1.0 unless overridden.
606
 
607
  Examples:
608
  # Basic text OCR (default)
609
- uv run dots-mocr.py my-docs analyzed-docs
610
-
611
- # SVG generation with optimized variant
612
- uv run dots-mocr.py charts svg-out --prompt-mode svg --model rednote-hilab/dots.mocr-svg
613
 
614
  # Web screen parsing
615
- uv run dots-mocr.py screenshots parsed --prompt-mode web-parsing
 
 
 
616
 
617
  # Full layout analysis with structure
618
- uv run dots-mocr.py papers structured --prompt-mode layout-all
619
 
620
  # Random sampling for testing
621
- uv run dots-mocr.py large-dataset test --max-samples 50 --shuffle
622
  """,
623
  )
624
 
@@ -637,8 +555,8 @@ Examples:
637
  )
638
  parser.add_argument(
639
  "--model",
640
- default="rednote-hilab/dots.mocr",
641
- help="Model to use (default: rednote-hilab/dots.mocr, or rednote-hilab/dots.mocr-svg for SVG)",
642
  )
643
  parser.add_argument(
644
  "--max-model-len",
@@ -694,12 +612,6 @@ Examples:
694
  default="markdown",
695
  help="Column name for output text (default: markdown)",
696
  )
697
- parser.add_argument(
698
- "--overwrite",
699
- action="store_true",
700
- help="Replace the output column if it already exists in the input dataset "
701
- "(default: error out to avoid clobbering an existing column).",
702
- )
703
  parser.add_argument(
704
  "--config",
705
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
@@ -747,7 +659,6 @@ Examples:
747
  prompt_mode=args.prompt_mode,
748
  custom_prompt=args.custom_prompt,
749
  output_column=args.output_column,
750
- overwrite=args.overwrite,
751
  config=args.config,
752
  create_pr=args.create_pr,
753
  temperature=args.temperature,
 
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
+ # "datasets",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm>=0.15.1",
8
  # "tqdm",
9
  # "toolz",
10
  # "torch",
 
 
 
11
  # ]
12
  #
13
  # ///
14
 
15
  """
16
+ Convert document images to markdown using DoTS.ocr-1.5 with vLLM.
17
 
18
+ DoTS.ocr-1.5 is a 3B multilingual document parsing model with SOTA performance
19
+ on 100+ languages. Compared to v1 (1.7B), it adds web screen parsing, scene text
20
+ spotting, SVG code generation, and stronger multilingual document parsing.
 
21
 
22
  Features:
23
  - Multilingual support (100+ languages)
24
  - Table extraction and formatting
25
  - Formula recognition
26
  - Layout-aware text extraction
27
+ - Web screen parsing (NEW in v1.5)
28
+ - Scene text spotting (NEW in v1.5)
29
+ - SVG code generation (requires dots.ocr-1.5-svg variant)
30
+
31
+ Model: rednote-hilab/dots.ocr-1.5
32
+ vLLM: Officially supported (same DotsOCRForCausalLM architecture as v1)
 
 
 
33
  """
34
 
35
  import argparse
 
46
  import torch
47
  from datasets import load_dataset
48
  from huggingface_hub import DatasetCard, login
49
+ from PIL import Image
50
  from toolz import partition_all
51
  from tqdm.auto import tqdm
 
 
 
 
52
  from vllm import LLM, SamplingParams
53
 
54
  logging.basicConfig(level=logging.INFO)
 
56
 
57
 
58
  # ────────────────────────────────────────────────────────────────
59
+ # DoTS OCR 1.5 Prompt Templates (from official dots.ocr repo)
60
+ # Source: https://github.com/rednote-hilab/dots.ocr/blob/master/dots_ocr/utils/prompts.py
61
  # ────────────────────────────────────────────────────────────────
62
 
63
  PROMPT_TEMPLATES = {
 
80
 
81
  5. Final Output: The entire output must be a single JSON object.
82
  """,
 
 
 
 
 
83
  "layout-only": """Please output the layout information from this PDF image, including each layout's bbox and its category. The bbox should be in the format [x1, y1, x2, y2]. The layout categories for the PDF document include ['Caption', 'Footnote', 'Formula', 'List-item', 'Page-footer', 'Page-header', 'Picture', 'Section-header', 'Table', 'Text', 'Title']. Do not output the corresponding text. The layout result should be in JSON format.""",
84
+ # NEW in v1.5:
85
  "web-parsing": """Parsing the layout info of this webpage image with format json:\n""",
86
  "scene-spotting": """Detect and recognize the text in the image.""",
87
  "grounding-ocr": """Extract text from the given bounding box on the image (format: [x1, y1, x2, y2]).\nBounding Box:\n""",
 
 
 
 
88
  "general": """ """,
89
  }
90
 
 
99
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def make_ocr_message(
103
  image: Union[Image.Image, Dict[str, Any], str],
104
  prompt: str = PROMPT_TEMPLATES["ocr"],
 
117
  # Convert to RGB
118
  pil_img = pil_img.convert("RGB")
119
 
 
 
 
 
 
 
120
  # Convert to base64 data URI
121
  buf = io.BytesIO()
122
  pil_img.save(buf, format="PNG")
 
154
  tags:
155
  - ocr
156
  - document-processing
157
+ - dots-ocr-1.5
158
  - multilingual
159
  - markdown
160
  - uv-script
 
163
 
164
  # Document OCR using {model_name}
165
 
166
+ This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using DoTS.ocr-1.5, a 3B multilingual model with SOTA document parsing.
167
 
168
  ## Processing Details
169
 
 
186
 
187
  ## Model Information
188
 
189
+ DoTS.ocr-1.5 is a 3B multilingual document parsing model that excels at:
190
  - 100+ Languages — Multilingual document support
191
  - Table extraction — Structured data recognition
192
  - Formulas — Mathematical notation preservation
193
  - Layout-aware — Reading order and structure preservation
194
  - Web screen parsing — Webpage layout analysis
195
  - Scene text spotting — Text detection in natural scenes
 
196
 
197
  ## Dataset Structure
198
 
 
222
 
223
  ## Reproduction
224
 
225
+ This dataset was generated using the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) DoTS OCR 1.5 script:
226
 
227
  ```bash
228
+ uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-ocr-1.5.py \\
229
  {source_dataset} \\
230
  <output-dataset> \\
231
  --image-column {image_column} \\
 
245
  output_dataset: str,
246
  image_column: str = "image",
247
  batch_size: int = 16,
248
+ model: str = "rednote-hilab/dots.ocr-1.5",
249
  max_model_len: int = 24000,
250
  max_tokens: int = 24000,
251
  gpu_memory_utilization: float = 0.9,
 
258
  prompt_mode: str = "ocr",
259
  custom_prompt: str = None,
260
  output_column: str = "markdown",
 
261
  config: str = None,
262
  create_pr: bool = False,
263
  temperature: float = 0.1,
264
  top_p: float = 0.9,
265
  verbose: bool = False,
266
  ):
267
+ """Process images from HF dataset through DoTS.ocr-1.5 model."""
268
 
269
  # Check CUDA availability first
270
  check_cuda_availability()
 
295
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
296
  )
297
 
 
 
 
298
  # Shuffle if requested
299
  if shuffle:
300
  logger.info(f"Shuffling dataset with seed {seed}")
 
315
  gpu_memory_utilization=gpu_memory_utilization,
316
  )
317
 
 
 
 
 
 
 
318
  sampling_params = SamplingParams(
319
  temperature=temperature,
320
  top_p=top_p,
 
330
  for batch_indices in tqdm(
331
  partition_all(batch_size, range(len(dataset))),
332
  total=(len(dataset) + batch_size - 1) // batch_size,
333
+ desc="DoTS.ocr-1.5 processing",
334
  ):
335
  batch_indices = list(batch_indices)
336
+ batch_images = [dataset[i][image_column] for i in batch_indices]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
  try:
339
  # Create messages for batch
340
  batch_messages = [make_ocr_message(img, prompt) for img in batch_images]
341
 
342
+ # Process with vLLM
343
+ outputs = llm.chat(batch_messages, sampling_params)
 
 
 
 
344
 
345
  # Extract outputs
346
  for output in outputs:
 
363
  # Handle inference_info tracking (for multi-model comparisons)
364
  inference_entry = {
365
  "model_id": model,
366
+ "model_name": "DoTS.ocr-1.5",
367
  "column_name": output_column,
368
  "timestamp": datetime.now().isoformat(),
369
  "prompt_mode": prompt_mode if not custom_prompt else "custom",
 
444
  card = DatasetCard(card_content)
445
  card.push_to_hub(output_dataset, token=HF_TOKEN)
446
 
447
+ logger.info("DoTS.ocr-1.5 processing complete!")
448
  logger.info(
449
  f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
450
  )
 
466
  # Show example usage if no arguments
467
  if len(sys.argv) == 1:
468
  print("=" * 80)
469
+ print("DoTS.ocr-1.5 Document Processing")
470
  print("=" * 80)
471
+ print("\n3B multilingual OCR model supporting 100+ languages")
472
  print("\nFeatures:")
473
  print("- Multilingual support (100+ languages)")
474
  print("- Fast processing with vLLM")
475
  print("- Table extraction and formatting")
476
  print("- Formula recognition")
477
  print("- Layout-aware text extraction")
478
+ print("- Web screen parsing (NEW in v1.5)")
479
+ print("- Scene text spotting (NEW in v1.5)")
 
480
  print("\nPrompt modes:")
481
+ print(" ocr - Text extraction (default)")
482
+ print(" layout-all - Layout + bboxes + text (JSON)")
483
+ print(" layout-only - Layout + bboxes only (JSON)")
484
+ print(" web-parsing - Webpage layout analysis (JSON)")
485
  print(" scene-spotting - Scene text detection")
486
+ print(" grounding-ocr - Text from bounding box region")
487
+ print(" general - Free-form (use with --custom-prompt)")
 
488
  print("\nExample usage:")
489
  print("\n1. Basic OCR:")
490
+ print(" uv run dots-ocr-1.5.py input-dataset output-dataset")
491
+ print("\n2. Web screen parsing:")
492
+ print(" uv run dots-ocr-1.5.py screenshots parsed --prompt-mode web-parsing")
493
+ print("\n3. Scene text spotting:")
494
+ print(" uv run dots-ocr-1.5.py photos detected --prompt-mode scene-spotting")
 
 
495
  print("\n4. Layout analysis with structure:")
496
+ print(" uv run dots-ocr-1.5.py papers analyzed --prompt-mode layout-all")
497
  print("\n5. Running on HF Jobs:")
498
  print(" hf jobs uv run --flavor l4x1 \\")
499
  print(" -s HF_TOKEN \\")
500
  print(
501
+ " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-ocr-1.5.py \\"
502
  )
503
  print(" input-dataset output-dataset")
504
  print("\n" + "=" * 80)
505
+ print("\nFor full help, run: uv run dots-ocr-1.5.py --help")
506
  sys.exit(0)
507
 
508
  parser = argparse.ArgumentParser(
509
+ description="Document OCR using DoTS.ocr-1.5 (3B multilingual model)",
510
  formatter_class=argparse.RawDescriptionHelpFormatter,
511
  epilog="""
512
+ Prompt Modes (official DoTS.ocr-1.5 prompts):
513
  ocr - Simple text extraction (default)
514
  layout-all - Layout analysis with bboxes, categories, and text (JSON output)
515
  layout-only - Layout detection with bboxes and categories only (JSON output)
516
+ web-parsing - Webpage layout analysis (JSON output) [NEW in v1.5]
517
+ scene-spotting - Scene text detection and recognition [NEW in v1.5]
518
+ grounding-ocr - Extract text from bounding box region [NEW in v1.5]
519
+ general - Free-form QA (use with --custom-prompt) [NEW in v1.5]
 
520
 
521
  SVG Code Generation:
522
+ For SVG output, use --model rednote-hilab/dots.ocr-1.5-svg with:
523
+ --custom-prompt 'Please generate the SVG code based on the image.'
 
524
 
525
  Examples:
526
  # Basic text OCR (default)
527
+ uv run dots-ocr-1.5.py my-docs analyzed-docs
 
 
 
528
 
529
  # Web screen parsing
530
+ uv run dots-ocr-1.5.py screenshots parsed --prompt-mode web-parsing
531
+
532
+ # Scene text spotting
533
+ uv run dots-ocr-1.5.py photos spotted --prompt-mode scene-spotting
534
 
535
  # Full layout analysis with structure
536
+ uv run dots-ocr-1.5.py papers structured --prompt-mode layout-all
537
 
538
  # Random sampling for testing
539
+ uv run dots-ocr-1.5.py large-dataset test --max-samples 50 --shuffle
540
  """,
541
  )
542
 
 
555
  )
556
  parser.add_argument(
557
  "--model",
558
+ default="rednote-hilab/dots.ocr-1.5",
559
+ help="Model to use (default: rednote-hilab/dots.ocr-1.5)",
560
  )
561
  parser.add_argument(
562
  "--max-model-len",
 
612
  default="markdown",
613
  help="Column name for output text (default: markdown)",
614
  )
 
 
 
 
 
 
615
  parser.add_argument(
616
  "--config",
617
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
 
659
  prompt_mode=args.prompt_mode,
660
  custom_prompt=args.custom_prompt,
661
  output_column=args.output_column,
 
662
  config=args.config,
663
  create_pr=args.create_pr,
664
  temperature=args.temperature,
dots-ocr.py CHANGED
@@ -1,17 +1,13 @@
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
- # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm>=0.15.1",
8
  # "tqdm",
9
  # "toolz",
10
  # "torch",
11
- # # dots.ocr's remote code (AutoProcessor.register with a string key)
12
- # # breaks on transformers v5; vllm pulls transformers unpinned.
13
- # # Same fix as dots-mocr (#76).
14
- # "transformers<5",
15
  # ]
16
  #
17
  # ///
@@ -49,10 +45,6 @@ from huggingface_hub import DatasetCard, login
49
  from PIL import Image
50
  from toolz import partition_all
51
  from tqdm.auto import tqdm
52
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
53
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
54
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
55
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
56
  from vllm import LLM, SamplingParams
57
 
58
  logging.basicConfig(level=logging.INFO)
@@ -97,28 +89,6 @@ def check_cuda_availability():
97
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
98
 
99
 
100
- def ensure_output_columns_free(dataset, columns, overwrite=False):
101
- """Fail fast if an output column would collide with an existing input column.
102
-
103
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
104
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
105
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
106
- column(s) here instead (logged) so the later add_column is clean.
107
- """
108
- clash = [c for c in columns if c in dataset.column_names]
109
- if not clash:
110
- return dataset
111
- if overwrite:
112
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
113
- return dataset.remove_columns(clash)
114
- logger.error(
115
- f"Output column(s) {clash} already exist in the input dataset "
116
- f"(columns: {dataset.column_names})."
117
- )
118
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
119
- sys.exit(1)
120
-
121
-
122
  def make_ocr_message(
123
  image: Union[Image.Image, Dict[str, Any], str],
124
  prompt: str = PROMPT_TEMPLATES["ocr"],
@@ -265,8 +235,7 @@ def main(
265
  image_column: str = "image",
266
  batch_size: int = 16,
267
  model: str = "rednote-hilab/dots.ocr",
268
- max_model_len: int = 32768,
269
- max_pixels: int = None,
270
  max_tokens: int = 8192,
271
  gpu_memory_utilization: float = 0.8,
272
  hf_token: str = None,
@@ -278,7 +247,6 @@ def main(
278
  prompt_mode: str = "ocr",
279
  custom_prompt: str = None,
280
  output_column: str = "markdown",
281
- overwrite: bool = False,
282
  config: str = None,
283
  create_pr: bool = False,
284
  ):
@@ -313,9 +281,6 @@ def main(
313
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
314
  )
315
 
316
- # Fail fast if the output column would collide with an existing input column
317
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
318
-
319
  # Shuffle if requested
320
  if shuffle:
321
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -329,17 +294,13 @@ def main(
329
  # Initialize vLLM model
330
  logger.info(f"Initializing vLLM with model: {model}")
331
  logger.info("This may take a few minutes on first run...")
332
- llm_kwargs = dict(
333
  model=model,
334
  trust_remote_code=True,
335
  max_model_len=max_model_len,
336
  gpu_memory_utilization=gpu_memory_utilization,
337
  limit_mm_per_prompt={"image": 1},
338
  )
339
- if max_pixels is not None:
340
- logger.info(f"Capping input images to max_pixels={max_pixels}")
341
- llm_kwargs["mm_processor_kwargs"] = {"max_pixels": max_pixels}
342
- llm = LLM(**llm_kwargs)
343
 
344
  sampling_params = SamplingParams(
345
  temperature=0.0, # Deterministic for OCR
@@ -544,23 +505,8 @@ Examples:
544
  parser.add_argument(
545
  "--max-model-len",
546
  type=int,
547
- default=32768,
548
- help=(
549
- "Maximum model context length (default: 32768). dots.ocr does NOT resize "
550
- "input images, so a full page can reach ~14k image tokens (the model's "
551
- "11.29M-px processor cap); the old 8192 default rejected such requests and "
552
- "the row was written as '[OCR ERROR]'. Pair with --max-pixels to cap memory."
553
- ),
554
- )
555
- parser.add_argument(
556
- "--max-pixels",
557
- type=int,
558
- default=None,
559
- help=(
560
- "Optional cap on input image pixels (width*height) passed to vLLM's "
561
- "mm_processor. Lower this (e.g. 4000000) to bound image tokens and GPU "
562
- "memory on very large scans. Default: model's own cap (~11.29M px)."
563
- ),
564
  )
565
  parser.add_argument(
566
  "--max-tokens",
@@ -610,12 +556,6 @@ Examples:
610
  default="markdown",
611
  help="Column name for output text (default: markdown)",
612
  )
613
- parser.add_argument(
614
- "--overwrite",
615
- action="store_true",
616
- help="Replace the output column if it already exists in the input dataset "
617
- "(default: error out to avoid clobbering an existing column).",
618
- )
619
  parser.add_argument(
620
  "--config",
621
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
@@ -635,7 +575,6 @@ Examples:
635
  batch_size=args.batch_size,
636
  model=args.model,
637
  max_model_len=args.max_model_len,
638
- max_pixels=args.max_pixels,
639
  max_tokens=args.max_tokens,
640
  gpu_memory_utilization=args.gpu_memory_utilization,
641
  hf_token=args.hf_token,
@@ -647,7 +586,6 @@ Examples:
647
  prompt_mode=args.prompt_mode,
648
  custom_prompt=args.custom_prompt,
649
  output_column=args.output_column,
650
- overwrite=args.overwrite,
651
  config=args.config,
652
  create_pr=args.create_pr,
653
  )
 
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
+ # "datasets",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm>=0.15.1",
8
  # "tqdm",
9
  # "toolz",
10
  # "torch",
 
 
 
 
11
  # ]
12
  #
13
  # ///
 
45
  from PIL import Image
46
  from toolz import partition_all
47
  from tqdm.auto import tqdm
 
 
 
 
48
  from vllm import LLM, SamplingParams
49
 
50
  logging.basicConfig(level=logging.INFO)
 
89
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def make_ocr_message(
93
  image: Union[Image.Image, Dict[str, Any], str],
94
  prompt: str = PROMPT_TEMPLATES["ocr"],
 
235
  image_column: str = "image",
236
  batch_size: int = 16,
237
  model: str = "rednote-hilab/dots.ocr",
238
+ max_model_len: int = 8192,
 
239
  max_tokens: int = 8192,
240
  gpu_memory_utilization: float = 0.8,
241
  hf_token: str = None,
 
247
  prompt_mode: str = "ocr",
248
  custom_prompt: str = None,
249
  output_column: str = "markdown",
 
250
  config: str = None,
251
  create_pr: bool = False,
252
  ):
 
281
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
282
  )
283
 
 
 
 
284
  # Shuffle if requested
285
  if shuffle:
286
  logger.info(f"Shuffling dataset with seed {seed}")
 
294
  # Initialize vLLM model
295
  logger.info(f"Initializing vLLM with model: {model}")
296
  logger.info("This may take a few minutes on first run...")
297
+ llm = LLM(
298
  model=model,
299
  trust_remote_code=True,
300
  max_model_len=max_model_len,
301
  gpu_memory_utilization=gpu_memory_utilization,
302
  limit_mm_per_prompt={"image": 1},
303
  )
 
 
 
 
304
 
305
  sampling_params = SamplingParams(
306
  temperature=0.0, # Deterministic for OCR
 
505
  parser.add_argument(
506
  "--max-model-len",
507
  type=int,
508
+ default=8192,
509
+ help="Maximum model context length (default: 8192)",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  )
511
  parser.add_argument(
512
  "--max-tokens",
 
556
  default="markdown",
557
  help="Column name for output text (default: markdown)",
558
  )
 
 
 
 
 
 
559
  parser.add_argument(
560
  "--config",
561
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
 
575
  batch_size=args.batch_size,
576
  model=args.model,
577
  max_model_len=args.max_model_len,
 
578
  max_tokens=args.max_tokens,
579
  gpu_memory_utilization=args.gpu_memory_utilization,
580
  hf_token=args.hf_token,
 
586
  prompt_mode=args.prompt_mode,
587
  custom_prompt=args.custom_prompt,
588
  output_column=args.output_column,
 
589
  config=args.config,
590
  create_pr=args.create_pr,
591
  )
examples/nls-index-card-v2.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "image_type": ["index_card", "verso", "cover", "blank", "other"],
3
- "heading": "verbatim-string",
4
- "heading_type": ["person", "family", "corporate", "geographic", "subject"],
5
- "epithet": "string",
6
- "entries": [
7
- {
8
- "ms_no": "verbatim-string",
9
- "folios": ["verbatim-string"],
10
- "description": "string"
11
- }
12
- ]
13
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
examples/nls-index-card-verbose.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "image_type": ["index_card", "verso", "cover", "blank", "other"],
3
- "main_heading_name": "verbatim-string",
4
- "heading_category": ["person", "family", "corporate", "geographic", "subject"],
5
- "epithet_title_or_occupation": "string",
6
- "manuscript_references": [
7
- {
8
- "manuscript_number": "verbatim-string",
9
- "folio_references": ["verbatim-string"],
10
- "entry_description_with_date": "string"
11
- }
12
- ]
13
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
falcon-ocr-bucket.py DELETED
@@ -1,303 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "pillow",
5
- # "pymupdf",
6
- # "torch>=2.5",
7
- # "torchvision",
8
- # "falcon-perception[ocr]",
9
- # ]
10
- # ///
11
-
12
- """
13
- OCR images and PDFs from a directory using Falcon OCR, writing markdown files.
14
-
15
- Designed to work with HF Buckets mounted as volumes via `hf jobs uv run -v ...`.
16
- Reads images/PDFs from INPUT_DIR, runs Falcon OCR via the optimized falcon-perception
17
- engine (CUDA graphs + paged inference), and writes one .md file per image (or per
18
- PDF page) to OUTPUT_DIR, preserving directory structure.
19
-
20
- Input: Output:
21
- /input/page1.png -> /output/page1.md
22
- /input/report.pdf -> /output/report/page_001.md
23
- (3 pages) /output/report/page_002.md
24
- /output/report/page_003.md
25
- /input/sub/photo.jpg -> /output/sub/photo.md
26
-
27
- Examples:
28
-
29
- # Local test
30
- uv run falcon-ocr-bucket.py ./test-images ./test-output
31
-
32
- # HF Jobs with bucket volumes
33
- hf jobs uv run --flavor l4x1 \\
34
- -s HF_TOKEN \\
35
- -v hf://buckets/user/ocr-input:/input:ro \\
36
- -v hf://buckets/user/ocr-output:/output \\
37
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/falcon-ocr-bucket.py \\
38
- /input /output
39
-
40
- Model: tiiuae/Falcon-OCR (0.3B, 80.3% olmOCR, Apache 2.0)
41
- Backend: falcon-perception (OCRInferenceEngine with CUDA graphs)
42
- """
43
-
44
- import argparse
45
- import logging
46
- import sys
47
- import time
48
- from pathlib import Path
49
-
50
- import torch
51
- from PIL import Image
52
-
53
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
54
- logger = logging.getLogger(__name__)
55
-
56
- MODEL_ID = "tiiuae/Falcon-OCR"
57
- IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".webp"}
58
-
59
-
60
- def check_cuda_availability():
61
- if not torch.cuda.is_available():
62
- logger.error("CUDA is not available. This script requires a GPU.")
63
- sys.exit(1)
64
- logger.info(f"CUDA available. GPU: {torch.cuda.get_device_name(0)}")
65
-
66
-
67
- def discover_files(input_dir: Path, limit: int | None = None) -> list[Path]:
68
- """Discover image and PDF files under input_dir.
69
-
70
- Without `limit`, returns the full sorted list (deterministic order).
71
- With `limit`, stops scanning once `limit` matching files are found
72
- and returns them in filesystem order (much faster on huge mounted
73
- buckets, but ordering is not deterministic).
74
- """
75
- files = []
76
- iterator = (
77
- input_dir.rglob("*") if limit is not None else sorted(input_dir.rglob("*"))
78
- )
79
- for path in iterator:
80
- if not path.is_file():
81
- continue
82
- ext = path.suffix.lower()
83
- if ext in IMAGE_EXTENSIONS or ext == ".pdf":
84
- files.append(path)
85
- if limit is not None and len(files) >= limit:
86
- break
87
- return files
88
-
89
-
90
- def prepare_images(
91
- files: list[Path], input_dir: Path, output_dir: Path, pdf_dpi: int
92
- ) -> list[tuple[Image.Image, Path]]:
93
- import fitz # pymupdf
94
-
95
- items: list[tuple[Image.Image, Path]] = []
96
-
97
- for file_path in files:
98
- rel = file_path.relative_to(input_dir)
99
- ext = file_path.suffix.lower()
100
-
101
- if ext == ".pdf":
102
- pdf_output_dir = output_dir / rel.with_suffix("")
103
- try:
104
- doc = fitz.open(file_path)
105
- num_pages = len(doc)
106
- logger.info(f"PDF: {rel} ({num_pages} pages)")
107
- for page_num in range(num_pages):
108
- page = doc[page_num]
109
- zoom = pdf_dpi / 72.0
110
- mat = fitz.Matrix(zoom, zoom)
111
- pix = page.get_pixmap(matrix=mat)
112
- img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
113
- md_path = pdf_output_dir / f"page_{page_num + 1:03d}.md"
114
- items.append((img, md_path))
115
- doc.close()
116
- except Exception as e:
117
- logger.error(f"Failed to open PDF {rel}: {e}")
118
- else:
119
- try:
120
- img = Image.open(file_path).convert("RGB")
121
- md_path = output_dir / rel.with_suffix(".md")
122
- items.append((img, md_path))
123
- except Exception as e:
124
- logger.error(f"Failed to open image {rel}: {e}")
125
-
126
- return items
127
-
128
-
129
- def main():
130
- parser = argparse.ArgumentParser(
131
- description="OCR images/PDFs from a directory using Falcon OCR, output markdown files.",
132
- formatter_class=argparse.RawDescriptionHelpFormatter,
133
- epilog=__doc__,
134
- )
135
- parser.add_argument("input_dir", help="Directory containing images and/or PDFs")
136
- parser.add_argument("output_dir", help="Directory to write markdown output files")
137
- parser.add_argument(
138
- "--batch-size", type=int, default=8, help="Images per batch (default: 8)",
139
- )
140
- parser.add_argument(
141
- "--pdf-dpi", type=int, default=300,
142
- help="DPI for PDF page rendering (default: 300)",
143
- )
144
- parser.add_argument(
145
- "--no-compile", action="store_true", help="Disable torch.compile",
146
- )
147
- parser.add_argument(
148
- "--no-cudagraph", action="store_true", help="Disable CUDA graph capture",
149
- )
150
- parser.add_argument(
151
- "--max-samples", type=int, default=None,
152
- help="Limit number of input files to discover. Stops scanning early "
153
- "once the limit is reached (much faster on large mounted buckets). "
154
- "Applied before PDF page expansion. With --max-samples set, file "
155
- "ordering is filesystem-dependent rather than sorted.",
156
- )
157
- parser.add_argument(
158
- "--verbose", action="store_true", help="Print resolved package versions",
159
- )
160
-
161
- args = parser.parse_args()
162
-
163
- check_cuda_availability()
164
-
165
- input_dir = Path(args.input_dir)
166
- output_dir = Path(args.output_dir)
167
-
168
- if not input_dir.is_dir():
169
- logger.error(f"Input directory does not exist: {input_dir}")
170
- sys.exit(1)
171
-
172
- output_dir.mkdir(parents=True, exist_ok=True)
173
-
174
- start_time = time.time()
175
-
176
- # Discover files
177
- if args.max_samples is not None:
178
- logger.info(
179
- f"Scanning {input_dir} for up to {args.max_samples} images/PDFs "
180
- f"(early termination, --max-samples)..."
181
- )
182
- else:
183
- logger.info(f"Scanning {input_dir} for images and PDFs...")
184
- files = discover_files(input_dir, limit=args.max_samples)
185
- if not files:
186
- logger.error(f"No image or PDF files found in {input_dir}")
187
- sys.exit(1)
188
-
189
- pdf_count = sum(1 for f in files if f.suffix.lower() == ".pdf")
190
- img_count = len(files) - pdf_count
191
- logger.info(f"Found {img_count} image(s) and {pdf_count} PDF(s)")
192
-
193
- # Prepare images
194
- logger.info("Preparing images (rendering PDFs)...")
195
- items = prepare_images(files, input_dir, output_dir, args.pdf_dpi)
196
- if not items:
197
- logger.error("No processable images after preparation")
198
- sys.exit(1)
199
-
200
- logger.info(f"Total images to OCR: {len(items)}")
201
-
202
- # Load model
203
- logger.info(f"Loading {MODEL_ID} via falcon-perception engine...")
204
- from falcon_perception import load_and_prepare_model
205
- from falcon_perception.data import ImageProcessor
206
- from falcon_perception.paged_ocr_inference import OCRInferenceEngine
207
-
208
- do_compile = not args.no_compile
209
- do_cudagraph = not args.no_cudagraph
210
-
211
- model, tokenizer, model_args = load_and_prepare_model(
212
- hf_model_id=MODEL_ID,
213
- device="cuda",
214
- dtype="bfloat16",
215
- compile=do_compile,
216
- )
217
-
218
- image_processor = ImageProcessor(patch_size=16, merge_size=1)
219
- engine = OCRInferenceEngine(
220
- model, tokenizer, image_processor, capture_cudagraph=do_cudagraph
221
- )
222
- logger.info(f"Engine loaded. compile={do_compile}, cudagraph={do_cudagraph}")
223
-
224
- # Process in batches
225
- errors = 0
226
- processed = 0
227
- total = len(items)
228
- batch_size = args.batch_size
229
-
230
- for batch_start in range(0, total, batch_size):
231
- batch_end = min(batch_start + batch_size, total)
232
- batch = items[batch_start:batch_end]
233
- batch_num = batch_start // batch_size + 1
234
- total_batches = (total + batch_size - 1) // batch_size
235
-
236
- logger.info(f"Batch {batch_num}/{total_batches} ({processed}/{total} done)")
237
-
238
- try:
239
- batch_images = [img for img, _ in batch]
240
- texts = engine.generate_plain(images=batch_images, use_tqdm=False)
241
-
242
- for (_, md_path), text in zip(batch, texts):
243
- md_path.parent.mkdir(parents=True, exist_ok=True)
244
- md_path.write_text(text.strip(), encoding="utf-8")
245
- processed += 1
246
-
247
- except Exception as e:
248
- logger.error(f"Batch {batch_num} failed: {e}")
249
- for _, md_path in batch:
250
- md_path.parent.mkdir(parents=True, exist_ok=True)
251
- md_path.write_text(f"[OCR ERROR: {e}]", encoding="utf-8")
252
- errors += len(batch)
253
- processed += len(batch)
254
-
255
- elapsed = time.time() - start_time
256
- elapsed_str = f"{elapsed / 60:.1f} min" if elapsed > 60 else f"{elapsed:.1f}s"
257
-
258
- logger.info("=" * 50)
259
- logger.info(f"Done! Processed {total} images in {elapsed_str}")
260
- logger.info(f" Output: {output_dir}")
261
- logger.info(f" Errors: {errors}")
262
- if total > 0:
263
- logger.info(f" Speed: {total / elapsed:.2f} images/sec")
264
-
265
- if args.verbose:
266
- import importlib.metadata
267
-
268
- logger.info("--- Package versions ---")
269
- for pkg in ["falcon-perception", "torch", "pillow", "pymupdf"]:
270
- try:
271
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
272
- except importlib.metadata.PackageNotFoundError:
273
- logger.info(f" {pkg}: not installed")
274
-
275
-
276
- if __name__ == "__main__":
277
- if len(sys.argv) == 1:
278
- print("=" * 60)
279
- print("Falcon OCR Bucket Script")
280
- print("=" * 60)
281
- print(f"\nModel: {MODEL_ID} (0.3B, Apache 2.0)")
282
- print("OCR images/PDFs from a directory -> markdown files.")
283
- print("Designed for HF Buckets mounted as volumes.")
284
- print()
285
- print("Usage:")
286
- print(" uv run falcon-ocr-bucket.py INPUT_DIR OUTPUT_DIR")
287
- print()
288
- print("Examples:")
289
- print(" uv run falcon-ocr-bucket.py ./images ./output")
290
- print()
291
- print("HF Jobs with bucket volumes:")
292
- print(" hf jobs uv run --flavor l4x1 -s HF_TOKEN \\")
293
- print(" -v hf://buckets/user/ocr-input:/input:ro \\")
294
- print(" -v hf://buckets/user/ocr-output:/output \\")
295
- print(
296
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/falcon-ocr-bucket.py \\"
297
- )
298
- print(" /input /output")
299
- print()
300
- print("For full help: uv run falcon-ocr-bucket.py --help")
301
- sys.exit(0)
302
-
303
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
falcon-ocr.py DELETED
@@ -1,466 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "torch>=2.5",
8
- # "torchvision",
9
- # "falcon-perception",
10
- # ]
11
- # ///
12
-
13
- """
14
- Convert document images to text using Falcon OCR with the falcon-perception engine.
15
-
16
- Uses the optimized OCRInferenceEngine with CUDA graphs and paged inference
17
- for much faster throughput than the raw transformers API.
18
-
19
- Features:
20
- - Compact: Only 0.3B parameters
21
- - Fast: Optimized inference with CUDA graphs
22
- - Multi-format: Plain text, LaTeX formulas, HTML tables
23
- - Layout-aware: Optional 2-stage pipeline (layout detection + per-region OCR)
24
-
25
- Model: tiiuae/Falcon-OCR
26
- Backend: falcon-perception (OCRInferenceEngine)
27
- License: Apache 2.0
28
-
29
- Examples:
30
- # Basic text OCR
31
- uv run falcon-ocr.py input-dataset output-dataset
32
-
33
- # Test with small sample
34
- uv run falcon-ocr.py dataset test --max-samples 5 --shuffle
35
-
36
- # Run on HF Jobs with GPU
37
- hf jobs uv run --flavor l4x1 \\
38
- -s HF_TOKEN \\
39
- falcon-ocr.py \\
40
- input-dataset output-dataset --max-samples 10
41
- """
42
-
43
- import argparse
44
- import io
45
- import json
46
- import logging
47
- import os
48
- import sys
49
- import time
50
- from datetime import datetime
51
- from typing import Any, Dict, Union
52
-
53
- import torch
54
- from datasets import load_dataset
55
- from huggingface_hub import DatasetCard, login
56
- from PIL import Image
57
-
58
- logging.basicConfig(level=logging.INFO)
59
- logger = logging.getLogger(__name__)
60
-
61
- MODEL_ID = "tiiuae/Falcon-OCR"
62
-
63
- TASK_MODES = {
64
- "plain": "Full-page text extraction",
65
- }
66
-
67
-
68
- def check_cuda_availability():
69
- if not torch.cuda.is_available():
70
- logger.error("CUDA is not available. This script requires a GPU.")
71
- logger.error("For cloud execution, use HF Jobs with --flavor l4x1 or similar.")
72
- sys.exit(1)
73
- else:
74
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
75
-
76
-
77
- def ensure_output_columns_free(dataset, columns, overwrite=False):
78
- """Fail fast if an output column would collide with an existing input column.
79
-
80
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
81
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
82
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
83
- column(s) here instead (logged) so the later add_column is clean.
84
- """
85
- clash = [c for c in columns if c in dataset.column_names]
86
- if not clash:
87
- return dataset
88
- if overwrite:
89
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
90
- return dataset.remove_columns(clash)
91
- logger.error(
92
- f"Output column(s) {clash} already exist in the input dataset "
93
- f"(columns: {dataset.column_names})."
94
- )
95
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
96
- sys.exit(1)
97
-
98
-
99
- def prepare_image(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
100
- if isinstance(image, Image.Image):
101
- pil_img = image
102
- elif isinstance(image, dict) and "bytes" in image:
103
- pil_img = Image.open(io.BytesIO(image["bytes"]))
104
- elif isinstance(image, str):
105
- pil_img = Image.open(image)
106
- else:
107
- raise ValueError(f"Unsupported image type: {type(image)}")
108
- return pil_img.convert("RGB")
109
-
110
-
111
- def create_dataset_card(
112
- source_dataset: str,
113
- task_mode: str,
114
- num_samples: int,
115
- processing_time: str,
116
- image_column: str = "image",
117
- split: str = "train",
118
- ) -> str:
119
- task_description = TASK_MODES[task_mode]
120
- return f"""---
121
- tags:
122
- - ocr
123
- - document-processing
124
- - falcon-ocr
125
- - {task_mode}
126
- - uv-script
127
- - generated
128
- ---
129
-
130
- # Document Processing using Falcon OCR ({task_mode} mode)
131
-
132
- This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using [Falcon OCR](https://huggingface.co/tiiuae/Falcon-OCR), a 0.3B early-fusion vision-language model.
133
-
134
- ## Processing Details
135
-
136
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
137
- - **Model**: [{MODEL_ID}](https://huggingface.co/{MODEL_ID})
138
- - **Task Mode**: `{task_mode}` - {task_description}
139
- - **Number of Samples**: {num_samples:,}
140
- - **Processing Time**: {processing_time}
141
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
142
- - **Backend**: falcon-perception (OCRInferenceEngine)
143
-
144
- ## Reproduction
145
-
146
- ```bash
147
- uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/falcon-ocr.py \\
148
- {source_dataset} \\
149
- <output-dataset> \\
150
- --task-mode {task_mode} \\
151
- --image-column {image_column}
152
- ```
153
-
154
- Generated with [UV Scripts](https://huggingface.co/uv-scripts)
155
- """
156
-
157
-
158
- def main(
159
- input_dataset: str,
160
- output_dataset: str,
161
- image_column: str = "image",
162
- task_mode: str = "plain",
163
- hf_token: str = None,
164
- split: str = "train",
165
- max_samples: int = None,
166
- private: bool = False,
167
- shuffle: bool = False,
168
- seed: int = 42,
169
- output_column: str = "markdown",
170
- overwrite: bool = False,
171
- config: str = None,
172
- create_pr: bool = False,
173
- compile: bool = True,
174
- cudagraph: bool = True,
175
- progress: bool = False,
176
- verbose: bool = False,
177
- ):
178
- check_cuda_availability()
179
- start_time = datetime.now()
180
-
181
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
182
- if HF_TOKEN:
183
- login(token=HF_TOKEN)
184
-
185
- if task_mode not in TASK_MODES:
186
- raise ValueError(
187
- f"Invalid task_mode '{task_mode}'. Choose from: {list(TASK_MODES.keys())}"
188
- )
189
-
190
- logger.info(f"Task mode: {task_mode} - {TASK_MODES[task_mode]}")
191
- logger.info(f"Output column: {output_column}")
192
-
193
- # Load dataset
194
- logger.info(f"Loading dataset: {input_dataset}")
195
- dataset = load_dataset(input_dataset, split=split)
196
-
197
- if image_column not in dataset.column_names:
198
- raise ValueError(
199
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
200
- )
201
-
202
- # Fail fast if the output column would collide with an existing input column
203
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
204
-
205
- if shuffle:
206
- logger.info(f"Shuffling dataset with seed {seed}")
207
- dataset = dataset.shuffle(seed=seed)
208
-
209
- if max_samples:
210
- dataset = dataset.select(range(min(max_samples, len(dataset))))
211
- logger.info(f"Limited to {len(dataset)} samples")
212
-
213
- # Load model using falcon-perception
214
- logger.info(f"Loading model: {MODEL_ID} via falcon-perception engine")
215
- from falcon_perception import load_and_prepare_model
216
- from falcon_perception.data import ImageProcessor
217
- from falcon_perception.paged_ocr_inference import OCRInferenceEngine
218
-
219
- model, tokenizer, model_args = load_and_prepare_model(
220
- hf_model_id=MODEL_ID,
221
- device="cuda",
222
- dtype="bfloat16",
223
- compile=compile,
224
- )
225
-
226
- image_processor = ImageProcessor(patch_size=16, merge_size=1)
227
- engine = OCRInferenceEngine(
228
- model, tokenizer, image_processor, capture_cudagraph=cudagraph
229
- )
230
- logger.info(f"Engine loaded. compile={compile}, cudagraph={cudagraph}")
231
-
232
- # Prepare all images
233
- logger.info(f"Processing {len(dataset)} images...")
234
- all_outputs = []
235
-
236
- # Batch plain OCR for better throughput
237
- batch_size = 8
238
- total_batches = (len(dataset) + batch_size - 1) // batch_size
239
- for batch_idx, batch_start in enumerate(range(0, len(dataset), batch_size), 1):
240
- batch_end = min(batch_start + batch_size, len(dataset))
241
- logger.info(f"Batch {batch_idx}/{total_batches} ({batch_start}/{len(dataset)} done)")
242
- batch_images = []
243
- for i in range(batch_start, batch_end):
244
- try:
245
- batch_images.append(prepare_image(dataset[i][image_column]))
246
- except Exception as e:
247
- logger.error(f"Error preparing image {i}: {e}")
248
- batch_images.append(Image.new("RGB", (100, 100)))
249
-
250
- try:
251
- texts = engine.generate_plain(
252
- images=batch_images, use_tqdm=progress
253
- )
254
- all_outputs.extend(texts)
255
- except Exception as e:
256
- logger.error(f"Error processing batch {batch_start}-{batch_end}: {e}")
257
- all_outputs.extend(
258
- [f"[OCR ERROR: {str(e)[:200]}]"] * len(batch_images)
259
- )
260
-
261
- # Calculate processing time
262
- processing_duration = datetime.now() - start_time
263
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
264
-
265
- # Add output column
266
- logger.info(f"Adding '{output_column}' column to dataset")
267
- dataset = dataset.add_column(output_column, all_outputs)
268
-
269
- # Track inference info
270
- inference_entry = {
271
- "model_id": MODEL_ID,
272
- "model_name": "Falcon-OCR",
273
- "model_size": "0.3B",
274
- "task_mode": task_mode,
275
- "column_name": output_column,
276
- "timestamp": datetime.now().isoformat(),
277
- "backend": "falcon-perception",
278
- }
279
-
280
- if "inference_info" in dataset.column_names:
281
- def update_inference_info(example):
282
- try:
283
- existing_info = (
284
- json.loads(example["inference_info"])
285
- if example["inference_info"]
286
- else []
287
- )
288
- except (json.JSONDecodeError, TypeError):
289
- existing_info = []
290
- existing_info.append(inference_entry)
291
- return {"inference_info": json.dumps(existing_info)}
292
-
293
- dataset = dataset.map(update_inference_info)
294
- else:
295
- inference_list = [json.dumps([inference_entry])] * len(dataset)
296
- dataset = dataset.add_column("inference_info", inference_list)
297
-
298
- # Push to hub
299
- logger.info(f"Pushing to {output_dataset}")
300
- max_retries = 3
301
- for attempt in range(1, max_retries + 1):
302
- try:
303
- if attempt > 1:
304
- logger.warning("Disabling XET (fallback to HTTP upload)")
305
- os.environ["HF_HUB_DISABLE_XET"] = "1"
306
- dataset.push_to_hub(
307
- output_dataset,
308
- private=private,
309
- token=HF_TOKEN,
310
- max_shard_size="500MB",
311
- **({"config_name": config} if config else {}),
312
- create_pr=create_pr,
313
- commit_message=f"Add {MODEL_ID} OCR results ({len(dataset)} samples)"
314
- + (f" [{config}]" if config else ""),
315
- )
316
- break
317
- except Exception as e:
318
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
319
- if attempt < max_retries:
320
- delay = 30 * (2 ** (attempt - 1))
321
- logger.info(f"Retrying in {delay}s...")
322
- time.sleep(delay)
323
- else:
324
- logger.error("All upload attempts failed. OCR results are lost.")
325
- sys.exit(1)
326
-
327
- # Create and push dataset card
328
- logger.info("Creating dataset card")
329
- card_content = create_dataset_card(
330
- source_dataset=input_dataset,
331
- task_mode=task_mode,
332
- num_samples=len(dataset),
333
- processing_time=processing_time_str,
334
- image_column=image_column,
335
- split=split,
336
- )
337
- card = DatasetCard(card_content)
338
- card.push_to_hub(output_dataset, token=HF_TOKEN)
339
-
340
- logger.info("Falcon OCR processing complete!")
341
- logger.info(
342
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
343
- )
344
- logger.info(f"Processing time: {processing_time_str}")
345
- logger.info(
346
- f"Speed: {len(dataset) / processing_duration.total_seconds():.2f} images/sec"
347
- )
348
-
349
- if verbose:
350
- import importlib.metadata
351
-
352
- logger.info("--- Resolved package versions ---")
353
- for pkg in [
354
- "falcon-perception", "transformers", "torch", "datasets", "pillow"
355
- ]:
356
- try:
357
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
358
- except importlib.metadata.PackageNotFoundError:
359
- logger.info(f" {pkg}: not installed")
360
-
361
-
362
- if __name__ == "__main__":
363
- if len(sys.argv) == 1:
364
- print("=" * 70)
365
- print("Falcon OCR - 0.3B Document OCR (falcon-perception engine)")
366
- print("=" * 70)
367
- print(f"\nModel: {MODEL_ID}")
368
- print("License: Apache 2.0")
369
- print("\nTask Modes:")
370
- for mode, description in TASK_MODES.items():
371
- print(f" {mode:10} - {description}")
372
- print("\nExamples:")
373
- print(" uv run falcon-ocr.py input-dataset output-dataset")
374
- print(" uv run falcon-ocr.py dense-docs output --task-mode layout")
375
- print("\nFor full help: uv run falcon-ocr.py --help")
376
- sys.exit(0)
377
-
378
- parser = argparse.ArgumentParser(
379
- description="Document OCR using Falcon OCR (0.3B, falcon-perception engine)",
380
- formatter_class=argparse.RawDescriptionHelpFormatter,
381
- epilog=__doc__,
382
- )
383
-
384
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
385
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
386
- parser.add_argument(
387
- "--image-column", default="image",
388
- help="Column containing images (default: image)",
389
- )
390
- parser.add_argument(
391
- "--task-mode", choices=list(TASK_MODES.keys()), default="plain",
392
- help="Task type: plain (default), layout",
393
- )
394
- parser.add_argument("--hf-token", help="Hugging Face API token")
395
- parser.add_argument(
396
- "--split", default="train", help="Dataset split (default: train)",
397
- )
398
- parser.add_argument(
399
- "--max-samples", type=int,
400
- help="Maximum number of samples to process (for testing)",
401
- )
402
- parser.add_argument(
403
- "--private", action="store_true", help="Make output dataset private",
404
- )
405
- parser.add_argument(
406
- "--shuffle", action="store_true", help="Shuffle dataset before processing",
407
- )
408
- parser.add_argument(
409
- "--seed", type=int, default=42, help="Random seed for shuffling (default: 42)",
410
- )
411
- parser.add_argument(
412
- "--output-column", default="markdown",
413
- help="Column name for output text (default: markdown)",
414
- )
415
- parser.add_argument(
416
- "--overwrite",
417
- action="store_true",
418
- help="Replace the output column if it already exists in the input dataset "
419
- "(default: error out to avoid clobbering an existing column).",
420
- )
421
- parser.add_argument(
422
- "--config",
423
- help="Config/subset name for Hub (for benchmarking multiple models)",
424
- )
425
- parser.add_argument(
426
- "--create-pr", action="store_true",
427
- help="Create a pull request instead of pushing directly",
428
- )
429
- parser.add_argument(
430
- "--no-compile", action="store_true",
431
- help="Disable torch.compile",
432
- )
433
- parser.add_argument(
434
- "--no-cudagraph", action="store_true",
435
- help="Disable CUDA graph capture",
436
- )
437
- parser.add_argument(
438
- "--progress", action="store_true",
439
- help="Show per-image progress bar from the inference engine",
440
- )
441
- parser.add_argument(
442
- "--verbose", action="store_true", help="Log resolved package versions",
443
- )
444
-
445
- args = parser.parse_args()
446
-
447
- main(
448
- input_dataset=args.input_dataset,
449
- output_dataset=args.output_dataset,
450
- image_column=args.image_column,
451
- task_mode=args.task_mode,
452
- hf_token=args.hf_token,
453
- split=args.split,
454
- max_samples=args.max_samples,
455
- private=args.private,
456
- shuffle=args.shuffle,
457
- seed=args.seed,
458
- output_column=args.output_column,
459
- overwrite=args.overwrite,
460
- config=args.config,
461
- create_pr=args.create_pr,
462
- compile=not args.no_compile,
463
- cudagraph=not args.no_cudagraph,
464
- progress=args.progress,
465
- verbose=args.verbose,
466
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
firered-ocr.py CHANGED
@@ -1,7 +1,7 @@
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
- # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm>=0.15.1",
@@ -39,10 +39,6 @@ from huggingface_hub import DatasetCard, login
39
  from PIL import Image
40
  from toolz import partition_all
41
  from tqdm.auto import tqdm
42
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
43
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
44
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
45
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
46
  from vllm import LLM, SamplingParams
47
 
48
  logging.basicConfig(level=logging.INFO)
@@ -90,28 +86,6 @@ def check_cuda_availability():
90
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
91
 
92
 
93
- def ensure_output_columns_free(dataset, columns, overwrite=False):
94
- """Fail fast if an output column would collide with an existing input column.
95
-
96
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
97
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
98
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
99
- column(s) here instead (logged) so the later add_column is clean.
100
- """
101
- clash = [c for c in columns if c in dataset.column_names]
102
- if not clash:
103
- return dataset
104
- if overwrite:
105
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
106
- return dataset.remove_columns(clash)
107
- logger.error(
108
- f"Output column(s) {clash} already exist in the input dataset "
109
- f"(columns: {dataset.column_names})."
110
- )
111
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
112
- sys.exit(1)
113
-
114
-
115
  def make_ocr_message(
116
  image: Union[Image.Image, Dict[str, Any], str],
117
  prompt: str = FIRERED_OCR_PROMPT,
@@ -130,10 +104,10 @@ def make_ocr_message(
130
  # Convert to RGB
131
  pil_img = pil_img.convert("RGB")
132
 
133
- # Convert to base64 data URI (JPEG is faster than PNG for encoding)
134
  buf = io.BytesIO()
135
- pil_img.save(buf, format="JPEG", quality=95)
136
- data_uri = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
137
 
138
  # Return message in vLLM format
139
  return [
@@ -254,7 +228,7 @@ def main(
254
  image_column: str = "image",
255
  batch_size: int = 16,
256
  model: str = "FireRedTeam/FireRed-OCR",
257
- max_model_len: int = 32768,
258
  max_tokens: int = 8192,
259
  gpu_memory_utilization: float = 0.8,
260
  hf_token: str = None,
@@ -264,7 +238,6 @@ def main(
264
  shuffle: bool = False,
265
  seed: int = 42,
266
  output_column: str = "markdown",
267
- overwrite: bool = False,
268
  config: str = None,
269
  create_pr: bool = False,
270
  ):
@@ -291,9 +264,6 @@ def main(
291
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
292
  )
293
 
294
- # Fail fast if the output column would collide with an existing input column
295
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
296
-
297
  # Shuffle if requested
298
  if shuffle:
299
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -365,10 +335,7 @@ def main(
365
  processing_duration = datetime.now() - start_time
366
  processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
367
 
368
- # Add output column to dataset (remove existing column if present)
369
- if output_column in dataset.column_names:
370
- logger.info(f"Removing existing '{output_column}' column before adding new results")
371
- dataset = dataset.remove_columns([output_column])
372
  logger.info(f"Adding '{output_column}' column to dataset")
373
  dataset = dataset.add_column(output_column, all_outputs)
374
 
@@ -513,8 +480,8 @@ Examples:
513
  parser.add_argument(
514
  "--max-model-len",
515
  type=int,
516
- default=32768,
517
- help="Maximum model context length (default: 32768)",
518
  )
519
  parser.add_argument(
520
  "--max-tokens",
@@ -554,12 +521,6 @@ Examples:
554
  default="markdown",
555
  help="Column name for output text (default: markdown)",
556
  )
557
- parser.add_argument(
558
- "--overwrite",
559
- action="store_true",
560
- help="Replace the output column if it already exists in the input dataset "
561
- "(default: error out to avoid clobbering an existing column).",
562
- )
563
  parser.add_argument(
564
  "--config",
565
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
@@ -588,7 +549,6 @@ Examples:
588
  shuffle=args.shuffle,
589
  seed=args.seed,
590
  output_column=args.output_column,
591
- overwrite=args.overwrite,
592
  config=args.config,
593
  create_pr=args.create_pr,
594
  )
 
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
+ # "datasets",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm>=0.15.1",
 
39
  from PIL import Image
40
  from toolz import partition_all
41
  from tqdm.auto import tqdm
 
 
 
 
42
  from vllm import LLM, SamplingParams
43
 
44
  logging.basicConfig(level=logging.INFO)
 
86
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
87
 
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  def make_ocr_message(
90
  image: Union[Image.Image, Dict[str, Any], str],
91
  prompt: str = FIRERED_OCR_PROMPT,
 
104
  # Convert to RGB
105
  pil_img = pil_img.convert("RGB")
106
 
107
+ # Convert to base64 data URI
108
  buf = io.BytesIO()
109
+ pil_img.save(buf, format="PNG")
110
+ data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
111
 
112
  # Return message in vLLM format
113
  return [
 
228
  image_column: str = "image",
229
  batch_size: int = 16,
230
  model: str = "FireRedTeam/FireRed-OCR",
231
+ max_model_len: int = 8192,
232
  max_tokens: int = 8192,
233
  gpu_memory_utilization: float = 0.8,
234
  hf_token: str = None,
 
238
  shuffle: bool = False,
239
  seed: int = 42,
240
  output_column: str = "markdown",
 
241
  config: str = None,
242
  create_pr: bool = False,
243
  ):
 
264
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
265
  )
266
 
 
 
 
267
  # Shuffle if requested
268
  if shuffle:
269
  logger.info(f"Shuffling dataset with seed {seed}")
 
335
  processing_duration = datetime.now() - start_time
336
  processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
337
 
338
+ # Add output column to dataset
 
 
 
339
  logger.info(f"Adding '{output_column}' column to dataset")
340
  dataset = dataset.add_column(output_column, all_outputs)
341
 
 
480
  parser.add_argument(
481
  "--max-model-len",
482
  type=int,
483
+ default=8192,
484
+ help="Maximum model context length (default: 8192)",
485
  )
486
  parser.add_argument(
487
  "--max-tokens",
 
521
  default="markdown",
522
  help="Column name for output text (default: markdown)",
523
  )
 
 
 
 
 
 
524
  parser.add_argument(
525
  "--config",
526
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
 
549
  shuffle=args.shuffle,
550
  seed=args.seed,
551
  output_column=args.output_column,
 
552
  config=args.config,
553
  create_pr=args.create_pr,
554
  )
glm-ocr-bucket.py DELETED
@@ -1,369 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "pillow",
5
- # "pymupdf",
6
- # "vllm",
7
- # "torch",
8
- # ]
9
- #
10
- # [[tool.uv.index]]
11
- # url = "https://wheels.vllm.ai/nightly/cu129"
12
- #
13
- # [tool.uv]
14
- # prerelease = "allow"
15
- # override-dependencies = ["transformers>=5.1.0"]
16
- # ///
17
-
18
- """
19
- OCR images and PDFs from a directory using GLM-OCR, writing markdown files.
20
-
21
- Designed to work with HF Buckets mounted as volumes via `hf jobs uv run -v ...`
22
- (requires huggingface_hub with PR #3936 volume mounting support).
23
-
24
- The script reads images/PDFs from INPUT_DIR, runs GLM-OCR via vLLM, and writes
25
- one .md file per image (or per PDF page) to OUTPUT_DIR, preserving directory structure.
26
-
27
- Input: Output:
28
- /input/page1.png → /output/page1.md
29
- /input/report.pdf → /output/report/page_001.md
30
- (3 pages) /output/report/page_002.md
31
- /output/report/page_003.md
32
- /input/sub/photo.jpg → /output/sub/photo.md
33
-
34
- Examples:
35
-
36
- # Local test
37
- uv run glm-ocr-bucket.py ./test-images ./test-output
38
-
39
- # HF Jobs with bucket volumes (PR #3936)
40
- hf jobs uv run --flavor l4x1 \\
41
- -s HF_TOKEN \\
42
- -v bucket/user/ocr-input:/input:ro \\
43
- -v bucket/user/ocr-output:/output \\
44
- glm-ocr-bucket.py /input /output
45
-
46
- Model: zai-org/GLM-OCR (0.9B, 94.62% OmniDocBench V1.5, MIT licensed)
47
- """
48
-
49
- import argparse
50
- import base64
51
- import io
52
- import logging
53
- import sys
54
- import time
55
- from pathlib import Path
56
-
57
- import torch
58
- from PIL import Image
59
- import os
60
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
61
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
62
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
63
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
64
- from vllm import LLM, SamplingParams
65
-
66
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
67
- logger = logging.getLogger(__name__)
68
-
69
- MODEL = "zai-org/GLM-OCR"
70
-
71
- TASK_PROMPTS = {
72
- "ocr": "Text Recognition:",
73
- "formula": "Formula Recognition:",
74
- "table": "Table Recognition:",
75
- }
76
-
77
- IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".tiff", ".tif", ".bmp", ".webp"}
78
-
79
-
80
- def check_cuda_availability():
81
- if not torch.cuda.is_available():
82
- logger.error("CUDA is not available. This script requires a GPU.")
83
- sys.exit(1)
84
- logger.info(f"CUDA available. GPU: {torch.cuda.get_device_name(0)}")
85
-
86
-
87
- def make_ocr_message(image: Image.Image, task: str = "ocr") -> list[dict]:
88
- """Create chat message for GLM-OCR from a PIL Image."""
89
- image = image.convert("RGB")
90
- buf = io.BytesIO()
91
- image.save(buf, format="PNG")
92
- data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
93
-
94
- return [
95
- {
96
- "role": "user",
97
- "content": [
98
- {"type": "image_url", "image_url": {"url": data_uri}},
99
- {"type": "text", "text": TASK_PROMPTS.get(task, TASK_PROMPTS["ocr"])},
100
- ],
101
- }
102
- ]
103
-
104
-
105
- def discover_files(input_dir: Path) -> list[Path]:
106
- """Walk input_dir recursively, returning sorted list of image and PDF files."""
107
- files = []
108
- for path in sorted(input_dir.rglob("*")):
109
- if not path.is_file():
110
- continue
111
- ext = path.suffix.lower()
112
- if ext in IMAGE_EXTENSIONS or ext == ".pdf":
113
- files.append(path)
114
- return files
115
-
116
-
117
- def prepare_images(
118
- files: list[Path], input_dir: Path, output_dir: Path, pdf_dpi: int
119
- ) -> list[tuple[Image.Image, Path]]:
120
- """
121
- Convert discovered files into (PIL.Image, output_md_path) pairs.
122
-
123
- Images map 1:1. PDFs expand to one image per page in a subdirectory.
124
- """
125
- import fitz # pymupdf
126
-
127
- items: list[tuple[Image.Image, Path]] = []
128
-
129
- for file_path in files:
130
- rel = file_path.relative_to(input_dir)
131
- ext = file_path.suffix.lower()
132
-
133
- if ext == ".pdf":
134
- # PDF → one .md per page in a subdirectory named after the PDF
135
- pdf_output_dir = output_dir / rel.with_suffix("")
136
- try:
137
- doc = fitz.open(file_path)
138
- num_pages = len(doc)
139
- logger.info(f"PDF: {rel} ({num_pages} pages)")
140
- for page_num in range(num_pages):
141
- page = doc[page_num]
142
- # Render at specified DPI
143
- zoom = pdf_dpi / 72.0
144
- mat = fitz.Matrix(zoom, zoom)
145
- pix = page.get_pixmap(matrix=mat)
146
- img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
147
- md_path = pdf_output_dir / f"page_{page_num + 1:03d}.md"
148
- items.append((img, md_path))
149
- doc.close()
150
- except Exception as e:
151
- logger.error(f"Failed to open PDF {rel}: {e}")
152
- else:
153
- # Image �� single .md
154
- try:
155
- img = Image.open(file_path).convert("RGB")
156
- md_path = output_dir / rel.with_suffix(".md")
157
- items.append((img, md_path))
158
- except Exception as e:
159
- logger.error(f"Failed to open image {rel}: {e}")
160
-
161
- return items
162
-
163
-
164
- def main():
165
- parser = argparse.ArgumentParser(
166
- description="OCR images/PDFs from a directory using GLM-OCR, output markdown files.",
167
- formatter_class=argparse.RawDescriptionHelpFormatter,
168
- epilog="""
169
- Task modes:
170
- ocr Text recognition to markdown (default)
171
- formula LaTeX formula recognition
172
- table Table extraction (HTML)
173
-
174
- Examples:
175
- uv run glm-ocr-bucket.py ./images ./output
176
- uv run glm-ocr-bucket.py /input /output --task table --pdf-dpi 200
177
-
178
- HF Jobs with bucket volumes (requires huggingface_hub PR #3936):
179
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \\
180
- -v bucket/user/input-bucket:/input:ro \\
181
- -v bucket/user/output-bucket:/output \\
182
- glm-ocr-bucket.py /input /output
183
- """,
184
- )
185
- parser.add_argument("input_dir", help="Directory containing images and/or PDFs")
186
- parser.add_argument("output_dir", help="Directory to write markdown output files")
187
- parser.add_argument(
188
- "--task",
189
- choices=["ocr", "formula", "table"],
190
- default="ocr",
191
- help="OCR task mode (default: ocr)",
192
- )
193
- parser.add_argument(
194
- "--batch-size", type=int, default=16, help="Batch size for vLLM (default: 16)"
195
- )
196
- parser.add_argument(
197
- "--max-model-len",
198
- type=int,
199
- default=8192,
200
- help="Max model context length (default: 8192)",
201
- )
202
- parser.add_argument(
203
- "--max-tokens",
204
- type=int,
205
- default=8192,
206
- help="Max output tokens (default: 8192)",
207
- )
208
- parser.add_argument(
209
- "--gpu-memory-utilization",
210
- type=float,
211
- default=0.8,
212
- help="GPU memory utilization (default: 0.8)",
213
- )
214
- parser.add_argument(
215
- "--pdf-dpi",
216
- type=int,
217
- default=300,
218
- help="DPI for PDF page rendering (default: 300)",
219
- )
220
- parser.add_argument(
221
- "--temperature",
222
- type=float,
223
- default=0.01,
224
- help="Sampling temperature (default: 0.01)",
225
- )
226
- parser.add_argument(
227
- "--top-p", type=float, default=0.00001, help="Top-p sampling (default: 0.00001)"
228
- )
229
- parser.add_argument(
230
- "--repetition-penalty",
231
- type=float,
232
- default=1.1,
233
- help="Repetition penalty (default: 1.1)",
234
- )
235
- parser.add_argument(
236
- "--verbose",
237
- action="store_true",
238
- help="Print resolved package versions",
239
- )
240
-
241
- args = parser.parse_args()
242
-
243
- check_cuda_availability()
244
-
245
- input_dir = Path(args.input_dir)
246
- output_dir = Path(args.output_dir)
247
-
248
- if not input_dir.is_dir():
249
- logger.error(f"Input directory does not exist: {input_dir}")
250
- sys.exit(1)
251
-
252
- output_dir.mkdir(parents=True, exist_ok=True)
253
-
254
- # Discover and prepare
255
- start_time = time.time()
256
-
257
- logger.info(f"Scanning {input_dir} for images and PDFs...")
258
- files = discover_files(input_dir)
259
- if not files:
260
- logger.error(f"No image or PDF files found in {input_dir}")
261
- sys.exit(1)
262
-
263
- pdf_count = sum(1 for f in files if f.suffix.lower() == ".pdf")
264
- img_count = len(files) - pdf_count
265
- logger.info(f"Found {img_count} image(s) and {pdf_count} PDF(s)")
266
-
267
- logger.info("Preparing images (rendering PDFs)...")
268
- items = prepare_images(files, input_dir, output_dir, args.pdf_dpi)
269
- if not items:
270
- logger.error("No processable images after preparation")
271
- sys.exit(1)
272
-
273
- logger.info(f"Total images to OCR: {len(items)}")
274
-
275
- # Init vLLM
276
- logger.info(f"Initializing vLLM with {MODEL}...")
277
- llm = LLM(
278
- model=MODEL,
279
- trust_remote_code=True,
280
- max_model_len=args.max_model_len,
281
- gpu_memory_utilization=args.gpu_memory_utilization,
282
- limit_mm_per_prompt={"image": 1},
283
- )
284
-
285
- sampling_params = SamplingParams(
286
- temperature=args.temperature,
287
- top_p=args.top_p,
288
- max_tokens=args.max_tokens,
289
- repetition_penalty=args.repetition_penalty,
290
- )
291
-
292
- # Process in batches
293
- errors = 0
294
- processed = 0
295
- total = len(items)
296
-
297
- for batch_start in range(0, total, args.batch_size):
298
- batch_end = min(batch_start + args.batch_size, total)
299
- batch = items[batch_start:batch_end]
300
- batch_num = batch_start // args.batch_size + 1
301
- total_batches = (total + args.batch_size - 1) // args.batch_size
302
-
303
- logger.info(f"Batch {batch_num}/{total_batches} ({processed}/{total} done)")
304
-
305
- try:
306
- messages = [make_ocr_message(img, task=args.task) for img, _ in batch]
307
- outputs = llm.chat(messages, sampling_params)
308
-
309
- for (_, md_path), output in zip(batch, outputs):
310
- text = output.outputs[0].text.strip()
311
- md_path.parent.mkdir(parents=True, exist_ok=True)
312
- md_path.write_text(text, encoding="utf-8")
313
- processed += 1
314
-
315
- except Exception as e:
316
- logger.error(f"Batch {batch_num} failed: {e}")
317
- # Write error markers for failed batch
318
- for _, md_path in batch:
319
- md_path.parent.mkdir(parents=True, exist_ok=True)
320
- md_path.write_text(f"[OCR ERROR: {e}]", encoding="utf-8")
321
- errors += len(batch)
322
- processed += len(batch)
323
-
324
- elapsed = time.time() - start_time
325
- elapsed_str = f"{elapsed / 60:.1f} min" if elapsed > 60 else f"{elapsed:.1f}s"
326
-
327
- logger.info("=" * 50)
328
- logger.info(f"Done! Processed {total} images in {elapsed_str}")
329
- logger.info(f" Output: {output_dir}")
330
- logger.info(f" Errors: {errors}")
331
- if total > 0:
332
- logger.info(f" Speed: {total / elapsed:.2f} images/sec")
333
-
334
- if args.verbose:
335
- import importlib.metadata
336
-
337
- logger.info("--- Package versions ---")
338
- for pkg in ["vllm", "transformers", "torch", "pillow", "pymupdf"]:
339
- try:
340
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
341
- except importlib.metadata.PackageNotFoundError:
342
- logger.info(f" {pkg}: not installed")
343
-
344
-
345
- if __name__ == "__main__":
346
- if len(sys.argv) == 1:
347
- print("=" * 60)
348
- print("GLM-OCR Bucket Script")
349
- print("=" * 60)
350
- print("\nOCR images/PDFs from a directory → markdown files.")
351
- print("Designed for HF Buckets mounted as volumes (PR #3936).")
352
- print()
353
- print("Usage:")
354
- print(" uv run glm-ocr-bucket.py INPUT_DIR OUTPUT_DIR")
355
- print()
356
- print("Examples:")
357
- print(" uv run glm-ocr-bucket.py ./images ./output")
358
- print(" uv run glm-ocr-bucket.py /input /output --task table")
359
- print()
360
- print("HF Jobs with bucket volumes:")
361
- print(" hf jobs uv run --flavor l4x1 -s HF_TOKEN \\")
362
- print(" -v bucket/user/ocr-input:/input:ro \\")
363
- print(" -v bucket/user/ocr-output:/output \\")
364
- print(" glm-ocr-bucket.py /input /output")
365
- print()
366
- print("For full help: uv run glm-ocr-bucket.py --help")
367
- sys.exit(0)
368
-
369
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
glm-ocr-v2.py CHANGED
@@ -69,10 +69,6 @@ from datasets import load_dataset
69
  from huggingface_hub import CommitScheduler, DatasetCard, HfApi, login
70
  from PIL import Image
71
  from toolz import partition_all
72
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
73
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
74
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
75
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
76
  from vllm import LLM, SamplingParams
77
 
78
  logging.basicConfig(level=logging.INFO)
@@ -136,28 +132,6 @@ def check_cuda_availability():
136
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
137
 
138
 
139
- def ensure_output_columns_free(dataset, columns, overwrite=False):
140
- """Fail fast if an output column would collide with an existing input column.
141
-
142
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
143
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
144
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
145
- column(s) here instead (logged) so the later add_column is clean.
146
- """
147
- clash = [c for c in columns if c in dataset.column_names]
148
- if not clash:
149
- return dataset
150
- if overwrite:
151
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
152
- return dataset.remove_columns(clash)
153
- logger.error(
154
- f"Output column(s) {clash} already exist in the input dataset "
155
- f"(columns: {dataset.column_names})."
156
- )
157
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
158
- sys.exit(1)
159
-
160
-
161
  def make_ocr_message(
162
  image: Union[Image.Image, Dict[str, Any], str],
163
  task: str = "ocr",
@@ -408,7 +382,6 @@ def main(
408
  shuffle: bool = False,
409
  seed: int = 42,
410
  output_column: str = "markdown",
411
- overwrite: bool = False,
412
  verbose: bool = False,
413
  config: str = None,
414
  create_pr: bool = False,
@@ -461,11 +434,6 @@ def main(
461
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
462
  )
463
 
464
- # Fail fast if the output column would collide with an existing input column.
465
- # The incremental path derives batch datasets via dataset.select(...), so dropping
466
- # it here (on --overwrite) also keeps those per-batch add_columns clean.
467
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
468
-
469
  if shuffle:
470
  logger.info(f"Shuffling dataset with seed {seed}")
471
  dataset = dataset.shuffle(seed=seed)
@@ -983,12 +951,6 @@ Examples:
983
  default="markdown",
984
  help="Column name for output text (default: markdown)",
985
  )
986
- parser.add_argument(
987
- "--overwrite",
988
- action="store_true",
989
- help="Replace the output column if it already exists in the input dataset "
990
- "(default: error out to avoid clobbering an existing column).",
991
- )
992
  parser.add_argument(
993
  "--verbose",
994
  action="store_true",
@@ -1033,7 +995,6 @@ Examples:
1033
  shuffle=args.shuffle,
1034
  seed=args.seed,
1035
  output_column=args.output_column,
1036
- overwrite=args.overwrite,
1037
  verbose=args.verbose,
1038
  config=args.config,
1039
  create_pr=args.create_pr,
 
69
  from huggingface_hub import CommitScheduler, DatasetCard, HfApi, login
70
  from PIL import Image
71
  from toolz import partition_all
 
 
 
 
72
  from vllm import LLM, SamplingParams
73
 
74
  logging.basicConfig(level=logging.INFO)
 
132
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  def make_ocr_message(
136
  image: Union[Image.Image, Dict[str, Any], str],
137
  task: str = "ocr",
 
382
  shuffle: bool = False,
383
  seed: int = 42,
384
  output_column: str = "markdown",
 
385
  verbose: bool = False,
386
  config: str = None,
387
  create_pr: bool = False,
 
434
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
435
  )
436
 
 
 
 
 
 
437
  if shuffle:
438
  logger.info(f"Shuffling dataset with seed {seed}")
439
  dataset = dataset.shuffle(seed=seed)
 
951
  default="markdown",
952
  help="Column name for output text (default: markdown)",
953
  )
 
 
 
 
 
 
954
  parser.add_argument(
955
  "--verbose",
956
  action="store_true",
 
995
  shuffle=args.shuffle,
996
  seed=args.seed,
997
  output_column=args.output_column,
 
998
  verbose=args.verbose,
999
  config=args.config,
1000
  create_pr=args.create_pr,
glm-ocr.py CHANGED
@@ -2,6 +2,7 @@
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
  # "datasets>=3.1.0",
 
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm",
@@ -52,21 +53,13 @@ import os
52
  import sys
53
  import time
54
  from datetime import datetime
55
- from typing import Any, Dict, List, Optional, Union
56
 
57
  import torch
58
  from datasets import load_dataset
59
  from huggingface_hub import DatasetCard, login
60
  from PIL import Image
61
  from toolz import partition_all
62
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
63
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
64
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
65
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
66
- # Same story for DeepGEMM (nightly vLLM): its init calls _find_cuda_home, which asserts on the
67
- # nvcc-less base image (a non-fatal warning that clutters the log and hides the real traceback).
68
- # Greedy OCR doesn't need the DeepGEMM JIT path, so disable it explicitly.
69
- os.environ.setdefault("VLLM_USE_DEEP_GEMM", "0")
70
  from vllm import LLM, SamplingParams
71
 
72
  logging.basicConfig(level=logging.INFO)
@@ -92,49 +85,9 @@ def check_cuda_availability():
92
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
93
 
94
 
95
- def ensure_output_columns_free(dataset, columns, overwrite=False):
96
- """Fail fast if an output column would collide with an existing input column.
97
-
98
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
99
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
100
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
101
- column(s) here instead (logged) so the later add_column is clean.
102
- """
103
- clash = [c for c in columns if c in dataset.column_names]
104
- if not clash:
105
- return dataset
106
- if overwrite:
107
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
108
- return dataset.remove_columns(clash)
109
- logger.error(
110
- f"Output column(s) {clash} already exist in the input dataset "
111
- f"(columns: {dataset.column_names})."
112
- )
113
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
114
- sys.exit(1)
115
-
116
-
117
- def downscale_to_max_pixels(img: Image.Image, max_pixels: Optional[int]) -> Image.Image:
118
- """Shrink an image so width*height <= max_pixels, preserving aspect ratio.
119
-
120
- GLM-OCR does no internal resizing and its card gives no resolution guidance. Capping
121
- input pixels bounds both image tokens and vision-encoder memory, a safety valve for very
122
- large (multi-MP) scans that can pressure GPU memory at high batch sizes. No-op when
123
- max_pixels is None or the image is already small enough (never upscales)."""
124
- if not max_pixels:
125
- return img
126
- w, h = img.size
127
- if w * h <= max_pixels:
128
- return img
129
- scale = (max_pixels / (w * h)) ** 0.5
130
- new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
131
- return img.resize(new_size, Image.Resampling.LANCZOS)
132
-
133
-
134
  def make_ocr_message(
135
  image: Union[Image.Image, Dict[str, Any], str],
136
  task: str = "ocr",
137
- max_pixels: Optional[int] = None,
138
  ) -> List[Dict]:
139
  """
140
  Create chat message for OCR processing.
@@ -155,9 +108,6 @@ def make_ocr_message(
155
  # Convert to RGB
156
  pil_img = pil_img.convert("RGB")
157
 
158
- # Optionally cap resolution to protect the vision encoder from OOM on huge scans
159
- pil_img = downscale_to_max_pixels(pil_img, max_pixels)
160
-
161
  # Convert to base64 data URI
162
  buf = io.BytesIO()
163
  pil_img.save(buf, format="PNG")
@@ -199,15 +149,6 @@ def create_dataset_card(
199
  "table": "table recognition",
200
  }
201
 
202
- # Canonical provenance stamp (see AGENTS.md): Jobs claim gated on JOB_ID, set by HF Jobs in-container.
203
- on_jobs = os.environ.get("JOB_ID") is not None
204
- hw = os.environ.get("ACCELERATOR") or ""
205
- origin = (
206
- "Produced on [Hugging Face Jobs](https://huggingface.co/docs/huggingface_hub/guides/jobs)"
207
- + (f" (`{hw}`)" if hw else "")
208
- ) if on_jobs else "Generated"
209
- jobs_tag = "\n- hf-jobs" if on_jobs else ""
210
-
211
  return f"""---
212
  tags:
213
  - ocr
@@ -215,7 +156,7 @@ tags:
215
  - glm-ocr
216
  - markdown
217
  - uv-script
218
- - generated{jobs_tag}
219
  ---
220
 
221
  # Document OCR using {model_name}
@@ -261,16 +202,16 @@ The dataset contains all original columns plus:
261
 
262
  ## Reproduction
263
 
264
- {origin} with the [`glm-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py) recipe from [uv-scripts](https://huggingface.co/uv-scripts). Run it yourself:
265
-
266
  ```bash
267
- hf jobs uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \\
268
  {source_dataset} \\
269
  <output-dataset> \\
270
  --image-column {image_column} \\
271
  --batch-size {batch_size} \\
272
  --task {task}
273
  ```
 
 
274
  """
275
 
276
 
@@ -280,7 +221,6 @@ def main(
280
  image_column: str = "image",
281
  batch_size: int = 16,
282
  max_model_len: int = 8192,
283
- max_pixels: Optional[int] = None,
284
  max_tokens: int = 8192,
285
  temperature: float = 0.01,
286
  top_p: float = 0.00001,
@@ -294,7 +234,6 @@ def main(
294
  shuffle: bool = False,
295
  seed: int = 42,
296
  output_column: str = "markdown",
297
- overwrite: bool = False,
298
  verbose: bool = False,
299
  config: str = None,
300
  create_pr: bool = False,
@@ -326,9 +265,6 @@ def main(
326
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
327
  )
328
 
329
- # Fail fast if the output column would collide with an existing input column
330
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
331
-
332
  if shuffle:
333
  logger.info(f"Shuffling dataset with seed {seed}")
334
  dataset = dataset.shuffle(seed=seed)
@@ -379,10 +315,7 @@ def main(
379
  )
380
 
381
  try:
382
- batch_messages = [
383
- make_ocr_message(img, task=task, max_pixels=max_pixels)
384
- for img in batch_images
385
- ]
386
 
387
  outputs = llm.chat(batch_messages, sampling_params)
388
 
@@ -572,17 +505,6 @@ Examples:
572
  default=8192,
573
  help="Maximum model context length (default: 8192)",
574
  )
575
- parser.add_argument(
576
- "--max-pixels",
577
- type=int,
578
- default=None,
579
- help=(
580
- "Optional cap on input image pixels (width*height); larger scans are "
581
- "downscaled (aspect preserved) before OCR. GLM-OCR does no internal resizing, "
582
- "so this bounds vision-encoder memory on very large scans — set e.g. 4000000 "
583
- "if you hit a GPU OOM at high batch sizes on a big-page corpus. Default: no cap."
584
- ),
585
- )
586
  parser.add_argument(
587
  "--max-tokens",
588
  type=int,
@@ -654,12 +576,6 @@ Examples:
654
  default="markdown",
655
  help="Column name for output text (default: markdown)",
656
  )
657
- parser.add_argument(
658
- "--overwrite",
659
- action="store_true",
660
- help="Replace the output column if it already exists in the input dataset "
661
- "(default: error out to avoid clobbering an existing column).",
662
- )
663
  parser.add_argument(
664
  "--verbose",
665
  action="store_true",
@@ -674,7 +590,6 @@ Examples:
674
  image_column=args.image_column,
675
  batch_size=args.batch_size,
676
  max_model_len=args.max_model_len,
677
- max_pixels=args.max_pixels,
678
  max_tokens=args.max_tokens,
679
  temperature=args.temperature,
680
  top_p=args.top_p,
@@ -688,7 +603,6 @@ Examples:
688
  shuffle=args.shuffle,
689
  seed=args.seed,
690
  output_column=args.output_column,
691
- overwrite=args.overwrite,
692
  verbose=args.verbose,
693
  config=args.config,
694
  create_pr=args.create_pr,
 
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
  # "datasets>=3.1.0",
5
+ # "pyarrow>=17.0.0,<18.0.0",
6
  # "huggingface-hub",
7
  # "pillow",
8
  # "vllm",
 
53
  import sys
54
  import time
55
  from datetime import datetime
56
+ from typing import Any, Dict, List, Union
57
 
58
  import torch
59
  from datasets import load_dataset
60
  from huggingface_hub import DatasetCard, login
61
  from PIL import Image
62
  from toolz import partition_all
 
 
 
 
 
 
 
 
63
  from vllm import LLM, SamplingParams
64
 
65
  logging.basicConfig(level=logging.INFO)
 
85
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
86
 
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  def make_ocr_message(
89
  image: Union[Image.Image, Dict[str, Any], str],
90
  task: str = "ocr",
 
91
  ) -> List[Dict]:
92
  """
93
  Create chat message for OCR processing.
 
108
  # Convert to RGB
109
  pil_img = pil_img.convert("RGB")
110
 
 
 
 
111
  # Convert to base64 data URI
112
  buf = io.BytesIO()
113
  pil_img.save(buf, format="PNG")
 
149
  "table": "table recognition",
150
  }
151
 
 
 
 
 
 
 
 
 
 
152
  return f"""---
153
  tags:
154
  - ocr
 
156
  - glm-ocr
157
  - markdown
158
  - uv-script
159
+ - generated
160
  ---
161
 
162
  # Document OCR using {model_name}
 
202
 
203
  ## Reproduction
204
 
 
 
205
  ```bash
206
+ uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/glm-ocr.py \\
207
  {source_dataset} \\
208
  <output-dataset> \\
209
  --image-column {image_column} \\
210
  --batch-size {batch_size} \\
211
  --task {task}
212
  ```
213
+
214
+ Generated with [UV Scripts](https://huggingface.co/uv-scripts)
215
  """
216
 
217
 
 
221
  image_column: str = "image",
222
  batch_size: int = 16,
223
  max_model_len: int = 8192,
 
224
  max_tokens: int = 8192,
225
  temperature: float = 0.01,
226
  top_p: float = 0.00001,
 
234
  shuffle: bool = False,
235
  seed: int = 42,
236
  output_column: str = "markdown",
 
237
  verbose: bool = False,
238
  config: str = None,
239
  create_pr: bool = False,
 
265
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
266
  )
267
 
 
 
 
268
  if shuffle:
269
  logger.info(f"Shuffling dataset with seed {seed}")
270
  dataset = dataset.shuffle(seed=seed)
 
315
  )
316
 
317
  try:
318
+ batch_messages = [make_ocr_message(img, task=task) for img in batch_images]
 
 
 
319
 
320
  outputs = llm.chat(batch_messages, sampling_params)
321
 
 
505
  default=8192,
506
  help="Maximum model context length (default: 8192)",
507
  )
 
 
 
 
 
 
 
 
 
 
 
508
  parser.add_argument(
509
  "--max-tokens",
510
  type=int,
 
576
  default="markdown",
577
  help="Column name for output text (default: markdown)",
578
  )
 
 
 
 
 
 
579
  parser.add_argument(
580
  "--verbose",
581
  action="store_true",
 
590
  image_column=args.image_column,
591
  batch_size=args.batch_size,
592
  max_model_len=args.max_model_len,
 
593
  max_tokens=args.max_tokens,
594
  temperature=args.temperature,
595
  top_p=args.top_p,
 
603
  shuffle=args.shuffle,
604
  seed=args.seed,
605
  output_column=args.output_column,
 
606
  verbose=args.verbose,
607
  config=args.config,
608
  create_pr=args.create_pr,
hunyuan-ocr-1.5.py DELETED
@@ -1,884 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "vllm>=0.18.1",
8
- # "transformers<5.13", # vLLM ≤0.24.0's HunyuanVL processor breaks on transformers 5.13
9
- # # (string-key AutoImageProcessor.register; fixed in vllm#47872).
10
- # # Drop this cap once that fix ships in a stable vLLM release.
11
- # "tqdm",
12
- # "toolz",
13
- # "torch",
14
- # ]
15
- # ///
16
-
17
- """
18
- Convert document images to markdown using HunyuanOCR-1.5 with vLLM.
19
-
20
- HunyuanOCR-1.5 is a lightweight ~1B-parameter, end-to-end OCR-specialized VLM
21
- from Tencent. It keeps the validated 1.0 backbone but extends the max image
22
- resolution to 4K and the context window to 128K, and adds targeted long-tail
23
- capabilities (low-resource / ancient-script OCR, multi-image text QA). Per the
24
- technical report (arXiv:2607.04884) it is faster than dots.ocr / DeepSeek-OCR-2
25
- and top-tier on OmniDocBench v1.6. This script runs it offline via vLLM.
26
-
27
- Features:
28
- - 📝 End-to-end document parsing to markdown (tables → HTML, formulas → LaTeX)
29
- - 🧩 Structured / layout-aware parsing
30
- - 📍 Text spotting with coordinates (JSON or Hunyuan format)
31
- - 📐 Formula (LaTeX) and 📊 table (HTML) recognition
32
- - 📈 Chart parsing (Mermaid / Markdown)
33
- - 🌐 Document + general-scene translation (→ zh / → en)
34
- - 🎯 Compact model (~1B parameters)
35
-
36
- Model: tencent/HunyuanOCR
37
- On 2026-07-06 Tencent replaced the repo root in-place with HunyuanOCR-1.5
38
- (1.0 archived under `v1.0/`, no git tag). So the repo *root* — the default
39
- here — is now 1.5. The sibling recipe `hunyuan-ocr.py` pins the last 1.0
40
- commit by revision to keep the 1.0 behavior; this script deliberately tracks
41
- root (1.5).
42
-
43
- vLLM: 0.18.1 (release) is the first stable wheel with native
44
- `HunYuanVLForConditionalGeneration` support for autoregressive decoding — no
45
- nightly or patch needed for batch OCR. The floor stays at 0.18.1; a bare
46
- `vllm` resolves to the latest stable (0.24.0 as of 2026-07), which also works
47
- once transformers is capped <5.13 (see the deps block for why). The DFlash
48
- speculative-decoding draft (a per-request *latency* win that needs a vLLM
49
- nightly) is intentionally NOT implemented: it does not change offline batch
50
- throughput or output distribution.
51
-
52
- trust_remote_code=True per the model card (the processor ships custom code).
53
-
54
- License: Tencent Hunyuan Community License (territory excludes EU/UK/South Korea)
55
- https://huggingface.co/tencent/HunyuanOCR/blob/main/LICENSE
56
-
57
- Note: batch_size defaults to 16 (untested on this arch as of writing — 1.5 on
58
- vLLM ≥0.18.1 should batch fine, unlike the 1.0 V1 batching issue; will be
59
- smoke-tested). Lower it if you hit engine errors.
60
-
61
- Post-processing note: only the shared tail-repetition cleanup
62
- (`clean_repeated_substrings`, byte-for-byte from the official toolkit) is
63
- ported. The upstream doc_parse-only markdown normalization (10 OmniDocBench
64
- GT-alignment regex passes in `hunyuan_utils.process_one`) is intentionally NOT
65
- ported — it is benchmark-GT alignment, not general OCR, and would bloat this
66
- self-contained recipe. For bench-exact output, use Tencent's toolkit directly.
67
- """
68
-
69
- import argparse
70
- import base64
71
- import io
72
- import json
73
- import logging
74
- import os
75
- import sys
76
- import time
77
- from datetime import datetime
78
- from typing import Any, Dict, List, Union
79
-
80
- import torch
81
- from datasets import load_dataset
82
- from huggingface_hub import DatasetCard, login
83
- from PIL import Image
84
- from toolz import partition_all
85
- from tqdm.auto import tqdm
86
-
87
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
88
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
89
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
90
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
91
- from vllm import LLM, SamplingParams
92
-
93
- logging.basicConfig(level=logging.INFO)
94
- logger = logging.getLogger(__name__)
95
-
96
-
97
- # ────────────────────────────────────────────────────────────────
98
- # HunyuanOCR-1.5 official task prompts.
99
- # Reproduced VERBATIM from the shipped client's `hunyuan_tasks.py`:
100
- # https://github.com/Tencent-Hunyuan/HunyuanOCR (inference/*/hunyuan_tasks.py)
101
- # The model card only prints the `doc_parse` prompt; the other 11 live only in
102
- # the client. Prompts are FIXED per task type — upstream deliberately does NOT
103
- # expose free-form prompt editing because hand-tweaked instructions were
104
- # observed to silently degrade quality (users pick a *task*, not a prompt).
105
- # All prompts are Chinese-language, including for English documents — this is
106
- # the officially recommended wording; the card provides no English variants.
107
- # ────────────────────────────────────────────────────────────────
108
-
109
- TASK_PROMPTS = {
110
- # 端到端文档解析
111
- "doc_parse": "提取文档图片中正文的所有信息用markdown格式表示,其中页眉、页脚部分忽略,"
112
- "表格用html格式表达,文档中公式用latex格式表示,按照阅读顺序组织进行解析。",
113
- # 结构化解析(古文、街景等非文档结构化场景)
114
- "structured_parse": "提取图中的文字。",
115
- # Spotting — JSON 格式
116
- "spotting_json": "检测并识别图中所有的文字行,请按从上到下、从左到右的阅读顺序进行识别。 "
117
- "输出格式为 JSON 数组,每个元素必须包含:"
118
- '"box": [xmin, ymin, xmax, ymax](坐标需归一化到 [0, 1000] 范围内);'
119
- '"text": "识别出的文字内容"。 '
120
- "注意:请直接输出 JSON 数组,不要包含任何多余的描述性文字。",
121
- # Spotting — Hunyuan 模式
122
- "spotting_hunyuan": "检测并识别图片中的文字,将文本坐标格式化输出。",
123
- # 版式分析
124
- "layout": "按照阅读顺序解析图中的版式信息。",
125
- # 版式分析 + 解析
126
- "layout_parse": "提取文档图片中所有内容用markdown格式表示,表格用html格式表达,"
127
- "文档中公式用latex格式表示,请按照阅读顺序组织进行全文解析,并输出版式分析信息。",
128
- # 图表解析
129
- "chart_parse": "解析图中的图表,对于流程图使用Mermaid格式表示,其他图表使用Markdown格式表示。",
130
- # 公式解析
131
- "formula": "识别图片中的公式,用LaTeX格式表示。",
132
- # 表格解析
133
- "table": "把图中的表格解析为HTML。",
134
- # 文档英译中
135
- "doc_trans_en2zh": "先解析文档,再将文档内容翻译为中文,其中页眉、页脚忽略,"
136
- "公式用latex格式表示,表格用html格式表示。",
137
- # 通用场景翻译 other2en
138
- "trans_other2en": "按照阅读顺序,提取图中文字,公式用latex格式表示,表格用markdown格式表示,"
139
- "再将文字内容翻译为英文。",
140
- # 通用场景翻译 other2zh
141
- "trans_other2zh": "按照阅读顺序,提取图中文字,公式用latex格式表示,表格用markdown格式表示,"
142
- "再将文字内容翻译为中文。",
143
- }
144
-
145
- # English glosses for --help / the no-args banner (upstream ships Chinese ones).
146
- TASK_DESCRIPTIONS = {
147
- "doc_parse": "End-to-end doc parse (body→markdown, tables→HTML, formulas→LaTeX, headers/footers ignored). Default.",
148
- "structured_parse": "Structured parse for non-document scenes (ancient scripts, street signs) — extract all text.",
149
- "spotting_json": "Text detect+recognize as a JSON array (box normalized to 0-1000 + text).",
150
- "spotting_hunyuan": "Text detect+recognize in Hunyuan coordinate format.",
151
- "layout": "Layout analysis in reading order.",
152
- "layout_parse": "Layout analysis + full-document parse (markdown/HTML/LaTeX).",
153
- "chart_parse": "Chart parsing (flowcharts→Mermaid, other charts→Markdown).",
154
- "formula": "Formula recognition → LaTeX.",
155
- "table": "Table parsing → HTML.",
156
- "doc_trans_en2zh": "Document translation to Chinese (parse then translate; formulas LaTeX, tables HTML).",
157
- "trans_other2en": "General-scene extraction + translation to English.",
158
- "trans_other2zh": "General-scene extraction + translation to Chinese.",
159
- }
160
-
161
- DEFAULT_TASK = "doc_parse"
162
-
163
- # Sampling params LOCKED by the model card across all official setups so outputs
164
- # are comparable: temperature=0.0, top_p=1.0, top_k=-1, repetition_penalty=1.08.
165
- # Only repetition_penalty is exposed as a flag (the others are fixed for
166
- # deterministic OCR); repetition_penalty is the model's built-in anti-repeat.
167
- DEFAULT_REPETITION_PENALTY = 1.08
168
-
169
-
170
- def clean_repeated_substrings(text: str, min_repeats: int = 10) -> str:
171
- """Trim a long repeated suffix as a final safety net against greedy-decoding
172
- degeneration. Byte-for-byte from the official `hunyuan_utils.py`.
173
- """
174
- n = len(text)
175
- if n < 2000:
176
- return text
177
- for length in range(2, n // min_repeats + 1):
178
- candidate = text[-length:]
179
- count = 0
180
- i = n - length
181
- while i >= 0 and text[i : i + length] == candidate:
182
- count += 1
183
- i -= length
184
- if count >= min_repeats:
185
- return text[: n - length * (count - 1)]
186
- return text
187
-
188
-
189
- def check_cuda_availability():
190
- """Check if CUDA is available and exit if not."""
191
- if not torch.cuda.is_available():
192
- logger.error("CUDA is not available. This script requires a GPU.")
193
- logger.error("Please run on a machine with a CUDA-capable GPU.")
194
- sys.exit(1)
195
- else:
196
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
197
-
198
-
199
- def ensure_output_columns_free(dataset, columns, overwrite=False):
200
- """Fail fast if an output column would collide with an existing input column.
201
-
202
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
203
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
204
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
205
- column(s) here instead (logged) so the later add_column is clean.
206
- """
207
- clash = [c for c in columns if c in dataset.column_names]
208
- if not clash:
209
- return dataset
210
- if overwrite:
211
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
212
- return dataset.remove_columns(clash)
213
- logger.error(
214
- f"Output column(s) {clash} already exist in the input dataset "
215
- f"(columns: {dataset.column_names})."
216
- )
217
- logger.error(
218
- "Choose a different --output-column, or pass --overwrite to replace them."
219
- )
220
- sys.exit(1)
221
-
222
-
223
- def get_prompt(task_type: str) -> str:
224
- """Return the official prompt for a task type."""
225
- if task_type not in TASK_PROMPTS:
226
- raise ValueError(
227
- f"Unknown task type: {task_type}. Available: {list(TASK_PROMPTS.keys())}"
228
- )
229
- return TASK_PROMPTS[task_type]
230
-
231
-
232
- def make_ocr_message(
233
- image: Union[Image.Image, Dict[str, Any], str],
234
- prompt: str,
235
- ) -> List[Dict]:
236
- """Create the chat messages for one image + prompt.
237
-
238
- Mirrors the official client: an empty system message followed by a user turn
239
- with the image *before* the text. The empty system content pins "no system
240
- prompt" (matching how the model is served) rather than letting the chat
241
- template inject a default.
242
- """
243
- # Convert to PIL Image if needed
244
- if isinstance(image, Image.Image):
245
- pil_img = image
246
- elif isinstance(image, dict) and "bytes" in image:
247
- pil_img = Image.open(io.BytesIO(image["bytes"]))
248
- elif isinstance(image, str):
249
- pil_img = Image.open(image)
250
- else:
251
- raise ValueError(f"Unsupported image type: {type(image)}")
252
-
253
- # Convert to RGB
254
- pil_img = pil_img.convert("RGB")
255
-
256
- # Convert to base64 data URI
257
- buf = io.BytesIO()
258
- pil_img.save(buf, format="PNG")
259
- data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
260
-
261
- return [
262
- {"role": "system", "content": ""},
263
- {
264
- "role": "user",
265
- "content": [
266
- {"type": "image_url", "image_url": {"url": data_uri}},
267
- {"type": "text", "text": prompt},
268
- ],
269
- },
270
- ]
271
-
272
-
273
- def create_dataset_card(
274
- source_dataset: str,
275
- model: str,
276
- num_samples: int,
277
- processing_time: str,
278
- batch_size: int,
279
- max_model_len: int,
280
- max_tokens: int,
281
- repetition_penalty: float,
282
- gpu_memory_utilization: float,
283
- image_column: str = "image",
284
- output_column: str = "markdown",
285
- split: str = "train",
286
- task_type: str = "doc_parse",
287
- ) -> str:
288
- """Create a dataset card documenting the OCR process."""
289
- model_name = model.split("/")[-1]
290
-
291
- return f"""---
292
- tags:
293
- - ocr
294
- - document-processing
295
- - hunyuan-ocr-1.5
296
- - multilingual
297
- - markdown
298
- - uv-script
299
- - generated
300
- ---
301
-
302
- # Document OCR using {model_name} (HunyuanOCR-1.5)
303
-
304
- This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using HunyuanOCR-1.5, a lightweight ~1B end-to-end OCR VLM from Tencent (128K context, 4K max image resolution).
305
-
306
- Model license: [Tencent Hunyuan Community License](https://huggingface.co/tencent/HunyuanOCR/blob/main/LICENSE) (territory excludes EU/UK/South Korea).
307
-
308
- ## Processing Details
309
-
310
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
311
- - **Model**: [{model}](https://huggingface.co/{model})
312
- - **Number of Samples**: {num_samples:,}
313
- - **Processing Time**: {processing_time}
314
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
315
-
316
- ### Configuration
317
-
318
- - **Image Column**: `{image_column}`
319
- - **Output Column**: `{output_column}`
320
- - **Dataset Split**: `{split}`
321
- - **Task Type**: `{task_type}`
322
- - **Batch Size**: {batch_size}
323
- - **Max Model Length**: {max_model_len:,} tokens
324
- - **Max Output Tokens**: {max_tokens:,}
325
- - **Repetition Penalty**: {repetition_penalty}
326
- - **GPU Memory Utilization**: {gpu_memory_utilization:.1%}
327
-
328
- ## Model Information
329
-
330
- HunyuanOCR-1.5 is a lightweight end-to-end OCR VLM that excels at:
331
- - 📝 **Document Parsing** - Full markdown extraction in reading order
332
- - 🧩 **Structured / Layout Parsing** - Layout-aware full-document parse
333
- - 📊 **Table Extraction** - HTML format tables
334
- - 📐 **Formula Recognition** - LaTeX format formulas
335
- - 📈 **Chart Parsing** - Mermaid / Markdown format
336
- - 📍 **Text Spotting** - Detection with coordinates (JSON / Hunyuan)
337
- - 🌐 **Translation** - Document and general-scene translation (→ zh / → en)
338
-
339
- Per the technical report ([arXiv:2607.04884](https://arxiv.org/pdf/2607.04884)),
340
- 1.5 is faster than dots.ocr / DeepSeek-OCR-2 and top-tier on OmniDocBench v1.6.
341
-
342
- ## Task Types Available
343
-
344
- - `doc_parse` - End-to-end document parsing (default)
345
- - `structured_parse` - Non-document structured scenes (ancient scripts, street signs)
346
- - `spotting_json` - Text detection + recognition as JSON array (box 0-1000 + text)
347
- - `spotting_hunyuan` - Text detection + recognition, Hunyuan coordinate format
348
- - `layout` - Layout analysis in reading order
349
- - `layout_parse` - Layout analysis + full-document parse
350
- - `chart_parse` - Chart parsing (flowcharts → Mermaid, others → Markdown)
351
- - `formula` - Formula recognition → LaTeX
352
- - `table` - Table parsing → HTML
353
- - `doc_trans_en2zh` - Document translation to Chinese
354
- - `trans_other2en` - General-scene extraction + translation to English
355
- - `trans_other2zh` - General-scene extraction + translation to Chinese
356
-
357
- ## Dataset Structure
358
-
359
- The dataset contains all original columns plus:
360
- - `{output_column}`: The extracted text (markdown for `doc_parse`, else the task's format)
361
- - `inference_info`: JSON list tracking all OCR models applied to this dataset
362
-
363
- ## Usage
364
-
365
- ```python
366
- from datasets import load_dataset
367
- import json
368
-
369
- # Load the dataset
370
- dataset = load_dataset("{{output_dataset_id}}", split="{split}")
371
-
372
- # Access the extracted text
373
- for example in dataset:
374
- print(example["{output_column}"])
375
- break
376
-
377
- # View all OCR models applied to this dataset
378
- inference_info = json.loads(dataset[0]["inference_info"])
379
- for info in inference_info:
380
- print(f"Column: {{info['column_name']}} - Model: {{info['model_id']}}")
381
- ```
382
-
383
- ## Reproduction
384
-
385
- This dataset was generated using the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) HunyuanOCR-1.5 script:
386
-
387
- ```bash
388
- uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/hunyuan-ocr-1.5.py \\
389
- {source_dataset} \\
390
- <output-dataset> \\
391
- --image-column {image_column} \\
392
- --batch-size {batch_size} \\
393
- --task-type {task_type} \\
394
- --max-model-len {max_model_len} \\
395
- --max-tokens {max_tokens} \\
396
- --gpu-memory-utilization {gpu_memory_utilization}
397
- ```
398
-
399
- Generated with [UV Scripts](https://huggingface.co/uv-scripts)
400
- """
401
-
402
-
403
- def main(
404
- input_dataset: str,
405
- output_dataset: str,
406
- image_column: str = "image",
407
- batch_size: int = 16,
408
- model: str = "tencent/HunyuanOCR",
409
- revision: str = None,
410
- max_model_len: int = 32768,
411
- max_tokens: int = 8192,
412
- repetition_penalty: float = DEFAULT_REPETITION_PENALTY,
413
- gpu_memory_utilization: float = 0.8,
414
- hf_token: str = None,
415
- split: str = "train",
416
- max_samples: int = None,
417
- private: bool = False,
418
- shuffle: bool = False,
419
- seed: int = 42,
420
- task_type: str = DEFAULT_TASK,
421
- custom_prompt: str = None,
422
- output_column: str = "markdown",
423
- overwrite: bool = False,
424
- clean_output: bool = True,
425
- config: str = None,
426
- create_pr: bool = False,
427
- verbose: bool = False,
428
- ):
429
- """Process images from an HF dataset through HunyuanOCR-1.5."""
430
-
431
- # Check CUDA availability first
432
- check_cuda_availability()
433
-
434
- # Context-length invariant (config.json): text max_position_embeddings=131072;
435
- # the vision processor caps a single image at img_max_token_num=16384. So the
436
- # default budget holds without resizing: 16384 (image) + prompt + 8192 (output)
437
- # ≈ 25k ≤ 32768 (default max_model_len). Enforce max_tokens ≤ max_model_len ≤ 131072.
438
- if max_model_len > 131072:
439
- logger.error(
440
- f"--max-model-len {max_model_len} exceeds the model's max context (131072)."
441
- )
442
- sys.exit(1)
443
- if max_tokens > max_model_len:
444
- logger.error(
445
- f"--max-tokens ({max_tokens}) cannot exceed --max-model-len ({max_model_len})."
446
- )
447
- sys.exit(1)
448
-
449
- # Track processing start time
450
- start_time = datetime.now()
451
-
452
- # Login to HF if token provided
453
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
454
- if HF_TOKEN:
455
- login(token=HF_TOKEN)
456
-
457
- # Determine prompt to use
458
- if custom_prompt:
459
- prompt = custom_prompt
460
- logger.warning(
461
- "Using --custom-prompt. Note: upstream deliberately locks prompts per "
462
- "task type — hand-tweaked instructions can silently degrade quality."
463
- )
464
- logger.info(f"Custom prompt: {prompt[:60]}...")
465
- else:
466
- prompt = get_prompt(task_type)
467
- logger.info(f"Using task type: {task_type}")
468
-
469
- # Load dataset
470
- logger.info(f"Loading dataset: {input_dataset}")
471
- dataset = load_dataset(input_dataset, split=split)
472
-
473
- # Validate image column
474
- if image_column not in dataset.column_names:
475
- raise ValueError(
476
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
477
- )
478
-
479
- # Fail fast if the output column would collide with an existing input column
480
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
481
-
482
- # Shuffle if requested
483
- if shuffle:
484
- logger.info(f"Shuffling dataset with seed {seed}")
485
- dataset = dataset.shuffle(seed=seed)
486
-
487
- # Limit samples if requested
488
- if max_samples:
489
- dataset = dataset.select(range(min(max_samples, len(dataset))))
490
- logger.info(f"Limited to {len(dataset)} samples")
491
-
492
- # Initialize vLLM model
493
- logger.info(f"Initializing vLLM with model: {model}")
494
- logger.info("This may take a few minutes on first run...")
495
-
496
- llm = LLM(
497
- model=model,
498
- revision=revision,
499
- trust_remote_code=True,
500
- max_model_len=max_model_len,
501
- gpu_memory_utilization=gpu_memory_utilization,
502
- limit_mm_per_prompt={"image": 1},
503
- )
504
-
505
- # Locked sampling per the model card (deterministic OCR); only repetition_penalty
506
- # is user-tunable.
507
- sampling_params = SamplingParams(
508
- temperature=0.0,
509
- top_p=1.0,
510
- top_k=-1,
511
- repetition_penalty=repetition_penalty,
512
- max_tokens=max_tokens,
513
- skip_special_tokens=True,
514
- )
515
-
516
- logger.info(f"Processing {len(dataset)} images in batches of {batch_size}")
517
- logger.info(f"Output will be written to column: {output_column}")
518
-
519
- # Process images in batches
520
- all_outputs = []
521
-
522
- for batch_indices in tqdm(
523
- partition_all(batch_size, range(len(dataset))),
524
- total=(len(dataset) + batch_size - 1) // batch_size,
525
- desc="HunyuanOCR-1.5 processing",
526
- ):
527
- batch_indices = list(batch_indices)
528
- batch_images = [dataset[i][image_column] for i in batch_indices]
529
-
530
- try:
531
- # Create messages for batch
532
- batch_messages = [make_ocr_message(img, prompt) for img in batch_images]
533
-
534
- # Process with vLLM
535
- outputs = llm.chat(batch_messages, sampling_params)
536
-
537
- # Extract outputs
538
- for output in outputs:
539
- text = output.outputs[0].text.strip()
540
- # Clean repeated substrings if enabled
541
- if clean_output:
542
- text = clean_repeated_substrings(text)
543
- all_outputs.append(text)
544
-
545
- except Exception as e:
546
- logger.error(f"Error processing batch: {e}")
547
- # Add error placeholders for failed batch
548
- all_outputs.extend(["[OCR ERROR]"] * len(batch_images))
549
-
550
- # Calculate processing time
551
- processing_duration = datetime.now() - start_time
552
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
553
-
554
- # Add output column to dataset
555
- logger.info(f"Adding '{output_column}' column to dataset")
556
- dataset = dataset.add_column(output_column, all_outputs)
557
-
558
- # Handle inference_info tracking (for multi-model comparisons)
559
- inference_entry = {
560
- "model_id": model,
561
- "model_name": "HunyuanOCR-1.5",
562
- "model_revision": revision or "main",
563
- "column_name": output_column,
564
- "timestamp": datetime.now().isoformat(),
565
- "task_type": task_type if not custom_prompt else "custom",
566
- "repetition_penalty": repetition_penalty,
567
- }
568
-
569
- if "inference_info" in dataset.column_names:
570
- # Append to existing inference info
571
- logger.info("Updating existing inference_info column")
572
-
573
- def update_inference_info(example):
574
- try:
575
- existing_info = (
576
- json.loads(example["inference_info"])
577
- if example["inference_info"]
578
- else []
579
- )
580
- except (json.JSONDecodeError, TypeError):
581
- existing_info = []
582
-
583
- existing_info.append(inference_entry)
584
- return {"inference_info": json.dumps(existing_info)}
585
-
586
- dataset = dataset.map(update_inference_info)
587
- else:
588
- # Create new inference_info column
589
- logger.info("Creating new inference_info column")
590
- inference_list = [json.dumps([inference_entry])] * len(dataset)
591
- dataset = dataset.add_column("inference_info", inference_list)
592
-
593
- # Push to hub with retry and XET fallback
594
- logger.info(f"Pushing to {output_dataset}")
595
- commit_msg = f"Add HunyuanOCR-1.5 OCR results ({len(dataset)} samples)" + (
596
- f" [{config}]" if config else ""
597
- )
598
- max_retries = 3
599
- for attempt in range(1, max_retries + 1):
600
- try:
601
- if attempt > 1:
602
- logger.warning("Disabling XET (fallback to HTTP upload)")
603
- os.environ["HF_HUB_DISABLE_XET"] = "1"
604
- dataset.push_to_hub(
605
- output_dataset,
606
- private=private,
607
- token=HF_TOKEN,
608
- max_shard_size="500MB",
609
- **({"config_name": config} if config else {}),
610
- create_pr=create_pr,
611
- commit_message=commit_msg,
612
- )
613
- break
614
- except Exception as e:
615
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
616
- if attempt < max_retries:
617
- delay = 30 * (2 ** (attempt - 1))
618
- logger.info(f"Retrying in {delay}s...")
619
- time.sleep(delay)
620
- else:
621
- logger.error("All upload attempts failed. OCR results are lost.")
622
- sys.exit(1)
623
-
624
- # Create and push dataset card (skip when creating PR to avoid conflicts)
625
- if not create_pr:
626
- logger.info("Creating dataset card")
627
- card_content = create_dataset_card(
628
- source_dataset=input_dataset,
629
- model=model,
630
- num_samples=len(dataset),
631
- processing_time=processing_time_str,
632
- batch_size=batch_size,
633
- max_model_len=max_model_len,
634
- max_tokens=max_tokens,
635
- repetition_penalty=repetition_penalty,
636
- gpu_memory_utilization=gpu_memory_utilization,
637
- image_column=image_column,
638
- output_column=output_column,
639
- split=split,
640
- task_type=task_type if not custom_prompt else "custom",
641
- )
642
-
643
- card = DatasetCard(card_content)
644
- card.push_to_hub(output_dataset, token=HF_TOKEN)
645
-
646
- logger.info("HunyuanOCR-1.5 processing complete!")
647
- logger.info(
648
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
649
- )
650
- logger.info(f"Processing time: {processing_time_str}")
651
-
652
- if verbose:
653
- import importlib.metadata
654
-
655
- logger.info("--- Resolved package versions ---")
656
- for pkg in [
657
- "vllm",
658
- "transformers",
659
- "torch",
660
- "datasets",
661
- "pyarrow",
662
- "pillow",
663
- ]:
664
- try:
665
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
666
- except importlib.metadata.PackageNotFoundError:
667
- logger.info(f" {pkg}: not installed")
668
- logger.info("--- End versions ---")
669
-
670
-
671
- if __name__ == "__main__":
672
- # Show example usage if no arguments
673
- if len(sys.argv) == 1:
674
- print("=" * 80)
675
- print("HunyuanOCR-1.5 Document Processing")
676
- print("=" * 80)
677
- print(
678
- "\nLightweight ~1B end-to-end OCR VLM from Tencent (128K context, 4K images)"
679
- )
680
- print("\nFeatures:")
681
- print("- 📝 End-to-end document parsing to markdown")
682
- print("- 📊 Table extraction (HTML format)")
683
- print("- 📐 Formula recognition (LaTeX format)")
684
- print("- 📍 Text spotting with coordinates (JSON / Hunyuan)")
685
- print("- 📈 Chart parsing (Mermaid / Markdown)")
686
- print("- 🌐 Document + general-scene translation (→ zh / → en)")
687
- print("\nExample usage:")
688
- print("\n1. Basic document parsing:")
689
- print(" uv run hunyuan-ocr-1.5.py input-dataset output-dataset")
690
- print("\n2. Formula extraction:")
691
- print(" uv run hunyuan-ocr-1.5.py math-docs formulas --task-type formula")
692
- print("\n3. Table extraction:")
693
- print(" uv run hunyuan-ocr-1.5.py docs tables --task-type table")
694
- print("\n4. Text spotting as JSON (box + text):")
695
- print(" uv run hunyuan-ocr-1.5.py images spotted --task-type spotting_json")
696
- print("\n5. Translate a document to Chinese:")
697
- print(
698
- " uv run hunyuan-ocr-1.5.py en-docs zh-docs --task-type doc_trans_en2zh"
699
- )
700
- print("\n6. Running on HF Jobs:")
701
- print(" hf jobs uv run --flavor l4x1 \\")
702
- print(
703
- ' -e HF_TOKEN=$(python3 -c "from huggingface_hub import get_token; print(get_token())") \\'
704
- )
705
- print(
706
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/hunyuan-ocr-1.5.py \\"
707
- )
708
- print(" input-dataset output-dataset")
709
- print("\n" + "=" * 80)
710
- print("\nFor full help, run: uv run hunyuan-ocr-1.5.py --help")
711
- sys.exit(0)
712
-
713
- task_help = "\n".join(f" {k:18s}- {TASK_DESCRIPTIONS[k]}" for k in TASK_PROMPTS)
714
- parser = argparse.ArgumentParser(
715
- description="Document OCR using HunyuanOCR-1.5 (lightweight ~1B end-to-end OCR VLM)",
716
- formatter_class=argparse.RawDescriptionHelpFormatter,
717
- epilog=f"""
718
- Task Types (official HunyuanOCR-1.5 prompts, all Chinese-language):
719
- {task_help}
720
-
721
- Examples:
722
- # Basic document OCR (default)
723
- uv run hunyuan-ocr-1.5.py my-docs analyzed-docs
724
-
725
- # Extract formulas as LaTeX
726
- uv run hunyuan-ocr-1.5.py math-papers formulas --task-type formula
727
-
728
- # Extract tables as HTML
729
- uv run hunyuan-ocr-1.5.py reports tables --task-type table
730
-
731
- # Text spotting as JSON (box normalized 0-1000 + text)
732
- uv run hunyuan-ocr-1.5.py images spotted --task-type spotting_json
733
-
734
- # Translate documents to Chinese
735
- uv run hunyuan-ocr-1.5.py en-docs translated --task-type doc_trans_en2zh
736
-
737
- # Random sampling for testing
738
- uv run hunyuan-ocr-1.5.py large-dataset test --max-samples 50 --shuffle
739
- """,
740
- )
741
-
742
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
743
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
744
- parser.add_argument(
745
- "--image-column",
746
- default="image",
747
- help="Column containing images (default: image)",
748
- )
749
- parser.add_argument(
750
- "--batch-size",
751
- type=int,
752
- default=16,
753
- help="Batch size for processing (default: 16; lower it if you hit engine errors)",
754
- )
755
- parser.add_argument(
756
- "--model",
757
- default="tencent/HunyuanOCR",
758
- help="Model to use (default: tencent/HunyuanOCR — repo root is 1.5)",
759
- )
760
- parser.add_argument(
761
- "--revision",
762
- default=None,
763
- help="Model repo revision (default: main). Tencent has replaced this repo's "
764
- "root in-place before (1.0 → 1.5); pin a commit hash for reproducible runs.",
765
- )
766
- parser.add_argument(
767
- "--max-model-len",
768
- type=int,
769
- default=32768,
770
- help="Maximum model context length (default: 32768; max 131072). A single "
771
- "image is capped at ~16384 tokens by the vision processor, so 32768 fits "
772
- "image + 8192 output; raise for very long outputs.",
773
- )
774
- parser.add_argument(
775
- "--max-tokens",
776
- type=int,
777
- default=8192,
778
- help="Maximum tokens to generate (default: 8192; must be ≤ --max-model-len). "
779
- "Dense pages may need more — raise toward 32768.",
780
- )
781
- parser.add_argument(
782
- "--repetition-penalty",
783
- type=float,
784
- default=DEFAULT_REPETITION_PENALTY,
785
- help=f"Repetition penalty (default: {DEFAULT_REPETITION_PENALTY}, the model card's locked value)",
786
- )
787
- parser.add_argument(
788
- "--gpu-memory-utilization",
789
- type=float,
790
- default=0.8,
791
- help="GPU memory utilization (default: 0.8)",
792
- )
793
- parser.add_argument("--hf-token", help="Hugging Face API token")
794
- parser.add_argument(
795
- "--split", default="train", help="Dataset split to use (default: train)"
796
- )
797
- parser.add_argument(
798
- "--max-samples",
799
- type=int,
800
- help="Maximum number of samples to process (for testing)",
801
- )
802
- parser.add_argument(
803
- "--private", action="store_true", help="Make output dataset private"
804
- )
805
- parser.add_argument(
806
- "--shuffle", action="store_true", help="Shuffle dataset before processing"
807
- )
808
- parser.add_argument(
809
- "--seed",
810
- type=int,
811
- default=42,
812
- help="Random seed for shuffling (default: 42)",
813
- )
814
- parser.add_argument(
815
- "--task-type",
816
- choices=list(TASK_PROMPTS.keys()),
817
- default=DEFAULT_TASK,
818
- metavar="TASK",
819
- help=f"Official task type (default: {DEFAULT_TASK}). See the epilog for all types.",
820
- )
821
- parser.add_argument(
822
- "--custom-prompt",
823
- help="Custom prompt text (overrides --task-type; may degrade quality — upstream "
824
- "locks prompts per task)",
825
- )
826
- parser.add_argument(
827
- "--output-column",
828
- default="markdown",
829
- help="Column name for output text (default: markdown)",
830
- )
831
- parser.add_argument(
832
- "--overwrite",
833
- action="store_true",
834
- help="Replace the output column if it already exists in the input dataset "
835
- "(default: error out to avoid clobbering an existing column).",
836
- )
837
- parser.add_argument(
838
- "--no-clean-output",
839
- action="store_true",
840
- help="Disable cleaning of repeated substrings in output",
841
- )
842
- parser.add_argument(
843
- "--config",
844
- help="Dataset config name for multi-model benchmarks",
845
- )
846
- parser.add_argument(
847
- "--create-pr",
848
- action="store_true",
849
- help="Push results as a pull request instead of direct commit",
850
- )
851
- parser.add_argument(
852
- "--verbose",
853
- action="store_true",
854
- help="Log resolved package versions at the end of the run",
855
- )
856
-
857
- args = parser.parse_args()
858
-
859
- main(
860
- input_dataset=args.input_dataset,
861
- output_dataset=args.output_dataset,
862
- image_column=args.image_column,
863
- batch_size=args.batch_size,
864
- model=args.model,
865
- revision=args.revision,
866
- max_model_len=args.max_model_len,
867
- max_tokens=args.max_tokens,
868
- repetition_penalty=args.repetition_penalty,
869
- gpu_memory_utilization=args.gpu_memory_utilization,
870
- hf_token=args.hf_token,
871
- split=args.split,
872
- max_samples=args.max_samples,
873
- private=args.private,
874
- shuffle=args.shuffle,
875
- seed=args.seed,
876
- task_type=args.task_type,
877
- custom_prompt=args.custom_prompt,
878
- output_column=args.output_column,
879
- overwrite=args.overwrite,
880
- clean_output=not args.no_clean_output,
881
- config=args.config,
882
- create_pr=args.create_pr,
883
- verbose=args.verbose,
884
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
hunyuan-ocr.py CHANGED
@@ -5,9 +5,6 @@
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm>=0.15.1",
8
- # "transformers<5.13", # vLLM ≤0.24.0's HunyuanVL processor breaks on transformers 5.13
9
- # # (string-key AutoImageProcessor.register; fixed in vllm#47872).
10
- # # Drop this cap once that fix ships in a stable vLLM release.
11
  # "tqdm",
12
  # "toolz",
13
  # "torch",
@@ -15,18 +12,11 @@
15
  # ///
16
 
17
  """
18
- Convert document images to markdown using HunyuanOCR (v1.0) with vLLM.
19
 
20
  HunyuanOCR is a lightweight 1B parameter VLM from Tencent designed for complex
21
  multilingual document parsing. This script uses vLLM for processing.
22
 
23
- ⚠️ This recipe targets HunyuanOCR **1.0**. On 2026-07-06 Tencent pushed
24
- HunyuanOCR-1.5 into the *same* repo (new weights at root, 1.0 archived under
25
- `v1.0/`, no git tag), so loading `tencent/HunyuanOCR@main` now yields 1.5 —
26
- different context length, prompts, and transformers/vLLM requirements. We pin
27
- the last 1.0 commit by revision to keep this script's validated behavior.
28
- For 1.5, use the sibling `hunyuan-ocr-1.5.py`.
29
-
30
  Features:
31
  - 📝 Full document parsing to markdown
32
  - 📊 Table extraction (HTML format)
@@ -36,10 +26,8 @@ Features:
36
  - 🌐 Photo translation
37
  - 🎯 Compact model (1B parameters)
38
 
39
- Model: tencent/HunyuanOCR (pinned to the last 1.0 revision)
40
-
41
- License: Tencent Hunyuan Community License (territory excludes EU/UK/South Korea)
42
- https://huggingface.co/tencent/HunyuanOCR/blob/main/LICENSE
43
 
44
  Note: Due to vLLM V1 engine batching issues with HunyuanOCR, batch_size defaults to 1.
45
  """
@@ -61,21 +49,11 @@ from huggingface_hub import DatasetCard, login
61
  from PIL import Image
62
  from toolz import partition_all
63
  from tqdm.auto import tqdm
64
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
65
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
66
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
67
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
68
  from vllm import LLM, SamplingParams
69
 
70
  logging.basicConfig(level=logging.INFO)
71
  logger = logging.getLogger(__name__)
72
 
73
- # Last commit where the repo root held the 1.0 weights (2026-01-13). Not a temporary
74
- # workaround pin: upstream replaced root with 1.5 in-place (2026-07-06) and left no
75
- # 1.0 tag/branch, so this revision IS the 1.0 identity. Never loosen to "main" here —
76
- # 1.5 lives in hunyuan-ocr-1.5.py. Override with --revision only to reproduce old runs.
77
- DEFAULT_REVISION = "f6af82ee007fe6091b29fb3bb287b491ead41c82"
78
-
79
 
80
  # ────────────────────────────────────────────────────────────────
81
  # HunyuanOCR Prompt Templates (from official README)
@@ -168,28 +146,6 @@ def check_cuda_availability():
168
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
169
 
170
 
171
- def ensure_output_columns_free(dataset, columns, overwrite=False):
172
- """Fail fast if an output column would collide with an existing input column.
173
-
174
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
175
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
176
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
177
- column(s) here instead (logged) so the later add_column is clean.
178
- """
179
- clash = [c for c in columns if c in dataset.column_names]
180
- if not clash:
181
- return dataset
182
- if overwrite:
183
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
184
- return dataset.remove_columns(clash)
185
- logger.error(
186
- f"Output column(s) {clash} already exist in the input dataset "
187
- f"(columns: {dataset.column_names})."
188
- )
189
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
190
- sys.exit(1)
191
-
192
-
193
  def get_prompt(
194
  prompt_mode: str,
195
  use_chinese: bool = False,
@@ -266,7 +222,6 @@ def make_ocr_message(
266
  def create_dataset_card(
267
  source_dataset: str,
268
  model: str,
269
- revision: str,
270
  num_samples: int,
271
  processing_time: str,
272
  batch_size: int,
@@ -296,13 +251,10 @@ tags:
296
 
297
  This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using HunyuanOCR, a lightweight 1B VLM from Tencent.
298
 
299
- Model license: [Tencent Hunyuan Community License](https://huggingface.co/tencent/HunyuanOCR/blob/main/LICENSE) (territory excludes EU/UK/South Korea).
300
-
301
  ## Processing Details
302
 
303
  - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
304
  - **Model**: [{model}](https://huggingface.co/{model})
305
- - **Model Revision**: `{revision}` (HunyuanOCR 1.0 — the repo root holds 1.5 since 2026-07-06)
306
  - **Number of Samples**: {num_samples:,}
307
  - **Processing Time**: {processing_time}
308
  - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
@@ -394,7 +346,6 @@ def main(
394
  image_column: str = "image",
395
  batch_size: int = 1, # Default to 1 due to vLLM V1 batching issues with HunyuanOCR
396
  model: str = "tencent/HunyuanOCR",
397
- revision: str = None,
398
  max_model_len: int = 16384,
399
  max_tokens: int = 16384,
400
  gpu_memory_utilization: float = 0.8,
@@ -411,7 +362,6 @@ def main(
411
  use_chinese: bool = False,
412
  custom_prompt: str = None,
413
  output_column: str = "markdown",
414
- overwrite: bool = False,
415
  clean_output: bool = True,
416
  config: str = None,
417
  create_pr: bool = False,
@@ -455,9 +405,6 @@ def main(
455
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
456
  )
457
 
458
- # Fail fast if the output column would collide with an existing input column
459
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
460
-
461
  # Shuffle if requested
462
  if shuffle:
463
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -468,21 +415,14 @@ def main(
468
  dataset = dataset.select(range(min(max_samples, len(dataset))))
469
  logger.info(f"Limited to {len(dataset)} samples")
470
 
471
- # Pin the 1.0 revision only for the default repo — a user-supplied --model
472
- # must not inherit a foreign commit hash.
473
- if revision is None and model == "tencent/HunyuanOCR":
474
- revision = DEFAULT_REVISION
475
- logger.info(f"Pinning tencent/HunyuanOCR to 1.0 revision {revision[:8]}")
476
-
477
  # Initialize vLLM model
478
- logger.info(f"Initializing vLLM: {model} (revision: {revision or 'main'})")
479
  logger.info("This may take a few minutes on first run...")
480
 
481
  # Note: HunyuanOCR has batching issues with vLLM V1 engine when batch_size > 1
482
  # Using limit_mm_per_prompt for stability
483
  llm = LLM(
484
  model=model,
485
- revision=revision,
486
  trust_remote_code=True,
487
  max_model_len=max_model_len,
488
  gpu_memory_utilization=gpu_memory_utilization,
@@ -541,7 +481,6 @@ def main(
541
  inference_entry = {
542
  "model_id": model,
543
  "model_name": "HunyuanOCR",
544
- "model_revision": revision or "main",
545
  "column_name": output_column,
546
  "timestamp": datetime.now().isoformat(),
547
  "prompt_mode": prompt_mode if not custom_prompt else "custom",
@@ -609,7 +548,6 @@ def main(
609
  card_content = create_dataset_card(
610
  source_dataset=input_dataset,
611
  model=model,
612
- revision=revision or "main",
613
  num_samples=len(dataset),
614
  processing_time=processing_time_str,
615
  batch_size=batch_size,
@@ -763,13 +701,6 @@ Examples:
763
  default="tencent/HunyuanOCR",
764
  help="Model to use (default: tencent/HunyuanOCR)",
765
  )
766
- parser.add_argument(
767
- "--revision",
768
- default=None,
769
- help="Model repo revision. Defaults to the last 1.0 commit when --model is "
770
- "tencent/HunyuanOCR (the repo root now holds 1.5 — see hunyuan-ocr-1.5.py); "
771
- "defaults to main for any other model.",
772
- )
773
  parser.add_argument(
774
  "--max-model-len",
775
  type=int,
@@ -851,12 +782,6 @@ Examples:
851
  default="markdown",
852
  help="Column name for output text (default: markdown)",
853
  )
854
- parser.add_argument(
855
- "--overwrite",
856
- action="store_true",
857
- help="Replace the output column if it already exists in the input dataset "
858
- "(default: error out to avoid clobbering an existing column).",
859
- )
860
  parser.add_argument(
861
  "--no-clean-output",
862
  action="store_true",
@@ -890,7 +815,6 @@ Examples:
890
  image_column=args.image_column,
891
  batch_size=args.batch_size,
892
  model=args.model,
893
- revision=args.revision,
894
  max_model_len=args.max_model_len,
895
  max_tokens=args.max_tokens,
896
  gpu_memory_utilization=args.gpu_memory_utilization,
@@ -907,7 +831,6 @@ Examples:
907
  use_chinese=args.use_chinese_prompts,
908
  custom_prompt=args.custom_prompt,
909
  output_column=args.output_column,
910
- overwrite=args.overwrite,
911
  clean_output=not args.no_clean_output,
912
  config=args.config,
913
  create_pr=args.create_pr,
 
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm>=0.15.1",
 
 
 
8
  # "tqdm",
9
  # "toolz",
10
  # "torch",
 
12
  # ///
13
 
14
  """
15
+ Convert document images to markdown using HunyuanOCR with vLLM.
16
 
17
  HunyuanOCR is a lightweight 1B parameter VLM from Tencent designed for complex
18
  multilingual document parsing. This script uses vLLM for processing.
19
 
 
 
 
 
 
 
 
20
  Features:
21
  - 📝 Full document parsing to markdown
22
  - 📊 Table extraction (HTML format)
 
26
  - 🌐 Photo translation
27
  - 🎯 Compact model (1B parameters)
28
 
29
+ Model: tencent/HunyuanOCR
30
+ vLLM: Requires nightly build (trust_remote_code=True)
 
 
31
 
32
  Note: Due to vLLM V1 engine batching issues with HunyuanOCR, batch_size defaults to 1.
33
  """
 
49
  from PIL import Image
50
  from toolz import partition_all
51
  from tqdm.auto import tqdm
 
 
 
 
52
  from vllm import LLM, SamplingParams
53
 
54
  logging.basicConfig(level=logging.INFO)
55
  logger = logging.getLogger(__name__)
56
 
 
 
 
 
 
 
57
 
58
  # ────────────────────────────────────────────────────────────────
59
  # HunyuanOCR Prompt Templates (from official README)
 
146
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  def get_prompt(
150
  prompt_mode: str,
151
  use_chinese: bool = False,
 
222
  def create_dataset_card(
223
  source_dataset: str,
224
  model: str,
 
225
  num_samples: int,
226
  processing_time: str,
227
  batch_size: int,
 
251
 
252
  This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using HunyuanOCR, a lightweight 1B VLM from Tencent.
253
 
 
 
254
  ## Processing Details
255
 
256
  - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
257
  - **Model**: [{model}](https://huggingface.co/{model})
 
258
  - **Number of Samples**: {num_samples:,}
259
  - **Processing Time**: {processing_time}
260
  - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
 
346
  image_column: str = "image",
347
  batch_size: int = 1, # Default to 1 due to vLLM V1 batching issues with HunyuanOCR
348
  model: str = "tencent/HunyuanOCR",
 
349
  max_model_len: int = 16384,
350
  max_tokens: int = 16384,
351
  gpu_memory_utilization: float = 0.8,
 
362
  use_chinese: bool = False,
363
  custom_prompt: str = None,
364
  output_column: str = "markdown",
 
365
  clean_output: bool = True,
366
  config: str = None,
367
  create_pr: bool = False,
 
405
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
406
  )
407
 
 
 
 
408
  # Shuffle if requested
409
  if shuffle:
410
  logger.info(f"Shuffling dataset with seed {seed}")
 
415
  dataset = dataset.select(range(min(max_samples, len(dataset))))
416
  logger.info(f"Limited to {len(dataset)} samples")
417
 
 
 
 
 
 
 
418
  # Initialize vLLM model
419
+ logger.info(f"Initializing vLLM with model: {model}")
420
  logger.info("This may take a few minutes on first run...")
421
 
422
  # Note: HunyuanOCR has batching issues with vLLM V1 engine when batch_size > 1
423
  # Using limit_mm_per_prompt for stability
424
  llm = LLM(
425
  model=model,
 
426
  trust_remote_code=True,
427
  max_model_len=max_model_len,
428
  gpu_memory_utilization=gpu_memory_utilization,
 
481
  inference_entry = {
482
  "model_id": model,
483
  "model_name": "HunyuanOCR",
 
484
  "column_name": output_column,
485
  "timestamp": datetime.now().isoformat(),
486
  "prompt_mode": prompt_mode if not custom_prompt else "custom",
 
548
  card_content = create_dataset_card(
549
  source_dataset=input_dataset,
550
  model=model,
 
551
  num_samples=len(dataset),
552
  processing_time=processing_time_str,
553
  batch_size=batch_size,
 
701
  default="tencent/HunyuanOCR",
702
  help="Model to use (default: tencent/HunyuanOCR)",
703
  )
 
 
 
 
 
 
 
704
  parser.add_argument(
705
  "--max-model-len",
706
  type=int,
 
782
  default="markdown",
783
  help="Column name for output text (default: markdown)",
784
  )
 
 
 
 
 
 
785
  parser.add_argument(
786
  "--no-clean-output",
787
  action="store_true",
 
815
  image_column=args.image_column,
816
  batch_size=args.batch_size,
817
  model=args.model,
 
818
  max_model_len=args.max_model_len,
819
  max_tokens=args.max_tokens,
820
  gpu_memory_utilization=args.gpu_memory_utilization,
 
831
  use_chinese=args.use_chinese_prompts,
832
  custom_prompt=args.custom_prompt,
833
  output_column=args.output_column,
 
834
  clean_output=not args.no_clean_output,
835
  config=args.config,
836
  create_pr=args.create_pr,
lfm2-extract.py DELETED
@@ -1,327 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "vllm",
7
- # "transformers",
8
- # "tqdm",
9
- # "toolz",
10
- # "torch",
11
- # ]
12
- # ///
13
- """
14
- Extract structured data (JSON / XML / YAML) from text using LiquidAI's LFM2-1.2B-Extract.
15
-
16
- LFM2-1.2B-Extract is a compact 1.2B text-only model purpose-built for turning unstructured
17
- documents into structured data: give it a schema, it returns JSON, XML, or YAML. It reports
18
- beating Gemma 3 27B (22x larger) on syntax validity / format accuracy / faithfulness, and
19
- is multilingual (en, ar, zh, fr, de, ja, ko, pt, es).
20
-
21
- This is the *text* counterpart to `lfm2-vl-extract.py` (which extracts from images). Pair them:
22
- OCR a page to markdown with one of the OCR recipes, then extract fields from that text here.
23
-
24
- Pass `--schema` as inline text/JSON, a URL, or a file path describing the structure to extract:
25
-
26
- --schema '{"invoice_number": "string", "total": "number", "line_items": "array"}'
27
-
28
- Model: https://huggingface.co/LiquidAI/LFM2-1.2B-Extract
29
- Docs: https://docs.liquid.ai/deployment/gpu-inference/vllm
30
-
31
- HF Jobs note: run on the vLLM image so the CUDA toolkit + prebuilt FlashInfer kernels are
32
- present and startup is fast (it reuses the image's CUDA-matched vLLM build):
33
-
34
- hf jobs uv run --flavor l4x1 --secrets HF_TOKEN \
35
- --image vllm/vllm-openai --python /usr/bin/python3 \
36
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \
37
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lfm2-extract.py \
38
- INPUT OUTPUT --text-column text --schema '{"field": "description"}'
39
-
40
- It also runs on the default uv image, just with a slower first-time vLLM build. Deps are left
41
- unpinned so uv resolves a recent vLLM; FlashInfer sampling is disabled (see below) so the engine
42
- never JIT-compiles a kernel that needs nvcc — absent from the default image.
43
- """
44
-
45
- import argparse
46
- import json
47
- import logging
48
- import os
49
- import sys
50
- from datetime import datetime, timezone
51
- from typing import List, Optional
52
- from urllib.request import urlopen
53
-
54
- # Disable vLLM's FlashInfer sampler before the engine starts: it JIT-compiles at warmup and
55
- # needs nvcc (absent from the default uv image). Harmless for greedy decoding.
56
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
57
-
58
- import torch
59
- from datasets import load_dataset
60
- from huggingface_hub import DatasetCard, login
61
- from toolz import partition_all
62
- from tqdm import tqdm
63
- from vllm import LLM, SamplingParams
64
-
65
- logging.basicConfig(level=logging.INFO)
66
- logger = logging.getLogger(__name__)
67
-
68
- DEFAULT_MODEL = "LiquidAI/LFM2-1.2B-Extract"
69
- FORMATS = {"json": "JSON", "xml": "XML", "yaml": "YAML"}
70
-
71
-
72
- def check_cuda_availability() -> None:
73
- if not torch.cuda.is_available():
74
- logger.error("CUDA is not available. This script requires a GPU.")
75
- logger.error("Run on Hugging Face Jobs with: hf jobs uv run --flavor l4x1 ...")
76
- sys.exit(1)
77
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name()}")
78
-
79
-
80
- def ensure_output_columns_free(dataset, columns, overwrite=False):
81
- """Fail fast if an output column would collide with an existing input column.
82
-
83
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
84
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
85
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
86
- column(s) here instead (logged) so the later add_column is clean.
87
- """
88
- clash = [c for c in columns if c in dataset.column_names]
89
- if not clash:
90
- return dataset
91
- if overwrite:
92
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
93
- return dataset.remove_columns(clash)
94
- logger.error(
95
- f"Output column(s) {clash} already exist in the input dataset "
96
- f"(columns: {dataset.column_names})."
97
- )
98
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
99
- sys.exit(1)
100
-
101
-
102
- def load_text_arg(value: str) -> str:
103
- """Resolve --schema (inline text/JSON, URL, or file path) into a string."""
104
- text = value.strip()
105
- if text.startswith("http://") or text.startswith("https://"):
106
- logger.info(f"Loading schema from URL: {text}")
107
- return urlopen(text).read().decode("utf-8").strip()
108
- if os.path.exists(text):
109
- logger.info(f"Loading schema from file: {text}")
110
- with open(text) as f:
111
- return f.read().strip()
112
- return text
113
-
114
-
115
- def build_system_prompt(schema_text: str, fmt: str) -> str:
116
- return f"Return data as a {FORMATS[fmt]} object with the following schema:\n\n{schema_text}"
117
-
118
-
119
- def parse_output(text: str, fmt: str) -> tuple[str, bool]:
120
- """Strip code fences; for JSON, validate. Returns (cleaned_text, is_valid)."""
121
- stripped = text.strip()
122
- if stripped.startswith("```"):
123
- stripped = stripped.split("\n", 1)[-1]
124
- if stripped.endswith("```"):
125
- stripped = stripped.rsplit("```", 1)[0]
126
- stripped = stripped.strip()
127
- if fmt == "json":
128
- try:
129
- return json.dumps(json.loads(stripped), ensure_ascii=False), True
130
- except (json.JSONDecodeError, ValueError):
131
- return stripped, False
132
- return stripped, True # xml/yaml: store as-is (no strict validator)
133
-
134
-
135
- def main(
136
- input_dataset: str,
137
- output_dataset: str,
138
- schema: str,
139
- text_column: str = "text",
140
- output_column: str = "extraction",
141
- overwrite: bool = False,
142
- output_format: str = "json",
143
- split: str = "train",
144
- max_samples: Optional[int] = None,
145
- shuffle: bool = False,
146
- seed: int = 42,
147
- batch_size: int = 32,
148
- model: str = DEFAULT_MODEL,
149
- max_model_len: int = 8192,
150
- max_tokens: int = 4096,
151
- private: bool = False,
152
- hf_token: Optional[str] = None,
153
- ) -> None:
154
- check_cuda_availability()
155
- if output_format not in FORMATS:
156
- logger.error(f"--format must be one of {list(FORMATS)}; got {output_format}")
157
- sys.exit(1)
158
-
159
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
160
- if HF_TOKEN:
161
- login(token=HF_TOKEN)
162
-
163
- schema_text = load_text_arg(schema)
164
- system_prompt = build_system_prompt(schema_text, output_format)
165
-
166
- logger.info(f"Loading dataset: {input_dataset} (split={split})")
167
- dataset = load_dataset(input_dataset, split=split)
168
-
169
- # Fail fast if the output column would collide with an existing input column
170
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
171
-
172
- if shuffle:
173
- dataset = dataset.shuffle(seed=seed)
174
- if max_samples:
175
- dataset = dataset.select(range(min(max_samples, len(dataset))))
176
- logger.info(f"Processing {len(dataset)} examples; format={output_format}")
177
-
178
- if text_column not in dataset.column_names:
179
- logger.error(f"Text column '{text_column}' not found. Columns: {dataset.column_names}")
180
- sys.exit(1)
181
-
182
- logger.info(f"Loading model: {model}")
183
- llm = LLM(model=model, max_model_len=max_model_len, enforce_eager=True)
184
- sampling_params = SamplingParams(temperature=0.0, max_tokens=max_tokens)
185
-
186
- all_outputs: List[str] = []
187
- n_valid = 0
188
- texts = dataset[text_column]
189
- for batch in tqdm(list(partition_all(batch_size, texts)), desc="Extracting"):
190
- batch_messages = [
191
- [
192
- {"role": "system", "content": system_prompt},
193
- {"role": "user", "content": str(doc)},
194
- ]
195
- for doc in batch
196
- ]
197
- outputs = llm.chat(batch_messages, sampling_params)
198
- for out in outputs:
199
- cleaned, ok = parse_output(out.outputs[0].text, output_format)
200
- n_valid += int(ok)
201
- all_outputs.append(cleaned)
202
-
203
- logger.info(f"Valid {output_format.upper()}: {n_valid}/{len(all_outputs)}")
204
- dataset = dataset.add_column(output_column, all_outputs)
205
-
206
- inference_entry = {
207
- "model": model,
208
- "column_name": output_column,
209
- "task": "structured extraction",
210
- "format": output_format,
211
- "timestamp": datetime.now(timezone.utc).isoformat(),
212
- "script": "lfm2-extract.py",
213
- }
214
- if "inference_info" in dataset.column_names:
215
- def update_info(example):
216
- try:
217
- existing = json.loads(example["inference_info"]) if example["inference_info"] else []
218
- except (json.JSONDecodeError, TypeError):
219
- existing = []
220
- existing.append(inference_entry)
221
- return {"inference_info": json.dumps(existing)}
222
- dataset = dataset.map(update_info)
223
- else:
224
- dataset = dataset.add_column(
225
- "inference_info", [json.dumps([inference_entry])] * len(dataset)
226
- )
227
-
228
- logger.info(f"Pushing to {output_dataset}")
229
- dataset.push_to_hub(output_dataset, private=private, token=HF_TOKEN)
230
-
231
- card_text = f"""---
232
- tags:
233
- - uv-script
234
- - extraction
235
- - lfm2
236
- - {output_format}
237
- ---
238
-
239
- # Structured extraction with LFM2-1.2B-Extract
240
-
241
- `{output_format.upper()}` extracted from the `{text_column}` column of
242
- [{input_dataset}](https://huggingface.co/datasets/{input_dataset})
243
- using [{model}](https://huggingface.co/{model}).
244
-
245
- - **Source**: `{input_dataset}` (split `{split}`, column `{text_column}`)
246
- - **Model**: `{model}`
247
- - **Format**: `{output_format}`
248
- - **Output column**: `{output_column}`
249
- - **Valid {output_format.upper()}**: {n_valid}/{len(all_outputs)}
250
- - **Date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
251
-
252
- Generated with the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) `lfm2-extract.py` script.
253
- """
254
- try:
255
- DatasetCard(card_text).push_to_hub(output_dataset, token=HF_TOKEN)
256
- except Exception as e:
257
- logger.warning(f"Could not push dataset card: {e}")
258
-
259
- logger.info("Done! Extraction complete.")
260
- logger.info(f"Dataset: https://huggingface.co/datasets/{output_dataset}")
261
-
262
-
263
- if __name__ == "__main__":
264
- if len(sys.argv) == 1:
265
- print("LFM2-1.2B-Extract — structured extraction (JSON/XML/YAML) from text")
266
- print("\nUsage:")
267
- print(" uv run lfm2-extract.py INPUT OUTPUT --schema SCHEMA [--text-column text] [--format json]")
268
- print("\nExample:")
269
- print(' uv run lfm2-extract.py my-docs my-fields \\')
270
- print(' --text-column markdown \\')
271
- print(' --schema \'{"title": "the title", "date": "any date", "summary": "one sentence"}\'')
272
- print("\n --schema accepts inline text/JSON, a URL, or a file path.")
273
- print("\nFor full help: uv run lfm2-extract.py --help")
274
- sys.exit(0)
275
-
276
- parser = argparse.ArgumentParser(
277
- description="Structured extraction (JSON/XML/YAML) from text using LFM2-1.2B-Extract",
278
- )
279
- parser.add_argument("input_dataset", help="Input dataset ID (with a text column)")
280
- parser.add_argument("output_dataset", help="Output dataset ID")
281
- parser.add_argument(
282
- "--schema", required=True,
283
- help="Structure to extract: inline text/JSON, a URL, or a file path",
284
- )
285
- parser.add_argument("--text-column", default="text", help="Text column (default: text)")
286
- parser.add_argument("--output-column", default="extraction", help="Output column (default: extraction)")
287
- parser.add_argument(
288
- "--overwrite",
289
- action="store_true",
290
- help="Replace the output column if it already exists in the input dataset "
291
- "(default: error out to avoid clobbering an existing column).",
292
- )
293
- parser.add_argument(
294
- "--format", dest="output_format", default="json", choices=list(FORMATS),
295
- help="Output format (default: json)",
296
- )
297
- parser.add_argument("--split", default="train", help="Dataset split (default: train)")
298
- parser.add_argument("--max-samples", type=int, help="Limit number of samples")
299
- parser.add_argument("--shuffle", action="store_true", help="Shuffle before sampling")
300
- parser.add_argument("--seed", type=int, default=42, help="Shuffle seed (default: 42)")
301
- parser.add_argument("--batch-size", type=int, default=32, help="Batch size (default: 32)")
302
- parser.add_argument("--model", default=DEFAULT_MODEL, help=f"Model (default: {DEFAULT_MODEL})")
303
- parser.add_argument("--max-model-len", type=int, default=8192, help="Max context length (default: 8192)")
304
- parser.add_argument("--max-tokens", type=int, default=4096, help="Max output tokens (default: 4096)")
305
- parser.add_argument("--private", action="store_true", help="Make output dataset private")
306
- parser.add_argument("--hf-token", help="HF token (or set HF_TOKEN)")
307
- args = parser.parse_args()
308
-
309
- main(
310
- input_dataset=args.input_dataset,
311
- output_dataset=args.output_dataset,
312
- schema=args.schema,
313
- text_column=args.text_column,
314
- output_column=args.output_column,
315
- overwrite=args.overwrite,
316
- output_format=args.output_format,
317
- split=args.split,
318
- max_samples=args.max_samples,
319
- shuffle=args.shuffle,
320
- seed=args.seed,
321
- batch_size=args.batch_size,
322
- model=args.model,
323
- max_model_len=args.max_model_len,
324
- max_tokens=args.max_tokens,
325
- private=args.private,
326
- hf_token=args.hf_token,
327
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lfm2-vl-extract.py DELETED
@@ -1,358 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "vllm",
8
- # "transformers",
9
- # "tqdm",
10
- # "toolz",
11
- # "torch",
12
- # ]
13
- # ///
14
- """
15
- Extract structured JSON from images using LiquidAI's LFM2.5-VL-1.6B-Extract with vLLM.
16
-
17
- LFM2.5-VL-1.6B-Extract (1.6B = LFM2 1.2B LM + SigLIP2 0.4B vision) is a compact
18
- vision-language model purpose-built for *schema-guided* extraction: you give it a
19
- list of fields, it returns a flat JSON object with those fields filled from the image.
20
- It reports 99.6 JSON-validity / F1 on its benchmark, beating similarly-sized VLMs.
21
-
22
- Unlike the markdown-OCR scripts here, this one needs a SCHEMA (a field list). Pass
23
- `--schema` as inline JSON, a URL, or a file path, mapping field names to short
24
- descriptions:
25
-
26
- --schema '{"invoice_number": "the invoice number", "total": "the total amount"}'
27
-
28
- Model: https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-Extract
29
- Docs: https://docs.liquid.ai/deployment/gpu-inference/vllm
30
-
31
- HF Jobs note: run on the vLLM image so the CUDA toolkit + prebuilt FlashInfer kernels
32
- are present and startup is fast (it reuses the image's CUDA-matched vLLM build):
33
-
34
- hf jobs uv run --flavor l4x1 --secrets HF_TOKEN \
35
- --image vllm/vllm-openai --python /usr/bin/python3 \
36
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \
37
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lfm2-vl-extract.py \
38
- INPUT OUTPUT --schema '{"title": "the document title", "date": "any date shown"}'
39
-
40
- It also runs on the default uv image, just with a slower first-time vLLM build. Deps are
41
- left unpinned so uv resolves a vLLM that supports the LFM2-VL (transformers 5) architecture,
42
- and FlashInfer sampling is disabled (VLLM_USE_FLASHINFER_SAMPLER=0, see below) so the engine
43
- never JIT-compiles a kernel that needs nvcc — absent from the default image.
44
- """
45
-
46
- import argparse
47
- import base64
48
- import io
49
- import json
50
- import logging
51
- import os
52
- import sys
53
- from datetime import datetime, timezone
54
- from typing import Any, Dict, List, Optional, Union
55
- from urllib.request import urlopen
56
-
57
- # Disable vLLM's FlashInfer top-k/top-p sampler before the engine starts: it JIT-compiles
58
- # at warmup and needs nvcc (absent from the default uv image). Harmless for greedy decoding.
59
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
60
-
61
- import torch
62
- from datasets import load_dataset
63
- from huggingface_hub import DatasetCard, login
64
- from PIL import Image
65
- from toolz import partition_all
66
- from tqdm import tqdm
67
- from vllm import LLM, SamplingParams
68
-
69
- logging.basicConfig(level=logging.INFO)
70
- logger = logging.getLogger(__name__)
71
-
72
- DEFAULT_MODEL = "LiquidAI/LFM2.5-VL-1.6B-Extract"
73
-
74
-
75
- def check_cuda_availability() -> None:
76
- if not torch.cuda.is_available():
77
- logger.error("CUDA is not available. This script requires a GPU.")
78
- logger.error("Run on Hugging Face Jobs with: hf jobs uv run --flavor l4x1 ...")
79
- sys.exit(1)
80
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name()}")
81
-
82
-
83
- def ensure_output_columns_free(dataset, columns, overwrite=False):
84
- """Fail fast if an output column would collide with an existing input column.
85
-
86
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
87
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
88
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
89
- column(s) here instead (logged) so the later add_column is clean.
90
- """
91
- clash = [c for c in columns if c in dataset.column_names]
92
- if not clash:
93
- return dataset
94
- if overwrite:
95
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
96
- return dataset.remove_columns(clash)
97
- logger.error(
98
- f"Output column(s) {clash} already exist in the input dataset "
99
- f"(columns: {dataset.column_names})."
100
- )
101
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
102
- sys.exit(1)
103
-
104
-
105
- def load_schema_arg(value: str) -> Dict[str, str]:
106
- """Resolve --schema (inline JSON, URL, or file path) into a {field: description} dict."""
107
- text = value.strip()
108
- if text.startswith("http://") or text.startswith("https://"):
109
- logger.info(f"Loading schema from URL: {text}")
110
- text = urlopen(text).read().decode("utf-8")
111
- elif not text.startswith("{") and not text.startswith("["):
112
- if os.path.exists(text):
113
- logger.info(f"Loading schema from file: {text}")
114
- with open(text) as f:
115
- text = f.read()
116
- parsed = json.loads(text)
117
- # Accept {"field": "description"} or ["field1", "field2"]
118
- if isinstance(parsed, list):
119
- return {str(field): "" for field in parsed}
120
- if isinstance(parsed, dict):
121
- return {str(k): str(v) for k, v in parsed.items()}
122
- raise ValueError("--schema must be a JSON object {field: description} or a JSON list of field names.")
123
-
124
-
125
- def build_system_prompt(schema: Dict[str, str]) -> str:
126
- """LFM2.5-VL-Extract prompt: a field list in the system message → flat JSON out."""
127
- lines = []
128
- for field, desc in schema.items():
129
- lines.append(f"{field}: {desc}" if desc else field)
130
- fields_block = "\n".join(lines)
131
- return (
132
- f"Extract the following from the image:\n\n{fields_block}\n\n"
133
- "Respond with only a JSON object."
134
- )
135
-
136
-
137
- def image_to_data_uri(image: Union[Image.Image, Dict[str, Any], str]) -> str:
138
- if isinstance(image, dict) and "bytes" in image:
139
- image = Image.open(io.BytesIO(image["bytes"]))
140
- elif isinstance(image, str):
141
- image = Image.open(image)
142
- if image.mode != "RGB":
143
- image = image.convert("RGB")
144
- buf = io.BytesIO()
145
- image.save(buf, format="PNG")
146
- return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
147
-
148
-
149
- def make_message(image: Any, system_prompt: str) -> List[Dict]:
150
- data_uri = image_to_data_uri(image)
151
- return [
152
- {"role": "system", "content": system_prompt},
153
- {"role": "user", "content": [{"type": "image_url", "image_url": {"url": data_uri}}]},
154
- ]
155
-
156
-
157
- def parse_json_output(text: str) -> tuple[Optional[Any], bool]:
158
- """Return (parsed, ok). Strips ```json fences if present."""
159
- stripped = text.strip()
160
- if stripped.startswith("```"):
161
- stripped = stripped.split("\n", 1)[-1]
162
- if stripped.endswith("```"):
163
- stripped = stripped.rsplit("```", 1)[0]
164
- stripped = stripped.strip()
165
- try:
166
- return json.loads(stripped), True
167
- except (json.JSONDecodeError, ValueError):
168
- return None, False
169
-
170
-
171
- def main(
172
- input_dataset: str,
173
- output_dataset: str,
174
- schema: str,
175
- image_column: str = "image",
176
- output_column: str = "extraction",
177
- overwrite: bool = False,
178
- split: str = "train",
179
- max_samples: Optional[int] = None,
180
- shuffle: bool = False,
181
- seed: int = 42,
182
- batch_size: int = 16,
183
- model: str = DEFAULT_MODEL,
184
- max_model_len: int = 4096,
185
- max_tokens: int = 1024,
186
- private: bool = False,
187
- hf_token: Optional[str] = None,
188
- ) -> None:
189
- check_cuda_availability()
190
-
191
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
192
- if HF_TOKEN:
193
- login(token=HF_TOKEN)
194
-
195
- schema_dict = load_schema_arg(schema)
196
- system_prompt = build_system_prompt(schema_dict)
197
- logger.info(f"Extraction fields: {list(schema_dict.keys())}")
198
-
199
- logger.info(f"Loading dataset: {input_dataset} (split={split})")
200
- dataset = load_dataset(input_dataset, split=split)
201
-
202
- # Fail fast if the output column would collide with an existing input column
203
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
204
-
205
- if shuffle:
206
- dataset = dataset.shuffle(seed=seed)
207
- if max_samples:
208
- dataset = dataset.select(range(min(max_samples, len(dataset))))
209
- logger.info(f"Processing {len(dataset)} examples")
210
-
211
- if image_column not in dataset.column_names:
212
- logger.error(f"Image column '{image_column}' not found. Columns: {dataset.column_names}")
213
- sys.exit(1)
214
-
215
- logger.info(f"Loading model: {model}")
216
- llm = LLM(
217
- model=model,
218
- max_model_len=max_model_len,
219
- limit_mm_per_prompt={"image": 1},
220
- enforce_eager=True,
221
- )
222
- sampling_params = SamplingParams(temperature=0.0, max_tokens=max_tokens)
223
-
224
- all_outputs: List[str] = []
225
- n_valid = 0
226
- images = dataset[image_column]
227
- for batch in tqdm(list(partition_all(batch_size, images)), desc="Extracting"):
228
- batch_messages = [make_message(img, system_prompt) for img in batch]
229
- outputs = llm.chat(batch_messages, sampling_params)
230
- for out in outputs:
231
- text = out.outputs[0].text.strip()
232
- parsed, ok = parse_json_output(text)
233
- if ok:
234
- n_valid += 1
235
- all_outputs.append(json.dumps(parsed, ensure_ascii=False))
236
- else:
237
- all_outputs.append(text) # keep raw on parse failure
238
-
239
- logger.info(f"Valid JSON: {n_valid}/{len(all_outputs)}")
240
-
241
- dataset = dataset.add_column(output_column, all_outputs)
242
-
243
- inference_entry = {
244
- "model": model,
245
- "column_name": output_column,
246
- "task": "schema-guided extraction",
247
- "fields": list(schema_dict.keys()),
248
- "timestamp": datetime.now(timezone.utc).isoformat(),
249
- "script": "lfm2-vl-extract.py",
250
- }
251
- if "inference_info" in dataset.column_names:
252
- def update_info(example):
253
- try:
254
- existing = json.loads(example["inference_info"]) if example["inference_info"] else []
255
- except (json.JSONDecodeError, TypeError):
256
- existing = []
257
- existing.append(inference_entry)
258
- return {"inference_info": json.dumps(existing)}
259
- dataset = dataset.map(update_info)
260
- else:
261
- dataset = dataset.add_column(
262
- "inference_info", [json.dumps([inference_entry])] * len(dataset)
263
- )
264
-
265
- logger.info(f"Pushing to {output_dataset}")
266
- dataset.push_to_hub(output_dataset, private=private, token=HF_TOKEN)
267
-
268
- card_text = f"""---
269
- tags:
270
- - uv-script
271
- - extraction
272
- - lfm2-vl
273
- - json
274
- ---
275
-
276
- # Structured extraction with LFM2.5-VL-1.6B-Extract
277
-
278
- JSON fields extracted from images in [{input_dataset}](https://huggingface.co/datasets/{input_dataset})
279
- using [{model}](https://huggingface.co/{model}).
280
-
281
- - **Source**: `{input_dataset}` (split `{split}`)
282
- - **Model**: `{model}`
283
- - **Fields**: {", ".join(f"`{k}`" for k in schema_dict.keys())}
284
- - **Output column**: `{output_column}` (JSON string per row)
285
- - **Valid JSON**: {n_valid}/{len(all_outputs)}
286
- - **Date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
287
-
288
- Generated with the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) `lfm2-vl-extract.py` script.
289
- """
290
- try:
291
- card = DatasetCard(card_text)
292
- card.push_to_hub(output_dataset, token=HF_TOKEN)
293
- except Exception as e:
294
- logger.warning(f"Could not push dataset card: {e}")
295
-
296
- logger.info("Done! Extraction complete.")
297
- logger.info(f"Dataset: https://huggingface.co/datasets/{output_dataset}")
298
-
299
-
300
- if __name__ == "__main__":
301
- if len(sys.argv) == 1:
302
- print("LFM2.5-VL-1.6B-Extract — schema-guided JSON extraction from images")
303
- print("\nUsage:")
304
- print(" uv run lfm2-vl-extract.py INPUT OUTPUT --schema SCHEMA [options]")
305
- print("\nExample:")
306
- print(' uv run lfm2-vl-extract.py my-images my-extractions \\')
307
- print(' --schema \'{"title": "the document title", "date": "any date shown"}\'')
308
- print("\n --schema accepts inline JSON, a URL, or a file path.")
309
- print("\nFor full help: uv run lfm2-vl-extract.py --help")
310
- sys.exit(0)
311
-
312
- parser = argparse.ArgumentParser(
313
- description="Schema-guided JSON extraction from images using LFM2.5-VL-1.6B-Extract",
314
- )
315
- parser.add_argument("input_dataset", help="Input dataset ID (with images)")
316
- parser.add_argument("output_dataset", help="Output dataset ID")
317
- parser.add_argument(
318
- "--schema", required=True,
319
- help="Fields to extract: inline JSON {field: description}, a URL, or a file path",
320
- )
321
- parser.add_argument("--image-column", default="image", help="Image column (default: image)")
322
- parser.add_argument("--output-column", default="extraction", help="Output column (default: extraction)")
323
- parser.add_argument(
324
- "--overwrite",
325
- action="store_true",
326
- help="Replace the output column if it already exists in the input dataset "
327
- "(default: error out to avoid clobbering an existing column).",
328
- )
329
- parser.add_argument("--split", default="train", help="Dataset split (default: train)")
330
- parser.add_argument("--max-samples", type=int, help="Limit number of samples")
331
- parser.add_argument("--shuffle", action="store_true", help="Shuffle before sampling")
332
- parser.add_argument("--seed", type=int, default=42, help="Shuffle seed (default: 42)")
333
- parser.add_argument("--batch-size", type=int, default=16, help="Batch size (default: 16)")
334
- parser.add_argument("--model", default=DEFAULT_MODEL, help=f"Model (default: {DEFAULT_MODEL})")
335
- parser.add_argument("--max-model-len", type=int, default=4096, help="Max context length (default: 4096)")
336
- parser.add_argument("--max-tokens", type=int, default=1024, help="Max output tokens (default: 1024)")
337
- parser.add_argument("--private", action="store_true", help="Make output dataset private")
338
- parser.add_argument("--hf-token", help="HF token (or set HF_TOKEN)")
339
- args = parser.parse_args()
340
-
341
- main(
342
- input_dataset=args.input_dataset,
343
- output_dataset=args.output_dataset,
344
- schema=args.schema,
345
- image_column=args.image_column,
346
- output_column=args.output_column,
347
- overwrite=args.overwrite,
348
- split=args.split,
349
- max_samples=args.max_samples,
350
- shuffle=args.shuffle,
351
- seed=args.seed,
352
- batch_size=args.batch_size,
353
- model=args.model,
354
- max_model_len=args.max_model_len,
355
- max_tokens=args.max_tokens,
356
- private=args.private,
357
- hf_token=args.hf_token,
358
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lift-extract.py DELETED
@@ -1,846 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.12"
3
- # dependencies = [
4
- # "lift-pdf[hf]",
5
- # "datasets>=3.1.0",
6
- # "huggingface-hub",
7
- # "pillow",
8
- # "toolz",
9
- # "tqdm",
10
- # ]
11
- # ///
12
- """
13
- Extract structured JSON from document images OR multi-page PDFs using Datalab's
14
- `lift` model (`datalab-to/lift`, 9B, Qwen3.5-based).
15
-
16
- Unlike the markdown-OCR scripts here, lift does *schema-constrained* extraction:
17
- you give it a JSON Schema, it returns a JSON object matching that schema. It
18
- natively handles multi-page documents — a whole PDF is collapsed into a single
19
- extraction.
20
-
21
- Two in-process backends, selected with `--method` (no server, single command):
22
-
23
- --method hf (default) Transformers via the `lift-pdf` package. Runs on the
24
- default uv image. Simplest path; best for small jobs.
25
- --method vllm vLLM's offline `LLM()` engine (`llm.chat`) with
26
- structured-output decoding — the fast batched path the
27
- other vLLM OCR scripts here use. Needs the
28
- `vllm/vllm-openai` image (which ships vLLM). Reproduces
29
- lift's own prompt + guided-JSON recipe against the
30
- offline engine. Wins on large jobs via continuous batching.
31
-
32
- Benchmark the two by pushing each to one repo with `--config hf` / `--config vllm`.
33
-
34
- Input is one document per row:
35
- --image-column COL (default `image`) one image per row -> one extraction
36
- --pdf-column COL PDF bytes per row -> one extraction
37
- (multi-page; respects --page-range)
38
-
39
- Pass `--schema` as inline JSON, a URL, or a file path (standard JSON Schema):
40
-
41
- --schema '{"type":"object","properties":{"invoice_number":{"type":"string"},
42
- "total":{"type":"number"}},"required":["invoice_number"]}'
43
-
44
- LICENSE NOTE: lift's *code* is Apache-2.0 but the *weights* are a modified
45
- OpenRAIL-M license — free for research, personal use, and startups under $5M
46
- funding/revenue, but restricted from competitive use against Datalab's API.
47
- Confirm you are within those terms before using it. https://huggingface.co/datalab-to/lift
48
-
49
- HF Jobs — HF backend (default image is fine; 9B needs a roomy GPU):
50
-
51
- hf jobs uv run --flavor a100-large -s HF_TOKEN \\
52
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lift-extract.py \\
53
- INPUT_DATASET OUTPUT_DATASET \\
54
- --schema '{"type":"object","properties":{"title":{"type":"string"}}}' \\
55
- --max-samples 5 --shuffle --seed 42
56
-
57
- HF Jobs — vLLM offline backend (use the vllm image so vLLM is present):
58
-
59
- hf jobs uv run --flavor a100-large -s HF_TOKEN \\
60
- --image vllm/vllm-openai --python /usr/bin/python3 \\
61
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\
62
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lift-extract.py \\
63
- INPUT_DATASET OUTPUT_DATASET --method vllm \\
64
- --schema '{"type":"object","properties":{"title":{"type":"string"}}}' \\
65
- --max-samples 5
66
-
67
- Model: datalab-to/lift (package: lift-pdf, https://github.com/datalab-to/lift)
68
- """
69
-
70
- import argparse
71
- import base64
72
- import io
73
- import json
74
- import logging
75
- import os
76
- import sys
77
- import tempfile
78
- import time
79
- from datetime import datetime, timezone
80
- from typing import Any, Dict, List, Optional, Tuple
81
- from urllib.request import urlopen
82
-
83
- from datasets import load_dataset
84
- from huggingface_hub import DatasetCard, login
85
- from PIL import Image
86
- from toolz import partition_all
87
- from tqdm import tqdm
88
-
89
- logging.basicConfig(level=logging.INFO)
90
- logger = logging.getLogger(__name__)
91
-
92
- # The package default checkpoint drifts between releases (e.g. "datalab-to/lift-extract");
93
- # pin to the canonical card repo so the script is stable across lift-pdf versions.
94
- DEFAULT_MODEL = "datalab-to/lift"
95
- DEFAULT_MAX_TOKENS = 12384 # lift-pdf's own MAX_OUTPUT_TOKENS default
96
-
97
- # A processed document: (parsed JSON or None, error flag, raw model text).
98
- DocResult = Tuple[Optional[Any], bool, str]
99
-
100
-
101
- def check_cuda_availability() -> None:
102
- """Exit early with a clear message if there's no GPU."""
103
- import torch
104
-
105
- if not torch.cuda.is_available():
106
- logger.error("CUDA is not available. This script requires a GPU.")
107
- logger.error(
108
- "Run on Hugging Face Jobs with: hf jobs uv run --flavor a100-large ..."
109
- )
110
- sys.exit(1)
111
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
112
-
113
-
114
- def ensure_output_columns_free(dataset, columns, overwrite=False):
115
- """Fail fast if an output column would collide with an existing input column.
116
-
117
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
118
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
119
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
120
- column(s) here instead (logged) so the later add_column is clean.
121
- """
122
- clash = [c for c in columns if c in dataset.column_names]
123
- if not clash:
124
- return dataset
125
- if overwrite:
126
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
127
- return dataset.remove_columns(clash)
128
- logger.error(
129
- f"Output column(s) {clash} already exist in the input dataset "
130
- f"(columns: {dataset.column_names})."
131
- )
132
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
133
- sys.exit(1)
134
-
135
-
136
- def load_schema_arg(value: str) -> Dict[str, Any]:
137
- """Resolve --schema (inline JSON, a URL, or a file path) into a JSON Schema dict."""
138
- text = value.strip()
139
- if text.startswith(("http://", "https://")):
140
- logger.info(f"Loading schema from URL: {text}")
141
- text = urlopen(text).read().decode("utf-8") # noqa: S310
142
- elif not text.startswith("{"):
143
- # Looks like a path (inline JSON would start with "{"); read it if it exists.
144
- if os.path.isfile(text):
145
- logger.info(f"Loading schema from file: {text}")
146
- with open(text) as f:
147
- text = f.read()
148
- try:
149
- parsed = json.loads(text)
150
- except json.JSONDecodeError as e:
151
- raise ValueError(
152
- f"Could not parse --schema as JSON (tried URL/path/inline): {e}"
153
- ) from e
154
- if not isinstance(parsed, dict):
155
- raise ValueError("--schema must be a JSON object (a JSON Schema).")
156
- return parsed
157
-
158
-
159
- def cell_to_bytes(cell: Any) -> bytes:
160
- """Normalize an HF dataset cell (image or document) to raw file bytes.
161
-
162
- Handles decoded PIL images (Image feature), {"bytes"/"path"} dicts, raw bytes
163
- (e.g. a binary PDF column), and string paths/URLs.
164
- """
165
- if isinstance(cell, Image.Image):
166
- buf = io.BytesIO()
167
- cell.convert("RGB").save(buf, format="PNG")
168
- return buf.getvalue()
169
- if isinstance(cell, dict):
170
- if cell.get("bytes"):
171
- return cell["bytes"]
172
- if cell.get("path"):
173
- with open(cell["path"], "rb") as f:
174
- return f.read()
175
- raise ValueError(
176
- f"Unsupported image/document dict (no bytes/path): {list(cell)}"
177
- )
178
- if isinstance(cell, (bytes, bytearray)):
179
- return bytes(cell)
180
- if isinstance(cell, str):
181
- if cell.startswith(("http://", "https://")):
182
- return urlopen(cell).read() # noqa: S310
183
- with open(cell, "rb") as f:
184
- return f.read()
185
- raise ValueError(f"Unsupported cell type: {type(cell)}")
186
-
187
-
188
- def load_document_images(
189
- load_file, cell: Any, page_range: Optional[str]
190
- ) -> List[Image.Image]:
191
- """Render one dataset cell into the page images lift expects.
192
-
193
- Reuses lift's own `load_file`, which auto-detects PDF vs image by content
194
- (pypdfium2 for PDFs, with the model's DPI/min-dim and page-range handling).
195
- """
196
- data = cell_to_bytes(cell)
197
- # load_file detects type from content, so the temp file needs no extension.
198
- with tempfile.NamedTemporaryFile(delete=False) as tmp:
199
- tmp.write(data)
200
- path = tmp.name
201
- try:
202
- config = {"page_range": page_range} if page_range else {}
203
- return load_file(path, config)
204
- finally:
205
- os.unlink(path)
206
-
207
-
208
- def pil_to_data_uri(img: Image.Image) -> str:
209
- """PNG data URI for an OpenAI-format image content block."""
210
- if img.mode != "RGB":
211
- img = img.convert("RGB")
212
- buf = io.BytesIO()
213
- img.save(buf, format="PNG")
214
- return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
215
-
216
-
217
- def parse_json_output(text: str) -> Tuple[Optional[Any], bool]:
218
- """Return (parsed, ok). Strips ```json fences if present."""
219
- stripped = text.strip()
220
- if stripped.startswith("```"):
221
- stripped = stripped.split("\n", 1)[-1] if "\n" in stripped else stripped[3:]
222
- if stripped.endswith("```"):
223
- stripped = stripped[:-3].rstrip()
224
- try:
225
- return json.loads(stripped), True
226
- except (json.JSONDecodeError, ValueError):
227
- return None, False
228
-
229
-
230
- # --- HF backend (lift-pdf package, in-process Transformers) ---
231
- def make_hf_processor(schema: Dict[str, Any], max_tokens: Optional[int]):
232
- """Load lift via the package's HF backend; return a batch-processing closure."""
233
- from lift.model import InferenceManager
234
- from lift.model.schema import BatchInputItem
235
-
236
- logger.info("Loading lift via Transformers (method=hf)...")
237
- manager = InferenceManager(method="hf")
238
-
239
- def process(image_lists: List[List[Image.Image]]) -> List[DocResult]:
240
- items = [
241
- BatchInputItem(images=imgs, schema=schema, prompt_type="direct")
242
- for imgs in image_lists
243
- ]
244
- results = manager.generate(items, max_output_tokens=max_tokens)
245
- return [(r.extraction, bool(r.error), r.raw) for r in results]
246
-
247
- return process
248
-
249
-
250
- # --- vLLM backend (offline LLM() engine + structured outputs) ---
251
- def build_guided_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
252
- """Reproduce lift's vLLM guided-decoding schema: JSON Schema -> pydantic ->
253
- json_schema with every leaf made nullable (so absent fields can be null,
254
- matching lift's own server-side behavior)."""
255
- from json_schema_to_pydantic import create_model
256
- from lift.model.vllm import make_properties_nullable
257
-
258
- schema_model = create_model(schema)
259
- json_schema = schema_model.model_json_schema()
260
- make_properties_nullable(json_schema)
261
- return json_schema
262
-
263
-
264
- def make_sampling_params(json_schema: Dict[str, Any], max_tokens: int):
265
- """SamplingParams with structured JSON output, across vLLM API versions.
266
-
267
- lift uses greedy-ish decoding (temperature 0.0, top_p 0.1).
268
- """
269
- from vllm import SamplingParams
270
-
271
- # vLLM >= 0.12
272
- try:
273
- from vllm.sampling_params import StructuredOutputsParams
274
-
275
- return SamplingParams(
276
- temperature=0.0,
277
- top_p=0.1,
278
- max_tokens=max_tokens,
279
- structured_outputs=StructuredOutputsParams(json=json_schema),
280
- )
281
- except (ImportError, TypeError):
282
- pass
283
- # Older vLLM
284
- try:
285
- from vllm.sampling_params import GuidedDecodingParams
286
-
287
- return SamplingParams(
288
- temperature=0.0,
289
- top_p=0.1,
290
- max_tokens=max_tokens,
291
- guided_decoding=GuidedDecodingParams(json=json_schema),
292
- )
293
- except (ImportError, TypeError):
294
- pass
295
- logger.warning(
296
- "Structured output unavailable in this vLLM version; relying on lift's "
297
- "training to emit valid JSON."
298
- )
299
- return SamplingParams(temperature=0.0, top_p=0.1, max_tokens=max_tokens)
300
-
301
-
302
- def make_vllm_processor(
303
- schema: Dict[str, Any],
304
- model: str,
305
- max_tokens: Optional[int],
306
- max_model_len: int,
307
- gpu_memory_utilization: float,
308
- max_images_per_doc: int,
309
- ):
310
- """Load lift into vLLM's offline engine; return a batch-processing closure."""
311
- try:
312
- from vllm import LLM
313
- except ImportError as e:
314
- raise RuntimeError(
315
- "--method vllm needs vLLM. Run on the vllm/vllm-openai image: "
316
- "--image vllm/vllm-openai --python /usr/bin/python3 "
317
- "-e PYTHONPATH=/usr/local/lib/python3.12/dist-packages"
318
- ) from e
319
- from lift.model.util import scale_to_fit
320
- from lift.prompts import PROMPT_MAPPING
321
-
322
- json_schema = build_guided_schema(schema)
323
- prompt = PROMPT_MAPPING["direct"].replace("{schema}", json.dumps(schema, indent=2))
324
-
325
- logger.info("Loading lift via vLLM offline engine (method=vllm)...")
326
- llm = LLM(
327
- model=model,
328
- trust_remote_code=True,
329
- max_model_len=max_model_len,
330
- gpu_memory_utilization=gpu_memory_utilization,
331
- limit_mm_per_prompt={"image": max_images_per_doc},
332
- # lift's own server-side image bounds, applied by the offline processor too.
333
- mm_processor_kwargs={"min_pixels": 3136, "max_pixels": 861696},
334
- )
335
- sampling_params = make_sampling_params(
336
- json_schema, max_tokens or DEFAULT_MAX_TOKENS
337
- )
338
-
339
- def process(image_lists: List[List[Image.Image]]) -> List[DocResult]:
340
- messages = []
341
- for imgs in image_lists:
342
- content = [
343
- {
344
- "type": "image_url",
345
- "image_url": {"url": pil_to_data_uri(scale_to_fit(img))},
346
- }
347
- for img in imgs
348
- ]
349
- content.append({"type": "text", "text": prompt})
350
- messages.append([{"role": "user", "content": content}])
351
- outputs = llm.chat(
352
- messages, sampling_params, chat_template_content_format="openai"
353
- )
354
- results: List[DocResult] = []
355
- for o in outputs:
356
- raw = o.outputs[0].text
357
- parsed, ok = parse_json_output(raw)
358
- results.append((parsed if ok else None, not ok, raw))
359
- return results
360
-
361
- return process
362
-
363
-
364
- def create_dataset_card(
365
- source_dataset: str,
366
- model: str,
367
- method: str,
368
- schema: Dict[str, Any],
369
- num_samples: int,
370
- n_valid: int,
371
- source_column: str,
372
- is_pdf: bool,
373
- page_range: Optional[str],
374
- output_column: str,
375
- split: str,
376
- processing_time: str,
377
- ) -> str:
378
- """Build the output dataset card documenting the lift run."""
379
- schema_block = json.dumps(schema, indent=2)
380
- input_kind = "PDF documents" if is_pdf else "images"
381
- col_desc = "PDF" if is_pdf else "image"
382
- if page_range:
383
- col_desc += f", pages {page_range}"
384
- backend_desc = (
385
- "vLLM offline engine" if method == "vllm" else "Transformers (lift-pdf)"
386
- )
387
- return f"""---
388
- tags:
389
- - ocr
390
- - structured-extraction
391
- - document-processing
392
- - lift
393
- - json
394
- - uv-script
395
- - generated
396
- ---
397
-
398
- # lift structured extraction on {source_dataset}
399
-
400
- Schema-constrained JSON extracted from {input_kind} in
401
- [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using
402
- [lift](https://huggingface.co/{model}) (9B, Qwen3.5-based) by Datalab, via the
403
- [`lift-pdf`](https://github.com/datalab-to/lift) package.
404
-
405
- ## Processing Details
406
-
407
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
408
- - **Model**: [{model}](https://huggingface.co/{model})
409
- - **Backend**: `{method}` ({backend_desc})
410
- - **Input column**: `{source_column}` ({col_desc})
411
- - **Output column**: `{output_column}` (JSON string per row)
412
- - **Split**: `{split}`
413
- - **Samples**: {num_samples:,}
414
- - **Valid JSON**: {n_valid:,} / {num_samples:,}
415
- - **Processing time**: {processing_time}
416
- - **Date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
417
-
418
- ### Extraction Schema
419
-
420
- ```json
421
- {schema_block}
422
- ```
423
-
424
- ## License note
425
-
426
- lift's code is Apache-2.0, but the model **weights** use a modified OpenRAIL-M
427
- license: free for research, personal use, and startups under $5M funding/revenue,
428
- restricted from competitive use against Datalab's API. See the
429
- [model card](https://huggingface.co/{model}).
430
-
431
- ## Dataset Structure
432
-
433
- Original columns plus:
434
- - `{output_column}`: lift output (JSON string; raw text kept on parse failure)
435
- - `inference_info`: JSON list tracking models applied to this dataset
436
-
437
- Generated with [UV Scripts](https://huggingface.co/uv-scripts).
438
- """
439
-
440
-
441
- def main(
442
- input_dataset: str,
443
- output_dataset: str,
444
- schema_arg: str,
445
- image_column: str = "image",
446
- pdf_column: Optional[str] = None,
447
- output_column: str = "extraction",
448
- overwrite: bool = False,
449
- method: str = "hf",
450
- page_range: Optional[str] = None,
451
- split: str = "train",
452
- max_samples: Optional[int] = None,
453
- shuffle: bool = False,
454
- seed: int = 42,
455
- batch_size: int = 8,
456
- max_tokens: Optional[int] = None,
457
- max_model_len: int = 32768,
458
- gpu_memory_utilization: float = 0.9,
459
- max_images_per_doc: Optional[int] = None,
460
- model: str = DEFAULT_MODEL,
461
- private: bool = False,
462
- config: Optional[str] = None,
463
- create_pr: bool = False,
464
- hf_token: Optional[str] = None,
465
- verbose: bool = False,
466
- ) -> None:
467
- # Unlock full Xet bandwidth for the 9B (~19GB) model download (repo convention).
468
- os.environ["HF_XET_HIGH_PERFORMANCE"] = "1"
469
- check_cuda_availability()
470
- start_time = datetime.now(timezone.utc)
471
-
472
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
473
- if HF_TOKEN:
474
- login(token=HF_TOKEN)
475
-
476
- schema = load_schema_arg(schema_arg)
477
-
478
- # lift reads the checkpoint from env (pydantic-settings) at import time; set it first.
479
- os.environ["MODEL_CHECKPOINT"] = model
480
-
481
- # Import lift only after env is set so settings pick up the right checkpoint.
482
- from lift import resolve_schema
483
- from lift.input import load_file
484
-
485
- schema = resolve_schema(schema) # validates and normalizes
486
- fields = list(schema.get("properties", {}).keys())
487
-
488
- source_column = pdf_column or image_column
489
- is_pdf = pdf_column is not None
490
- # vLLM caps images per prompt at init; PDFs need headroom for multiple pages.
491
- if max_images_per_doc is None:
492
- max_images_per_doc = 30 if is_pdf else 1
493
-
494
- logger.info(f"Model: {model} Backend: {method}")
495
- logger.info(f"Schema top-level fields: {fields}")
496
-
497
- logger.info(f"Loading dataset: {input_dataset} (split={split})")
498
- dataset = load_dataset(input_dataset, split=split)
499
- if source_column not in dataset.column_names:
500
- logger.error(
501
- f"Column '{source_column}' not found. Available: {dataset.column_names}"
502
- )
503
- sys.exit(1)
504
-
505
- # Fail fast if the output column would collide with an existing input column
506
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
507
-
508
- if shuffle:
509
- dataset = dataset.shuffle(seed=seed)
510
- if max_samples:
511
- dataset = dataset.select(range(min(max_samples, len(dataset))))
512
- logger.info(f"Processing {len(dataset)} documents from column '{source_column}'")
513
-
514
- if method == "vllm":
515
- process_batch = make_vllm_processor(
516
- schema,
517
- model,
518
- max_tokens,
519
- max_model_len,
520
- gpu_memory_utilization,
521
- max_images_per_doc,
522
- )
523
- else:
524
- process_batch = make_hf_processor(schema, max_tokens)
525
-
526
- extractions: List[Optional[str]] = [None] * len(dataset)
527
- error_flags: List[bool] = [True] * len(dataset)
528
-
529
- chunks = list(partition_all(batch_size, range(len(dataset))))
530
- for chunk in tqdm(chunks, desc="Extracting"):
531
- chunk = list(chunk)
532
- rendered: Dict[int, List[Image.Image]] = {}
533
- for i in chunk:
534
- try:
535
- rendered[i] = load_document_images(
536
- load_file, dataset[i][source_column], page_range
537
- )
538
- except Exception as e:
539
- logger.warning(f"Row {i}: failed to load document: {e}")
540
- extractions[i] = f"[LIFT LOAD ERROR] {e}"
541
- error_flags[i] = True
542
- if not rendered:
543
- continue
544
-
545
- idxs = list(rendered.keys())
546
- try:
547
- results = process_batch([rendered[i] for i in idxs])
548
- except Exception as e:
549
- logger.error(f"Batch generate failed: {e}")
550
- for i in idxs:
551
- extractions[i] = "[LIFT GENERATE ERROR]"
552
- error_flags[i] = True
553
- continue
554
-
555
- for i, (parsed, err, raw) in zip(idxs, results):
556
- if parsed is not None and not err:
557
- extractions[i] = json.dumps(parsed, ensure_ascii=False)
558
- error_flags[i] = False
559
- else:
560
- extractions[i] = raw if raw else "[LIFT EMPTY OUTPUT]"
561
- error_flags[i] = True
562
-
563
- n_valid = sum(not f for f in error_flags)
564
- logger.info(f"Valid JSON: {n_valid}/{len(dataset)}")
565
-
566
- dataset = dataset.add_column(output_column, extractions)
567
-
568
- inference_entry = {
569
- "model": model,
570
- "model_name": "lift",
571
- "column_name": output_column,
572
- "task": "schema-constrained extraction",
573
- "backend": method,
574
- "fields": fields,
575
- "page_range": page_range,
576
- "parse_error_rate": (len(dataset) - n_valid) / len(dataset)
577
- if len(dataset)
578
- else 0.0,
579
- "timestamp": datetime.now(timezone.utc).isoformat(),
580
- "script": "lift-extract.py",
581
- }
582
- if "inference_info" in dataset.column_names:
583
-
584
- def update_info(example):
585
- try:
586
- existing = (
587
- json.loads(example["inference_info"])
588
- if example["inference_info"]
589
- else []
590
- )
591
- except (json.JSONDecodeError, TypeError):
592
- existing = []
593
- existing.append(inference_entry)
594
- return {"inference_info": json.dumps(existing)}
595
-
596
- dataset = dataset.map(update_info)
597
- else:
598
- dataset = dataset.add_column(
599
- "inference_info", [json.dumps([inference_entry])] * len(dataset)
600
- )
601
-
602
- processing_time = (
603
- f"{(datetime.now(timezone.utc) - start_time).total_seconds() / 60:.1f} min"
604
- )
605
-
606
- logger.info(f"Pushing to {output_dataset}")
607
- max_retries = 3
608
- for attempt in range(1, max_retries + 1):
609
- try:
610
- if attempt > 1:
611
- logger.warning("Disabling XET (fallback to HTTP upload)")
612
- os.environ["HF_HUB_DISABLE_XET"] = "1"
613
- dataset.push_to_hub(
614
- output_dataset,
615
- private=private,
616
- token=HF_TOKEN,
617
- max_shard_size="500MB",
618
- create_pr=create_pr,
619
- **({"config_name": config} if config else {}),
620
- commit_message=f"Add lift extraction results ({len(dataset)} samples)"
621
- + (f" [{config}]" if config else ""),
622
- )
623
- break
624
- except Exception as e:
625
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
626
- if attempt < max_retries:
627
- delay = 30 * (2 ** (attempt - 1))
628
- logger.info(f"Retrying in {delay}s...")
629
- time.sleep(delay)
630
- else:
631
- logger.error("All upload attempts failed. Results are lost.")
632
- sys.exit(1)
633
-
634
- try:
635
- card = DatasetCard(
636
- create_dataset_card(
637
- source_dataset=input_dataset,
638
- model=model,
639
- method=method,
640
- schema=schema,
641
- num_samples=len(dataset),
642
- n_valid=n_valid,
643
- source_column=source_column,
644
- is_pdf=is_pdf,
645
- page_range=page_range,
646
- output_column=output_column,
647
- split=split,
648
- processing_time=processing_time,
649
- )
650
- )
651
- card.push_to_hub(output_dataset, token=HF_TOKEN)
652
- except Exception as e:
653
- logger.warning(f"Could not push dataset card: {e}")
654
-
655
- logger.info("Done! lift extraction complete.")
656
- logger.info(f"Dataset: https://huggingface.co/datasets/{output_dataset}")
657
- logger.info(f"Processing time: {processing_time}")
658
-
659
- if verbose:
660
- import importlib.metadata
661
-
662
- logger.info("--- Resolved package versions ---")
663
- pkgs = ["lift-pdf", "transformers", "torch", "datasets", "pillow", "openai"]
664
- if method == "vllm":
665
- pkgs.append("vllm")
666
- for pkg in pkgs:
667
- try:
668
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
669
- except importlib.metadata.PackageNotFoundError:
670
- logger.info(f" {pkg}: not installed")
671
-
672
-
673
- if __name__ == "__main__":
674
- if len(sys.argv) == 1:
675
- print("lift — schema-constrained JSON extraction from images & PDFs (9B)")
676
- print("\nUsage:")
677
- print(" uv run lift-extract.py INPUT OUTPUT --schema SCHEMA [options]")
678
- print("\nExamples:")
679
- print(" # image column -> JSON")
680
- print(" uv run lift-extract.py my-images my-fields \\")
681
- print(
682
- ' --schema \'{"type":"object","properties":{"title":{"type":"string"}}}\''
683
- )
684
- print("\n # multi-page PDFs -> JSON (one extraction per document)")
685
- print(
686
- " uv run lift-extract.py my-pdfs my-fields --pdf-column pdf --page-range 0-5 \\"
687
- )
688
- print(" --schema schema.json")
689
- print("\n --schema accepts inline JSON, a URL, or a file path.")
690
- print(
691
- " --method hf (default) | vllm (offline LLM engine; needs the vllm image)"
692
- )
693
- print("\nFor full help: uv run lift-extract.py --help")
694
- sys.exit(0)
695
-
696
- parser = argparse.ArgumentParser(
697
- description="Schema-constrained JSON extraction from images & PDFs using datalab-to/lift",
698
- formatter_class=argparse.RawDescriptionHelpFormatter,
699
- epilog="""
700
- Backends (both in-process, single command):
701
- --method hf Transformers via lift-pdf (default). Simplest; default image.
702
- --method vllm vLLM offline LLM() engine with structured outputs. Faster on
703
- large jobs. Needs the vllm/vllm-openai image.
704
-
705
- Input (one document per row):
706
- --image-column COL one image per row (default: image)
707
- --pdf-column COL PDF bytes per row (multi-page; honors --page-range)
708
- """,
709
- )
710
- parser.add_argument(
711
- "input_dataset", help="Input dataset ID from the Hugging Face Hub"
712
- )
713
- parser.add_argument(
714
- "output_dataset", help="Output dataset ID for the Hugging Face Hub"
715
- )
716
- parser.add_argument(
717
- "--schema",
718
- required=True,
719
- help="JSON Schema: inline JSON, a URL, or a file path",
720
- )
721
- parser.add_argument(
722
- "--image-column", default="image", help="Image column (default: image)"
723
- )
724
- parser.add_argument(
725
- "--pdf-column",
726
- default=None,
727
- help="PDF column (bytes/path). Mutually exclusive with --image-column.",
728
- )
729
- parser.add_argument(
730
- "--output-column",
731
- default="extraction",
732
- help="Output column (default: extraction)",
733
- )
734
- parser.add_argument(
735
- "--overwrite",
736
- action="store_true",
737
- help="Replace the output column if it already exists in the input dataset "
738
- "(default: error out to avoid clobbering an existing column).",
739
- )
740
- parser.add_argument(
741
- "--method",
742
- choices=["hf", "vllm"],
743
- default="hf",
744
- help="Inference backend (default: hf)",
745
- )
746
- parser.add_argument(
747
- "--page-range",
748
- default=None,
749
- help="Pages to extract from PDFs, e.g. '0-5,7' (PDF column only)",
750
- )
751
- parser.add_argument(
752
- "--split", default="train", help="Dataset split (default: train)"
753
- )
754
- parser.add_argument(
755
- "--max-samples", type=int, help="Limit number of documents (for testing)"
756
- )
757
- parser.add_argument(
758
- "--shuffle", action="store_true", help="Shuffle before sampling"
759
- )
760
- parser.add_argument(
761
- "--seed", type=int, default=42, help="Shuffle seed (default: 42)"
762
- )
763
- parser.add_argument(
764
- "--batch-size",
765
- type=int,
766
- default=8,
767
- help="Documents per generate() call (default: 8; lower for big multi-page PDFs)",
768
- )
769
- parser.add_argument(
770
- "--max-tokens",
771
- type=int,
772
- default=None,
773
- help=f"Max output tokens (default: lift's {DEFAULT_MAX_TOKENS})",
774
- )
775
- parser.add_argument(
776
- "--max-model-len",
777
- type=int,
778
- default=32768,
779
- help="vLLM context length (default: 32768; raise for long multi-page PDFs)",
780
- )
781
- parser.add_argument(
782
- "--gpu-memory-utilization",
783
- type=float,
784
- default=0.9,
785
- help="vLLM GPU memory fraction (default: 0.9)",
786
- )
787
- parser.add_argument(
788
- "--max-images-per-doc",
789
- type=int,
790
- default=None,
791
- help="vLLM images-per-prompt cap (default: 1 for images, 30 for PDFs)",
792
- )
793
- parser.add_argument(
794
- "--model", default=DEFAULT_MODEL, help=f"Model ID (default: {DEFAULT_MODEL})"
795
- )
796
- parser.add_argument(
797
- "--private", action="store_true", help="Make output dataset private"
798
- )
799
- parser.add_argument(
800
- "--config",
801
- default=None,
802
- help="Config/subset name when pushing (for benchmarking backends in one repo)",
803
- )
804
- parser.add_argument(
805
- "--create-pr",
806
- action="store_true",
807
- help="Push as a pull request instead of directly (for parallel benchmarking)",
808
- )
809
- parser.add_argument("--hf-token", help="Hugging Face API token (or set HF_TOKEN)")
810
- parser.add_argument(
811
- "--verbose",
812
- action="store_true",
813
- help="Log resolved package versions after processing",
814
- )
815
-
816
- args = parser.parse_args()
817
-
818
- if args.pdf_column and args.image_column != "image":
819
- parser.error("--image-column and --pdf-column are mutually exclusive.")
820
-
821
- main(
822
- input_dataset=args.input_dataset,
823
- output_dataset=args.output_dataset,
824
- schema_arg=args.schema,
825
- image_column=args.image_column,
826
- pdf_column=args.pdf_column,
827
- output_column=args.output_column,
828
- overwrite=args.overwrite,
829
- method=args.method,
830
- page_range=args.page_range,
831
- split=args.split,
832
- max_samples=args.max_samples,
833
- shuffle=args.shuffle,
834
- seed=args.seed,
835
- batch_size=args.batch_size,
836
- max_tokens=args.max_tokens,
837
- max_model_len=args.max_model_len,
838
- gpu_memory_utilization=args.gpu_memory_utilization,
839
- max_images_per_doc=args.max_images_per_doc,
840
- model=args.model,
841
- private=args.private,
842
- config=args.config,
843
- create_pr=args.create_pr,
844
- hf_token=args.hf_token,
845
- verbose=args.verbose,
846
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lighton-ocr.py CHANGED
@@ -1,7 +1,7 @@
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
- # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm",
@@ -57,10 +57,6 @@ from huggingface_hub import DatasetCard, login
57
  from PIL import Image
58
  from toolz import partition_all
59
  from tqdm.auto import tqdm
60
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
61
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
62
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
63
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
64
  from vllm import LLM, SamplingParams
65
 
66
  logging.basicConfig(level=logging.INFO)
@@ -85,28 +81,6 @@ def check_cuda_availability():
85
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
86
 
87
 
88
- def ensure_output_columns_free(dataset, columns, overwrite=False):
89
- """Fail fast if an output column would collide with an existing input column.
90
-
91
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
92
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
93
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
94
- column(s) here instead (logged) so the later add_column is clean.
95
- """
96
- clash = [c for c in columns if c in dataset.column_names]
97
- if not clash:
98
- return dataset
99
- if overwrite:
100
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
101
- return dataset.remove_columns(clash)
102
- logger.error(
103
- f"Output column(s) {clash} already exist in the input dataset "
104
- f"(columns: {dataset.column_names})."
105
- )
106
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
107
- sys.exit(1)
108
-
109
-
110
  def resize_image_to_target(image: Image.Image, target_size: int = 1540) -> Image.Image:
111
  """
112
  Resize image so longest dimension is target_size while maintaining aspect ratio.
@@ -317,7 +291,6 @@ def main(
317
  shuffle: bool = False,
318
  seed: int = 42,
319
  output_column: str = "markdown",
320
- overwrite: bool = False,
321
  ):
322
  """Process images from HF dataset through LightOnOCR model."""
323
 
@@ -350,9 +323,6 @@ def main(
350
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
351
  )
352
 
353
- # Fail fast if the output column would collide with an existing input column
354
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
355
-
356
  # Shuffle if requested
357
  if shuffle:
358
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -651,12 +621,6 @@ Examples:
651
  default="markdown",
652
  help="Column name for output text (default: markdown)",
653
  )
654
- parser.add_argument(
655
- "--overwrite",
656
- action="store_true",
657
- help="Replace the output column if it already exists in the input dataset "
658
- "(default: error out to avoid clobbering an existing column).",
659
- )
660
 
661
  args = parser.parse_args()
662
 
@@ -680,5 +644,4 @@ Examples:
680
  shuffle=args.shuffle,
681
  seed=args.seed,
682
  output_column=args.output_column,
683
- overwrite=args.overwrite,
684
  )
 
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
+ # "datasets",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm",
 
57
  from PIL import Image
58
  from toolz import partition_all
59
  from tqdm.auto import tqdm
 
 
 
 
60
  from vllm import LLM, SamplingParams
61
 
62
  logging.basicConfig(level=logging.INFO)
 
81
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  def resize_image_to_target(image: Image.Image, target_size: int = 1540) -> Image.Image:
85
  """
86
  Resize image so longest dimension is target_size while maintaining aspect ratio.
 
291
  shuffle: bool = False,
292
  seed: int = 42,
293
  output_column: str = "markdown",
 
294
  ):
295
  """Process images from HF dataset through LightOnOCR model."""
296
 
 
323
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
324
  )
325
 
 
 
 
326
  # Shuffle if requested
327
  if shuffle:
328
  logger.info(f"Shuffling dataset with seed {seed}")
 
621
  default="markdown",
622
  help="Column name for output text (default: markdown)",
623
  )
 
 
 
 
 
 
624
 
625
  args = parser.parse_args()
626
 
 
644
  shuffle=args.shuffle,
645
  seed=args.seed,
646
  output_column=args.output_column,
 
647
  )
lighton-ocr2-server.py DELETED
@@ -1,651 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "requests",
8
- # ]
9
- # ///
10
-
11
- """
12
- Convert document images to markdown using LightOnOCR-2 via an in-job vLLM server.
13
-
14
- Same model, message shape, and sampling as lighton-ocr2.py, but serves the
15
- model behind `vllm serve` inside the job (the model card's own documented
16
- path) and posts images concurrently — continuous batching stays fed instead
17
- of draining at each offline batch barrier, and a bad image fails one request
18
- instead of a whole batch. Measured ~1.8x the offline recipe's inference
19
- throughput on a 100-page historical-scan smoke test (l4x1, concurrency 32).
20
-
21
- This script is the *driver* half: it expects the server on localhost
22
- (started by the job command below), loads the input dataset, posts images
23
- concurrently, and pushes the result dataset. The driver has no torch/vllm
24
- deps, so `uv run` starts in seconds while the server warms up in parallel.
25
-
26
- Run on HF Jobs (standard uv-run shape — the script starts `vllm serve` itself
27
- as a subprocess when no server is already reachable; the only thing to get
28
- right is the --image flag, which provides the `vllm` binary):
29
-
30
- hf jobs uv run --detach --flavor l4x1 -s HF_TOKEN --timeout 4h \\
31
- --image vllm/vllm-openai:v0.22.1 \\
32
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr2-server.py \\
33
- <input-dataset> <output-dataset>
34
-
35
- To use an already-running or remote endpoint instead, pass --server URL — the
36
- script only spawns a server when the (default localhost) URL is unreachable.
37
- The serve flags live in SERVE_ARGS below (the model card's own recommended
38
- command: `--limit-mm-per-prompt`, `--mm-processor-cache-gb 0`,
39
- `--no-enable-prefix-caching` — OCR never reuses images, so the caches only
40
- cost memory).
41
-
42
- Model: lightonai/LightOnOCR-2-1B (1B, Apache-2.0)
43
- - Message is the image ONLY (no text prompt) — LightOnOCR-2's trained format.
44
- - Images resized client-side so the longest dimension is 1540px (training
45
- resolution at 200 DPI), same as the offline recipe.
46
- - Sampling per the card: temperature 0.2, top_p 0.9, max_tokens 4096.
47
- """
48
-
49
- import argparse
50
- import atexit
51
- import base64
52
- import concurrent.futures
53
- import io
54
- import json
55
- import logging
56
- import os
57
- import shutil
58
- import subprocess
59
- import sys
60
- import threading
61
- import time
62
- from datetime import datetime
63
- from typing import Any, Dict, Union
64
- from urllib.parse import urlparse
65
-
66
- import requests
67
- from datasets import load_dataset
68
- from huggingface_hub import DatasetCard, login
69
- from PIL import Image
70
-
71
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
72
- logger = logging.getLogger(__name__)
73
-
74
- MODEL = "lightonai/LightOnOCR-2-1B"
75
-
76
- DEFAULT_TARGET_SIZE = 1540 # longest dimension; LightOnOCR-2 training resolution
77
-
78
- # The serve command this script spawns when no server is reachable — the model
79
- # card's own recommended flags; single source of truth for the serving config.
80
- SERVE_ARGS = [
81
- "vllm", "serve", MODEL,
82
- "--limit-mm-per-prompt", '{"image": 1}',
83
- "--mm-processor-cache-gb", "0",
84
- "--no-enable-prefix-caching",
85
- "--max-model-len", "8192",
86
- "--gpu-memory-utilization", "0.8",
87
- "--port", "8000",
88
- ]
89
-
90
- RUN_COMMAND = (
91
- "hf jobs uv run --detach --flavor l4x1 -s HF_TOKEN --timeout 4h \\\n"
92
- " --image vllm/vllm-openai:v0.22.1 \\\n"
93
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr2-server.py \\\n"
94
- " <input-dataset> <output-dataset>"
95
- )
96
-
97
-
98
- def ensure_output_columns_free(dataset, columns, overwrite=False):
99
- """Fail fast if an output column would collide with an existing input column.
100
-
101
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
102
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
103
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
104
- column(s) here instead (logged) so the later add_column is clean.
105
- """
106
- clash = [c for c in columns if c in dataset.column_names]
107
- if not clash:
108
- return dataset
109
- if overwrite:
110
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
111
- return dataset.remove_columns(clash)
112
- logger.error(
113
- f"Output column(s) {clash} already exist in the input dataset "
114
- f"(columns: {dataset.column_names})."
115
- )
116
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
117
- sys.exit(1)
118
-
119
-
120
- def to_pil_image(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
121
- """Convert a dataset image cell (PIL image, bytes dict, or path) to RGB PIL."""
122
- if isinstance(image, Image.Image):
123
- pil_img = image
124
- elif isinstance(image, dict) and "bytes" in image:
125
- pil_img = Image.open(io.BytesIO(image["bytes"]))
126
- elif isinstance(image, str):
127
- pil_img = Image.open(image)
128
- else:
129
- raise ValueError(f"Unsupported image type: {type(image)}")
130
- return pil_img.convert("RGB")
131
-
132
-
133
- def encode_image(image, target_size: int) -> str:
134
- """RGB-convert, resize longest dimension to target_size, return base64 PNG."""
135
- img = to_pil_image(image)
136
- if target_size:
137
- w, h = img.size
138
- if max(w, h) != target_size:
139
- scale = target_size / max(w, h)
140
- img = img.resize(
141
- (max(1, int(w * scale)), max(1, int(h * scale))),
142
- Image.Resampling.LANCZOS,
143
- )
144
- buf = io.BytesIO()
145
- img.save(buf, format="PNG")
146
- return base64.b64encode(buf.getvalue()).decode()
147
-
148
-
149
- def server_alive(server: str) -> bool:
150
- try:
151
- return requests.get(f"{server}/health", timeout=5).status_code == 200
152
- except requests.RequestException:
153
- return False
154
-
155
-
156
- def wait_for_server(server: str, timeout_s: int, proc: "subprocess.Popen | None" = None):
157
- logger.info(f"Waiting for server at {server}...")
158
- deadline = time.time() + timeout_s
159
- while time.time() < deadline:
160
- if server_alive(server):
161
- logger.info("Server is ready")
162
- return
163
- if proc is not None and proc.poll() is not None:
164
- logger.error(f"Spawned vllm serve exited with code {proc.returncode} before becoming ready")
165
- sys.exit(1)
166
- time.sleep(10)
167
- logger.error(f"Server did not become ready within {timeout_s}s")
168
- sys.exit(1)
169
-
170
-
171
- def ensure_server(server: str, timeout_s: int = 1800):
172
- """Use a reachable server; otherwise spawn `vllm serve` ourselves; else fail fast.
173
-
174
- Spawning is only attempted for a localhost URL — a remote --server that is
175
- down is the user's to fix, not ours to shadow with a local model.
176
- """
177
- if server_alive(server):
178
- logger.info(f"Using already-running server at {server}")
179
- return
180
-
181
- host = urlparse(server).hostname or ""
182
- if host not in ("127.0.0.1", "localhost", "0.0.0.0"):
183
- logger.info(f"Remote server {server} not up yet — waiting for it")
184
- wait_for_server(server, timeout_s)
185
- return
186
-
187
- if shutil.which("vllm") is None:
188
- logger.error("No server is running and the `vllm` binary is not on PATH.")
189
- logger.error("Run this script on a vLLM image so it can start the server itself:\n")
190
- logger.error(RUN_COMMAND)
191
- logger.error("\n(or start `vllm serve` yourself / pass --server URL of a running endpoint)")
192
- sys.exit(1)
193
-
194
- logger.info(f"Starting server: {' '.join(SERVE_ARGS)}")
195
- proc = subprocess.Popen(SERVE_ARGS) # logs interleave with ours on stdout/stderr
196
- atexit.register(proc.terminate) # don't leave a GPU server behind on local runs
197
- wait_for_server(server, timeout_s, proc=proc)
198
-
199
-
200
- def ocr_one(
201
- server: str,
202
- image,
203
- target_size: int,
204
- max_tokens: int,
205
- temperature: float,
206
- top_p: float,
207
- timeout_s: int,
208
- retries: int = 2,
209
- ) -> str:
210
- """OCR a single image via the chat completions API. Returns raw model text."""
211
- b64 = encode_image(image, target_size)
212
- payload = {
213
- "model": MODEL,
214
- "messages": [
215
- {
216
- "role": "user",
217
- "content": [
218
- # Image ONLY — LightOnOCR-2 uses no text prompt.
219
- {
220
- "type": "image_url",
221
- "image_url": {"url": f"data:image/png;base64,{b64}"},
222
- },
223
- ],
224
- }
225
- ],
226
- "temperature": temperature,
227
- "top_p": top_p,
228
- "max_tokens": max_tokens,
229
- }
230
- last_err = None
231
- for attempt in range(retries + 1):
232
- try:
233
- resp = requests.post(
234
- f"{server}/v1/chat/completions", json=payload, timeout=timeout_s
235
- )
236
- resp.raise_for_status()
237
- return resp.json()["choices"][0]["message"]["content"]
238
- except Exception as e:
239
- last_err = e
240
- if attempt < retries:
241
- time.sleep(10 * (attempt + 1))
242
- raise RuntimeError(f"request failed after {retries + 1} attempts: {last_err}")
243
-
244
-
245
- def create_dataset_card(
246
- source_dataset: str,
247
- model: str,
248
- num_samples: int,
249
- num_errors: int,
250
- processing_time: str,
251
- images_per_sec: float,
252
- concurrency: int,
253
- max_tokens: int,
254
- temperature: float,
255
- target_size: int,
256
- image_column: str = "image",
257
- split: str = "train",
258
- ) -> str:
259
- """Create a dataset card documenting the OCR process."""
260
- model_name = model.split("/")[-1]
261
-
262
- # Canonical provenance stamp (see AGENTS.md): Jobs claim gated on JOB_ID, set by HF Jobs in-container.
263
- on_jobs = os.environ.get("JOB_ID") is not None
264
- hw = os.environ.get("ACCELERATOR") or ""
265
- origin = (
266
- "Produced on [Hugging Face Jobs](https://huggingface.co/docs/huggingface_hub/guides/jobs)"
267
- + (f" (`{hw}`)" if hw else "")
268
- ) if on_jobs else "Generated"
269
- jobs_tag = "\n- hf-jobs" if on_jobs else ""
270
-
271
- return f"""---
272
- tags:
273
- - ocr
274
- - document-processing
275
- - lighton-ocr
276
- - markdown
277
- - uv-script
278
- - generated{jobs_tag}
279
- ---
280
-
281
- # Document OCR using {model_name} (server mode)
282
-
283
- This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using LightOnOCR-2 (1B), served behind an in-job vLLM server with concurrent requests (continuous batching).
284
-
285
- ## Processing Details
286
-
287
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
288
- - **Model**: [{model}](https://huggingface.co/{model})
289
- - **Number of Samples**: {num_samples:,}
290
- - **Failed Requests**: {num_errors:,} (marked `[OCR ERROR]`)
291
- - **Processing Time**: {processing_time}
292
- - **Throughput**: {images_per_sec:.2f} images/sec
293
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
294
-
295
- ### Configuration
296
-
297
- - **Mode**: vLLM server (`vllm serve`) + concurrent driver, {concurrency} concurrent requests
298
- - **Image Column**: `{image_column}`
299
- - **Dataset Split**: `{split}`
300
- - **Target Image Size**: {target_size}px (longest dimension)
301
- - **Max Output Tokens**: {max_tokens:,}
302
- - **Temperature**: {temperature}
303
-
304
- ## Dataset Structure
305
-
306
- The dataset contains all original columns plus:
307
- - `markdown`: The extracted text in markdown format
308
- - `inference_info`: JSON list tracking all OCR models applied to this dataset
309
-
310
- ## Reproduction
311
-
312
- {origin} with the [`lighton-ocr2-server.py`](https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr2-server.py) recipe from [uv-scripts](https://huggingface.co/uv-scripts) — see the script docstring for the single `hf jobs run` command that starts the server and driver together. The offline-vLLM sibling recipe is [`lighton-ocr2.py`](https://huggingface.co/datasets/uv-scripts/ocr/raw/main/lighton-ocr2.py).
313
- """
314
-
315
-
316
- def main(
317
- input_dataset: str,
318
- output_dataset: str,
319
- image_column: str = "image",
320
- server: str = "http://127.0.0.1:8000",
321
- concurrency: int = 32,
322
- max_tokens: int = 4096,
323
- temperature: float = 0.2,
324
- top_p: float = 0.9,
325
- target_size: int = DEFAULT_TARGET_SIZE,
326
- request_timeout: int = 1800,
327
- hf_token: str = None,
328
- split: str = "train",
329
- max_samples: int = None,
330
- private: bool = False,
331
- shuffle: bool = False,
332
- seed: int = 42,
333
- output_column: str = "markdown",
334
- overwrite: bool = False,
335
- config: str = None,
336
- create_pr: bool = False,
337
- ):
338
- """Process images from HF dataset through a LightOnOCR-2 vLLM server."""
339
-
340
- start_time = datetime.now()
341
-
342
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
343
- if HF_TOKEN:
344
- login(token=HF_TOKEN)
345
-
346
- logger.info(f"Using model: {MODEL} via server {server}")
347
-
348
- logger.info(f"Loading dataset: {input_dataset}")
349
- dataset = load_dataset(input_dataset, split=split)
350
-
351
- if image_column not in dataset.column_names:
352
- raise ValueError(
353
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
354
- )
355
-
356
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
357
-
358
- if shuffle:
359
- logger.info(f"Shuffling dataset with seed {seed}")
360
- dataset = dataset.shuffle(seed=seed)
361
-
362
- if max_samples:
363
- dataset = dataset.select(range(min(max_samples, len(dataset))))
364
- logger.info(f"Limited to {len(dataset)} samples")
365
-
366
- # Reuse a reachable server, else spawn `vllm serve` (needs the vllm binary,
367
- # i.e. a vllm/vllm-openai image), else fail fast with the correct command.
368
- ensure_server(server)
369
-
370
- n = len(dataset)
371
- logger.info(f"Processing {n} images, concurrency {concurrency}")
372
- all_outputs = [None] * n
373
- errors = 0
374
- done = 0
375
- inference_start = time.time()
376
- lock = threading.Lock()
377
-
378
- def worker(i: int) -> None:
379
- nonlocal errors, done
380
- try:
381
- text = ocr_one(
382
- server,
383
- dataset[i][image_column],
384
- target_size,
385
- max_tokens,
386
- temperature,
387
- top_p,
388
- request_timeout,
389
- )
390
- all_outputs[i] = text.strip()
391
- except Exception as e:
392
- logger.error(f"Image {i} failed: {e}")
393
- all_outputs[i] = "[OCR ERROR]"
394
- with lock:
395
- errors += 1
396
- with lock:
397
- done += 1
398
- if done % 25 == 0 or done == n:
399
- rate = done / max(time.time() - inference_start, 1e-9)
400
- logger.info(f"{done}/{n} done ({rate:.2f} img/s, {errors} errors)")
401
-
402
- with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
403
- list(pool.map(worker, range(n)))
404
-
405
- inference_secs = time.time() - inference_start
406
- processing_duration = datetime.now() - start_time
407
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
408
- images_per_sec = n / inference_secs if inference_secs else 0.0
409
-
410
- logger.info(f"Adding '{output_column}' column to dataset")
411
- dataset = dataset.add_column(output_column, all_outputs)
412
-
413
- # Inference info tracking
414
- inference_entry = {
415
- "model_id": MODEL,
416
- "model_name": "LightOnOCR-2-1B",
417
- "column_name": output_column,
418
- "timestamp": datetime.now().isoformat(),
419
- "temperature": temperature,
420
- "top_p": top_p,
421
- "max_tokens": max_tokens,
422
- "target_size": target_size,
423
- "mode": "vllm-server",
424
- "concurrency": concurrency,
425
- }
426
-
427
- if "inference_info" in dataset.column_names:
428
- logger.info("Updating existing inference_info column")
429
-
430
- def update_inference_info(example):
431
- try:
432
- existing_info = (
433
- json.loads(example["inference_info"])
434
- if example["inference_info"]
435
- else []
436
- )
437
- except (json.JSONDecodeError, TypeError):
438
- existing_info = []
439
- existing_info.append(inference_entry)
440
- return {"inference_info": json.dumps(existing_info)}
441
-
442
- dataset = dataset.map(update_inference_info)
443
- else:
444
- logger.info("Creating new inference_info column")
445
- inference_list = [json.dumps([inference_entry])] * len(dataset)
446
- dataset = dataset.add_column("inference_info", inference_list)
447
-
448
- # Push to hub with retry and XET fallback
449
- logger.info(f"Pushing to {output_dataset}")
450
- max_retries = 3
451
- for attempt in range(1, max_retries + 1):
452
- try:
453
- if attempt > 1:
454
- logger.warning("Disabling XET (fallback to HTTP upload)")
455
- os.environ["HF_HUB_DISABLE_XET"] = "1"
456
- dataset.push_to_hub(
457
- output_dataset,
458
- private=private,
459
- token=HF_TOKEN,
460
- max_shard_size="500MB",
461
- **({"config_name": config} if config else {}),
462
- create_pr=create_pr,
463
- commit_message=f"Add {MODEL} OCR results ({len(dataset)} samples, server mode)"
464
- + (f" [{config}]" if config else ""),
465
- )
466
- break
467
- except Exception as e:
468
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
469
- if attempt < max_retries:
470
- delay = 30 * (2 ** (attempt - 1))
471
- logger.info(f"Retrying in {delay}s...")
472
- time.sleep(delay)
473
- else:
474
- logger.error("All upload attempts failed. OCR results are lost.")
475
- sys.exit(1)
476
-
477
- logger.info("Creating dataset card")
478
- card_content = create_dataset_card(
479
- source_dataset=input_dataset,
480
- model=MODEL,
481
- num_samples=len(dataset),
482
- num_errors=errors,
483
- processing_time=processing_time_str,
484
- images_per_sec=images_per_sec,
485
- concurrency=concurrency,
486
- max_tokens=max_tokens,
487
- temperature=temperature,
488
- target_size=target_size,
489
- image_column=image_column,
490
- split=split,
491
- )
492
-
493
- card = DatasetCard(card_content)
494
- card.push_to_hub(output_dataset, token=HF_TOKEN)
495
-
496
- logger.info("Done! LightOnOCR-2 server-mode processing complete.")
497
- logger.info(
498
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
499
- )
500
- logger.info(f"Processing time: {processing_time_str}")
501
- logger.info(
502
- f"Throughput: {images_per_sec:.2f} images/sec "
503
- f"(inference only, excl. dataset load/push; {errors} errors)"
504
- )
505
-
506
-
507
- if __name__ == "__main__":
508
- if len(sys.argv) == 1:
509
- print("=" * 70)
510
- print("LightOnOCR-2 Document Processing (vLLM server mode)")
511
- print("=" * 70)
512
- print("\nSame model + outputs as lighton-ocr2.py, but drives an in-job")
513
- print("`vllm serve` with concurrent requests — no batch barriers,")
514
- print("per-image (not per-batch) failure isolation.")
515
- print("\nThe server must already be running (the job command starts")
516
- print("both — see the module docstring for the full `hf jobs run`).")
517
- print("\nExamples:")
518
- print("\n1. Basic OCR (server on localhost:8000):")
519
- print(" uv run lighton-ocr2-server.py input-dataset output-dataset")
520
- print("\n2. Test with a small sample:")
521
- print(" uv run lighton-ocr2-server.py large-dataset test --max-samples 10 --shuffle")
522
- print("\nFor full help: uv run lighton-ocr2-server.py --help")
523
- sys.exit(0)
524
-
525
- parser = argparse.ArgumentParser(
526
- description="Document OCR using LightOnOCR-2 via an in-job vLLM server",
527
- formatter_class=argparse.RawDescriptionHelpFormatter,
528
- epilog="""
529
- Examples:
530
- uv run lighton-ocr2-server.py my-docs analyzed-docs
531
- uv run lighton-ocr2-server.py large-dataset test --max-samples 50 --shuffle
532
- See the module docstring for the full `hf jobs run` command (server + driver in one job).
533
- """,
534
- )
535
-
536
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
537
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
538
- parser.add_argument(
539
- "--image-column",
540
- default="image",
541
- help="Column containing images (default: image)",
542
- )
543
- parser.add_argument(
544
- "--server",
545
- default="http://127.0.0.1:8000",
546
- help="vLLM server base URL (default: in-job localhost:8000)",
547
- )
548
- parser.add_argument(
549
- "--concurrency",
550
- type=int,
551
- default=32,
552
- help="Concurrent OCR requests (default: 32; vLLM queues excess internally, "
553
- "so this mainly needs to be high enough to keep continuous batching fed)",
554
- )
555
- parser.add_argument(
556
- "--max-tokens",
557
- type=int,
558
- default=4096,
559
- help="Maximum tokens to generate (default: 4096, the model card value)",
560
- )
561
- parser.add_argument(
562
- "--temperature",
563
- type=float,
564
- default=0.2,
565
- help="Sampling temperature (default: 0.2, the model card value)",
566
- )
567
- parser.add_argument(
568
- "--top-p",
569
- type=float,
570
- default=0.9,
571
- help="Top-p sampling (default: 0.9, the model card value)",
572
- )
573
- parser.add_argument(
574
- "--target-size",
575
- type=int,
576
- default=DEFAULT_TARGET_SIZE,
577
- help=f"Resize images so the longest dimension is this many pixels before upload "
578
- f"(default: {DEFAULT_TARGET_SIZE}, the model's training resolution); 0 disables",
579
- )
580
- parser.add_argument(
581
- "--request-timeout",
582
- type=int,
583
- default=1800,
584
- help="Per-request timeout in seconds (default: 1800)",
585
- )
586
- parser.add_argument("--hf-token", help="Hugging Face API token")
587
- parser.add_argument(
588
- "--split", default="train", help="Dataset split to use (default: train)"
589
- )
590
- parser.add_argument(
591
- "--max-samples",
592
- type=int,
593
- help="Maximum number of samples to process (for testing)",
594
- )
595
- parser.add_argument(
596
- "--private", action="store_true", help="Make output dataset private"
597
- )
598
- parser.add_argument(
599
- "--config",
600
- help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
601
- )
602
- parser.add_argument(
603
- "--create-pr",
604
- action="store_true",
605
- help="Create a pull request instead of pushing directly (for parallel benchmarking)",
606
- )
607
- parser.add_argument(
608
- "--shuffle", action="store_true", help="Shuffle dataset before processing"
609
- )
610
- parser.add_argument(
611
- "--seed",
612
- type=int,
613
- default=42,
614
- help="Random seed for shuffling (default: 42)",
615
- )
616
- parser.add_argument(
617
- "--output-column",
618
- default="markdown",
619
- help="Column name for output text (default: markdown)",
620
- )
621
- parser.add_argument(
622
- "--overwrite",
623
- action="store_true",
624
- help="Replace the output column if it already exists in the input dataset "
625
- "(default: error out to avoid clobbering an existing column).",
626
- )
627
-
628
- args = parser.parse_args()
629
-
630
- main(
631
- input_dataset=args.input_dataset,
632
- output_dataset=args.output_dataset,
633
- image_column=args.image_column,
634
- server=args.server,
635
- concurrency=args.concurrency,
636
- max_tokens=args.max_tokens,
637
- temperature=args.temperature,
638
- top_p=args.top_p,
639
- target_size=args.target_size,
640
- request_timeout=args.request_timeout,
641
- hf_token=args.hf_token,
642
- split=args.split,
643
- max_samples=args.max_samples,
644
- private=args.private,
645
- shuffle=args.shuffle,
646
- seed=args.seed,
647
- output_column=args.output_column,
648
- overwrite=args.overwrite,
649
- config=args.config,
650
- create_pr=args.create_pr,
651
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
lighton-ocr2.py CHANGED
@@ -53,10 +53,6 @@ from huggingface_hub import DatasetCard, login
53
  from PIL import Image
54
  from toolz import partition_all
55
  from tqdm.auto import tqdm
56
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
57
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
58
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
59
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
60
  from vllm import LLM, SamplingParams
61
 
62
  logging.basicConfig(level=logging.INFO)
@@ -77,28 +73,6 @@ def check_cuda_availability():
77
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
78
 
79
 
80
- def ensure_output_columns_free(dataset, columns, overwrite=False):
81
- """Fail fast if an output column would collide with an existing input column.
82
-
83
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
84
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
85
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
86
- column(s) here instead (logged) so the later add_column is clean.
87
- """
88
- clash = [c for c in columns if c in dataset.column_names]
89
- if not clash:
90
- return dataset
91
- if overwrite:
92
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
93
- return dataset.remove_columns(clash)
94
- logger.error(
95
- f"Output column(s) {clash} already exist in the input dataset "
96
- f"(columns: {dataset.column_names})."
97
- )
98
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
99
- sys.exit(1)
100
-
101
-
102
  def resize_image_to_target(image: Image.Image, target_size: int = 1540) -> Image.Image:
103
  """
104
  Resize image so longest dimension is target_size while maintaining aspect ratio.
@@ -308,7 +282,6 @@ def main(
308
  shuffle: bool = False,
309
  seed: int = 42,
310
  output_column: str = "markdown",
311
- overwrite: bool = False,
312
  config: str = None,
313
  create_pr: bool = False,
314
  verbose: bool = False,
@@ -338,9 +311,6 @@ def main(
338
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
339
  )
340
 
341
- # Fail fast if the output column would collide with an existing input column
342
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
343
-
344
  # Shuffle if requested
345
  if shuffle:
346
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -584,12 +554,8 @@ Examples:
584
  parser.add_argument(
585
  "--max-model-len",
586
  type=int,
587
- default=16384,
588
- help=(
589
- "Maximum model context length (default: 16384). A full page resized to "
590
- "1540px is ~6k image tokens; with --max-tokens 4096 output that overflows "
591
- "the old 8192 default at admission and vLLM rejects the request."
592
- ),
593
  )
594
  parser.add_argument(
595
  "--max-tokens",
@@ -661,12 +627,6 @@ Examples:
661
  default="markdown",
662
  help="Column name for output text (default: markdown)",
663
  )
664
- parser.add_argument(
665
- "--overwrite",
666
- action="store_true",
667
- help="Replace the output column if it already exists in the input dataset "
668
- "(default: error out to avoid clobbering an existing column).",
669
- )
670
  parser.add_argument(
671
  "--verbose",
672
  action="store_true",
@@ -694,7 +654,6 @@ Examples:
694
  shuffle=args.shuffle,
695
  seed=args.seed,
696
  output_column=args.output_column,
697
- overwrite=args.overwrite,
698
  config=args.config,
699
  create_pr=args.create_pr,
700
  verbose=args.verbose,
 
53
  from PIL import Image
54
  from toolz import partition_all
55
  from tqdm.auto import tqdm
 
 
 
 
56
  from vllm import LLM, SamplingParams
57
 
58
  logging.basicConfig(level=logging.INFO)
 
73
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def resize_image_to_target(image: Image.Image, target_size: int = 1540) -> Image.Image:
77
  """
78
  Resize image so longest dimension is target_size while maintaining aspect ratio.
 
282
  shuffle: bool = False,
283
  seed: int = 42,
284
  output_column: str = "markdown",
 
285
  config: str = None,
286
  create_pr: bool = False,
287
  verbose: bool = False,
 
311
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
312
  )
313
 
 
 
 
314
  # Shuffle if requested
315
  if shuffle:
316
  logger.info(f"Shuffling dataset with seed {seed}")
 
554
  parser.add_argument(
555
  "--max-model-len",
556
  type=int,
557
+ default=8192,
558
+ help="Maximum model context length (default: 8192)",
 
 
 
 
559
  )
560
  parser.add_argument(
561
  "--max-tokens",
 
627
  default="markdown",
628
  help="Column name for output text (default: markdown)",
629
  )
 
 
 
 
 
 
630
  parser.add_argument(
631
  "--verbose",
632
  action="store_true",
 
654
  shuffle=args.shuffle,
655
  seed=args.seed,
656
  output_column=args.output_column,
 
657
  config=args.config,
658
  create_pr=args.create_pr,
659
  verbose=args.verbose,
models.json DELETED
@@ -1,386 +0,0 @@
1
- {
2
- "_meta": {
3
- "description": "Machine-readable catalog of the OCR recipes in this directory: script -> model, params, backend, required image pins, and what the model's own card claims about language coverage. Human-readable language notes: LANGUAGES.md. Language evidence levels, strongest first: per-language-benchmark | count-claim | named-list | multilingual-unspecified | english-only | not-stated | not-applicable. Claims come from model cards (compiled 2026-07-15) - treat as claims, not guarantees.",
4
- "updated": "2026-07-15",
5
- "run_pattern": "hf jobs uv run --flavor <flavor> -s HF_TOKEN https://huggingface.co/datasets/uv-scripts/ocr/raw/main/<script> <input-dataset> <output-dataset>"
6
- },
7
- "tesseract-ocr.py": {
8
- "model_id": null,
9
- "model_name": "Tesseract 5",
10
- "params": null,
11
- "backend": "pytesseract (CPU)",
12
- "task": "ocr",
13
- "output": "plain text",
14
- "license": "apache-2.0",
15
- "languages": {
16
- "evidence": "named-list",
17
- "count": "125 traineddata packs (~100+ languages + script models)",
18
- "list_url": "https://github.com/tesseract-ocr/tessdata_best",
19
- "notes": "Broadest named coverage; script models (Latin, Cyrillic, Devanagari, ...) cover languages without a dedicated pack. Legacy baseline quality."
20
- }
21
- },
22
- "pp-ocrv6.py": {
23
- "model_id": "PaddlePaddle/PP-OCRv6",
24
- "params": "1.5M-34.5M",
25
- "backend": "paddleocr",
26
- "task": "ocr",
27
- "output": "plain text",
28
- "license": "apache-2.0",
29
- "languages": { "evidence": "count-claim", "count": 48 }
30
- },
31
- "falcon-ocr.py": {
32
- "model_id": "tiiuae/Falcon-OCR",
33
- "params": "0.3B",
34
- "backend": "falcon-perception",
35
- "task": "ocr",
36
- "output": "markdown",
37
- "license": "apache-2.0",
38
- "languages": { "evidence": "not-stated" }
39
- },
40
- "smoldocling-ocr.py": {
41
- "model_id": "ds4sd/SmolDocling-256M-preview",
42
- "params": "256M",
43
- "backend": "transformers",
44
- "task": "ocr",
45
- "output": "DocTags",
46
- "languages": { "evidence": "english-only" }
47
- },
48
- "surya-ocr.py": {
49
- "model_id": "datalab-to/surya-ocr-2",
50
- "params": "0.65B",
51
- "backend": "vllm",
52
- "image": "vllm/vllm-openai:v0.20.1",
53
- "task": "ocr | layout | table",
54
- "output": "markdown + surya_blocks (per-block HTML, bboxes, reading order)",
55
- "license": "modified OpenRAIL-M",
56
- "languages": {
57
- "evidence": "per-language-benchmark",
58
- "count": 91,
59
- "benchmark_url": "https://github.com/datalab-to/surya/blob/master/static/docs/multilingual.md",
60
- "notes": "38/91 languages score >=90%, 76/91 >=80%; weakest of top-15: Arabic 72.7%, Vietnamese 73.2%."
61
- }
62
- },
63
- "glm-ocr.py": {
64
- "model_id": "zai-org/GLM-OCR",
65
- "params": "0.9B",
66
- "backend": "vllm",
67
- "task": "ocr",
68
- "output": "markdown",
69
- "languages": {
70
- "evidence": "named-list",
71
- "count": 8,
72
- "named": ["zh", "en", "fr", "es", "ru", "de", "ja", "ko"],
73
- "notes": "Declared in card metadata only; no per-language evidence in the card body."
74
- }
75
- },
76
- "paddleocr-vl.py": {
77
- "model_id": "PaddlePaddle/PaddleOCR-VL",
78
- "params": "0.9B",
79
- "backend": "vllm",
80
- "task": "ocr | table | formula | chart",
81
- "output": "markdown",
82
- "languages": {
83
- "evidence": "count-claim",
84
- "count": 109,
85
- "notes": "Names scripts (Cyrillic, Arabic, Devanagari, Thai); claims handwriting + historical documents; no per-language numbers."
86
- }
87
- },
88
- "paddleocr-vl-1.5.py": {
89
- "model_id": "PaddlePaddle/PaddleOCR-VL-1.5",
90
- "params": "0.9B",
91
- "backend": "transformers",
92
- "task": "ocr (6 modes)",
93
- "output": "markdown",
94
- "languages": {
95
- "evidence": "count-claim",
96
- "count": "109+ (adds Tibetan, Bengali)",
97
- "notes": "Claims improved rare-character and ancient-text recognition."
98
- }
99
- },
100
- "paddleocr-vl-1.6.py": {
101
- "model_id": "PaddlePaddle/PaddleOCR-VL-1.6",
102
- "params": "0.9B",
103
- "backend": "vllm",
104
- "task": "ocr",
105
- "output": "markdown",
106
- "languages": {
107
- "evidence": "count-claim",
108
- "count": "109+ (inherited from 1.5, not restated)",
109
- "notes": "Upgrade-focused card; language-specific claims are Chinese ancient documents and rare characters."
110
- }
111
- },
112
- "ovis-ocr2.py": {
113
- "model_id": "ATH-MaaS/OvisOCR2",
114
- "params": "0.9B",
115
- "backend": "vllm",
116
- "task": "ocr",
117
- "output": "markdown + LaTeX + HTML tables",
118
- "license": "apache-2.0",
119
- "languages": { "evidence": "not-stated" }
120
- },
121
- "ovis-ocr2-server.py": {
122
- "model_id": "ATH-MaaS/OvisOCR2",
123
- "params": "0.9B",
124
- "backend": "vllm-server (in-job vllm serve + concurrent driver)",
125
- "image": "vllm/vllm-openai:v0.22.1",
126
- "task": "ocr",
127
- "output": "markdown + LaTeX + HTML tables",
128
- "license": "apache-2.0",
129
- "notes": "Server-mode sibling of ovis-ocr2.py: ~1.7x its inference throughput, per-request failure isolation. See SERVING.md.",
130
- "languages": { "evidence": "not-stated" }
131
- },
132
- "lighton-ocr.py": {
133
- "model_id": "lightonai/LightOnOCR-1B-1025",
134
- "params": "1B",
135
- "backend": "vllm",
136
- "task": "ocr",
137
- "output": "markdown",
138
- "languages": {
139
- "evidence": "named-list",
140
- "count": 9,
141
- "named": ["en", "fr", "de", "es", "it", "nl", "pt", "sv", "da"],
142
- "notes": "Explicitly European / Latin-alphabet focused."
143
- }
144
- },
145
- "lighton-ocr2.py": {
146
- "model_id": "lightonai/LightOnOCR-2-1B",
147
- "params": "1B",
148
- "backend": "vllm",
149
- "task": "ocr",
150
- "output": "markdown",
151
- "languages": {
152
- "evidence": "named-list",
153
- "count": 11,
154
- "named": ["en", "fr", "de", "es", "it", "nl", "pt", "sv", "da", "zh", "ja"],
155
- "notes": "Adds zh/ja over v1; declared, not benchmarked per language."
156
- }
157
- },
158
- "lighton-ocr2-server.py": {
159
- "model_id": "lightonai/LightOnOCR-2-1B",
160
- "params": "1B",
161
- "backend": "vllm-server (in-job vllm serve + concurrent driver)",
162
- "image": "vllm/vllm-openai:v0.22.1",
163
- "task": "ocr",
164
- "output": "markdown",
165
- "notes": "Server-mode sibling of lighton-ocr2.py (the model card's own documented path): ~1.8x its inference throughput. See SERVING.md.",
166
- "languages": {
167
- "evidence": "named-list",
168
- "count": 11,
169
- "named": ["en", "fr", "de", "es", "it", "nl", "pt", "sv", "da", "zh", "ja"],
170
- "notes": "Adds zh/ja over v1; declared, not benchmarked per language."
171
- }
172
- },
173
- "hunyuan-ocr.py": {
174
- "model_id": "tencent/HunyuanOCR",
175
- "revision": "f6af82ee007fe6091b29fb3bb287b491ead41c82",
176
- "params": "1B",
177
- "backend": "vllm",
178
- "task": "ocr",
179
- "output": "markdown",
180
- "license": "Hunyuan Community License (excludes EU/UK/KR)",
181
- "languages": { "evidence": "multilingual-unspecified" }
182
- },
183
- "hunyuan-ocr-1.5.py": {
184
- "model_id": "tencent/HunyuanOCR",
185
- "params": "1B",
186
- "backend": "vllm",
187
- "task": "ocr | 12 task types",
188
- "output": "markdown",
189
- "license": "Hunyuan Community License (excludes EU/UK/KR)",
190
- "languages": {
191
- "evidence": "multilingual-unspecified",
192
- "notes": "Explicitly targets low-resource + ancient-script OCR as a design goal; nothing enumerated."
193
- }
194
- },
195
- "dots-ocr.py": {
196
- "model_id": "rednote-hilab/dots.ocr",
197
- "params": "1.7B",
198
- "backend": "vllm",
199
- "task": "ocr + layout",
200
- "output": "markdown",
201
- "languages": {
202
- "evidence": "count-claim",
203
- "count": 100,
204
- "notes": "Explicit low-resource claim backed by in-house dots.ocr-bench (1,493 PDFs across 100 languages); aggregate scores only."
205
- }
206
- },
207
- "firered-ocr.py": {
208
- "model_id": "FireRedTeam/FireRed-OCR",
209
- "params": "2.1B",
210
- "backend": "vllm",
211
- "task": "ocr",
212
- "output": "markdown",
213
- "license": "apache-2.0",
214
- "languages": { "evidence": "not-stated" }
215
- },
216
- "abot-ocr.py": {
217
- "model_id": "acvlab/ABot-OCR",
218
- "params": "2B",
219
- "backend": "vllm",
220
- "image": "vllm/vllm-openai",
221
- "task": "ocr",
222
- "output": "markdown (text, LaTeX, HTML tables)",
223
- "languages": { "evidence": "not-stated" }
224
- },
225
- "nanonets-ocr.py": {
226
- "model_id": "nanonets/Nanonets-OCR-s",
227
- "params": "2B",
228
- "backend": "vllm",
229
- "task": "ocr",
230
- "output": "markdown (LaTeX, tables, forms)",
231
- "languages": { "evidence": "english-only" }
232
- },
233
- "dots-mocr.py": {
234
- "model_id": "rednote-hilab/dots.mocr",
235
- "params": "3B",
236
- "backend": "vllm",
237
- "task": "ocr | 8 prompt modes incl. SVG, layout + bbox",
238
- "output": "markdown",
239
- "languages": { "evidence": "multilingual-unspecified" }
240
- },
241
- "nanonets-ocr2.py": {
242
- "model_id": "nanonets/Nanonets-OCR2-3B",
243
- "params": "3B",
244
- "backend": "vllm",
245
- "image": "vllm/vllm-openai:v0.10.2",
246
- "task": "ocr",
247
- "output": "markdown",
248
- "languages": {
249
- "evidence": "named-list",
250
- "count": "11 named + 'many more'",
251
- "named": ["en", "zh", "fr", "es", "pt", "de", "it", "ru", "ja", "ko", "ar"],
252
- "notes": "Only card claiming multilingual handwriting; list illustrative, no per-language benchmark."
253
- }
254
- },
255
- "deepseek-ocr-vllm.py": {
256
- "model_id": "deepseek-ai/DeepSeek-OCR",
257
- "params": "4B",
258
- "backend": "vllm",
259
- "task": "ocr | 5 resolution + 5 prompt modes",
260
- "output": "markdown",
261
- "license": "mit",
262
- "languages": { "evidence": "multilingual-unspecified" }
263
- },
264
- "deepseek-ocr.py": {
265
- "model_id": "deepseek-ai/DeepSeek-OCR",
266
- "params": "4B",
267
- "backend": "transformers",
268
- "task": "ocr",
269
- "output": "markdown",
270
- "license": "mit",
271
- "languages": { "evidence": "multilingual-unspecified" }
272
- },
273
- "deepseek-ocr2-vllm.py": {
274
- "model_id": "deepseek-ai/DeepSeek-OCR-2",
275
- "params": "3B",
276
- "backend": "vllm (nightly)",
277
- "image": "vllm/vllm-openai",
278
- "task": "ocr",
279
- "output": "markdown",
280
- "license": "apache-2.0",
281
- "languages": { "evidence": "multilingual-unspecified" }
282
- },
283
- "unlimited-ocr-vllm.py": {
284
- "model_id": "baidu/Unlimited-OCR",
285
- "params": "3.3B",
286
- "backend": "vllm",
287
- "image": "vllm/vllm-openai:unlimited-ocr",
288
- "task": "ocr (layout-grounded markdown; single-image batch)",
289
- "output": "markdown",
290
- "license": "mit",
291
- "languages": { "evidence": "multilingual-unspecified" }
292
- },
293
- "nuextract3.py": {
294
- "model_id": "numind/NuExtract3",
295
- "params": "4B",
296
- "backend": "vllm",
297
- "image": "vllm/vllm-openai",
298
- "task": "ocr | schema-guided extraction",
299
- "output": "markdown or JSON",
300
- "languages": { "evidence": "multilingual-unspecified" }
301
- },
302
- "qianfan-ocr.py": {
303
- "model_id": "baidu/Qianfan-OCR",
304
- "params": "4.7B",
305
- "backend": "vllm",
306
- "task": "ocr",
307
- "output": "markdown",
308
- "languages": {
309
- "evidence": "count-claim",
310
- "count": 192,
311
- "notes": "Headline claim; no list or per-language numbers."
312
- }
313
- },
314
- "olmocr2-vllm.py": {
315
- "model_id": "allenai/olmOCR-2-7B-1025-FP8",
316
- "params": "7B",
317
- "backend": "vllm",
318
- "task": "ocr",
319
- "output": "markdown",
320
- "languages": { "evidence": "english-only" }
321
- },
322
- "rolm-ocr.py": {
323
- "model_id": "reducto/RolmOCR",
324
- "params": "7B",
325
- "backend": "vllm",
326
- "task": "ocr",
327
- "output": "plain text",
328
- "languages": { "evidence": "not-stated" }
329
- },
330
- "numarkdown-ocr.py": {
331
- "model_id": "numind/NuMarkdown-8B-Thinking",
332
- "params": "8B",
333
- "backend": "vllm",
334
- "task": "ocr (reasoning)",
335
- "output": "markdown",
336
- "languages": { "evidence": "not-stated" }
337
- },
338
- "lfm2-vl-extract.py": {
339
- "model_id": "LiquidAI/LFM2.5-VL-1.6B-Extract",
340
- "params": "1.6B",
341
- "backend": "vllm",
342
- "image": "vllm/vllm-openai",
343
- "task": "schema-guided extraction (image -> JSON)",
344
- "output": "JSON",
345
- "languages": {
346
- "evidence": "english-only",
347
- "notes": "Vision card declares en only; the text sibling's 9-language list does NOT carry over."
348
- }
349
- },
350
- "lfm2-extract.py": {
351
- "model_id": "LiquidAI/LFM2-1.2B-Extract",
352
- "params": "1.2B",
353
- "backend": "vllm",
354
- "image": "vllm/vllm-openai",
355
- "task": "schema-guided extraction (text -> structured); chain after any OCR recipe",
356
- "output": "JSON / XML / YAML",
357
- "languages": {
358
- "evidence": "named-list",
359
- "count": 9,
360
- "named": ["en", "ar", "zh", "fr", "de", "ja", "ko", "pt", "es"],
361
- "notes": "Text-only model (not OCR). Card prose names 9 incl. Portuguese; card YAML lists 8 (pt missing)."
362
- }
363
- },
364
- "lift-extract.py": {
365
- "model_id": "datalab-to/lift",
366
- "params": "9B",
367
- "backend": "transformers | vllm",
368
- "task": "schema-guided extraction (image or multi-page PDF -> JSON)",
369
- "output": "JSON",
370
- "license": "modified OpenRAIL-M",
371
- "languages": { "evidence": "not-stated" }
372
- },
373
- "pp-doclayout.py": {
374
- "model_id": "PaddlePaddle/PP-DocLayout-L",
375
- "params": "123M",
376
- "backend": "paddleocr",
377
- "task": "layout detection (no text extraction)",
378
- "output": "bounding boxes + region classes",
379
- "languages": { "evidence": "not-applicable" }
380
- },
381
- "glm-ocr-v2.py": { "variant_of": "glm-ocr.py", "notes": "Adds checkpoint/resume for very large jobs." },
382
- "glm-ocr-bucket.py": { "variant_of": "glm-ocr.py", "notes": "Reads images/PDFs from a mounted bucket, writes one .md per page." },
383
- "falcon-ocr-bucket.py": { "variant_of": "falcon-ocr.py", "notes": "Reads images/PDFs from a mounted bucket, writes one .md per page." },
384
- "surya-ocr-bucket.py": { "variant_of": "surya-ocr.py", "notes": "Bucket-to-bucket structured OCR (--io-mode mount|copy), resumable." },
385
- "ocr-vllm-judge.py": { "model_id": "configurable", "task": "pairwise OCR-quality judging (VLM-as-judge)", "languages": { "evidence": "not-applicable" } }
386
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
nanonets-ocr.py CHANGED
@@ -40,10 +40,6 @@ from huggingface_hub import DatasetCard, login
40
  from PIL import Image
41
  from toolz import partition_all
42
  from tqdm.auto import tqdm
43
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
44
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
45
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
46
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
47
  from vllm import LLM, SamplingParams
48
  from datetime import datetime
49
 
@@ -61,28 +57,6 @@ def check_cuda_availability():
61
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
62
 
63
 
64
- def ensure_output_columns_free(dataset, columns, overwrite=False):
65
- """Fail fast if an output column would collide with an existing input column.
66
-
67
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
68
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
69
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
70
- column(s) here instead (logged) so the later add_column is clean.
71
- """
72
- clash = [c for c in columns if c in dataset.column_names]
73
- if not clash:
74
- return dataset
75
- if overwrite:
76
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
77
- return dataset.remove_columns(clash)
78
- logger.error(
79
- f"Output column(s) {clash} already exist in the input dataset "
80
- f"(columns: {dataset.column_names})."
81
- )
82
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
83
- sys.exit(1)
84
-
85
-
86
  def make_ocr_message(
87
  image: Union[Image.Image, Dict[str, Any], str],
88
  prompt: str = "Extract the text from the above document as if you were reading it naturally. Return the tables in html format. Return the equations in LaTeX representation. If there is an image in the document and image caption is not present, add a small description of the image inside the <img></img> tag; otherwise, add the image caption inside <img></img>. Watermarks should be wrapped in brackets. Ex: <watermark>OFFICIAL COPY</watermark>. Page numbers should be wrapped in brackets. Ex: <page_number>14</page_number> or <page_number>9/22</page_number>. Prefer using ☐ and ☑ for check boxes.",
@@ -130,15 +104,6 @@ def create_dataset_card(
130
  """Create a dataset card documenting the OCR process."""
131
  model_name = model.split("/")[-1]
132
 
133
- # Canonical provenance stamp (see AGENTS.md): Jobs claim gated on JOB_ID, set by HF Jobs in-container.
134
- on_jobs = os.environ.get("JOB_ID") is not None
135
- hw = os.environ.get("ACCELERATOR") or ""
136
- origin = (
137
- "Produced on [Hugging Face Jobs](https://huggingface.co/docs/huggingface_hub/guides/jobs)"
138
- + (f" (`{hw}`)" if hw else "")
139
- ) if on_jobs else "Generated"
140
- jobs_tag = "\n- hf-jobs" if on_jobs else ""
141
-
142
  return f"""---
143
  viewer: false
144
  tags:
@@ -147,7 +112,7 @@ tags:
147
  - nanonets
148
  - markdown
149
  - uv-script
150
- - generated{jobs_tag}
151
  ---
152
 
153
  # Document OCR using {model_name}
@@ -211,10 +176,10 @@ for info in inference_info:
211
 
212
  ## Reproduction
213
 
214
- {origin} with the [`nanonets-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py) recipe from [uv-scripts](https://huggingface.co/uv-scripts). Run it yourself:
215
 
216
  ```bash
217
- hf jobs uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py \\
218
  {source_dataset} \\
219
  <output-dataset> \\
220
  --image-column {image_column} \\
@@ -228,6 +193,8 @@ hf jobs uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-
228
 
229
  - **Processing Speed**: ~{num_samples / (float(processing_time.split()[0]) * 60):.1f} images/second
230
  - **GPU Configuration**: vLLM with {gpu_memory_utilization:.0%} GPU memory utilization
 
 
231
  """
232
 
233
 
@@ -237,7 +204,7 @@ def main(
237
  image_column: str = "image",
238
  batch_size: int = 32,
239
  model: str = "nanonets/Nanonets-OCR-s",
240
- max_model_len: int = 32768,
241
  max_tokens: int = 15000,
242
  gpu_memory_utilization: float = 0.8,
243
  hf_token: str = None,
@@ -246,8 +213,6 @@ def main(
246
  private: bool = False,
247
  shuffle: bool = False,
248
  seed: int = 42,
249
- output_column: str = "markdown",
250
- overwrite: bool = False,
251
  verbose: bool = False,
252
  ):
253
  """Process images from HF dataset through OCR model."""
@@ -276,9 +241,6 @@ def main(
276
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
277
  )
278
 
279
- # Fail fast if the output column would collide with an existing input column
280
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
281
-
282
  # Shuffle if requested
283
  if shuffle:
284
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -335,9 +297,9 @@ def main(
335
  # Add error placeholders for failed batch
336
  all_markdown.extend(["[OCR FAILED]"] * len(batch_images))
337
 
338
- # Add output column to dataset
339
- logger.info(f"Adding '{output_column}' column to dataset")
340
- dataset = dataset.add_column(output_column, all_markdown)
341
 
342
  # Handle inference_info tracking
343
  logger.info("Updating inference_info...")
@@ -345,7 +307,7 @@ def main(
345
  inference_entry = {
346
  "model_id": model,
347
  "model_name": "Nanonets-OCR-s",
348
- "column_name": output_column,
349
  "timestamp": datetime.now().isoformat(),
350
  "batch_size": batch_size,
351
  "max_tokens": max_tokens,
@@ -500,10 +462,8 @@ Examples:
500
  parser.add_argument(
501
  "--max-model-len",
502
  type=int,
503
- default=32768,
504
- help="Maximum model context length (default: 32768; must exceed --max-tokens "
505
- "15000 plus the image/prompt tokens — the old 8192 default couldn't hold the "
506
- "output budget). Model max is 128k, so there's ample room.",
507
  )
508
  parser.add_argument(
509
  "--max-tokens",
@@ -540,17 +500,6 @@ Examples:
540
  default=42,
541
  help="Random seed for shuffling (default: 42)",
542
  )
543
- parser.add_argument(
544
- "--output-column",
545
- default="markdown",
546
- help="Column name for the OCR output text (default: markdown)",
547
- )
548
- parser.add_argument(
549
- "--overwrite",
550
- action="store_true",
551
- help="Replace the output column if it already exists in the input dataset "
552
- "(default: error out to avoid clobbering an existing column).",
553
- )
554
  parser.add_argument(
555
  "--verbose",
556
  action="store_true",
@@ -574,7 +523,5 @@ Examples:
574
  private=args.private,
575
  shuffle=args.shuffle,
576
  seed=args.seed,
577
- output_column=args.output_column,
578
- overwrite=args.overwrite,
579
  verbose=args.verbose,
580
  )
 
40
  from PIL import Image
41
  from toolz import partition_all
42
  from tqdm.auto import tqdm
 
 
 
 
43
  from vllm import LLM, SamplingParams
44
  from datetime import datetime
45
 
 
57
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def make_ocr_message(
61
  image: Union[Image.Image, Dict[str, Any], str],
62
  prompt: str = "Extract the text from the above document as if you were reading it naturally. Return the tables in html format. Return the equations in LaTeX representation. If there is an image in the document and image caption is not present, add a small description of the image inside the <img></img> tag; otherwise, add the image caption inside <img></img>. Watermarks should be wrapped in brackets. Ex: <watermark>OFFICIAL COPY</watermark>. Page numbers should be wrapped in brackets. Ex: <page_number>14</page_number> or <page_number>9/22</page_number>. Prefer using ☐ and ☑ for check boxes.",
 
104
  """Create a dataset card documenting the OCR process."""
105
  model_name = model.split("/")[-1]
106
 
 
 
 
 
 
 
 
 
 
107
  return f"""---
108
  viewer: false
109
  tags:
 
112
  - nanonets
113
  - markdown
114
  - uv-script
115
+ - generated
116
  ---
117
 
118
  # Document OCR using {model_name}
 
176
 
177
  ## Reproduction
178
 
179
+ This dataset was generated using the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) Nanonets OCR script:
180
 
181
  ```bash
182
+ uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nanonets-ocr.py \\
183
  {source_dataset} \\
184
  <output-dataset> \\
185
  --image-column {image_column} \\
 
193
 
194
  - **Processing Speed**: ~{num_samples / (float(processing_time.split()[0]) * 60):.1f} images/second
195
  - **GPU Configuration**: vLLM with {gpu_memory_utilization:.0%} GPU memory utilization
196
+
197
+ Generated with 🤖 [UV Scripts](https://huggingface.co/uv-scripts)
198
  """
199
 
200
 
 
204
  image_column: str = "image",
205
  batch_size: int = 32,
206
  model: str = "nanonets/Nanonets-OCR-s",
207
+ max_model_len: int = 8192,
208
  max_tokens: int = 15000,
209
  gpu_memory_utilization: float = 0.8,
210
  hf_token: str = None,
 
213
  private: bool = False,
214
  shuffle: bool = False,
215
  seed: int = 42,
 
 
216
  verbose: bool = False,
217
  ):
218
  """Process images from HF dataset through OCR model."""
 
241
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
242
  )
243
 
 
 
 
244
  # Shuffle if requested
245
  if shuffle:
246
  logger.info(f"Shuffling dataset with seed {seed}")
 
297
  # Add error placeholders for failed batch
298
  all_markdown.extend(["[OCR FAILED]"] * len(batch_images))
299
 
300
+ # Add markdown column to dataset
301
+ logger.info("Adding markdown column to dataset")
302
+ dataset = dataset.add_column("markdown", all_markdown)
303
 
304
  # Handle inference_info tracking
305
  logger.info("Updating inference_info...")
 
307
  inference_entry = {
308
  "model_id": model,
309
  "model_name": "Nanonets-OCR-s",
310
+ "column_name": "markdown",
311
  "timestamp": datetime.now().isoformat(),
312
  "batch_size": batch_size,
313
  "max_tokens": max_tokens,
 
462
  parser.add_argument(
463
  "--max-model-len",
464
  type=int,
465
+ default=8192,
466
+ help="Maximum model context length (default: 8192)",
 
 
467
  )
468
  parser.add_argument(
469
  "--max-tokens",
 
500
  default=42,
501
  help="Random seed for shuffling (default: 42)",
502
  )
 
 
 
 
 
 
 
 
 
 
 
503
  parser.add_argument(
504
  "--verbose",
505
  action="store_true",
 
523
  private=args.private,
524
  shuffle=args.shuffle,
525
  seed=args.seed,
 
 
526
  verbose=args.verbose,
527
  )
nanonets-ocr2.py CHANGED
@@ -4,8 +4,10 @@
4
  # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
 
7
  # "tqdm",
8
  # "toolz",
 
9
  # ]
10
  #
11
  # ///
@@ -24,20 +26,6 @@ Features:
24
  - Signature and watermark detection
25
  - Checkbox recognition
26
  - Multilingual support
27
-
28
- ⚠️ Pinned vLLM image required. Nanonets-OCR2-3B is a Qwen2.5-VL model, and vLLM
29
- >=0.11 regressed Qwen2.5-VL decoding to a stream of "!" (vllm-project/vllm#27775,
30
- #14126); 0.10.2 is the last known-good. So `vllm` and `torch` are intentionally
31
- omitted from the deps above and come from the `vllm/vllm-openai:v0.10.2` image at
32
- runtime via PYTHONPATH. Run it like:
33
-
34
- hf jobs uv run --flavor a10g-small -s HF_TOKEN \
35
- --image vllm/vllm-openai:v0.10.2 --python /usr/bin/python3 \
36
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \
37
- nanonets-ocr2.py INPUT_DATASET OUTPUT_DATASET --max-samples 10 --shuffle
38
-
39
- Re-test the pin when a newer vLLM ships a Qwen2.5-VL decode fix; it can then move
40
- back to a plain `vllm>=...` dep on the default image.
41
  """
42
 
43
  import argparse
@@ -50,33 +38,12 @@ import sys
50
  from typing import Any, Dict, List, Union
51
  from datetime import datetime
52
 
53
- # Preflight: torch/vllm are deliberately NOT PEP 723 deps (see docstring) — they come from
54
- # the pinned vllm/vllm-openai:v0.10.2 image. On the bare uv image the imports below would
55
- # die with a raw ModuleNotFoundError; fail fast naming the exact flags instead.
56
- import importlib.util
57
-
58
- if importlib.util.find_spec("vllm") is None or importlib.util.find_spec("torch") is None:
59
- sys.stderr.write(
60
- "ERROR: vllm/torch not importable — this recipe cannot run on the bare uv image.\n"
61
- "Nanonets-OCR2-3B needs vLLM 0.10.2 (>=0.11 regresses Qwen2.5-VL decoding to '!').\n"
62
- "Re-run with the pinned image + interpreter flags:\n"
63
- " hf jobs uv run --flavor a10g-small -s HF_TOKEN \\\n"
64
- " --image vllm/vllm-openai:v0.10.2 --python /usr/bin/python3 \\\n"
65
- " -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\\n"
66
- " nanonets-ocr2.py INPUT_DATASET OUTPUT_DATASET ...\n"
67
- )
68
- sys.exit(1)
69
-
70
  import torch
71
  from datasets import load_dataset
72
  from huggingface_hub import DatasetCard, login
73
  from PIL import Image
74
  from toolz import partition_all
75
  from tqdm.auto import tqdm
76
- # Disable vLLM's FlashInfer sampler (JIT-compiles a CUDA kernel; greedy OCR doesn't use it).
77
- # Harmless no-op on the pinned vllm/vllm-openai image; kept in case the pin is ever relaxed
78
- # back to an environment without nvcc.
79
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
80
  from vllm import LLM, SamplingParams
81
 
82
  logging.basicConfig(level=logging.INFO)
@@ -93,28 +60,6 @@ def check_cuda_availability():
93
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
94
 
95
 
96
- def ensure_output_columns_free(dataset, columns, overwrite=False):
97
- """Fail fast if an output column would collide with an existing input column.
98
-
99
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
100
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
101
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
102
- column(s) here instead (logged) so the later add_column is clean.
103
- """
104
- clash = [c for c in columns if c in dataset.column_names]
105
- if not clash:
106
- return dataset
107
- if overwrite:
108
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
109
- return dataset.remove_columns(clash)
110
- logger.error(
111
- f"Output column(s) {clash} already exist in the input dataset "
112
- f"(columns: {dataset.column_names})."
113
- )
114
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
115
- sys.exit(1)
116
-
117
-
118
  def make_ocr_message(
119
  image: Union[Image.Image, Dict[str, Any], str],
120
  prompt: str = "Extract the text from the above document as if you were reading it naturally. Return the tables in html format. Return the equations in LaTeX representation. If there is an image in the document and image caption is not present, add a small description of the image inside the <img></img> tag; otherwise, add the image caption inside <img></img>. Watermarks should be wrapped in brackets. Ex: <watermark>OFFICIAL COPY</watermark>. Page numbers should be wrapped in brackets. Ex: <page_number>14</page_number> or <page_number>9/22</page_number>. Prefer using ☐ and ☑ for check boxes.",
@@ -265,7 +210,7 @@ def main(
265
  image_column: str = "image",
266
  batch_size: int = 16,
267
  model: str = "nanonets/Nanonets-OCR2-3B",
268
- max_model_len: int = 32768,
269
  max_tokens: int = 15000,
270
  gpu_memory_utilization: float = 0.8,
271
  hf_token: str = None,
@@ -274,11 +219,7 @@ def main(
274
  private: bool = False,
275
  shuffle: bool = False,
276
  seed: int = 42,
277
- output_column: str = "markdown",
278
- overwrite: bool = False,
279
  verbose: bool = False,
280
- config: str = None,
281
- create_pr: bool = False,
282
  ):
283
  """Process images from HF dataset through Nanonets-OCR2-3B model."""
284
 
@@ -306,9 +247,6 @@ def main(
306
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
307
  )
308
 
309
- # Fail fast if the output column would collide with an existing input column
310
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
311
-
312
  # Shuffle if requested
313
  if shuffle:
314
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -365,9 +303,9 @@ def main(
365
  # Add error placeholders for failed batch
366
  all_markdown.extend(["[OCR FAILED]"] * len(batch_images))
367
 
368
- # Add output column to dataset
369
- logger.info(f"Adding '{output_column}' column to dataset")
370
- dataset = dataset.add_column(output_column, all_markdown)
371
 
372
  # Handle inference_info tracking
373
  logger.info("Updating inference_info...")
@@ -375,7 +313,7 @@ def main(
375
  inference_entry = {
376
  "model_id": model,
377
  "model_name": "Nanonets-OCR2-3B",
378
- "column_name": output_column,
379
  "timestamp": datetime.now().isoformat(),
380
  "batch_size": batch_size,
381
  "max_tokens": max_tokens,
@@ -408,15 +346,7 @@ def main(
408
 
409
  # Push to hub
410
  logger.info(f"Pushing to {output_dataset}")
411
- commit_info = dataset.push_to_hub(
412
- output_dataset,
413
- private=private,
414
- token=HF_TOKEN,
415
- **({"config_name": config} if config else {}),
416
- create_pr=create_pr,
417
- commit_message=f"Add {model} OCR results ({len(dataset)} samples)"
418
- + (f" [{config}]" if config else ""),
419
- )
420
 
421
  # Calculate processing time
422
  end_time = datetime.now()
@@ -446,8 +376,6 @@ def main(
446
  logger.info(
447
  f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
448
  )
449
- if create_pr and getattr(commit_info, "pr_url", None):
450
- logger.info(f"Pull request created: {commit_info.pr_url}")
451
 
452
  if verbose:
453
  import importlib.metadata
@@ -544,8 +472,8 @@ Examples:
544
  parser.add_argument(
545
  "--max-model-len",
546
  type=int,
547
- default=32768,
548
- help="Maximum model context length (default: 32768)",
549
  )
550
  parser.add_argument(
551
  "--max-tokens",
@@ -571,15 +499,6 @@ Examples:
571
  parser.add_argument(
572
  "--private", action="store_true", help="Make output dataset private"
573
  )
574
- parser.add_argument(
575
- "--config",
576
- help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
577
- )
578
- parser.add_argument(
579
- "--create-pr",
580
- action="store_true",
581
- help="Create a pull request instead of pushing directly (for parallel benchmarking)",
582
- )
583
  parser.add_argument(
584
  "--shuffle",
585
  action="store_true",
@@ -591,17 +510,6 @@ Examples:
591
  default=42,
592
  help="Random seed for shuffling (default: 42)",
593
  )
594
- parser.add_argument(
595
- "--output-column",
596
- default="markdown",
597
- help="Column name for the OCR output text (default: markdown)",
598
- )
599
- parser.add_argument(
600
- "--overwrite",
601
- action="store_true",
602
- help="Replace the output column if it already exists in the input dataset "
603
- "(default: error out to avoid clobbering an existing column).",
604
- )
605
  parser.add_argument(
606
  "--verbose",
607
  action="store_true",
@@ -625,9 +533,5 @@ Examples:
625
  private=args.private,
626
  shuffle=args.shuffle,
627
  seed=args.seed,
628
- output_column=args.output_column,
629
- overwrite=args.overwrite,
630
  verbose=args.verbose,
631
- config=args.config,
632
- create_pr=args.create_pr,
633
  )
 
4
  # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
7
+ # "vllm>=0.15.1",
8
  # "tqdm",
9
  # "toolz",
10
+ # "torch",
11
  # ]
12
  #
13
  # ///
 
26
  - Signature and watermark detection
27
  - Checkbox recognition
28
  - Multilingual support
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  """
30
 
31
  import argparse
 
38
  from typing import Any, Dict, List, Union
39
  from datetime import datetime
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  import torch
42
  from datasets import load_dataset
43
  from huggingface_hub import DatasetCard, login
44
  from PIL import Image
45
  from toolz import partition_all
46
  from tqdm.auto import tqdm
 
 
 
 
47
  from vllm import LLM, SamplingParams
48
 
49
  logging.basicConfig(level=logging.INFO)
 
60
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def make_ocr_message(
64
  image: Union[Image.Image, Dict[str, Any], str],
65
  prompt: str = "Extract the text from the above document as if you were reading it naturally. Return the tables in html format. Return the equations in LaTeX representation. If there is an image in the document and image caption is not present, add a small description of the image inside the <img></img> tag; otherwise, add the image caption inside <img></img>. Watermarks should be wrapped in brackets. Ex: <watermark>OFFICIAL COPY</watermark>. Page numbers should be wrapped in brackets. Ex: <page_number>14</page_number> or <page_number>9/22</page_number>. Prefer using ☐ and ☑ for check boxes.",
 
210
  image_column: str = "image",
211
  batch_size: int = 16,
212
  model: str = "nanonets/Nanonets-OCR2-3B",
213
+ max_model_len: int = 8192,
214
  max_tokens: int = 15000,
215
  gpu_memory_utilization: float = 0.8,
216
  hf_token: str = None,
 
219
  private: bool = False,
220
  shuffle: bool = False,
221
  seed: int = 42,
 
 
222
  verbose: bool = False,
 
 
223
  ):
224
  """Process images from HF dataset through Nanonets-OCR2-3B model."""
225
 
 
247
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
248
  )
249
 
 
 
 
250
  # Shuffle if requested
251
  if shuffle:
252
  logger.info(f"Shuffling dataset with seed {seed}")
 
303
  # Add error placeholders for failed batch
304
  all_markdown.extend(["[OCR FAILED]"] * len(batch_images))
305
 
306
+ # Add markdown column to dataset
307
+ logger.info("Adding markdown column to dataset")
308
+ dataset = dataset.add_column("markdown", all_markdown)
309
 
310
  # Handle inference_info tracking
311
  logger.info("Updating inference_info...")
 
313
  inference_entry = {
314
  "model_id": model,
315
  "model_name": "Nanonets-OCR2-3B",
316
+ "column_name": "markdown",
317
  "timestamp": datetime.now().isoformat(),
318
  "batch_size": batch_size,
319
  "max_tokens": max_tokens,
 
346
 
347
  # Push to hub
348
  logger.info(f"Pushing to {output_dataset}")
349
+ dataset.push_to_hub(output_dataset, private=private, token=HF_TOKEN)
 
 
 
 
 
 
 
 
350
 
351
  # Calculate processing time
352
  end_time = datetime.now()
 
376
  logger.info(
377
  f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
378
  )
 
 
379
 
380
  if verbose:
381
  import importlib.metadata
 
472
  parser.add_argument(
473
  "--max-model-len",
474
  type=int,
475
+ default=8192,
476
+ help="Maximum model context length (default: 8192)",
477
  )
478
  parser.add_argument(
479
  "--max-tokens",
 
499
  parser.add_argument(
500
  "--private", action="store_true", help="Make output dataset private"
501
  )
 
 
 
 
 
 
 
 
 
502
  parser.add_argument(
503
  "--shuffle",
504
  action="store_true",
 
510
  default=42,
511
  help="Random seed for shuffling (default: 42)",
512
  )
 
 
 
 
 
 
 
 
 
 
 
513
  parser.add_argument(
514
  "--verbose",
515
  action="store_true",
 
533
  private=args.private,
534
  shuffle=args.shuffle,
535
  seed=args.seed,
 
 
536
  verbose=args.verbose,
 
 
537
  )
nuextract3.py DELETED
@@ -1,786 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=3.1.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "vllm",
8
- # "toolz",
9
- # "torch",
10
- # "numind",
11
- # ]
12
- # ///
13
-
14
- """
15
- Convert document images to markdown OR extract structured JSON using NuExtract3 with vLLM.
16
-
17
- NuExtract3 is a 4B Qwen3.5-based VLM for document understanding. It does two things:
18
-
19
- 1. Document-to-Markdown OCR (default): images -> clean markdown with HTML tables,
20
- LaTeX math, and <figure> tags.
21
- 2. Schema-guided structured extraction: images + a JSON template -> JSON output
22
- shaped exactly like the template. Useful for invoices, receipts, forms, contracts.
23
-
24
- Modes are selected via flags:
25
- - (no flags) -> markdown OCR
26
- - --mode content -> plain-content extraction
27
- - --template SOURCE -> structured extraction with a NuExtract template
28
- - --schema SOURCE -> structured extraction with a JSON Schema
29
- (auto-converted via numind.nuextract_utils)
30
- - --instructions STR -> free-text guidance passed through to the model
31
- (output-format rules, branch routing, etc.).
32
- Combines with any of the modes above.
33
- See https://huggingface.co/numind/NuExtract3#instructions
34
-
35
- --template / --schema each accept inline JSON, a URL, or a local file path, so a
36
- schema can be hosted (e.g. on an HF dataset's raw URL) and reused across jobs:
37
- --template https://huggingface.co/datasets/ORG/REPO/raw/main/card.json
38
-
39
- HF Jobs invocation (recommended): use the vllm/vllm-openai:latest image so the
40
- pre-built CUDA kernels (flashinfer etc.) are reused — the default uv-script
41
- image lacks nvcc and flashinfer's JIT compile fails at engine warmup.
42
-
43
- hf jobs uv run \\
44
- --image vllm/vllm-openai:latest \\
45
- --flavor a100-large \\
46
- --python /usr/bin/python3 \\
47
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\
48
- -s HF_TOKEN \\
49
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nuextract3.py \\
50
- INPUT_DATASET OUTPUT_DATASET --max-samples 5 --shuffle --seed 42
51
-
52
- Model: numind/NuExtract3
53
- License: Apache-2.0
54
- """
55
-
56
- import argparse
57
- import base64
58
- import io
59
- import json
60
- import logging
61
- import os
62
- import sys
63
- import time
64
- from datetime import datetime
65
- from pathlib import Path
66
- from typing import Any, Dict, List, Optional, Union
67
-
68
- import torch
69
- from datasets import load_dataset
70
- from huggingface_hub import DatasetCard, login
71
- from PIL import Image
72
- from toolz import partition_all
73
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
74
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
75
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
76
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
77
- from vllm import LLM, SamplingParams
78
-
79
- logging.basicConfig(level=logging.INFO)
80
- logger = logging.getLogger(__name__)
81
-
82
- MODEL_DEFAULT = "numind/NuExtract3"
83
- MODEL_NAME = "NuExtract3"
84
-
85
-
86
- def check_cuda_availability():
87
- """Check if CUDA is available and exit if not."""
88
- if not torch.cuda.is_available():
89
- logger.error("CUDA is not available. This script requires a GPU.")
90
- logger.error("Please run on a machine with a CUDA-capable GPU.")
91
- sys.exit(1)
92
- else:
93
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
94
-
95
-
96
- def ensure_output_columns_free(dataset, columns, overwrite=False):
97
- """Fail fast if an output column would collide with an existing input column.
98
-
99
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
100
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
101
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
102
- column(s) here instead (logged) so the later add_column is clean.
103
- """
104
- clash = [c for c in columns if c in dataset.column_names]
105
- if not clash:
106
- return dataset
107
- if overwrite:
108
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
109
- return dataset.remove_columns(clash)
110
- logger.error(
111
- f"Output column(s) {clash} already exist in the input dataset "
112
- f"(columns: {dataset.column_names})."
113
- )
114
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
115
- sys.exit(1)
116
-
117
-
118
- def load_template_arg(value: Optional[str]) -> Optional[Dict[str, Any]]:
119
- """Load a NuExtract template/JSON Schema from inline JSON, a URL, or a file path."""
120
- if value is None:
121
- return None
122
- text = value
123
- if value.startswith(("http://", "https://")):
124
- import urllib.request
125
-
126
- with urllib.request.urlopen(value) as resp: # noqa: S310
127
- text = resp.read().decode("utf-8")
128
- elif "{" not in value:
129
- # Inline JSON often exceeds the OS filename limit, so only probe the
130
- # filesystem when the value doesn't look like JSON; treat OSError as
131
- # "not a path".
132
- try:
133
- candidate_path = Path(value)
134
- if candidate_path.is_file():
135
- text = candidate_path.read_text()
136
- except OSError:
137
- pass
138
- try:
139
- return json.loads(text)
140
- except json.JSONDecodeError as e:
141
- raise ValueError(
142
- f"Could not parse template/schema as JSON (tried URL/path/inline): {e}"
143
- ) from e
144
-
145
-
146
- def resolve_template(
147
- template_arg: Optional[str],
148
- schema_arg: Optional[str],
149
- ) -> Optional[Dict[str, Any]]:
150
- """Resolve --template / --schema into a NuExtract template dict, or None."""
151
- if template_arg and schema_arg:
152
- raise ValueError("--template and --schema are mutually exclusive.")
153
-
154
- if template_arg is not None:
155
- return load_template_arg(template_arg)
156
-
157
- if schema_arg is not None:
158
- schema = load_template_arg(schema_arg)
159
- try:
160
- from numind.nuextract_utils import convert_json_schema_to_nuextract_template
161
- except ImportError as e:
162
- raise RuntimeError(
163
- "--schema requires the `numind` package. "
164
- "It should be listed in this script's PEP 723 dependencies."
165
- ) from e
166
- template, dropped = convert_json_schema_to_nuextract_template(schema)
167
- if dropped:
168
- logger.warning(
169
- f"numind dropped {len(dropped)} unsupported branches from the JSON Schema: "
170
- f"{dropped}"
171
- )
172
- return template
173
-
174
- return None
175
-
176
-
177
- def image_to_data_uri(image: Union[Image.Image, Dict[str, Any], str]) -> str:
178
- """Normalize an HF dataset image cell to a PNG data URI."""
179
- if isinstance(image, Image.Image):
180
- pil_img = image
181
- elif isinstance(image, dict) and "bytes" in image:
182
- pil_img = Image.open(io.BytesIO(image["bytes"]))
183
- elif isinstance(image, str):
184
- pil_img = Image.open(image)
185
- else:
186
- raise ValueError(f"Unsupported image type: {type(image)}")
187
-
188
- pil_img = pil_img.convert("RGB")
189
- buf = io.BytesIO()
190
- pil_img.save(buf, format="PNG")
191
- return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
192
-
193
-
194
- def make_message(image: Union[Image.Image, Dict[str, Any], str]) -> List[Dict]:
195
- """Build an OpenAI-format chat message containing one image."""
196
- data_uri = image_to_data_uri(image)
197
- return [
198
- {
199
- "role": "user",
200
- "content": [
201
- {"type": "image_url", "image_url": {"url": data_uri}},
202
- ],
203
- }
204
- ]
205
-
206
-
207
- def split_thinking(text: str) -> tuple[Optional[str], str]:
208
- """Return (reasoning, answer) if <think>...</think> is present, else (None, text)."""
209
- if "<think>" in text and "</think>" in text:
210
- reasoning = text.split("<think>", 1)[1].split("</think>", 1)[0].strip()
211
- answer = text.split("</think>", 1)[1].strip()
212
- return reasoning, answer
213
- return None, text.strip()
214
-
215
-
216
- def parse_json_output(text: str) -> tuple[Optional[Any], bool]:
217
- """Parse an extraction output; strip ``` fences as the model card describes.
218
-
219
- Returns (parsed_value, parse_error). On failure, parsed_value is None.
220
- """
221
- stripped = text.strip()
222
- if stripped.startswith("```"):
223
- stripped = stripped.split("\n", 1)[-1] if "\n" in stripped else stripped[3:]
224
- if stripped.endswith("```"):
225
- stripped = stripped[:-3].rstrip()
226
- try:
227
- return json.loads(stripped), False
228
- except json.JSONDecodeError:
229
- return None, True
230
-
231
-
232
- def create_dataset_card(
233
- source_dataset: str,
234
- model: str,
235
- num_samples: int,
236
- processing_time: str,
237
- mode_label: str,
238
- template: Optional[Dict[str, Any]],
239
- enable_thinking: bool,
240
- temperature: float,
241
- output_column: str,
242
- image_column: str,
243
- split: str,
244
- ) -> str:
245
- """Create a dataset card documenting the NuExtract3 run."""
246
- model_name = model.split("/")[-1]
247
- template_block = ""
248
- if template is not None:
249
- template_block = (
250
- "\n### Extraction Template\n\n```json\n"
251
- + json.dumps(template, indent=2)
252
- + "\n```\n"
253
- )
254
-
255
- return f"""---
256
- tags:
257
- - ocr
258
- - structured-extraction
259
- - document-processing
260
- - nuextract3
261
- - markdown
262
- - uv-script
263
- - generated
264
- ---
265
-
266
- # {model_name} on {source_dataset}
267
-
268
- This dataset contains outputs from [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) processed with [NuExtract3](https://huggingface.co/{model}), a 4B vision-language model for document understanding.
269
-
270
- ## Processing Details
271
-
272
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
273
- - **Model**: [{model}](https://huggingface.co/{model})
274
- - **Mode**: {mode_label}
275
- - **Number of Samples**: {num_samples:,}
276
- - **Processing Time**: {processing_time}
277
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
278
-
279
- ### Configuration
280
-
281
- - **Image Column**: `{image_column}`
282
- - **Output Column**: `{output_column}`
283
- - **Dataset Split**: `{split}`
284
- - **Temperature**: {temperature}
285
- - **Thinking Mode**: {"enabled" if enable_thinking else "disabled"}
286
- {template_block}
287
- ## Dataset Structure
288
-
289
- Original columns plus:
290
- - `{output_column}`: NuExtract3 output ({"JSON string" if template else "markdown"})
291
- - `inference_info`: JSON list tracking models applied to this dataset
292
- {"- `" + output_column + "_reasoning`: model's thinking trace (when enabled)" if enable_thinking else ""}
293
-
294
- Generated with [UV Scripts](https://huggingface.co/uv-scripts)
295
- """
296
-
297
-
298
- def main(
299
- input_dataset: str,
300
- output_dataset: str,
301
- image_column: str = "image",
302
- batch_size: int = 16,
303
- max_model_len: int = 16384,
304
- max_tokens: int = 8192,
305
- gpu_memory_utilization: float = 0.8,
306
- mode: str = "markdown",
307
- template_arg: Optional[str] = None,
308
- schema_arg: Optional[str] = None,
309
- enable_thinking: bool = False,
310
- instructions: Optional[str] = None,
311
- temperature: Optional[float] = None,
312
- model: str = MODEL_DEFAULT,
313
- hf_token: str = None,
314
- split: str = "train",
315
- max_samples: int = None,
316
- private: bool = False,
317
- shuffle: bool = False,
318
- seed: int = 42,
319
- output_column: Optional[str] = None,
320
- overwrite: bool = False,
321
- verbose: bool = False,
322
- config: str = None,
323
- create_pr: bool = False,
324
- ):
325
- """Process images from an HF dataset through NuExtract3."""
326
-
327
- check_cuda_availability()
328
- start_time = datetime.now()
329
-
330
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
331
- if HF_TOKEN:
332
- login(token=HF_TOKEN)
333
-
334
- template = resolve_template(template_arg, schema_arg)
335
- extraction_mode = template is not None
336
- mode_label = "structured-extraction" if extraction_mode else mode
337
-
338
- if output_column is None:
339
- output_column = "extraction" if extraction_mode else "markdown"
340
-
341
- if temperature is None:
342
- temperature = 0.6 if enable_thinking else 0.2
343
-
344
- logger.info(f"Using model: {model}")
345
- logger.info(f"Mode: {mode_label}")
346
- logger.info(f"Thinking: {enable_thinking} Temperature: {temperature}")
347
- if extraction_mode:
348
- logger.info(f"Template: {json.dumps(template, indent=2)}")
349
-
350
- logger.info(f"Loading dataset: {input_dataset}")
351
- dataset = load_dataset(input_dataset, split=split)
352
-
353
- if image_column not in dataset.column_names:
354
- raise ValueError(
355
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
356
- )
357
-
358
- # Fail fast if an output column would collide with an existing input column.
359
- # With --enable-thinking the script also writes "{output_column}_reasoning".
360
- out_cols = [output_column]
361
- if enable_thinking:
362
- out_cols.append(f"{output_column}_reasoning")
363
- dataset = ensure_output_columns_free(dataset, out_cols, overwrite=overwrite)
364
-
365
- if shuffle:
366
- logger.info(f"Shuffling dataset with seed {seed}")
367
- dataset = dataset.shuffle(seed=seed)
368
-
369
- if max_samples:
370
- dataset = dataset.select(range(min(max_samples, len(dataset))))
371
- logger.info(f"Limited to {len(dataset)} samples")
372
-
373
- logger.info("Initializing vLLM with NuExtract3")
374
- logger.info("This may take a few minutes on first run...")
375
- llm = LLM(
376
- model=model,
377
- trust_remote_code=True,
378
- max_model_len=max_model_len,
379
- gpu_memory_utilization=gpu_memory_utilization,
380
- limit_mm_per_prompt={"image": 1},
381
- )
382
-
383
- sampling_params = SamplingParams(
384
- temperature=temperature,
385
- max_tokens=max_tokens,
386
- )
387
-
388
- chat_template_kwargs: Dict[str, Any] = {"enable_thinking": enable_thinking}
389
- if extraction_mode:
390
- chat_template_kwargs["template"] = json.dumps(template, indent=4)
391
- else:
392
- chat_template_kwargs["mode"] = mode
393
- if instructions:
394
- chat_template_kwargs["instructions"] = instructions
395
-
396
- logger.info(f"Processing {len(dataset)} images in batches of {batch_size}")
397
- logger.info(f"Output will be written to column: {output_column}")
398
-
399
- all_outputs: List[str] = []
400
- all_reasoning: List[Optional[str]] = []
401
- all_parse_errors: List[bool] = []
402
- total_batches = (len(dataset) + batch_size - 1) // batch_size
403
- processed = 0
404
-
405
- for batch_num, batch_indices in enumerate(
406
- partition_all(batch_size, range(len(dataset))), 1
407
- ):
408
- batch_indices = list(batch_indices)
409
- batch_images = [dataset[i][image_column] for i in batch_indices]
410
-
411
- logger.info(
412
- f"Batch {batch_num}/{total_batches} "
413
- f"({processed}/{len(dataset)} images done)"
414
- )
415
-
416
- try:
417
- batch_messages = [make_message(img) for img in batch_images]
418
- outputs = llm.chat(
419
- batch_messages,
420
- sampling_params,
421
- chat_template_kwargs=chat_template_kwargs,
422
- chat_template_content_format="openai",
423
- )
424
-
425
- for output in outputs:
426
- raw_text = output.outputs[0].text
427
- reasoning, answer = split_thinking(raw_text)
428
-
429
- if extraction_mode:
430
- parsed, parse_error = parse_json_output(answer)
431
- stored = (
432
- json.dumps(parsed, ensure_ascii=False)
433
- if parsed is not None
434
- else answer
435
- )
436
- all_outputs.append(stored)
437
- all_parse_errors.append(parse_error)
438
- else:
439
- all_outputs.append(answer)
440
- all_parse_errors.append(False)
441
-
442
- all_reasoning.append(reasoning)
443
-
444
- processed += len(batch_images)
445
-
446
- except Exception as e:
447
- logger.error(f"Error processing batch: {e}")
448
- all_outputs.extend(["[NUEXTRACT3 ERROR]"] * len(batch_images))
449
- all_reasoning.extend([None] * len(batch_images))
450
- all_parse_errors.extend([True] * len(batch_images))
451
- processed += len(batch_images)
452
-
453
- processing_duration = datetime.now() - start_time
454
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
455
-
456
- logger.info(f"Adding '{output_column}' column to dataset")
457
- dataset = dataset.add_column(output_column, all_outputs)
458
-
459
- if enable_thinking and any(r is not None for r in all_reasoning):
460
- reasoning_col = f"{output_column}_reasoning"
461
- logger.info(f"Adding '{reasoning_col}' column to dataset")
462
- dataset = dataset.add_column(reasoning_col, all_reasoning)
463
-
464
- if extraction_mode:
465
- parse_error_count = sum(all_parse_errors)
466
- if parse_error_count:
467
- logger.warning(
468
- f"{parse_error_count}/{len(all_parse_errors)} extractions failed to parse as JSON"
469
- )
470
-
471
- inference_entry = {
472
- "model_id": model,
473
- "model_name": MODEL_NAME,
474
- "column_name": output_column,
475
- "timestamp": datetime.now().isoformat(),
476
- "mode": mode_label,
477
- "has_template": extraction_mode,
478
- "enable_thinking": enable_thinking,
479
- "temperature": temperature,
480
- "max_tokens": max_tokens,
481
- }
482
- if extraction_mode:
483
- inference_entry["parse_error_rate"] = (
484
- sum(all_parse_errors) / len(all_parse_errors) if all_parse_errors else 0.0
485
- )
486
-
487
- if "inference_info" in dataset.column_names:
488
- logger.info("Updating existing inference_info column")
489
-
490
- def update_inference_info(example):
491
- try:
492
- existing_info = (
493
- json.loads(example["inference_info"])
494
- if example["inference_info"]
495
- else []
496
- )
497
- except (json.JSONDecodeError, TypeError):
498
- existing_info = []
499
- existing_info.append(inference_entry)
500
- return {"inference_info": json.dumps(existing_info)}
501
-
502
- dataset = dataset.map(update_inference_info)
503
- else:
504
- logger.info("Creating new inference_info column")
505
- inference_list = [json.dumps([inference_entry])] * len(dataset)
506
- dataset = dataset.add_column("inference_info", inference_list)
507
-
508
- logger.info(f"Pushing to {output_dataset}")
509
- max_retries = 3
510
- for attempt in range(1, max_retries + 1):
511
- try:
512
- if attempt > 1:
513
- logger.warning("Disabling XET (fallback to HTTP upload)")
514
- os.environ["HF_HUB_DISABLE_XET"] = "1"
515
- dataset.push_to_hub(
516
- output_dataset,
517
- private=private,
518
- token=HF_TOKEN,
519
- max_shard_size="500MB",
520
- **({"config_name": config} if config else {}),
521
- create_pr=create_pr,
522
- commit_message=f"Add {model} {mode_label} results ({len(dataset)} samples)"
523
- + (f" [{config}]" if config else ""),
524
- )
525
- break
526
- except Exception as e:
527
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
528
- if attempt < max_retries:
529
- delay = 30 * (2 ** (attempt - 1))
530
- logger.info(f"Retrying in {delay}s...")
531
- time.sleep(delay)
532
- else:
533
- logger.error("All upload attempts failed. Results are lost.")
534
- sys.exit(1)
535
-
536
- logger.info("Creating dataset card")
537
- card_content = create_dataset_card(
538
- source_dataset=input_dataset,
539
- model=model,
540
- num_samples=len(dataset),
541
- processing_time=processing_time_str,
542
- mode_label=mode_label,
543
- template=template,
544
- enable_thinking=enable_thinking,
545
- temperature=temperature,
546
- output_column=output_column,
547
- image_column=image_column,
548
- split=split,
549
- )
550
- card = DatasetCard(card_content)
551
- card.push_to_hub(output_dataset, token=HF_TOKEN)
552
-
553
- logger.info("Done! NuExtract3 processing complete.")
554
- logger.info(
555
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
556
- )
557
- logger.info(f"Processing time: {processing_time_str}")
558
- logger.info(
559
- f"Processing speed: {len(dataset) / processing_duration.total_seconds():.2f} images/sec"
560
- )
561
-
562
- if verbose:
563
- import importlib.metadata
564
-
565
- logger.info("--- Resolved package versions ---")
566
- for pkg in [
567
- "vllm",
568
- "transformers",
569
- "torch",
570
- "datasets",
571
- "pyarrow",
572
- "pillow",
573
- "numind",
574
- ]:
575
- try:
576
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
577
- except importlib.metadata.PackageNotFoundError:
578
- logger.info(f" {pkg}: not installed")
579
- logger.info("--- End versions ---")
580
-
581
-
582
- if __name__ == "__main__":
583
- if len(sys.argv) == 1:
584
- print("=" * 70)
585
- print("NuExtract3 - Document-to-Markdown + Structured Extraction (4B)")
586
- print("=" * 70)
587
- print("\nModes:")
588
- print(" markdown - Image -> markdown (default)")
589
- print(" content - Image -> plain content")
590
- print(" --template / --schema - Image -> JSON shaped like the template")
591
- print("\nExamples:")
592
- print("\n1. Markdown OCR:")
593
- print(" uv run nuextract3.py input-dataset output-dataset")
594
- print("\n2. Structured extraction with an inline template:")
595
- print(" uv run nuextract3.py input output \\")
596
- print(' --template \'{"title": "verbatim-string", "date": "date"}\'')
597
- print("\n3. Structured extraction from a JSON Schema (e.g. Pydantic):")
598
- print(" uv run nuextract3.py input output --schema schema.json")
599
- print("\n (--template / --schema also accept a URL or a local file path)")
600
- print("\n4. Reasoning mode for harder documents:")
601
- print(" uv run nuextract3.py input output --enable-thinking")
602
- print("\n5. Test with 10 samples:")
603
- print(" uv run nuextract3.py large-ds test --max-samples 10 --shuffle")
604
- print("\n6. Running on HF Jobs (use vllm/vllm-openai image for built kernels):")
605
- print(" hf jobs uv run --flavor a100-large \\")
606
- print(" --image vllm/vllm-openai:latest \\")
607
- print(" --python /usr/bin/python3 \\")
608
- print(" -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\")
609
- print(" -s HF_TOKEN \\")
610
- print(
611
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/nuextract3.py \\"
612
- )
613
- print(" input-dataset output-dataset --batch-size 16")
614
- print("\nFor full help: uv run nuextract3.py --help")
615
- sys.exit(0)
616
-
617
- parser = argparse.ArgumentParser(
618
- description="NuExtract3: document-to-markdown + schema-guided JSON extraction (4B VLM)",
619
- formatter_class=argparse.RawDescriptionHelpFormatter,
620
- epilog="""
621
- Modes:
622
- (default) Markdown OCR (image -> clean markdown)
623
- --mode content
624
- Plain-content extraction (less structured than markdown)
625
- --template PATH_OR_JSON
626
- Structured extraction with a NuExtract template
627
- --schema PATH_OR_JSON
628
- Structured extraction from a JSON Schema
629
- (e.g. Pydantic Model.model_json_schema())
630
-
631
- Examples:
632
- uv run nuextract3.py my-docs analyzed-docs
633
- uv run nuextract3.py receipts extracted \\
634
- --template '{"store": "verbatim-string", "total": "number"}'
635
- uv run nuextract3.py contracts extracted --schema contract_schema.json
636
- uv run nuextract3.py hard-docs out --enable-thinking
637
- """,
638
- )
639
-
640
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
641
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
642
- parser.add_argument(
643
- "--image-column",
644
- default="image",
645
- help="Column containing images (default: image)",
646
- )
647
- parser.add_argument(
648
- "--batch-size",
649
- type=int,
650
- default=16,
651
- help="Batch size for processing (default: 16)",
652
- )
653
- parser.add_argument(
654
- "--max-model-len",
655
- type=int,
656
- default=16384,
657
- help="Maximum model context length (default: 16384)",
658
- )
659
- parser.add_argument(
660
- "--max-tokens",
661
- type=int,
662
- default=8192,
663
- help="Maximum tokens to generate (default: 8192)",
664
- )
665
- parser.add_argument(
666
- "--gpu-memory-utilization",
667
- type=float,
668
- default=0.8,
669
- help="GPU memory utilization (default: 0.8)",
670
- )
671
- parser.add_argument(
672
- "--mode",
673
- choices=["markdown", "content"],
674
- default="markdown",
675
- help="OCR mode when no template/schema is given (default: markdown)",
676
- )
677
- parser.add_argument(
678
- "--template",
679
- help="NuExtract template: inline JSON, a URL, or a file path",
680
- )
681
- parser.add_argument(
682
- "--schema",
683
- help="JSON Schema to auto-convert: inline JSON, a URL, or a file path",
684
- )
685
- parser.add_argument(
686
- "--enable-thinking",
687
- action="store_true",
688
- help="Enable reasoning mode (slower, better on hard documents)",
689
- )
690
- parser.add_argument(
691
- "--instructions",
692
- default=None,
693
- help=(
694
- "Free-text instructions passed to NuExtract via "
695
- "chat_template_kwargs.instructions (e.g. routing guidance across "
696
- "optional schema branches, output-format rules). "
697
- "See https://huggingface.co/numind/NuExtract3#instructions"
698
- ),
699
- )
700
- parser.add_argument(
701
- "--temperature",
702
- type=float,
703
- default=None,
704
- help="Sampling temperature (default: 0.2 non-thinking, 0.6 thinking)",
705
- )
706
- parser.add_argument(
707
- "--model",
708
- default=MODEL_DEFAULT,
709
- help=f"Model ID (default: {MODEL_DEFAULT})",
710
- )
711
- parser.add_argument("--hf-token", help="Hugging Face API token")
712
- parser.add_argument(
713
- "--split", default="train", help="Dataset split to use (default: train)"
714
- )
715
- parser.add_argument(
716
- "--max-samples",
717
- type=int,
718
- help="Maximum number of samples to process (for testing)",
719
- )
720
- parser.add_argument(
721
- "--private", action="store_true", help="Make output dataset private"
722
- )
723
- parser.add_argument(
724
- "--config",
725
- help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
726
- )
727
- parser.add_argument(
728
- "--create-pr",
729
- action="store_true",
730
- help="Create a pull request instead of pushing directly (for parallel benchmarking)",
731
- )
732
- parser.add_argument(
733
- "--shuffle", action="store_true", help="Shuffle dataset before processing"
734
- )
735
- parser.add_argument(
736
- "--seed",
737
- type=int,
738
- default=42,
739
- help="Random seed for shuffling (default: 42)",
740
- )
741
- parser.add_argument(
742
- "--output-column",
743
- default=None,
744
- help="Column name for output (default: 'markdown' in OCR mode, 'extraction' in template mode)",
745
- )
746
- parser.add_argument(
747
- "--overwrite",
748
- action="store_true",
749
- help="Replace the output column if it already exists in the input dataset "
750
- "(default: error out to avoid clobbering an existing column).",
751
- )
752
- parser.add_argument(
753
- "--verbose",
754
- action="store_true",
755
- help="Log resolved package versions after processing",
756
- )
757
-
758
- args = parser.parse_args()
759
-
760
- main(
761
- input_dataset=args.input_dataset,
762
- output_dataset=args.output_dataset,
763
- image_column=args.image_column,
764
- batch_size=args.batch_size,
765
- max_model_len=args.max_model_len,
766
- max_tokens=args.max_tokens,
767
- gpu_memory_utilization=args.gpu_memory_utilization,
768
- mode=args.mode,
769
- template_arg=args.template,
770
- schema_arg=args.schema,
771
- enable_thinking=args.enable_thinking,
772
- instructions=args.instructions,
773
- temperature=args.temperature,
774
- model=args.model,
775
- hf_token=args.hf_token,
776
- split=args.split,
777
- max_samples=args.max_samples,
778
- private=args.private,
779
- shuffle=args.shuffle,
780
- seed=args.seed,
781
- output_column=args.output_column,
782
- overwrite=args.overwrite,
783
- verbose=args.verbose,
784
- config=args.config,
785
- create_pr=args.create_pr,
786
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
numarkdown-ocr.py CHANGED
@@ -47,10 +47,6 @@ from huggingface_hub import DatasetCard, login
47
  from PIL import Image
48
  from toolz import partition_all
49
  from tqdm.auto import tqdm
50
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
51
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
52
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
53
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
54
  from vllm import LLM, SamplingParams
55
 
56
  logging.basicConfig(level=logging.INFO)
@@ -75,28 +71,6 @@ def check_gpu_availability() -> int:
75
  return num_gpus
76
 
77
 
78
- def ensure_output_columns_free(dataset, columns, overwrite=False):
79
- """Fail fast if an output column would collide with an existing input column.
80
-
81
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
82
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
83
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
84
- column(s) here instead (logged) so the later add_column is clean.
85
- """
86
- clash = [c for c in columns if c in dataset.column_names]
87
- if not clash:
88
- return dataset
89
- if overwrite:
90
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
91
- return dataset.remove_columns(clash)
92
- logger.error(
93
- f"Output column(s) {clash} already exist in the input dataset "
94
- f"(columns: {dataset.column_names})."
95
- )
96
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
97
- sys.exit(1)
98
-
99
-
100
  def validate_and_resize_image(
101
  image: Image.Image,
102
  min_pixels: int = 100 * 28 * 28,
@@ -339,7 +313,6 @@ def main(
339
  temperature: float = 0.0,
340
  custom_prompt: Optional[str] = None,
341
  output_column: str = "markdown",
342
- overwrite: bool = False,
343
  config: str = None,
344
  create_pr: bool = False,
345
  verbose: bool = False,
@@ -384,9 +357,6 @@ def main(
384
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
385
  )
386
 
387
- # Fail fast if the output column would collide with an existing input column
388
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
389
-
390
  # Shuffle if requested
391
  if shuffle:
392
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -726,12 +696,6 @@ Examples:
726
  default="markdown",
727
  help="Column name for output text (default: markdown)",
728
  )
729
- parser.add_argument(
730
- "--overwrite",
731
- action="store_true",
732
- help="Replace the output column if it already exists in the input dataset "
733
- "(default: error out to avoid clobbering an existing column).",
734
- )
735
  parser.add_argument(
736
  "--config",
737
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
@@ -769,7 +733,6 @@ Examples:
769
  temperature=args.temperature,
770
  custom_prompt=args.custom_prompt,
771
  output_column=args.output_column,
772
- overwrite=args.overwrite,
773
  config=args.config,
774
  create_pr=args.create_pr,
775
  verbose=args.verbose,
 
47
  from PIL import Image
48
  from toolz import partition_all
49
  from tqdm.auto import tqdm
 
 
 
 
50
  from vllm import LLM, SamplingParams
51
 
52
  logging.basicConfig(level=logging.INFO)
 
71
  return num_gpus
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def validate_and_resize_image(
75
  image: Image.Image,
76
  min_pixels: int = 100 * 28 * 28,
 
313
  temperature: float = 0.0,
314
  custom_prompt: Optional[str] = None,
315
  output_column: str = "markdown",
 
316
  config: str = None,
317
  create_pr: bool = False,
318
  verbose: bool = False,
 
357
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
358
  )
359
 
 
 
 
360
  # Shuffle if requested
361
  if shuffle:
362
  logger.info(f"Shuffling dataset with seed {seed}")
 
696
  default="markdown",
697
  help="Column name for output text (default: markdown)",
698
  )
 
 
 
 
 
 
699
  parser.add_argument(
700
  "--config",
701
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
 
733
  temperature=args.temperature,
734
  custom_prompt=args.custom_prompt,
735
  output_column=args.output_column,
 
736
  config=args.config,
737
  create_pr=args.create_pr,
738
  verbose=args.verbose,
olmocr2-vllm.py CHANGED
@@ -1,7 +1,7 @@
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
- # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm",
@@ -50,11 +50,8 @@ from huggingface_hub import DatasetCard, login
50
  from PIL import Image
51
  from toolz import partition_all
52
  from tqdm.auto import tqdm
53
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
54
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
55
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
56
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
57
  from vllm import LLM, SamplingParams
 
58
 
59
  logging.basicConfig(level=logging.INFO)
60
  logger = logging.getLogger(__name__)
@@ -81,28 +78,6 @@ def check_cuda_availability():
81
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
82
 
83
 
84
- def ensure_output_columns_free(dataset, columns, overwrite=False):
85
- """Fail fast if an output column would collide with an existing input column.
86
-
87
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
88
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
89
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
90
- column(s) here instead (logged) so the later add_column is clean.
91
- """
92
- clash = [c for c in columns if c in dataset.column_names]
93
- if not clash:
94
- return dataset
95
- if overwrite:
96
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
97
- return dataset.remove_columns(clash)
98
- logger.error(
99
- f"Output column(s) {clash} already exist in the input dataset "
100
- f"(columns: {dataset.column_names})."
101
- )
102
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
103
- sys.exit(1)
104
-
105
-
106
  def parse_yaml_frontmatter(text: str) -> tuple[dict, str]:
107
  """
108
  Parse YAML front matter from olmOCR output.
@@ -302,7 +277,6 @@ def main(
302
  output_dataset: str,
303
  image_column: str = "image",
304
  output_column: str = "markdown",
305
- overwrite: bool = False,
306
  batch_size: int = 16,
307
  model: str = "allenai/olmOCR-2-7B-1025-FP8",
308
  max_model_len: int = 16384,
@@ -316,8 +290,6 @@ def main(
316
  private: bool = False,
317
  shuffle: bool = False,
318
  seed: int = 42,
319
- config: str = None,
320
- create_pr: bool = False,
321
  ):
322
  """
323
  Process a dataset of document images through olmOCR-2 to extract markdown.
@@ -358,9 +330,6 @@ def main(
358
  logger.info(f"Loading dataset: {input_dataset}")
359
  ds = load_dataset(input_dataset, split=split)
360
 
361
- # Fail fast if the output column would collide with an existing input column
362
- ds = ensure_output_columns_free(ds, [output_column], overwrite=overwrite)
363
-
364
  # Shuffle if requested
365
  if shuffle:
366
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -396,25 +365,13 @@ def main(
396
  "stop": ["<|im_end|>", "<|endoftext|>"],
397
  }
398
 
399
- # Add guided decoding if requested (enforces YAML front matter structure).
400
- # vLLM 0.22.x renamed this API: GuidedDecodingParams (guided_decoding=) →
401
- # StructuredOutputsParams (structured_outputs=). Import lazily + shim so the
402
- # default path (guided_decoding=False) never touches the moved symbol.
403
  if guided_decoding:
404
  logger.info("Enabling guided decoding with YAML front matter regex")
405
- front_matter_regex = r"---\nprimary_language: (?:[a-z]{2}|null)\nis_rotation_valid: (?:True|False|true|false)\nrotation_correction: (?:0|90|180|270)\nis_table: (?:True|False|true|false)\nis_diagram: (?:True|False|true|false)\n(?:---|---\n[\s\S]+)"
406
- try:
407
- from vllm.sampling_params import StructuredOutputsParams
408
-
409
- sampling_params_kwargs["structured_outputs"] = StructuredOutputsParams(
410
- regex=front_matter_regex
411
- )
412
- except ImportError:
413
- from vllm.sampling_params import GuidedDecodingParams
414
-
415
- sampling_params_kwargs["guided_decoding"] = GuidedDecodingParams(
416
- regex=front_matter_regex
417
- )
418
 
419
  sampling_params = SamplingParams(**sampling_params_kwargs)
420
 
@@ -463,41 +420,36 @@ def main(
463
  ds = ds.remove_columns([metadata_column_name])
464
  ds = ds.add_column(metadata_column_name, all_metadata)
465
 
466
- # Add inference information — standard schema: a JSON-string LIST of entries, each carrying
467
- # model_id (used as the leaderboard label) and column_name (the output text column). The
468
- # script's own extras (version, timestamp, sampling params) ride along as extra keys.
469
- inference_entry = {
470
- "model_id": model,
471
- "column_name": output_column,
472
- "script": "olmocr2-vllm.py",
473
- "version": "1.0.0",
474
- "timestamp": datetime.now().isoformat(),
475
- "batch_size": batch_size,
476
- "max_tokens": max_tokens,
477
- "temperature": temperature,
478
- }
479
 
480
- # Handle existing inference_info column (append to the list; create a fresh list otherwise)
481
  if inference_info_column in ds.column_names:
482
- logger.info("Updating existing inference_info column")
483
-
484
  def update_inference_info(example):
485
  try:
486
- existing_info = (
487
- json.loads(example[inference_info_column])
488
- if example[inference_info_column]
489
- else []
490
- )
491
- except (json.JSONDecodeError, TypeError):
492
- existing_info = []
493
- existing_info.append(inference_entry)
494
- return {inference_info_column: json.dumps(existing_info)}
495
 
496
  ds = ds.map(update_inference_info)
497
  else:
498
- logger.info("Creating new inference_info column")
499
- inference_list = [json.dumps([inference_entry])] * len(ds)
500
- ds = ds.add_column(inference_info_column, inference_list)
501
 
502
  # Calculate processing time
503
  elapsed_time = time.time() - start_time
@@ -522,13 +474,9 @@ def main(
522
 
523
  # Push to hub
524
  logger.info(f"Pushing to HuggingFace Hub: {output_dataset}")
525
- commit_info = ds.push_to_hub(
526
  output_dataset,
527
  private=private,
528
- **({"config_name": config} if config else {}),
529
- create_pr=create_pr,
530
- commit_message=f"Add {model} OCR results ({len(ds)} samples)"
531
- + (f" [{config}]" if config else ""),
532
  )
533
 
534
  # Update dataset card
@@ -539,8 +487,6 @@ def main(
539
  logger.info(f"✓ Dataset: https://huggingface.co/datasets/{output_dataset}")
540
  logger.info(f"✓ Processing time: {processing_time}")
541
  logger.info(f"✓ Samples processed: {len(ds):,}")
542
- if create_pr and getattr(commit_info, "pr_url", None):
543
- logger.info(f"✓ Pull request created: {commit_info.pr_url}")
544
 
545
 
546
  if __name__ == "__main__":
@@ -597,12 +543,6 @@ Examples:
597
  default="markdown",
598
  help="Column name for markdown output (default: markdown)",
599
  )
600
- parser.add_argument(
601
- "--overwrite",
602
- action="store_true",
603
- help="Replace the output column if it already exists in the input dataset "
604
- "(default: error out to avoid clobbering an existing column).",
605
- )
606
  parser.add_argument(
607
  "--batch-size",
608
  type=int,
@@ -662,15 +602,6 @@ Examples:
662
  action="store_true",
663
  help="Make output dataset private",
664
  )
665
- parser.add_argument(
666
- "--config",
667
- help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
668
- )
669
- parser.add_argument(
670
- "--create-pr",
671
- action="store_true",
672
- help="Create a pull request instead of pushing directly (for parallel benchmarking)",
673
- )
674
  parser.add_argument(
675
  "--shuffle",
676
  action="store_true",
@@ -689,7 +620,6 @@ Examples:
689
  output_dataset=args.output_dataset,
690
  image_column=args.image_column,
691
  output_column=args.output_column,
692
- overwrite=args.overwrite,
693
  batch_size=args.batch_size,
694
  model=args.model,
695
  max_model_len=args.max_model_len,
@@ -703,6 +633,4 @@ Examples:
703
  private=args.private,
704
  shuffle=args.shuffle,
705
  seed=args.seed,
706
- config=args.config,
707
- create_pr=args.create_pr,
708
  )
 
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
+ # "datasets",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm",
 
50
  from PIL import Image
51
  from toolz import partition_all
52
  from tqdm.auto import tqdm
 
 
 
 
53
  from vllm import LLM, SamplingParams
54
+ from vllm.sampling_params import GuidedDecodingParams
55
 
56
  logging.basicConfig(level=logging.INFO)
57
  logger = logging.getLogger(__name__)
 
78
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  def parse_yaml_frontmatter(text: str) -> tuple[dict, str]:
82
  """
83
  Parse YAML front matter from olmOCR output.
 
277
  output_dataset: str,
278
  image_column: str = "image",
279
  output_column: str = "markdown",
 
280
  batch_size: int = 16,
281
  model: str = "allenai/olmOCR-2-7B-1025-FP8",
282
  max_model_len: int = 16384,
 
290
  private: bool = False,
291
  shuffle: bool = False,
292
  seed: int = 42,
 
 
293
  ):
294
  """
295
  Process a dataset of document images through olmOCR-2 to extract markdown.
 
330
  logger.info(f"Loading dataset: {input_dataset}")
331
  ds = load_dataset(input_dataset, split=split)
332
 
 
 
 
333
  # Shuffle if requested
334
  if shuffle:
335
  logger.info(f"Shuffling dataset with seed {seed}")
 
365
  "stop": ["<|im_end|>", "<|endoftext|>"],
366
  }
367
 
368
+ # Add guided decoding if requested (enforces YAML front matter structure)
 
 
 
369
  if guided_decoding:
370
  logger.info("Enabling guided decoding with YAML front matter regex")
371
+ guided_params = GuidedDecodingParams(
372
+ regex=r"---\nprimary_language: (?:[a-z]{2}|null)\nis_rotation_valid: (?:True|False|true|false)\nrotation_correction: (?:0|90|180|270)\nis_table: (?:True|False|true|false)\nis_diagram: (?:True|False|true|false)\n(?:---|---\n[\s\S]+)"
373
+ )
374
+ sampling_params_kwargs["guided_decoding"] = guided_params
 
 
 
 
 
 
 
 
 
375
 
376
  sampling_params = SamplingParams(**sampling_params_kwargs)
377
 
 
420
  ds = ds.remove_columns([metadata_column_name])
421
  ds = ds.add_column(metadata_column_name, all_metadata)
422
 
423
+ # Add inference information
424
+ inference_info = json.dumps(
425
+ {
426
+ "model": model,
427
+ "script": "olmocr2-vllm.py",
428
+ "version": "1.0.0",
429
+ "timestamp": datetime.now().isoformat(),
430
+ "batch_size": batch_size,
431
+ "max_tokens": max_tokens,
432
+ "temperature": temperature,
433
+ }
434
+ )
 
435
 
436
+ # Handle existing inference_info column
437
  if inference_info_column in ds.column_names:
438
+ # Parse existing, append new model info
 
439
  def update_inference_info(example):
440
  try:
441
+ existing = json.loads(example[inference_info_column])
442
+ if not isinstance(existing, list):
443
+ existing = [existing]
444
+ except (json.JSONDecodeError, KeyError):
445
+ existing = []
446
+
447
+ existing.append(json.loads(inference_info))
448
+ return {inference_info_column: json.dumps(existing)}
 
449
 
450
  ds = ds.map(update_inference_info)
451
  else:
452
+ ds = ds.add_column(inference_info_column, [inference_info] * len(ds))
 
 
453
 
454
  # Calculate processing time
455
  elapsed_time = time.time() - start_time
 
474
 
475
  # Push to hub
476
  logger.info(f"Pushing to HuggingFace Hub: {output_dataset}")
477
+ ds.push_to_hub(
478
  output_dataset,
479
  private=private,
 
 
 
 
480
  )
481
 
482
  # Update dataset card
 
487
  logger.info(f"✓ Dataset: https://huggingface.co/datasets/{output_dataset}")
488
  logger.info(f"✓ Processing time: {processing_time}")
489
  logger.info(f"✓ Samples processed: {len(ds):,}")
 
 
490
 
491
 
492
  if __name__ == "__main__":
 
543
  default="markdown",
544
  help="Column name for markdown output (default: markdown)",
545
  )
 
 
 
 
 
 
546
  parser.add_argument(
547
  "--batch-size",
548
  type=int,
 
602
  action="store_true",
603
  help="Make output dataset private",
604
  )
 
 
 
 
 
 
 
 
 
605
  parser.add_argument(
606
  "--shuffle",
607
  action="store_true",
 
620
  output_dataset=args.output_dataset,
621
  image_column=args.image_column,
622
  output_column=args.output_column,
 
623
  batch_size=args.batch_size,
624
  model=args.model,
625
  max_model_len=args.max_model_len,
 
633
  private=args.private,
634
  shuffle=args.shuffle,
635
  seed=args.seed,
 
 
636
  )
ovis-ocr2-server.py DELETED
@@ -1,725 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "requests",
8
- # ]
9
- # ///
10
-
11
- """
12
- Convert document images to markdown using OvisOCR2 via an in-job vLLM server.
13
-
14
- Same model, prompt, and outputs as ovis-ocr2.py, but serves the model behind
15
- `vllm serve` inside the job and posts images concurrently — continuous batching
16
- stays fed instead of draining at each offline `llm.generate()` batch barrier,
17
- and a bad image fails one request instead of a whole batch of 16.
18
-
19
- This script is the *driver* half: it expects the server on localhost (started
20
- by the job command below), loads the input dataset, posts images concurrently,
21
- postprocesses (repeat-trim + img-tag filter, as in ovis-ocr2.py), and pushes
22
- the result dataset. The driver has no torch/vllm deps, so `uv run` starts in
23
- seconds while the server warms up in parallel.
24
-
25
- NOTE: OvisOCR2's card documents offline vLLM only (checked 2026-07-16) — this
26
- server translation is ours, not the authors'. The serve flags below mirror the
27
- card's offline args; treat A/B output parity with ovis-ocr2.py as part of any
28
- benchmark run (same --max-samples slice, diff the markdown columns).
29
-
30
- Run on HF Jobs (standard uv-run shape — the script starts `vllm serve` itself
31
- as a subprocess when no server is already reachable; the only thing to get
32
- right is the --image flag, which provides the `vllm` binary):
33
-
34
- hf jobs uv run --detach --flavor l4x1 -s HF_TOKEN --timeout 4h \\
35
- --image vllm/vllm-openai:v0.22.1 \\
36
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/ovis-ocr2-server.py \\
37
- <input-dataset> <output-dataset>
38
-
39
- To use an already-running or remote endpoint instead, pass --server URL — the
40
- script only spawns a server when the (default localhost) URL is unreachable.
41
- The serve flags live in SERVE_ARGS below, the single source of truth.
42
-
43
- Serve-flag provenance (the card only shows offline `LLM(...)` args):
44
- - `--no-enable-prefix-caching --mm-processor-cache-gb 0`: the recurring official
45
- OCR-serving pattern (DeepSeek-OCR vLLM recipe, LightOnOCR, PaddleOCR-VL recipe)
46
- — OCR never reuses images, the caches only cost memory.
47
- - `--mm-processor-kwargs`: server-side equivalent of the card's per-request
48
- `images_kwargs` pixel bounds. The driver ALSO downscales oversized images
49
- client-side to the same max_pixels, so outputs match even if the server flag
50
- is dropped.
51
- - The card's offline `gdn_prefill_backend="triton"` (JIT/nvcc workaround for the
52
- bare uv image) is NOT needed here: the vllm-openai image ships the full CUDA
53
- toolchain. Add `--gdn-prefill-backend triton` to the serve command if the
54
- default backend misbehaves.
55
- - `enable_thinking=False` (card-mandated, Qwen3.5 templates can inject a
56
- thinking preamble) is passed per-request via `chat_template_kwargs`.
57
-
58
- Model: ATH-MaaS/OvisOCR2 (0.9B, Apache-2.0, 96.58 OmniDocBench v1.6)
59
- vLLM: stock Qwen3_5ForConditionalGeneration arch, needs vllm >= 0.22.1.
60
- """
61
-
62
- import argparse
63
- import atexit
64
- import base64
65
- import concurrent.futures
66
- import io
67
- import json
68
- import logging
69
- import math
70
- import os
71
- import shutil
72
- import subprocess
73
- import sys
74
- import threading
75
- import time
76
- from datetime import datetime
77
- from typing import Any, Dict, Union
78
- from urllib.parse import urlparse
79
-
80
- import requests
81
- from datasets import load_dataset
82
- from huggingface_hub import DatasetCard, login
83
- from PIL import Image
84
-
85
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
86
- logger = logging.getLogger(__name__)
87
-
88
- MODEL = "ATH-MaaS/OvisOCR2"
89
-
90
- # Fixed instruction prompt, verbatim from the model card (including the leading newline).
91
- # The card warns outputs are tuned to this exact wording — don't "improve" it.
92
- OCR_PROMPT = (
93
- "\nExtract all readable content from the image in natural human reading order "
94
- "and output the result as a single Markdown document. For charts or images, "
95
- 'represent them using an HTML image tag: <img src="images/bbox_{left}_{top}_'
96
- '{right}_{bottom}.jpg" />, where left, top, right, bottom are bounding box '
97
- "coordinates scaled to [0, 1000). Format formulas as LaTeX. Format tables as "
98
- "HTML: <table>...</table>. Transcribe all other text as standard Markdown. "
99
- "Preserve the original text without translation or paraphrasing."
100
- )
101
-
102
- # Image bounds from the model card's parser.
103
- DEFAULT_MIN_PIXELS = 448 * 448 # 200,704
104
- DEFAULT_MAX_PIXELS = 2880 * 2880 # 8,294,400
105
-
106
- # The serve command this script spawns when no server is reachable — single
107
- # source of truth for the serving configuration (see docstring for provenance).
108
- SERVE_ARGS = [
109
- "vllm", "serve", MODEL,
110
- "--max-model-len", "32768",
111
- "--gpu-memory-utilization", "0.85",
112
- "--limit-mm-per-prompt", '{"image": 1}',
113
- "--no-enable-prefix-caching",
114
- "--mm-processor-cache-gb", "0",
115
- "--mm-processor-kwargs",
116
- f'{{"images_kwargs": {{"min_pixels": {DEFAULT_MIN_PIXELS}, "max_pixels": {DEFAULT_MAX_PIXELS}}}}}',
117
- "--port", "8000",
118
- ]
119
-
120
- RUN_COMMAND = (
121
- "hf jobs uv run --detach --flavor l4x1 -s HF_TOKEN --timeout 4h \\\n"
122
- " --image vllm/vllm-openai:v0.22.1 \\\n"
123
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/ovis-ocr2-server.py \\\n"
124
- " <input-dataset> <output-dataset>"
125
- )
126
-
127
-
128
- def ensure_output_columns_free(dataset, columns, overwrite=False):
129
- """Fail fast if an output column would collide with an existing input column.
130
-
131
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
132
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
133
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
134
- column(s) here instead (logged) so the later add_column is clean.
135
- """
136
- clash = [c for c in columns if c in dataset.column_names]
137
- if not clash:
138
- return dataset
139
- if overwrite:
140
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
141
- return dataset.remove_columns(clash)
142
- logger.error(
143
- f"Output column(s) {clash} already exist in the input dataset "
144
- f"(columns: {dataset.column_names})."
145
- )
146
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
147
- sys.exit(1)
148
-
149
-
150
- def to_pil_image(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
151
- """Convert a dataset image cell (PIL image, bytes dict, or path) to RGB PIL."""
152
- if isinstance(image, Image.Image):
153
- pil_img = image
154
- elif isinstance(image, dict) and "bytes" in image:
155
- pil_img = Image.open(io.BytesIO(image["bytes"]))
156
- elif isinstance(image, str):
157
- pil_img = Image.open(image)
158
- else:
159
- raise ValueError(f"Unsupported image type: {type(image)}")
160
- return pil_img.convert("RGB")
161
-
162
-
163
- def encode_image(image, max_pixels: int) -> str:
164
- """RGB-convert, downscale to max_pixels if oversized, return base64 JPEG.
165
-
166
- The server's processor clamps to the same bound, so this only changes where
167
- the downscale happens — doing it client-side shrinks the request payload
168
- (a 30MP scan is ~4x the bytes of its 8.3MP clamp) and keeps outputs
169
- identical even if the serve command omits --mm-processor-kwargs.
170
- """
171
- img = to_pil_image(image)
172
- w, h = img.size
173
- if w * h > max_pixels:
174
- scale = math.sqrt(max_pixels / (w * h))
175
- img = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
176
- buf = io.BytesIO()
177
- img.save(buf, format="JPEG", quality=95)
178
- return base64.b64encode(buf.getvalue()).decode()
179
-
180
-
181
- def clean_truncated_repeats(
182
- text: str,
183
- min_text_len: int = 8000,
184
- max_period: int = 200,
185
- min_period: int = 1,
186
- min_repeat_chars: int = 100,
187
- min_repeat_times: int = 5,
188
- ) -> str:
189
- """Trim degenerate trailing repetition (verbatim port of the model card's cleanup).
190
-
191
- Long outputs that hit max_tokens can end in a repeated unit (a char, phrase, or
192
- table row); this detects the shortest repeating tail unit and keeps one copy.
193
- """
194
- n = len(text)
195
- if n < min_text_len:
196
- return text
197
-
198
- max_period = min(max_period, n - 1)
199
- for unit_len in range(min_period, max_period + 1):
200
- if text[n - 1] != text[n - 1 - unit_len]:
201
- continue
202
-
203
- match_len = 1
204
- idx = n - 2
205
- while idx >= unit_len and text[idx] == text[idx - unit_len]:
206
- match_len += 1
207
- idx -= 1
208
-
209
- total_len = match_len + unit_len
210
- repeat_times = total_len // unit_len
211
- tail_len = total_len % unit_len
212
-
213
- if repeat_times >= min_repeat_times and total_len >= min_repeat_chars:
214
- return text[: n - total_len + unit_len] + text[n - tail_len :]
215
-
216
- return text
217
-
218
-
219
- def filter_image_tags(text: str) -> str:
220
- """Drop visual-region <img> blocks (upstream parser's default behaviour)."""
221
- return "\n\n".join(
222
- block
223
- for block in text.split("\n\n")
224
- if not block.strip().startswith('<img src="images/bbox_')
225
- )
226
-
227
-
228
- def postprocess_output(text: str, keep_image_tags: bool) -> str:
229
- text = text.strip()
230
- if not keep_image_tags:
231
- text = filter_image_tags(text)
232
- return clean_truncated_repeats(text)
233
-
234
-
235
- def server_alive(server: str) -> bool:
236
- try:
237
- return requests.get(f"{server}/health", timeout=5).status_code == 200
238
- except requests.RequestException:
239
- return False
240
-
241
-
242
- def wait_for_server(server: str, timeout_s: int, proc: "subprocess.Popen | None" = None):
243
- logger.info(f"Waiting for server at {server}...")
244
- deadline = time.time() + timeout_s
245
- while time.time() < deadline:
246
- if server_alive(server):
247
- logger.info("Server is ready")
248
- return
249
- if proc is not None and proc.poll() is not None:
250
- logger.error(f"Spawned vllm serve exited with code {proc.returncode} before becoming ready")
251
- sys.exit(1)
252
- time.sleep(10)
253
- logger.error(f"Server did not become ready within {timeout_s}s")
254
- sys.exit(1)
255
-
256
-
257
- def ensure_server(server: str, timeout_s: int = 1800):
258
- """Use a reachable server; otherwise spawn `vllm serve` ourselves; else fail fast.
259
-
260
- Spawning is only attempted for a localhost URL — a remote --server that is
261
- down is the user's to fix, not ours to shadow with a local model.
262
- """
263
- if server_alive(server):
264
- logger.info(f"Using already-running server at {server}")
265
- return
266
-
267
- host = urlparse(server).hostname or ""
268
- if host not in ("127.0.0.1", "localhost", "0.0.0.0"):
269
- logger.info(f"Remote server {server} not up yet — waiting for it")
270
- wait_for_server(server, timeout_s)
271
- return
272
-
273
- if shutil.which("vllm") is None:
274
- logger.error("No server is running and the `vllm` binary is not on PATH.")
275
- logger.error("Run this script on a vLLM image so it can start the server itself:\n")
276
- logger.error(RUN_COMMAND)
277
- logger.error("\n(or start `vllm serve` yourself / pass --server URL of a running endpoint)")
278
- sys.exit(1)
279
-
280
- logger.info(f"Starting server: {' '.join(SERVE_ARGS)}")
281
- proc = subprocess.Popen(SERVE_ARGS) # logs interleave with ours on stdout/stderr
282
- atexit.register(proc.terminate) # don't leave a GPU server behind on local runs
283
- wait_for_server(server, timeout_s, proc=proc)
284
-
285
-
286
- def ocr_one(
287
- server: str,
288
- image,
289
- max_pixels: int,
290
- max_tokens: int,
291
- timeout_s: int,
292
- retries: int = 2,
293
- ) -> str:
294
- """OCR a single image via the chat completions API. Returns raw model text."""
295
- b64 = encode_image(image, max_pixels)
296
- payload = {
297
- "model": MODEL,
298
- "messages": [
299
- {
300
- "role": "user",
301
- "content": [
302
- # Image first, then text — same order the card's chat template uses.
303
- {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
304
- {"type": "text", "text": OCR_PROMPT},
305
- ],
306
- }
307
- ],
308
- "temperature": 0.0,
309
- "max_tokens": max_tokens,
310
- # Card-mandated: Qwen3.5 templates can inject a thinking preamble otherwise.
311
- "chat_template_kwargs": {"enable_thinking": False},
312
- }
313
- last_err = None
314
- for attempt in range(retries + 1):
315
- try:
316
- resp = requests.post(
317
- f"{server}/v1/chat/completions", json=payload, timeout=timeout_s
318
- )
319
- resp.raise_for_status()
320
- return resp.json()["choices"][0]["message"]["content"]
321
- except Exception as e:
322
- last_err = e
323
- if attempt < retries:
324
- time.sleep(10 * (attempt + 1))
325
- raise RuntimeError(f"request failed after {retries + 1} attempts: {last_err}")
326
-
327
-
328
- def create_dataset_card(
329
- source_dataset: str,
330
- model: str,
331
- num_samples: int,
332
- num_errors: int,
333
- processing_time: str,
334
- images_per_sec: float,
335
- concurrency: int,
336
- max_tokens: int,
337
- keep_image_tags: bool,
338
- image_column: str = "image",
339
- split: str = "train",
340
- ) -> str:
341
- """Create a dataset card documenting the OCR process."""
342
- model_name = model.split("/")[-1]
343
-
344
- # Canonical provenance stamp (see AGENTS.md): Jobs claim gated on JOB_ID, set by HF Jobs in-container.
345
- on_jobs = os.environ.get("JOB_ID") is not None
346
- hw = os.environ.get("ACCELERATOR") or ""
347
- origin = (
348
- "Produced on [Hugging Face Jobs](https://huggingface.co/docs/huggingface_hub/guides/jobs)"
349
- + (f" (`{hw}`)" if hw else "")
350
- ) if on_jobs else "Generated"
351
- jobs_tag = "\n- hf-jobs" if on_jobs else ""
352
-
353
- return f"""---
354
- tags:
355
- - ocr
356
- - document-processing
357
- - ovis-ocr2
358
- - markdown
359
- - uv-script
360
- - generated{jobs_tag}
361
- ---
362
-
363
- # Document OCR using {model_name} (server mode)
364
-
365
- This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using OvisOCR2, a compact 0.9B document parsing model (96.58 on OmniDocBench v1.6), served behind an in-job vLLM server with concurrent requests (continuous batching).
366
-
367
- ## Processing Details
368
-
369
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
370
- - **Model**: [{model}](https://huggingface.co/{model})
371
- - **Number of Samples**: {num_samples:,}
372
- - **Failed Requests**: {num_errors:,} (marked `[OCR ERROR]`)
373
- - **Processing Time**: {processing_time}
374
- - **Throughput**: {images_per_sec:.2f} images/sec
375
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
376
-
377
- ### Configuration
378
-
379
- - **Mode**: vLLM server (`vllm serve`) + concurrent driver, {concurrency} concurrent requests
380
- - **Image Column**: `{image_column}`
381
- - **Dataset Split**: `{split}`
382
- - **Max Output Tokens**: {max_tokens:,}
383
- - **Temperature**: 0.0 (greedy, per model card)
384
- - **Visual-region image tags**: {"kept" if keep_image_tags else "filtered (default)"}
385
-
386
- ## Dataset Structure
387
-
388
- The dataset contains all original columns plus:
389
- - `markdown`: The extracted text in markdown format
390
- - `inference_info`: JSON list tracking all OCR models applied to this dataset
391
-
392
- ## Reproduction
393
-
394
- {origin} with the [`ovis-ocr2-server.py`](https://huggingface.co/datasets/uv-scripts/ocr/raw/main/ovis-ocr2-server.py) recipe from [uv-scripts](https://huggingface.co/uv-scripts) — see the script docstring for the single `hf jobs run` command that starts the server and driver together. The offline-vLLM sibling recipe is [`ovis-ocr2.py`](https://huggingface.co/datasets/uv-scripts/ocr/raw/main/ovis-ocr2.py).
395
- """
396
-
397
-
398
- def main(
399
- input_dataset: str,
400
- output_dataset: str,
401
- image_column: str = "image",
402
- server: str = "http://127.0.0.1:8000",
403
- concurrency: int = 32,
404
- max_tokens: int = 16384,
405
- max_pixels: int = DEFAULT_MAX_PIXELS,
406
- request_timeout: int = 1800,
407
- keep_image_tags: bool = False,
408
- hf_token: str = None,
409
- split: str = "train",
410
- max_samples: int = None,
411
- private: bool = False,
412
- shuffle: bool = False,
413
- seed: int = 42,
414
- output_column: str = "markdown",
415
- overwrite: bool = False,
416
- config: str = None,
417
- create_pr: bool = False,
418
- ):
419
- """Process images from HF dataset through an OvisOCR2 vLLM server."""
420
-
421
- start_time = datetime.now()
422
-
423
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
424
- if HF_TOKEN:
425
- login(token=HF_TOKEN)
426
-
427
- logger.info(f"Using model: {MODEL} via server {server}")
428
-
429
- logger.info(f"Loading dataset: {input_dataset}")
430
- dataset = load_dataset(input_dataset, split=split)
431
-
432
- if image_column not in dataset.column_names:
433
- raise ValueError(
434
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
435
- )
436
-
437
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
438
-
439
- if shuffle:
440
- logger.info(f"Shuffling dataset with seed {seed}")
441
- dataset = dataset.shuffle(seed=seed)
442
-
443
- if max_samples:
444
- dataset = dataset.select(range(min(max_samples, len(dataset))))
445
- logger.info(f"Limited to {len(dataset)} samples")
446
-
447
- # Reuse a reachable server, else spawn `vllm serve` (needs the vllm binary,
448
- # i.e. a vllm/vllm-openai image), else fail fast with the correct command.
449
- ensure_server(server)
450
-
451
- n = len(dataset)
452
- logger.info(f"Processing {n} images, concurrency {concurrency}")
453
- all_outputs = [None] * n
454
- errors = 0
455
- done = 0
456
- inference_start = time.time()
457
- lock = threading.Lock()
458
-
459
- def worker(i: int) -> None:
460
- nonlocal errors, done
461
- try:
462
- text = ocr_one(
463
- server,
464
- dataset[i][image_column],
465
- max_pixels,
466
- max_tokens,
467
- request_timeout,
468
- )
469
- all_outputs[i] = postprocess_output(text, keep_image_tags)
470
- except Exception as e:
471
- logger.error(f"Image {i} failed: {e}")
472
- all_outputs[i] = "[OCR ERROR]"
473
- with lock:
474
- errors += 1
475
- with lock:
476
- done += 1
477
- if done % 25 == 0 or done == n:
478
- rate = done / max(time.time() - inference_start, 1e-9)
479
- logger.info(f"{done}/{n} done ({rate:.2f} img/s, {errors} errors)")
480
-
481
- with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
482
- list(pool.map(worker, range(n)))
483
-
484
- inference_secs = time.time() - inference_start
485
- processing_duration = datetime.now() - start_time
486
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
487
- images_per_sec = n / inference_secs if inference_secs else 0.0
488
-
489
- logger.info(f"Adding '{output_column}' column to dataset")
490
- dataset = dataset.add_column(output_column, all_outputs)
491
-
492
- # Inference info tracking
493
- inference_entry = {
494
- "model_id": MODEL,
495
- "model_name": "OvisOCR2",
496
- "column_name": output_column,
497
- "timestamp": datetime.now().isoformat(),
498
- "temperature": 0.0,
499
- "max_tokens": max_tokens,
500
- "max_pixels": max_pixels,
501
- "keep_image_tags": keep_image_tags,
502
- "mode": "vllm-server",
503
- "concurrency": concurrency,
504
- }
505
-
506
- if "inference_info" in dataset.column_names:
507
- logger.info("Updating existing inference_info column")
508
-
509
- def update_inference_info(example):
510
- try:
511
- existing_info = (
512
- json.loads(example["inference_info"])
513
- if example["inference_info"]
514
- else []
515
- )
516
- except (json.JSONDecodeError, TypeError):
517
- existing_info = []
518
- existing_info.append(inference_entry)
519
- return {"inference_info": json.dumps(existing_info)}
520
-
521
- dataset = dataset.map(update_inference_info)
522
- else:
523
- logger.info("Creating new inference_info column")
524
- inference_list = [json.dumps([inference_entry])] * len(dataset)
525
- dataset = dataset.add_column("inference_info", inference_list)
526
-
527
- # Push to hub with retry and XET fallback
528
- logger.info(f"Pushing to {output_dataset}")
529
- max_retries = 3
530
- for attempt in range(1, max_retries + 1):
531
- try:
532
- if attempt > 1:
533
- logger.warning("Disabling XET (fallback to HTTP upload)")
534
- os.environ["HF_HUB_DISABLE_XET"] = "1"
535
- dataset.push_to_hub(
536
- output_dataset,
537
- private=private,
538
- token=HF_TOKEN,
539
- max_shard_size="500MB",
540
- **({"config_name": config} if config else {}),
541
- create_pr=create_pr,
542
- commit_message=f"Add {MODEL} OCR results ({len(dataset)} samples, server mode)"
543
- + (f" [{config}]" if config else ""),
544
- )
545
- break
546
- except Exception as e:
547
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
548
- if attempt < max_retries:
549
- delay = 30 * (2 ** (attempt - 1))
550
- logger.info(f"Retrying in {delay}s...")
551
- time.sleep(delay)
552
- else:
553
- logger.error("All upload attempts failed. OCR results are lost.")
554
- sys.exit(1)
555
-
556
- logger.info("Creating dataset card")
557
- card_content = create_dataset_card(
558
- source_dataset=input_dataset,
559
- model=MODEL,
560
- num_samples=len(dataset),
561
- num_errors=errors,
562
- processing_time=processing_time_str,
563
- images_per_sec=images_per_sec,
564
- concurrency=concurrency,
565
- max_tokens=max_tokens,
566
- keep_image_tags=keep_image_tags,
567
- image_column=image_column,
568
- split=split,
569
- )
570
-
571
- card = DatasetCard(card_content)
572
- card.push_to_hub(output_dataset, token=HF_TOKEN)
573
-
574
- logger.info("Done! OvisOCR2 server-mode processing complete.")
575
- logger.info(
576
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
577
- )
578
- logger.info(f"Processing time: {processing_time_str}")
579
- logger.info(
580
- f"Throughput: {images_per_sec:.2f} images/sec "
581
- f"(inference only, excl. dataset load/push; {errors} errors)"
582
- )
583
-
584
-
585
- if __name__ == "__main__":
586
- if len(sys.argv) == 1:
587
- print("=" * 70)
588
- print("OvisOCR2 Document Processing (vLLM server mode)")
589
- print("=" * 70)
590
- print("\nSame model + outputs as ovis-ocr2.py, but drives an in-job")
591
- print("`vllm serve` with concurrent requests — no batch barriers,")
592
- print("per-image (not per-batch) failure isolation.")
593
- print("\nThe server must already be running (the job command starts")
594
- print("both — see the module docstring for the full `hf jobs run`).")
595
- print("\nExamples:")
596
- print("\n1. Basic OCR (server on localhost:8000):")
597
- print(" uv run ovis-ocr2-server.py input-dataset output-dataset")
598
- print("\n2. Test with a small sample:")
599
- print(" uv run ovis-ocr2-server.py large-dataset test --max-samples 10 --shuffle")
600
- print("\n3. Throughput A/B vs the offline recipe:")
601
- print(" run both scripts on the same --max-samples slice and compare")
602
- print(" the images/sec lines + diff the markdown columns")
603
- print("\nFor full help: uv run ovis-ocr2-server.py --help")
604
- sys.exit(0)
605
-
606
- parser = argparse.ArgumentParser(
607
- description="Document OCR using OvisOCR2 via an in-job vLLM server",
608
- formatter_class=argparse.RawDescriptionHelpFormatter,
609
- epilog="""
610
- Examples:
611
- uv run ovis-ocr2-server.py my-docs analyzed-docs
612
- uv run ovis-ocr2-server.py large-dataset test --max-samples 50 --shuffle
613
- See the module docstring for the full `hf jobs run` command (server + driver in one job).
614
- """,
615
- )
616
-
617
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
618
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
619
- parser.add_argument(
620
- "--image-column",
621
- default="image",
622
- help="Column containing images (default: image)",
623
- )
624
- parser.add_argument(
625
- "--server",
626
- default="http://127.0.0.1:8000",
627
- help="vLLM server base URL (default: in-job localhost:8000)",
628
- )
629
- parser.add_argument(
630
- "--concurrency",
631
- type=int,
632
- default=32,
633
- help="Concurrent OCR requests (default: 32; vLLM queues excess internally, "
634
- "so this mainly needs to be high enough to keep continuous batching fed)",
635
- )
636
- parser.add_argument(
637
- "--max-tokens",
638
- type=int,
639
- default=16384,
640
- help="Maximum tokens to generate (default: 16384, the model card value)",
641
- )
642
- parser.add_argument(
643
- "--max-pixels",
644
- type=int,
645
- default=DEFAULT_MAX_PIXELS,
646
- help=f"Maximum image pixels; larger images are downscaled client-side before "
647
- f"upload (default: {DEFAULT_MAX_PIXELS}, = 2880*2880, the model card value)",
648
- )
649
- parser.add_argument(
650
- "--request-timeout",
651
- type=int,
652
- default=1800,
653
- help="Per-request timeout in seconds (default: 1800)",
654
- )
655
- parser.add_argument(
656
- "--keep-image-tags",
657
- action="store_true",
658
- help="Keep visual-region <img src=\"images/bbox_...\"> tags in the output "
659
- "(default: filtered, matching the upstream parser)",
660
- )
661
- parser.add_argument("--hf-token", help="Hugging Face API token")
662
- parser.add_argument(
663
- "--split", default="train", help="Dataset split to use (default: train)"
664
- )
665
- parser.add_argument(
666
- "--max-samples",
667
- type=int,
668
- help="Maximum number of samples to process (for testing)",
669
- )
670
- parser.add_argument(
671
- "--private", action="store_true", help="Make output dataset private"
672
- )
673
- parser.add_argument(
674
- "--config",
675
- help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
676
- )
677
- parser.add_argument(
678
- "--create-pr",
679
- action="store_true",
680
- help="Create a pull request instead of pushing directly (for parallel benchmarking)",
681
- )
682
- parser.add_argument(
683
- "--shuffle", action="store_true", help="Shuffle dataset before processing"
684
- )
685
- parser.add_argument(
686
- "--seed",
687
- type=int,
688
- default=42,
689
- help="Random seed for shuffling (default: 42)",
690
- )
691
- parser.add_argument(
692
- "--output-column",
693
- default="markdown",
694
- help="Column name for output text (default: markdown)",
695
- )
696
- parser.add_argument(
697
- "--overwrite",
698
- action="store_true",
699
- help="Replace the output column if it already exists in the input dataset "
700
- "(default: error out to avoid clobbering an existing column).",
701
- )
702
-
703
- args = parser.parse_args()
704
-
705
- main(
706
- input_dataset=args.input_dataset,
707
- output_dataset=args.output_dataset,
708
- image_column=args.image_column,
709
- server=args.server,
710
- concurrency=args.concurrency,
711
- max_tokens=args.max_tokens,
712
- max_pixels=args.max_pixels,
713
- request_timeout=args.request_timeout,
714
- keep_image_tags=args.keep_image_tags,
715
- hf_token=args.hf_token,
716
- split=args.split,
717
- max_samples=args.max_samples,
718
- private=args.private,
719
- shuffle=args.shuffle,
720
- seed=args.seed,
721
- output_column=args.output_column,
722
- overwrite=args.overwrite,
723
- config=args.config,
724
- create_pr=args.create_pr,
725
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ovis-ocr2.py DELETED
@@ -1,682 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "vllm>=0.22.1",
8
- # "toolz",
9
- # "torch",
10
- # ]
11
- # ///
12
-
13
- """
14
- Convert document images to markdown using OvisOCR2 with vLLM.
15
-
16
- OvisOCR2 is a compact 0.9B end-to-end document parsing model (post-trained from
17
- Qwen3.5-0.8B with SFT + RL + OPD). It scores 96.58 on OmniDocBench v1.6 — the
18
- first end-to-end model to top that leaderboard — and 75.06 Avg3 on PureDocBench.
19
- Outputs a single Markdown document in natural reading order: LaTeX formulas,
20
- HTML tables, and (optionally) HTML <img> tags marking chart/image regions with
21
- bounding boxes scaled to [0, 1000).
22
-
23
- Model: ATH-MaaS/OvisOCR2 (Apache-2.0)
24
- vLLM: stock Qwen3_5ForConditionalGeneration arch, in stable vLLM >= 0.22.1
25
- (the version the model card installs); no trust_remote_code needed.
26
-
27
- Features:
28
- - 0.9B parameters (ultra-compact, runs on l4x1)
29
- - Markdown output with LaTeX formulas + HTML tables
30
- - Visual-region <img> tags filtered by default (upstream parser default);
31
- keep them with --keep-image-tags for downstream crop extraction
32
- - Upstream trailing-repeat cleanup applied to each output
33
- """
34
-
35
- import argparse
36
- import io
37
- import json
38
- import logging
39
- import os
40
- import sys
41
- import time
42
- from datetime import datetime
43
- from typing import Any, Dict, Union
44
-
45
- import torch
46
- from datasets import load_dataset
47
- from huggingface_hub import DatasetCard, login
48
- from PIL import Image
49
- from toolz import partition_all
50
-
51
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
52
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
53
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
54
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
55
- # DeepGEMM's init calls _find_cuda_home, which asserts on the nvcc-less base image — a
56
- # non-fatal warning traceback that clutters the log (seen in the 2026-07-14 smoke run on
57
- # vLLM 0.25.1). Greedy OCR doesn't need the DeepGEMM JIT path, so disable it explicitly.
58
- os.environ.setdefault("VLLM_USE_DEEP_GEMM", "0")
59
- from vllm import LLM, SamplingParams
60
-
61
- logging.basicConfig(level=logging.INFO)
62
- logger = logging.getLogger(__name__)
63
-
64
- MODEL = "ATH-MaaS/OvisOCR2"
65
-
66
- # Fixed instruction prompt, verbatim from the model card (including the leading newline).
67
- # The card warns outputs are tuned to this exact wording — don't "improve" it.
68
- OCR_PROMPT = (
69
- "\nExtract all readable content from the image in natural human reading order "
70
- "and output the result as a single Markdown document. For charts or images, "
71
- 'represent them using an HTML image tag: <img src="images/bbox_{left}_{top}_'
72
- '{right}_{bottom}.jpg" />, where left, top, right, bottom are bounding box '
73
- "coordinates scaled to [0, 1000). Format formulas as LaTeX. Format tables as "
74
- "HTML: <table>...</table>. Transcribe all other text as standard Markdown. "
75
- "Preserve the original text without translation or paraphrasing."
76
- )
77
-
78
- # Image bounds from the model card's parser (passed per-request as images_kwargs).
79
- DEFAULT_MIN_PIXELS = 448 * 448 # 200,704
80
- DEFAULT_MAX_PIXELS = 2880 * 2880 # 8,294,400
81
-
82
-
83
- def check_cuda_availability():
84
- """Check if CUDA is available and exit if not."""
85
- if not torch.cuda.is_available():
86
- logger.error("CUDA is not available. This script requires a GPU.")
87
- logger.error("Please run on a machine with a CUDA-capable GPU.")
88
- sys.exit(1)
89
- else:
90
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
91
-
92
-
93
- def ensure_output_columns_free(dataset, columns, overwrite=False):
94
- """Fail fast if an output column would collide with an existing input column.
95
-
96
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
97
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
98
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
99
- column(s) here instead (logged) so the later add_column is clean.
100
- """
101
- clash = [c for c in columns if c in dataset.column_names]
102
- if not clash:
103
- return dataset
104
- if overwrite:
105
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
106
- return dataset.remove_columns(clash)
107
- logger.error(
108
- f"Output column(s) {clash} already exist in the input dataset "
109
- f"(columns: {dataset.column_names})."
110
- )
111
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
112
- sys.exit(1)
113
-
114
-
115
- def to_pil_image(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
116
- """Convert a dataset image cell (PIL image, bytes dict, or path) to RGB PIL."""
117
- if isinstance(image, Image.Image):
118
- pil_img = image
119
- elif isinstance(image, dict) and "bytes" in image:
120
- pil_img = Image.open(io.BytesIO(image["bytes"]))
121
- elif isinstance(image, str):
122
- pil_img = Image.open(image)
123
- else:
124
- raise ValueError(f"Unsupported image type: {type(image)}")
125
- return pil_img.convert("RGB")
126
-
127
-
128
- def clean_truncated_repeats(
129
- text: str,
130
- min_text_len: int = 8000,
131
- max_period: int = 200,
132
- min_period: int = 1,
133
- min_repeat_chars: int = 100,
134
- min_repeat_times: int = 5,
135
- ) -> str:
136
- """Trim degenerate trailing repetition (verbatim port of the model card's cleanup).
137
-
138
- Long outputs that hit max_tokens can end in a repeated unit (a char, phrase, or
139
- table row); this detects the shortest repeating tail unit and keeps one copy.
140
- """
141
- n = len(text)
142
- if n < min_text_len:
143
- return text
144
-
145
- max_period = min(max_period, n - 1)
146
- for unit_len in range(min_period, max_period + 1):
147
- if text[n - 1] != text[n - 1 - unit_len]:
148
- continue
149
-
150
- match_len = 1
151
- idx = n - 2
152
- while idx >= unit_len and text[idx] == text[idx - unit_len]:
153
- match_len += 1
154
- idx -= 1
155
-
156
- total_len = match_len + unit_len
157
- repeat_times = total_len // unit_len
158
- tail_len = total_len % unit_len
159
-
160
- if repeat_times >= min_repeat_times and total_len >= min_repeat_chars:
161
- return text[: n - total_len + unit_len] + text[n - tail_len :]
162
-
163
- return text
164
-
165
-
166
- def filter_image_tags(text: str) -> str:
167
- """Drop visual-region <img> blocks (upstream parser's default behaviour)."""
168
- return "\n\n".join(
169
- block
170
- for block in text.split("\n\n")
171
- if not block.strip().startswith('<img src="images/bbox_')
172
- )
173
-
174
-
175
- def postprocess_output(text: str, keep_image_tags: bool) -> str:
176
- text = text.strip()
177
- if not keep_image_tags:
178
- text = filter_image_tags(text)
179
- return clean_truncated_repeats(text)
180
-
181
-
182
- def create_dataset_card(
183
- source_dataset: str,
184
- model: str,
185
- num_samples: int,
186
- processing_time: str,
187
- batch_size: int,
188
- max_model_len: int,
189
- max_tokens: int,
190
- gpu_memory_utilization: float,
191
- keep_image_tags: bool,
192
- image_column: str = "image",
193
- split: str = "train",
194
- ) -> str:
195
- """Create a dataset card documenting the OCR process."""
196
- model_name = model.split("/")[-1]
197
-
198
- # Canonical provenance stamp (see AGENTS.md): Jobs claim gated on JOB_ID, set by HF Jobs in-container.
199
- on_jobs = os.environ.get("JOB_ID") is not None
200
- hw = os.environ.get("ACCELERATOR") or ""
201
- origin = (
202
- "Produced on [Hugging Face Jobs](https://huggingface.co/docs/huggingface_hub/guides/jobs)"
203
- + (f" (`{hw}`)" if hw else "")
204
- ) if on_jobs else "Generated"
205
- jobs_tag = "\n- hf-jobs" if on_jobs else ""
206
-
207
- return f"""---
208
- tags:
209
- - ocr
210
- - document-processing
211
- - ovis-ocr2
212
- - markdown
213
- - uv-script
214
- - generated{jobs_tag}
215
- ---
216
-
217
- # Document OCR using {model_name}
218
-
219
- This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using OvisOCR2, a compact 0.9B document parsing model (96.58 on OmniDocBench v1.6).
220
-
221
- ## Processing Details
222
-
223
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
224
- - **Model**: [{model}](https://huggingface.co/{model})
225
- - **Number of Samples**: {num_samples:,}
226
- - **Processing Time**: {processing_time}
227
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
228
-
229
- ### Configuration
230
-
231
- - **Image Column**: `{image_column}`
232
- - **Dataset Split**: `{split}`
233
- - **Batch Size**: {batch_size}
234
- - **Max Model Length**: {max_model_len:,} tokens
235
- - **Max Output Tokens**: {max_tokens:,}
236
- - **Temperature**: 0.0 (greedy, per model card)
237
- - **GPU Memory Utilization**: {gpu_memory_utilization:.1%}
238
- - **Visual-region image tags**: {"kept" if keep_image_tags else "filtered (default)"}
239
-
240
- ## Model Information
241
-
242
- OvisOCR2 is a compact, high-performance document parsing model:
243
- - 0.9B parameters (post-trained from Qwen3.5-0.8B with SFT + RL + OPD)
244
- - 96.58 on OmniDocBench v1.6 (first end-to-end model to top the leaderboard)
245
- - Markdown output in natural reading order
246
- - LaTeX formula recognition, HTML table extraction
247
- - Apache-2.0 licensed
248
-
249
- ## Dataset Structure
250
-
251
- The dataset contains all original columns plus:
252
- - `markdown`: The extracted text in markdown format
253
- - `inference_info`: JSON list tracking all OCR models applied to this dataset
254
-
255
- ## Reproduction
256
-
257
- {origin} with the [`ovis-ocr2.py`](https://huggingface.co/datasets/uv-scripts/ocr/raw/main/ovis-ocr2.py) recipe from [uv-scripts](https://huggingface.co/uv-scripts). Run it yourself:
258
-
259
- ```bash
260
- hf jobs uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/ovis-ocr2.py \\
261
- {source_dataset} \\
262
- <output-dataset> \\
263
- --image-column {image_column} \\
264
- --batch-size {batch_size}
265
- ```
266
- """
267
-
268
-
269
- def main(
270
- input_dataset: str,
271
- output_dataset: str,
272
- image_column: str = "image",
273
- batch_size: int = 16,
274
- max_model_len: int = 32768,
275
- max_tokens: int = 16384,
276
- min_pixels: int = DEFAULT_MIN_PIXELS,
277
- max_pixels: int = DEFAULT_MAX_PIXELS,
278
- gpu_memory_utilization: float = 0.8,
279
- keep_image_tags: bool = False,
280
- hf_token: str = None,
281
- split: str = "train",
282
- max_samples: int = None,
283
- private: bool = False,
284
- shuffle: bool = False,
285
- seed: int = 42,
286
- output_column: str = "markdown",
287
- overwrite: bool = False,
288
- verbose: bool = False,
289
- config: str = None,
290
- create_pr: bool = False,
291
- ):
292
- """Process images from HF dataset through OvisOCR2."""
293
-
294
- check_cuda_availability()
295
-
296
- start_time = datetime.now()
297
-
298
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
299
- if HF_TOKEN:
300
- login(token=HF_TOKEN)
301
-
302
- logger.info(f"Using model: {MODEL}")
303
-
304
- # Load dataset
305
- logger.info(f"Loading dataset: {input_dataset}")
306
- dataset = load_dataset(input_dataset, split=split)
307
-
308
- if image_column not in dataset.column_names:
309
- raise ValueError(
310
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
311
- )
312
-
313
- # Fail fast if the output column would collide with an existing input column
314
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
315
-
316
- if shuffle:
317
- logger.info(f"Shuffling dataset with seed {seed}")
318
- dataset = dataset.shuffle(seed=seed)
319
-
320
- if max_samples:
321
- dataset = dataset.select(range(min(max_samples, len(dataset))))
322
- logger.info(f"Limited to {len(dataset)} samples")
323
-
324
- # Initialize vLLM
325
- logger.info("Initializing vLLM with OvisOCR2")
326
- logger.info("This may take a few minutes on first run...")
327
- # gdn_prefill_backend="triton" is from the model card: Qwen3.5 uses gated-delta-net
328
- # linear attention, and the non-triton GDN prefill path needs a JIT/CUDA toolchain
329
- # the bare uv image lacks (same class of problem as the FLASHINFER guard above).
330
- llm = LLM(
331
- model=MODEL,
332
- max_model_len=max_model_len,
333
- gpu_memory_utilization=gpu_memory_utilization,
334
- limit_mm_per_prompt={"image": 1},
335
- gdn_prefill_backend="triton",
336
- )
337
-
338
- # The model card builds the prompt once via the chat template with
339
- # enable_thinking=False — Qwen3.5 templates can otherwise inject a thinking
340
- # preamble, which is wrong for OCR. Mirror it exactly.
341
- prompt = llm.get_tokenizer().apply_chat_template(
342
- [
343
- {
344
- "role": "user",
345
- "content": [
346
- {"type": "image"},
347
- {"type": "text", "text": OCR_PROMPT},
348
- ],
349
- }
350
- ],
351
- tokenize=False,
352
- add_generation_prompt=True,
353
- enable_thinking=False,
354
- )
355
-
356
- # Card-locked sampling: greedy, 16384 max tokens.
357
- sampling_params = SamplingParams(temperature=0.0, max_tokens=max_tokens)
358
-
359
- logger.info(f"Processing {len(dataset)} images in batches of {batch_size}")
360
- logger.info(f"Output will be written to column: {output_column}")
361
-
362
- all_outputs = []
363
- total_batches = (len(dataset) + batch_size - 1) // batch_size
364
- processed = 0
365
-
366
- for batch_num, batch_indices in enumerate(
367
- partition_all(batch_size, range(len(dataset))), 1
368
- ):
369
- batch_indices = list(batch_indices)
370
- batch_images = [dataset[i][image_column] for i in batch_indices]
371
-
372
- logger.info(
373
- f"Batch {batch_num}/{total_batches} "
374
- f"({processed}/{len(dataset)} images done)"
375
- )
376
-
377
- try:
378
- # Per-request inputs exactly as the model card's parser builds them
379
- # (PIL image + images_kwargs pixel bounds), via llm.generate not llm.chat
380
- # so the enable_thinking=False prompt above is used verbatim.
381
- vllm_inputs = [
382
- {
383
- "prompt": prompt,
384
- "multi_modal_data": {"image": to_pil_image(img)},
385
- "mm_processor_kwargs": {
386
- "images_kwargs": {
387
- "min_pixels": min_pixels,
388
- "max_pixels": max_pixels,
389
- }
390
- },
391
- }
392
- for img in batch_images
393
- ]
394
-
395
- outputs = llm.generate(vllm_inputs, sampling_params)
396
-
397
- for output in outputs:
398
- text = output.outputs[0].text
399
- all_outputs.append(postprocess_output(text, keep_image_tags))
400
-
401
- processed += len(batch_images)
402
-
403
- except Exception as e:
404
- logger.error(f"Error processing batch: {e}")
405
- all_outputs.extend(["[OCR ERROR]"] * len(batch_images))
406
- processed += len(batch_images)
407
-
408
- processing_duration = datetime.now() - start_time
409
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
410
-
411
- logger.info(f"Adding '{output_column}' column to dataset")
412
- dataset = dataset.add_column(output_column, all_outputs)
413
-
414
- # Inference info tracking
415
- inference_entry = {
416
- "model_id": MODEL,
417
- "model_name": "OvisOCR2",
418
- "column_name": output_column,
419
- "timestamp": datetime.now().isoformat(),
420
- "temperature": 0.0,
421
- "max_tokens": max_tokens,
422
- "min_pixels": min_pixels,
423
- "max_pixels": max_pixels,
424
- "keep_image_tags": keep_image_tags,
425
- }
426
-
427
- if "inference_info" in dataset.column_names:
428
- logger.info("Updating existing inference_info column")
429
-
430
- def update_inference_info(example):
431
- try:
432
- existing_info = (
433
- json.loads(example["inference_info"])
434
- if example["inference_info"]
435
- else []
436
- )
437
- except (json.JSONDecodeError, TypeError):
438
- existing_info = []
439
- existing_info.append(inference_entry)
440
- return {"inference_info": json.dumps(existing_info)}
441
-
442
- dataset = dataset.map(update_inference_info)
443
- else:
444
- logger.info("Creating new inference_info column")
445
- inference_list = [json.dumps([inference_entry])] * len(dataset)
446
- dataset = dataset.add_column("inference_info", inference_list)
447
-
448
- # Push to hub with retry and XET fallback
449
- logger.info(f"Pushing to {output_dataset}")
450
- max_retries = 3
451
- for attempt in range(1, max_retries + 1):
452
- try:
453
- if attempt > 1:
454
- logger.warning("Disabling XET (fallback to HTTP upload)")
455
- os.environ["HF_HUB_DISABLE_XET"] = "1"
456
- dataset.push_to_hub(
457
- output_dataset,
458
- private=private,
459
- token=HF_TOKEN,
460
- max_shard_size="500MB",
461
- **({"config_name": config} if config else {}),
462
- create_pr=create_pr,
463
- commit_message=f"Add {MODEL} OCR results ({len(dataset)} samples)"
464
- + (f" [{config}]" if config else ""),
465
- )
466
- break
467
- except Exception as e:
468
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
469
- if attempt < max_retries:
470
- delay = 30 * (2 ** (attempt - 1))
471
- logger.info(f"Retrying in {delay}s...")
472
- time.sleep(delay)
473
- else:
474
- logger.error("All upload attempts failed. OCR results are lost.")
475
- sys.exit(1)
476
-
477
- # Create and push dataset card
478
- logger.info("Creating dataset card")
479
- card_content = create_dataset_card(
480
- source_dataset=input_dataset,
481
- model=MODEL,
482
- num_samples=len(dataset),
483
- processing_time=processing_time_str,
484
- batch_size=batch_size,
485
- max_model_len=max_model_len,
486
- max_tokens=max_tokens,
487
- gpu_memory_utilization=gpu_memory_utilization,
488
- keep_image_tags=keep_image_tags,
489
- image_column=image_column,
490
- split=split,
491
- )
492
-
493
- card = DatasetCard(card_content)
494
- card.push_to_hub(output_dataset, token=HF_TOKEN)
495
-
496
- logger.info("Done! OvisOCR2 processing complete.")
497
- logger.info(
498
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
499
- )
500
- logger.info(f"Processing time: {processing_time_str}")
501
- logger.info(
502
- f"Processing speed: {len(dataset) / processing_duration.total_seconds():.2f} images/sec"
503
- )
504
-
505
- if verbose:
506
- import importlib.metadata
507
-
508
- logger.info("--- Resolved package versions ---")
509
- for pkg in ["vllm", "transformers", "torch", "datasets", "pyarrow", "pillow"]:
510
- try:
511
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
512
- except importlib.metadata.PackageNotFoundError:
513
- logger.info(f" {pkg}: not installed")
514
- logger.info("--- End versions ---")
515
-
516
-
517
- if __name__ == "__main__":
518
- if len(sys.argv) == 1:
519
- print("=" * 70)
520
- print("OvisOCR2 Document Processing")
521
- print("=" * 70)
522
- print("\n0.9B document parsing model - 96.58 on OmniDocBench v1.6")
523
- print("\nOutputs markdown in natural reading order:")
524
- print(" - LaTeX formulas, HTML tables")
525
- print(" - Visual-region <img> tags filtered by default")
526
- print(" (--keep-image-tags to retain them)")
527
- print("\nExamples:")
528
- print("\n1. Basic OCR:")
529
- print(" uv run ovis-ocr2.py input-dataset output-dataset")
530
- print("\n2. Keep visual-region image tags:")
531
- print(" uv run ovis-ocr2.py docs results --keep-image-tags")
532
- print("\n3. Test with small sample:")
533
- print(" uv run ovis-ocr2.py large-dataset test --max-samples 10 --shuffle")
534
- print("\n4. Running on HF Jobs:")
535
- print(" hf jobs uv run --flavor l4x1 \\")
536
- print(" -s HF_TOKEN \\")
537
- print(
538
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/ovis-ocr2.py \\"
539
- )
540
- print(" input-dataset output-dataset --batch-size 16")
541
- print("\nFor full help: uv run ovis-ocr2.py --help")
542
- sys.exit(0)
543
-
544
- parser = argparse.ArgumentParser(
545
- description="Document OCR using OvisOCR2 (0.9B, 96.58 OmniDocBench v1.6)",
546
- formatter_class=argparse.RawDescriptionHelpFormatter,
547
- epilog="""
548
- Examples:
549
- uv run ovis-ocr2.py my-docs analyzed-docs
550
- uv run ovis-ocr2.py docs results --keep-image-tags
551
- uv run ovis-ocr2.py large-dataset test --max-samples 50 --shuffle
552
- """,
553
- )
554
-
555
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
556
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
557
- parser.add_argument(
558
- "--image-column",
559
- default="image",
560
- help="Column containing images (default: image)",
561
- )
562
- parser.add_argument(
563
- "--batch-size",
564
- type=int,
565
- default=16,
566
- help="Batch size for processing (default: 16)",
567
- )
568
- parser.add_argument(
569
- "--max-model-len",
570
- type=int,
571
- default=32768,
572
- help="Maximum model context length (default: 32768; model supports 262144)",
573
- )
574
- parser.add_argument(
575
- "--max-tokens",
576
- type=int,
577
- default=16384,
578
- help="Maximum tokens to generate (default: 16384, the model card value)",
579
- )
580
- parser.add_argument(
581
- "--min-pixels",
582
- type=int,
583
- default=DEFAULT_MIN_PIXELS,
584
- help=f"Minimum image pixels for the processor (default: {DEFAULT_MIN_PIXELS}, "
585
- "= 448*448, the model card value)",
586
- )
587
- parser.add_argument(
588
- "--max-pixels",
589
- type=int,
590
- default=DEFAULT_MAX_PIXELS,
591
- help=f"Maximum image pixels for the processor; larger images are downscaled "
592
- f"internally (default: {DEFAULT_MAX_PIXELS}, = 2880*2880, the model card value)",
593
- )
594
- parser.add_argument(
595
- "--gpu-memory-utilization",
596
- type=float,
597
- default=0.8,
598
- help="GPU memory utilization (default: 0.8)",
599
- )
600
- parser.add_argument(
601
- "--keep-image-tags",
602
- action="store_true",
603
- help="Keep visual-region <img src=\"images/bbox_...\"> tags in the output "
604
- "(default: filtered, matching the upstream parser)",
605
- )
606
- parser.add_argument("--hf-token", help="Hugging Face API token")
607
- parser.add_argument(
608
- "--split", default="train", help="Dataset split to use (default: train)"
609
- )
610
- parser.add_argument(
611
- "--max-samples",
612
- type=int,
613
- help="Maximum number of samples to process (for testing)",
614
- )
615
- parser.add_argument(
616
- "--private", action="store_true", help="Make output dataset private"
617
- )
618
- parser.add_argument(
619
- "--config",
620
- help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
621
- )
622
- parser.add_argument(
623
- "--create-pr",
624
- action="store_true",
625
- help="Create a pull request instead of pushing directly (for parallel benchmarking)",
626
- )
627
- parser.add_argument(
628
- "--shuffle", action="store_true", help="Shuffle dataset before processing"
629
- )
630
- parser.add_argument(
631
- "--seed",
632
- type=int,
633
- default=42,
634
- help="Random seed for shuffling (default: 42)",
635
- )
636
- parser.add_argument(
637
- "--output-column",
638
- default="markdown",
639
- help="Column name for output text (default: markdown)",
640
- )
641
- parser.add_argument(
642
- "--overwrite",
643
- action="store_true",
644
- help="Replace the output column if it already exists in the input dataset "
645
- "(default: error out to avoid clobbering an existing column).",
646
- )
647
- parser.add_argument(
648
- "--verbose",
649
- action="store_true",
650
- help="Log resolved package versions after processing (useful for pinning deps)",
651
- )
652
-
653
- args = parser.parse_args()
654
-
655
- if args.max_tokens > args.max_model_len:
656
- parser.error(
657
- f"--max-tokens ({args.max_tokens}) must be <= --max-model-len ({args.max_model_len})"
658
- )
659
-
660
- main(
661
- input_dataset=args.input_dataset,
662
- output_dataset=args.output_dataset,
663
- image_column=args.image_column,
664
- batch_size=args.batch_size,
665
- max_model_len=args.max_model_len,
666
- max_tokens=args.max_tokens,
667
- min_pixels=args.min_pixels,
668
- max_pixels=args.max_pixels,
669
- gpu_memory_utilization=args.gpu_memory_utilization,
670
- keep_image_tags=args.keep_image_tags,
671
- hf_token=args.hf_token,
672
- split=args.split,
673
- max_samples=args.max_samples,
674
- private=args.private,
675
- shuffle=args.shuffle,
676
- seed=args.seed,
677
- output_column=args.output_column,
678
- overwrite=args.overwrite,
679
- verbose=args.verbose,
680
- config=args.config,
681
- create_pr=args.create_pr,
682
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
paddleocr-vl-1.5.py CHANGED
@@ -1,7 +1,7 @@
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
- # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "torch",
@@ -101,28 +101,6 @@ def check_cuda_availability():
101
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
102
 
103
 
104
- def ensure_output_columns_free(dataset, columns, overwrite=False):
105
- """Fail fast if an output column would collide with an existing input column.
106
-
107
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
108
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
109
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
110
- column(s) here instead (logged) so the later add_column is clean.
111
- """
112
- clash = [c for c in columns if c in dataset.column_names]
113
- if not clash:
114
- return dataset
115
- if overwrite:
116
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
117
- return dataset.remove_columns(clash)
118
- logger.error(
119
- f"Output column(s) {clash} already exist in the input dataset "
120
- f"(columns: {dataset.column_names})."
121
- )
122
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
123
- sys.exit(1)
124
-
125
-
126
  def prepare_image(
127
  image: Union[Image.Image, Dict[str, Any], str],
128
  ) -> Image.Image:
@@ -304,7 +282,6 @@ def main(
304
  shuffle: bool = False,
305
  seed: int = 42,
306
  output_column: str = "markdown",
307
- overwrite: bool = False,
308
  config: str = None,
309
  create_pr: bool = False,
310
  verbose: bool = False,
@@ -341,9 +318,6 @@ def main(
341
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
342
  )
343
 
344
- # Fail fast if the output column would collide with an existing input column
345
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
346
-
347
  # Shuffle if requested
348
  if shuffle:
349
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -667,12 +641,6 @@ Backend: Transformers batch inference (not vLLM)
667
  default="markdown",
668
  help="Column name for output text (default: markdown)",
669
  )
670
- parser.add_argument(
671
- "--overwrite",
672
- action="store_true",
673
- help="Replace the output column if it already exists in the input dataset "
674
- "(default: error out to avoid clobbering an existing column).",
675
- )
676
  parser.add_argument(
677
  "--config",
678
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
@@ -703,7 +671,6 @@ Backend: Transformers batch inference (not vLLM)
703
  shuffle=args.shuffle,
704
  seed=args.seed,
705
  output_column=args.output_column,
706
- overwrite=args.overwrite,
707
  config=args.config,
708
  create_pr=args.create_pr,
709
  verbose=args.verbose,
 
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
+ # "datasets",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "torch",
 
101
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def prepare_image(
105
  image: Union[Image.Image, Dict[str, Any], str],
106
  ) -> Image.Image:
 
282
  shuffle: bool = False,
283
  seed: int = 42,
284
  output_column: str = "markdown",
 
285
  config: str = None,
286
  create_pr: bool = False,
287
  verbose: bool = False,
 
318
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
319
  )
320
 
 
 
 
321
  # Shuffle if requested
322
  if shuffle:
323
  logger.info(f"Shuffling dataset with seed {seed}")
 
641
  default="markdown",
642
  help="Column name for output text (default: markdown)",
643
  )
 
 
 
 
 
 
644
  parser.add_argument(
645
  "--config",
646
  help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
 
671
  shuffle=args.shuffle,
672
  seed=args.seed,
673
  output_column=args.output_column,
 
674
  config=args.config,
675
  create_pr=args.create_pr,
676
  verbose=args.verbose,
paddleocr-vl-1.6.py DELETED
@@ -1,831 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "vllm>=0.15.1",
8
- # "tqdm",
9
- # "toolz",
10
- # "torch",
11
- # "pyarrow",
12
- # "transformers",
13
- # ]
14
- # ///
15
-
16
- """
17
- Convert document images to text/tables/formulas using PaddleOCR-VL-1.6 with vLLM.
18
-
19
- PaddleOCR-VL-1.6 is a compact 0.9B OCR model that reaches a new SOTA of 96.33% on
20
- OmniDocBench v1.6. It combines a NaViT-style dynamic resolution visual encoder with
21
- the ERNIE-4.5-0.3B language model and is a plug-and-play upgrade of PaddleOCR-VL-1.5.
22
-
23
- Features:
24
- - 🎯 SOTA: 96.33% on OmniDocBench v1.6 (0.9B params, smallest top-tier OCR model)
25
- - 📝 OCR mode: General text extraction to markdown
26
- - 📊 Table mode: HTML table recognition and extraction
27
- - 📐 Formula mode: LaTeX mathematical notation
28
- - 📈 Chart mode: Structured chart analysis
29
- - 🔍 Spotting mode: Text spotting with localization
30
- - 🔖 Seal mode: Seal/stamp recognition
31
- - 🌍 Multilingual support (en/zh + more)
32
- - 🔧 Based on ERNIE-4.5 (different from Qwen-based models)
33
-
34
- Model: PaddlePaddle/PaddleOCR-VL-1.6
35
- Backend: vLLM offline (batch inference)
36
-
37
- HF Jobs note: PaddleOCR-VL-1.6 is supported by stable vLLM, but on HF Jobs you must run
38
- with the pre-built vLLM image so flashinfer's CUDA kernels are reused. The default
39
- uv-script image has the CUDA runtime but no `nvcc`, so vLLM's flashinfer sampler crashes
40
- at warmup with "Could not find nvcc". Use image-mode (see the example at the bottom):
41
- --image vllm/vllm-openai:latest --flavor a100-large
42
- --python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages
43
- This is the same image-mode pattern as nuextract3.py. Verified end-to-end on a100-large
44
- (2026-06-01): 5/5 clean markdown on davanstrien/ufo-ColPali, ~194 tok/s, 0 errors.
45
- """
46
-
47
- import argparse
48
- import base64
49
- import io
50
- import json
51
- import logging
52
- import math
53
- import os
54
- import sys
55
- import time
56
- from typing import Any, Dict, List, Union
57
- from datetime import datetime
58
-
59
- import torch
60
- from datasets import load_dataset
61
- from huggingface_hub import DatasetCard, login
62
- from PIL import Image
63
- from toolz import partition_all
64
- from tqdm.auto import tqdm
65
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
66
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
67
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
68
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
69
- from vllm import LLM, SamplingParams
70
-
71
- logging.basicConfig(level=logging.INFO)
72
- logger = logging.getLogger(__name__)
73
-
74
- MODEL_ID = "PaddlePaddle/PaddleOCR-VL-1.6"
75
-
76
- # Task mode configurations from official PaddleOCR-VL documentation
77
- TASK_MODES = {
78
- "ocr": "OCR:",
79
- "table": "Table Recognition:",
80
- "formula": "Formula Recognition:",
81
- "chart": "Chart Recognition:",
82
- "spotting": "Spotting:",
83
- "seal": "Seal Recognition:",
84
- }
85
-
86
- # Task descriptions for dataset card
87
- TASK_DESCRIPTIONS = {
88
- "ocr": "General text extraction to markdown format",
89
- "table": "Table extraction to HTML format",
90
- "formula": "Mathematical formula recognition to LaTeX",
91
- "chart": "Chart and diagram analysis",
92
- "spotting": "Text spotting with localization",
93
- "seal": "Seal and stamp recognition",
94
- }
95
-
96
-
97
- def check_cuda_availability():
98
- """Check if CUDA is available and exit if not."""
99
- if not torch.cuda.is_available():
100
- logger.error("CUDA is not available. This script requires a GPU.")
101
- logger.error("Please run on a machine with a CUDA-capable GPU.")
102
- sys.exit(1)
103
- else:
104
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
105
-
106
-
107
- def ensure_output_columns_free(dataset, columns, overwrite=False):
108
- """Fail fast if an output column would collide with an existing input column.
109
-
110
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
111
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
112
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
113
- column(s) here instead (logged) so the later add_column is clean.
114
- """
115
- clash = [c for c in columns if c in dataset.column_names]
116
- if not clash:
117
- return dataset
118
- if overwrite:
119
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
120
- return dataset.remove_columns(clash)
121
- logger.error(
122
- f"Output column(s) {clash} already exist in the input dataset "
123
- f"(columns: {dataset.column_names})."
124
- )
125
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
126
- sys.exit(1)
127
-
128
-
129
- def smart_resize(
130
- height: int,
131
- width: int,
132
- factor: int = 28,
133
- min_pixels: int = 28 * 28 * 130,
134
- max_pixels: int = 28 * 28 * 1280,
135
- ) -> tuple[int, int]:
136
- """
137
- PaddleOCR-VL's intelligent resize logic.
138
-
139
- Rescales the image so that:
140
- 1. Both dimensions are divisible by 'factor' (28)
141
- 2. Total pixels are within [min_pixels, max_pixels]
142
- 3. Aspect ratio is maintained as closely as possible
143
-
144
- Args:
145
- height: Original image height
146
- width: Original image width
147
- factor: Dimension divisibility factor (default: 28)
148
- min_pixels: Minimum total pixels (default: 100,880)
149
- max_pixels: Maximum total pixels (default: 1,003,520)
150
-
151
- Returns:
152
- Tuple of (new_height, new_width)
153
- """
154
- if height < factor:
155
- width = round((width * factor) / height)
156
- height = factor
157
-
158
- if width < factor:
159
- height = round((height * factor) / width)
160
- width = factor
161
-
162
- if max(height, width) / min(height, width) > 200:
163
- logger.warning(
164
- f"Extreme aspect ratio detected: {max(height, width) / min(height, width):.1f}"
165
- )
166
- # Continue anyway, but warn about potential issues
167
-
168
- h_bar = round(height / factor) * factor
169
- w_bar = round(width / factor) * factor
170
-
171
- if h_bar * w_bar > max_pixels:
172
- beta = math.sqrt((height * width) / max_pixels)
173
- h_bar = math.floor(height / beta / factor) * factor
174
- w_bar = math.floor(width / beta / factor) * factor
175
- elif h_bar * w_bar < min_pixels:
176
- beta = math.sqrt(min_pixels / (height * width))
177
- h_bar = math.ceil(height * beta / factor) * factor
178
- w_bar = math.ceil(width * beta / factor) * factor
179
-
180
- return h_bar, w_bar
181
-
182
-
183
- def make_ocr_message(
184
- image: Union[Image.Image, Dict[str, Any], str],
185
- task_mode: str = "ocr",
186
- apply_smart_resize: bool = True,
187
- ) -> List[Dict]:
188
- """
189
- Create chat message for PaddleOCR-VL processing.
190
-
191
- PaddleOCR-VL expects a specific format with the task prefix after the image.
192
- """
193
- # Convert to PIL Image if needed
194
- if isinstance(image, Image.Image):
195
- pil_img = image
196
- elif isinstance(image, dict) and "bytes" in image:
197
- pil_img = Image.open(io.BytesIO(image["bytes"]))
198
- elif isinstance(image, str):
199
- pil_img = Image.open(image)
200
- else:
201
- raise ValueError(f"Unsupported image type: {type(image)}")
202
-
203
- # Convert to RGB
204
- pil_img = pil_img.convert("RGB")
205
-
206
- # Apply smart resize if requested. Spotting benefits from higher resolution
207
- # (per the model card), so allow more pixels in that mode.
208
- if apply_smart_resize:
209
- original_size = pil_img.size
210
- max_pixels = 28 * 28 * (2048 if task_mode == "spotting" else 1280)
211
- new_height, new_width = smart_resize(
212
- pil_img.height, pil_img.width, max_pixels=max_pixels
213
- )
214
- if (new_width, new_height) != (pil_img.width, pil_img.height):
215
- pil_img = pil_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
216
- logger.debug(f"Resized image from {original_size} to {pil_img.size}")
217
-
218
- # Convert to base64 data URI
219
- buf = io.BytesIO()
220
- pil_img.save(buf, format="PNG")
221
- data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
222
-
223
- # PaddleOCR-VL message format: image first, then task prefix
224
- return [
225
- {
226
- "role": "user",
227
- "content": [
228
- {"type": "image_url", "image_url": {"url": data_uri}},
229
- {"type": "text", "text": TASK_MODES[task_mode]},
230
- ],
231
- }
232
- ]
233
-
234
-
235
- def create_dataset_card(
236
- source_dataset: str,
237
- model: str,
238
- task_mode: str,
239
- num_samples: int,
240
- processing_time: str,
241
- batch_size: int,
242
- max_model_len: int,
243
- max_tokens: int,
244
- gpu_memory_utilization: float,
245
- temperature: float,
246
- apply_smart_resize: bool,
247
- image_column: str = "image",
248
- split: str = "train",
249
- ) -> str:
250
- """Create a dataset card documenting the OCR process."""
251
- task_description = TASK_DESCRIPTIONS[task_mode]
252
-
253
- return f"""---
254
- tags:
255
- - ocr
256
- - document-processing
257
- - paddleocr-vl
258
- - paddleocr-vl-1.6
259
- - {task_mode}
260
- - uv-script
261
- - generated
262
- ---
263
-
264
- # Document Processing using PaddleOCR-VL-1.6 ({task_mode.upper()} mode)
265
-
266
- This dataset contains {task_mode.upper()} results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using PaddleOCR-VL-1.6, an ultra-compact 0.9B OCR model (96.33% SOTA on OmniDocBench v1.6).
267
-
268
- ## Processing Details
269
-
270
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
271
- - **Model**: [{model}](https://huggingface.co/{model})
272
- - **Task Mode**: `{task_mode}` - {task_description}
273
- - **Number of Samples**: {num_samples:,}
274
- - **Processing Time**: {processing_time}
275
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
276
-
277
- ### Configuration
278
-
279
- - **Image Column**: `{image_column}`
280
- - **Output Column**: `markdown`
281
- - **Dataset Split**: `{split}`
282
- - **Batch Size**: {batch_size}
283
- - **Smart Resize**: {"Enabled" if apply_smart_resize else "Disabled"}
284
- - **Max Model Length**: {max_model_len:,} tokens
285
- - **Max Output Tokens**: {max_tokens:,}
286
- - **Temperature**: {temperature}
287
- - **GPU Memory Utilization**: {gpu_memory_utilization:.1%}
288
-
289
- ## Model Information
290
-
291
- PaddleOCR-VL-1.6 is a state-of-the-art, resource-efficient model tailored for document parsing:
292
- - 🎯 **SOTA** - 96.33% on OmniDocBench v1.6
293
- - 🧩 **Ultra-compact** - Only 0.9B parameters
294
- - 📝 **OCR mode** - General text extraction
295
- - 📊 **Table mode** - HTML table recognition
296
- - 📐 **Formula mode** - LaTeX mathematical notation
297
- - 📈 **Chart mode** - Structured chart analysis
298
- - 🔍 **Spotting mode** - Text spotting with localization
299
- - 🔖 **Seal mode** - Seal/stamp recognition
300
- - 🌍 **Multilingual** - Support for multiple languages
301
- - 🔧 **ERNIE-4.5 based** - Different architecture from Qwen models
302
-
303
- ### Task Modes
304
-
305
- - **OCR**: Extract text content to markdown format
306
- - **Table Recognition**: Extract tables to HTML format
307
- - **Formula Recognition**: Extract mathematical formulas to LaTeX
308
- - **Chart Recognition**: Analyze and describe charts/diagrams
309
- - **Spotting**: Text spotting with localization
310
- - **Seal Recognition**: Seal and stamp recognition
311
-
312
- ## Dataset Structure
313
-
314
- The dataset contains all original columns plus:
315
- - `markdown`: The extracted content based on task mode
316
- - `inference_info`: JSON list tracking all OCR models applied to this dataset
317
-
318
- ## Usage
319
-
320
- ```python
321
- from datasets import load_dataset
322
- import json
323
-
324
- # Load the dataset
325
- dataset = load_dataset("{{output_dataset_id}}", split="{split}")
326
-
327
- # Access the extracted content
328
- for example in dataset:
329
- print(example["markdown"])
330
- break
331
-
332
- # View all OCR models applied to this dataset
333
- inference_info = json.loads(dataset[0]["inference_info"])
334
- for info in inference_info:
335
- print(f"Task: {{info['task_mode']}} - Model: {{info['model_id']}}")
336
- ```
337
-
338
- ## Reproduction
339
-
340
- This dataset was generated using the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) PaddleOCR-VL-1.6 script.
341
- On HF Jobs, run with the pre-built vLLM image (image-mode) so flashinfer kernels are reused:
342
-
343
- ```bash
344
- hf jobs uv run \\
345
- --image vllm/vllm-openai:latest --flavor a100-large \\
346
- --python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\
347
- -s HF_TOKEN \\
348
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.6.py \\
349
- {source_dataset} \\
350
- <output-dataset> \\
351
- --task-mode {task_mode} \\
352
- --image-column {image_column} \\
353
- --batch-size {batch_size} \\
354
- --max-model-len {max_model_len} \\
355
- --max-tokens {max_tokens} \\
356
- --gpu-memory-utilization {gpu_memory_utilization}
357
- ```
358
-
359
- ## Performance
360
-
361
- - **Model Size**: 0.9B parameters (smallest among top-tier OCR models)
362
- - **Processing Speed**: ~{num_samples / (float(processing_time.split()[0]) * 60):.2f} images/second
363
- - **Architecture**: NaViT visual encoder + ERNIE-4.5-0.3B language model
364
-
365
- Generated with 🤖 [UV Scripts](https://huggingface.co/uv-scripts)
366
- """
367
-
368
-
369
- def main(
370
- input_dataset: str,
371
- output_dataset: str,
372
- image_column: str = "image",
373
- batch_size: int = 16,
374
- task_mode: str = "ocr",
375
- max_model_len: int = 8192,
376
- max_tokens: int = 4096,
377
- temperature: float = 0.0,
378
- gpu_memory_utilization: float = 0.8,
379
- apply_smart_resize: bool = True,
380
- hf_token: str = None,
381
- split: str = "train",
382
- max_samples: int = None,
383
- private: bool = False,
384
- shuffle: bool = False,
385
- seed: int = 42,
386
- output_column: str = None,
387
- overwrite: bool = False,
388
- config: str = None,
389
- create_pr: bool = False,
390
- verbose: bool = False,
391
- ):
392
- """Process images from HF dataset through PaddleOCR-VL-1.6 model."""
393
-
394
- # Check CUDA availability first
395
- check_cuda_availability()
396
-
397
- # Track processing start time
398
- start_time = datetime.now()
399
-
400
- # Enable high-performance Xet downloads
401
- os.environ["HF_XET_HIGH_PERFORMANCE"] = "1"
402
-
403
- # Login to HF if token provided
404
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
405
- if HF_TOKEN:
406
- login(token=HF_TOKEN)
407
-
408
- # Validate task mode
409
- if task_mode not in TASK_MODES:
410
- raise ValueError(
411
- f"Invalid task_mode '{task_mode}'. Choose from: {list(TASK_MODES.keys())}"
412
- )
413
-
414
- # Default output column is 'markdown' for consistency across scripts
415
- if output_column is None:
416
- output_column = "markdown"
417
-
418
- logger.info(f"Using task mode: {task_mode} - {TASK_DESCRIPTIONS[task_mode]}")
419
- logger.info(f"Output will be written to column: {output_column}")
420
-
421
- # Load dataset
422
- logger.info(f"Loading dataset: {input_dataset}")
423
- dataset = load_dataset(input_dataset, split=split)
424
-
425
- # Validate image column
426
- if image_column not in dataset.column_names:
427
- raise ValueError(
428
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
429
- )
430
-
431
- # Fail fast if the output column would collide with an existing input column
432
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
433
-
434
- # Shuffle if requested
435
- if shuffle:
436
- logger.info(f"Shuffling dataset with seed {seed}")
437
- dataset = dataset.shuffle(seed=seed)
438
-
439
- # Limit samples if requested
440
- if max_samples:
441
- dataset = dataset.select(range(min(max_samples, len(dataset))))
442
- logger.info(f"Limited to {len(dataset)} samples")
443
-
444
- # Initialize vLLM model
445
- logger.info(f"Initializing vLLM with {MODEL_ID}")
446
- logger.info("This may take a minute on first run (model is only 0.9B)...")
447
-
448
- try:
449
- llm = LLM(
450
- model=MODEL_ID,
451
- trust_remote_code=True,
452
- max_model_len=max_model_len,
453
- gpu_memory_utilization=gpu_memory_utilization,
454
- limit_mm_per_prompt={"image": 1},
455
- max_num_batched_tokens=16384,
456
- enable_prefix_caching=False,
457
- enforce_eager=True,
458
- )
459
- except Exception as e:
460
- logger.error(f"Failed to initialize PaddleOCR-VL-1.6 with vLLM: {e}")
461
- logger.error(
462
- "On HF Jobs, run with the pre-built vLLM image so flashinfer kernels are "
463
- "reused (the default uv-script image has no nvcc):"
464
- )
465
- logger.error(
466
- " --image vllm/vllm-openai:latest --flavor a100-large "
467
- "--python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages"
468
- )
469
- sys.exit(1)
470
-
471
- # Sampling parameters - deterministic for OCR
472
- sampling_params = SamplingParams(
473
- temperature=temperature,
474
- max_tokens=max_tokens,
475
- )
476
-
477
- logger.info(f"Processing {len(dataset)} images in batches of {batch_size}")
478
- if apply_smart_resize:
479
- logger.info("Smart resize enabled (PaddleOCR-VL's adaptive resolution)")
480
-
481
- # Process images in batches
482
- all_outputs = []
483
-
484
- for batch_indices in tqdm(
485
- partition_all(batch_size, range(len(dataset))),
486
- total=(len(dataset) + batch_size - 1) // batch_size,
487
- desc=f"PaddleOCR-VL-1.6 {task_mode.upper()} processing",
488
- ):
489
- batch_indices = list(batch_indices)
490
- batch_images = [dataset[i][image_column] for i in batch_indices]
491
-
492
- try:
493
- # Create messages for batch with task-specific prefix
494
- batch_messages = [
495
- make_ocr_message(
496
- img, task_mode=task_mode, apply_smart_resize=apply_smart_resize
497
- )
498
- for img in batch_images
499
- ]
500
-
501
- # Process with vLLM
502
- outputs = llm.chat(batch_messages, sampling_params)
503
-
504
- # Extract outputs
505
- for output in outputs:
506
- text = output.outputs[0].text.strip()
507
- all_outputs.append(text)
508
-
509
- except Exception as e:
510
- logger.error(f"Error processing batch: {e}")
511
- # Add error placeholders for failed batch
512
- all_outputs.extend([f"[{task_mode.upper()} ERROR]"] * len(batch_images))
513
-
514
- # Calculate processing time
515
- processing_duration = datetime.now() - start_time
516
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
517
-
518
- # Add output column to dataset
519
- logger.info(f"Adding '{output_column}' column to dataset")
520
- dataset = dataset.add_column(output_column, all_outputs)
521
-
522
- # Handle inference_info tracking (for multi-model comparisons)
523
- inference_entry = {
524
- "model_id": MODEL_ID,
525
- "model_name": "PaddleOCR-VL-1.6",
526
- "model_size": "0.9B",
527
- "task_mode": task_mode,
528
- "column_name": output_column,
529
- "timestamp": datetime.now().isoformat(),
530
- "temperature": temperature,
531
- "max_tokens": max_tokens,
532
- "smart_resize": apply_smart_resize,
533
- "backend": "vllm",
534
- }
535
-
536
- if "inference_info" in dataset.column_names:
537
- # Append to existing inference info
538
- logger.info("Updating existing inference_info column")
539
-
540
- def update_inference_info(example):
541
- try:
542
- existing_info = (
543
- json.loads(example["inference_info"])
544
- if example["inference_info"]
545
- else []
546
- )
547
- except (json.JSONDecodeError, TypeError):
548
- existing_info = []
549
-
550
- existing_info.append(inference_entry)
551
- return {"inference_info": json.dumps(existing_info)}
552
-
553
- dataset = dataset.map(update_inference_info)
554
- else:
555
- # Create new inference_info column
556
- logger.info("Creating new inference_info column")
557
- inference_list = [json.dumps([inference_entry])] * len(dataset)
558
- dataset = dataset.add_column("inference_info", inference_list)
559
-
560
- # Push to hub with retry and XET fallback
561
- logger.info(f"Pushing to {output_dataset}")
562
- max_retries = 3
563
- for attempt in range(1, max_retries + 1):
564
- try:
565
- if attempt > 1:
566
- logger.warning("Disabling XET (fallback to HTTP upload)")
567
- os.environ["HF_HUB_DISABLE_XET"] = "1"
568
- dataset.push_to_hub(
569
- output_dataset,
570
- private=private,
571
- token=HF_TOKEN,
572
- max_shard_size="500MB",
573
- **({"config_name": config} if config else {}),
574
- create_pr=create_pr,
575
- commit_message=f"Add {MODEL_ID} OCR results ({len(dataset)} samples)"
576
- + (f" [{config}]" if config else ""),
577
- )
578
- break
579
- except Exception as e:
580
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
581
- if attempt < max_retries:
582
- delay = 30 * (2 ** (attempt - 1))
583
- logger.info(f"Retrying in {delay}s...")
584
- time.sleep(delay)
585
- else:
586
- logger.error("All upload attempts failed. OCR results are lost.")
587
- sys.exit(1)
588
-
589
- # Create and push dataset card (skip when creating a PR to avoid touching main)
590
- if not create_pr:
591
- logger.info("Creating dataset card")
592
- card_content = create_dataset_card(
593
- source_dataset=input_dataset,
594
- model=MODEL_ID,
595
- task_mode=task_mode,
596
- num_samples=len(dataset),
597
- processing_time=processing_time_str,
598
- batch_size=batch_size,
599
- max_model_len=max_model_len,
600
- max_tokens=max_tokens,
601
- gpu_memory_utilization=gpu_memory_utilization,
602
- temperature=temperature,
603
- apply_smart_resize=apply_smart_resize,
604
- image_column=image_column,
605
- split=split,
606
- )
607
-
608
- card = DatasetCard(card_content)
609
- card.push_to_hub(output_dataset, token=HF_TOKEN)
610
-
611
- logger.info("✅ PaddleOCR-VL-1.6 processing complete!")
612
- logger.info(
613
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
614
- )
615
- logger.info(f"Processing time: {processing_time_str}")
616
- logger.info(f"Task mode: {task_mode} - {TASK_DESCRIPTIONS[task_mode]}")
617
-
618
- if verbose:
619
- import importlib.metadata
620
-
621
- logger.info("--- Resolved package versions ---")
622
- for pkg in ["vllm", "transformers", "torch", "datasets", "pyarrow", "pillow"]:
623
- try:
624
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
625
- except importlib.metadata.PackageNotFoundError:
626
- logger.info(f" {pkg}: not installed")
627
- logger.info("--- End versions ---")
628
-
629
-
630
- if __name__ == "__main__":
631
- # Show example usage if no arguments
632
- if len(sys.argv) == 1:
633
- print("=" * 80)
634
- print("PaddleOCR-VL-1.6 Document Processing")
635
- print("=" * 80)
636
- print("\nUltra-compact 0.9B OCR model (96.33% SOTA on OmniDocBench v1.6)")
637
- print("\nFeatures:")
638
- print("- 🎯 SOTA - 96.33% on OmniDocBench v1.6 (0.9B params)")
639
- print("- 📝 OCR mode - General text extraction")
640
- print("- 📊 Table mode - HTML table recognition")
641
- print("- 📐 Formula mode - LaTeX mathematical notation")
642
- print("- 📈 Chart mode - Structured chart analysis")
643
- print("- 🔍 Spotting mode - Text spotting with localization")
644
- print("- 🔖 Seal mode - Seal/stamp recognition")
645
- print("- 🌍 Multilingual support")
646
- print("- 🔧 Based on ERNIE-4.5 (unique architecture)")
647
- print("\nTask Modes:")
648
- for mode, description in TASK_DESCRIPTIONS.items():
649
- print(f" {mode:8} - {description}")
650
- print("\nExample usage:")
651
- print("\n1. Basic OCR (default mode):")
652
- print(" uv run paddleocr-vl-1.6.py input-dataset output-dataset")
653
- print("\n2. Table extraction:")
654
- print(" uv run paddleocr-vl-1.6.py docs tables-extracted --task-mode table")
655
- print("\n3. Formula recognition:")
656
- print(
657
- " uv run paddleocr-vl-1.6.py papers formulas --task-mode formula --batch-size 32"
658
- )
659
- print("\n4. Chart analysis:")
660
- print(" uv run paddleocr-vl-1.6.py diagrams charts-analyzed --task-mode chart")
661
- print("\n5. Test with small sample:")
662
- print(" uv run paddleocr-vl-1.6.py dataset test --max-samples 10 --shuffle")
663
- print("\n6. Running on HF Jobs (image-mode required — see note below):")
664
- print(" hf jobs uv run \\")
665
- print(" --image vllm/vllm-openai:latest --flavor a100-large \\")
666
- print(
667
- " --python /usr/bin/python3 -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\"
668
- )
669
- print(" -s HF_TOKEN \\")
670
- print(
671
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/paddleocr-vl-1.6.py \\"
672
- )
673
- print(" input-dataset output-dataset --task-mode ocr")
674
- print("\n NOTE: the default uv-script image has no nvcc, so vLLM's flashinfer")
675
- print(" sampler crashes at warmup. The vllm/vllm-openai image ships the kernels.")
676
- print("\n" + "=" * 80)
677
- print("\nFor full help, run: uv run paddleocr-vl-1.6.py --help")
678
- sys.exit(0)
679
-
680
- parser = argparse.ArgumentParser(
681
- description="Document processing using PaddleOCR-VL-1.6 (0.9B SOTA OCR model)",
682
- formatter_class=argparse.RawDescriptionHelpFormatter,
683
- epilog="""
684
- Task Modes:
685
- ocr General text extraction to markdown (default)
686
- table Table extraction to HTML format
687
- formula Mathematical formula recognition to LaTeX
688
- chart Chart and diagram analysis
689
- spotting Text spotting with localization
690
- seal Seal and stamp recognition
691
-
692
- Examples:
693
- # Basic text OCR
694
- uv run paddleocr-vl-1.6.py my-docs analyzed-docs
695
-
696
- # Extract tables from documents
697
- uv run paddleocr-vl-1.6.py papers tables --task-mode table
698
-
699
- # Recognize mathematical formulas
700
- uv run paddleocr-vl-1.6.py textbooks formulas --task-mode formula
701
-
702
- # Analyze charts and diagrams
703
- uv run paddleocr-vl-1.6.py reports charts --task-mode chart
704
-
705
- # Test with random sampling
706
- uv run paddleocr-vl-1.6.py large-dataset test --max-samples 50 --shuffle --task-mode ocr
707
-
708
- # Disable smart resize for original resolution
709
- uv run paddleocr-vl-1.6.py images output --no-smart-resize
710
- """,
711
- )
712
-
713
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
714
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
715
- parser.add_argument(
716
- "--image-column",
717
- default="image",
718
- help="Column containing images (default: image)",
719
- )
720
- parser.add_argument(
721
- "--batch-size",
722
- type=int,
723
- default=16,
724
- help="Batch size for processing (default: 16)",
725
- )
726
- parser.add_argument(
727
- "--task-mode",
728
- choices=list(TASK_MODES.keys()),
729
- default="ocr",
730
- help="Task type: ocr (default), table, formula, chart, spotting, or seal",
731
- )
732
- parser.add_argument(
733
- "--max-model-len",
734
- type=int,
735
- default=8192,
736
- help="Maximum model context length (default: 8192)",
737
- )
738
- parser.add_argument(
739
- "--max-tokens",
740
- type=int,
741
- default=4096,
742
- help="Maximum tokens to generate (default: 4096)",
743
- )
744
- parser.add_argument(
745
- "--temperature",
746
- type=float,
747
- default=0.0,
748
- help="Sampling temperature (default: 0.0 for deterministic)",
749
- )
750
- parser.add_argument(
751
- "--gpu-memory-utilization",
752
- type=float,
753
- default=0.8,
754
- help="GPU memory utilization (default: 0.8)",
755
- )
756
- parser.add_argument(
757
- "--no-smart-resize",
758
- action="store_true",
759
- help="Disable PaddleOCR-VL's smart resize, use original image size",
760
- )
761
- parser.add_argument("--hf-token", help="Hugging Face API token")
762
- parser.add_argument(
763
- "--split", default="train", help="Dataset split to use (default: train)"
764
- )
765
- parser.add_argument(
766
- "--max-samples",
767
- type=int,
768
- help="Maximum number of samples to process (for testing)",
769
- )
770
- parser.add_argument(
771
- "--private", action="store_true", help="Make output dataset private"
772
- )
773
- parser.add_argument(
774
- "--shuffle", action="store_true", help="Shuffle dataset before processing"
775
- )
776
- parser.add_argument(
777
- "--seed",
778
- type=int,
779
- default=42,
780
- help="Random seed for shuffling (default: 42)",
781
- )
782
- parser.add_argument(
783
- "--output-column",
784
- help="Column name for output (default: markdown)",
785
- )
786
- parser.add_argument(
787
- "--overwrite",
788
- action="store_true",
789
- help="Replace the output column if it already exists in the input dataset "
790
- "(default: error out to avoid clobbering an existing column).",
791
- )
792
- parser.add_argument(
793
- "--config",
794
- help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
795
- )
796
- parser.add_argument(
797
- "--create-pr",
798
- action="store_true",
799
- help="Create a pull request instead of pushing directly (for parallel benchmarking)",
800
- )
801
- parser.add_argument(
802
- "--verbose",
803
- action="store_true",
804
- help="Log resolved package versions after processing (useful for pinning deps)",
805
- )
806
-
807
- args = parser.parse_args()
808
-
809
- main(
810
- input_dataset=args.input_dataset,
811
- output_dataset=args.output_dataset,
812
- image_column=args.image_column,
813
- batch_size=args.batch_size,
814
- task_mode=args.task_mode,
815
- max_model_len=args.max_model_len,
816
- max_tokens=args.max_tokens,
817
- temperature=args.temperature,
818
- gpu_memory_utilization=args.gpu_memory_utilization,
819
- apply_smart_resize=not args.no_smart_resize,
820
- hf_token=args.hf_token,
821
- split=args.split,
822
- max_samples=args.max_samples,
823
- private=args.private,
824
- shuffle=args.shuffle,
825
- seed=args.seed,
826
- output_column=args.output_column,
827
- overwrite=args.overwrite,
828
- config=args.config,
829
- create_pr=args.create_pr,
830
- verbose=args.verbose,
831
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
paddleocr-vl.py CHANGED
@@ -56,10 +56,6 @@ from huggingface_hub import DatasetCard, login
56
  from PIL import Image
57
  from toolz import partition_all
58
  from tqdm.auto import tqdm
59
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
60
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
61
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
62
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
63
  from vllm import LLM, SamplingParams
64
 
65
  logging.basicConfig(level=logging.INFO)
@@ -93,28 +89,6 @@ def check_cuda_availability():
93
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
94
 
95
 
96
- def ensure_output_columns_free(dataset, columns, overwrite=False):
97
- """Fail fast if an output column would collide with an existing input column.
98
-
99
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
100
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
101
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
102
- column(s) here instead (logged) so the later add_column is clean.
103
- """
104
- clash = [c for c in columns if c in dataset.column_names]
105
- if not clash:
106
- return dataset
107
- if overwrite:
108
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
109
- return dataset.remove_columns(clash)
110
- logger.error(
111
- f"Output column(s) {clash} already exist in the input dataset "
112
- f"(columns: {dataset.column_names})."
113
- )
114
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
115
- sys.exit(1)
116
-
117
-
118
  def smart_resize(
119
  height: int,
120
  width: int,
@@ -359,7 +333,6 @@ def main(
359
  shuffle: bool = False,
360
  seed: int = 42,
361
  output_column: str = None,
362
- overwrite: bool = False,
363
  verbose: bool = False,
364
  ):
365
  """Process images from HF dataset through PaddleOCR-VL model."""
@@ -408,9 +381,6 @@ def main(
408
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
409
  )
410
 
411
- # Fail fast if the output column would collide with an existing input column
412
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
413
-
414
  # Shuffle if requested
415
  if shuffle:
416
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -732,12 +702,6 @@ Examples:
732
  "--output-column",
733
  help="Column name for output (default: markdown)",
734
  )
735
- parser.add_argument(
736
- "--overwrite",
737
- action="store_true",
738
- help="Replace the output column if it already exists in the input dataset "
739
- "(default: error out to avoid clobbering an existing column).",
740
- )
741
  parser.add_argument(
742
  "--verbose",
743
  action="store_true",
@@ -764,6 +728,5 @@ Examples:
764
  shuffle=args.shuffle,
765
  seed=args.seed,
766
  output_column=args.output_column,
767
- overwrite=args.overwrite,
768
  verbose=args.verbose,
769
  )
 
56
  from PIL import Image
57
  from toolz import partition_all
58
  from tqdm.auto import tqdm
 
 
 
 
59
  from vllm import LLM, SamplingParams
60
 
61
  logging.basicConfig(level=logging.INFO)
 
89
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def smart_resize(
93
  height: int,
94
  width: int,
 
333
  shuffle: bool = False,
334
  seed: int = 42,
335
  output_column: str = None,
 
336
  verbose: bool = False,
337
  ):
338
  """Process images from HF dataset through PaddleOCR-VL model."""
 
381
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
382
  )
383
 
 
 
 
384
  # Shuffle if requested
385
  if shuffle:
386
  logger.info(f"Shuffling dataset with seed {seed}")
 
702
  "--output-column",
703
  help="Column name for output (default: markdown)",
704
  )
 
 
 
 
 
 
705
  parser.add_argument(
706
  "--verbose",
707
  action="store_true",
 
728
  shuffle=args.shuffle,
729
  seed=args.seed,
730
  output_column=args.output_column,
 
731
  verbose=args.verbose,
732
  )
pp-doclayout.py DELETED
@@ -1,1226 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.10"
3
- # dependencies = [
4
- # "paddlepaddle-gpu>=3.0.0",
5
- # "paddleocr>=3.0.0",
6
- # "opencv-contrib-python-headless",
7
- # "datasets>=4.0.0",
8
- # "huggingface-hub>=1.6.0",
9
- # "pyarrow>=15.0",
10
- # "pillow",
11
- # "numpy",
12
- # "tqdm",
13
- # ]
14
- #
15
- # [tool.uv]
16
- # # PaddleOCR/PaddleX pull in opencv-contrib-python (full) which needs system
17
- # # libGL.so.1 — not present in the slim uv-on-bookworm image used by HF Jobs.
18
- # # Swap to the headless cv2 variant (same `import cv2`, no GUI deps).
19
- # override-dependencies = [
20
- # "opencv-contrib-python ; python_version < '0'",
21
- # "opencv-python ; python_version < '0'",
22
- # ]
23
- #
24
- # [[tool.uv.index]]
25
- # name = "paddle"
26
- # url = "https://www.paddlepaddle.org.cn/packages/stable/cu126/"
27
- # explicit = true
28
- #
29
- # [tool.uv.sources]
30
- # paddlepaddle-gpu = { index = "paddle" }
31
- # ///
32
-
33
- """
34
- Detect document layout regions (text/title/table/figure/formula/...) with PP-DocLayout-L.
35
-
36
- Runs PaddleOCR's PP-DocLayout-L (or M / S / plus-L variant) over an image source
37
- and emits per-image bounding-box predictions. Unlike the OCR scripts in this repo
38
- this does NOT extract text — it only locates and classifies regions.
39
-
40
- Source can be:
41
- - HF dataset repo (default): "namespace/dataset"
42
- - HF bucket of image files: "hf://buckets/namespace/bucket/optional/prefix"
43
-
44
- Sink can be:
45
- - HF dataset repo (default): "namespace/dataset" (one push at end + dataset card)
46
- - HF bucket: "hf://buckets/namespace/bucket/run-name" (incremental parquet
47
- shards, resumable, no git overhead)
48
-
49
- Output schema (column `layout` is a JSON string):
50
- [{"bbox": [x1, y1, x2, y2], "label": "text", "score": 0.97, "cls_id": 2}, ...]
51
-
52
- Coordinates are in the original input-image pixel space.
53
-
54
- Example commands:
55
-
56
- # Dataset -> dataset (smoke on L4)
57
- hf jobs uv run --flavor l4x1 -s HF_TOKEN https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-doclayout.py \\
58
- davanstrien/ufo-ColPali pp-doclayout-smoke \\
59
- --max-samples 3 --shuffle --seed 42 --private
60
-
61
- # Dataset -> bucket (incremental shards, resumable)
62
- hf buckets create davanstrien/pp-doclayout-scratch --exist-ok
63
- hf jobs uv run --flavor l4x1 -s HF_TOKEN https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-doclayout.py \\
64
- davanstrien/ufo-ColPali \\
65
- hf://buckets/davanstrien/pp-doclayout-scratch/run1 \\
66
- --max-samples 20 --shard-size 5
67
-
68
- # Bucket of images -> dataset
69
- hf jobs uv run --flavor l4x1 -s HF_TOKEN https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-doclayout.py \\
70
- hf://buckets/davanstrien/pp-doclayout-images \\
71
- pp-doclayout-from-bucket --private
72
- """
73
-
74
- import argparse
75
- import io
76
- import json
77
- import logging
78
- import os
79
- import sys
80
- import time
81
- from dataclasses import dataclass
82
- from datetime import datetime, timezone
83
- from pathlib import Path
84
- from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
85
-
86
- import numpy as np
87
- from PIL import Image, UnidentifiedImageError
88
- from tqdm.auto import tqdm
89
-
90
- logging.basicConfig(level=logging.INFO)
91
- logger = logging.getLogger(__name__)
92
-
93
-
94
- # ---------------------------------------------------------------------------
95
- # Constants
96
- # ---------------------------------------------------------------------------
97
-
98
- VALID_MODELS = [
99
- "PP-DocLayout-L",
100
- "PP-DocLayout-M",
101
- "PP-DocLayout-S",
102
- "PP-DocLayout_plus-L",
103
- ]
104
-
105
- MODEL_SIZES = {
106
- "PP-DocLayout-L": "~123M params (RT-DETR-L backbone)",
107
- "PP-DocLayout-M": "~22M params (PicoDet-M)",
108
- "PP-DocLayout-S": "~4M params (PicoDet-S)",
109
- "PP-DocLayout_plus-L": "~123M params, 20-class plus variant",
110
- }
111
-
112
- IMAGE_EXTENSIONS = {
113
- ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp", ".jp2", ".j2k",
114
- }
115
-
116
- BUCKET_PREFIX = "hf://buckets/"
117
-
118
-
119
- # ---------------------------------------------------------------------------
120
- # URL helpers
121
- # ---------------------------------------------------------------------------
122
-
123
-
124
- def is_bucket_url(s: str) -> bool:
125
- return s.startswith(BUCKET_PREFIX)
126
-
127
-
128
- def parse_bucket_url(url: str) -> Tuple[str, str]:
129
- """Split `hf://buckets/ns/bucket/path/in/bucket` into (`ns/bucket`, `path/in/bucket`)."""
130
- if not is_bucket_url(url):
131
- raise ValueError(f"Not a bucket URL: {url}")
132
- rest = url[len(BUCKET_PREFIX) :].strip("/")
133
- parts = rest.split("/", 2)
134
- if len(parts) < 2:
135
- raise ValueError(
136
- f"Bucket URL must include namespace and bucket name: {url}"
137
- )
138
- bucket_id = f"{parts[0]}/{parts[1]}"
139
- prefix = parts[2] if len(parts) > 2 else ""
140
- return bucket_id, prefix
141
-
142
-
143
- # ---------------------------------------------------------------------------
144
- # Image helpers
145
- # ---------------------------------------------------------------------------
146
-
147
-
148
- def to_pil(image: Union[Image.Image, Dict[str, Any], str, bytes]) -> Image.Image:
149
- if isinstance(image, Image.Image):
150
- return image.convert("RGB")
151
- if isinstance(image, dict) and "bytes" in image:
152
- return Image.open(io.BytesIO(image["bytes"])).convert("RGB")
153
- if isinstance(image, (bytes, bytearray)):
154
- return Image.open(io.BytesIO(image)).convert("RGB")
155
- if isinstance(image, str):
156
- return Image.open(image).convert("RGB")
157
- raise ValueError(f"Unsupported image type: {type(image)}")
158
-
159
-
160
- def pil_to_array(pil_img: Image.Image) -> np.ndarray:
161
- """RGB PIL -> uint8 ndarray. PaddleOCR's predict() accepts numpy arrays directly."""
162
- return np.asarray(pil_img, dtype=np.uint8)
163
-
164
-
165
- # ---------------------------------------------------------------------------
166
- # Result extraction
167
- # ---------------------------------------------------------------------------
168
-
169
-
170
- def extract_detections(result: Any) -> List[Dict[str, Any]]:
171
- """Pull a clean list of detections out of a paddleocr LayoutDetection result."""
172
- payload = result.json
173
- res = payload.get("res", payload) if isinstance(payload, dict) else {}
174
- boxes = res.get("boxes", []) if isinstance(res, dict) else []
175
- detections = []
176
- for box in boxes:
177
- coord = box.get("coordinate") or box.get("bbox") or []
178
- coord = [float(x) for x in coord]
179
- detections.append(
180
- {
181
- "bbox": coord,
182
- "label": box.get("label"),
183
- "score": float(box.get("score", 0.0)),
184
- "cls_id": int(box.get("cls_id", -1)),
185
- }
186
- )
187
- return detections
188
-
189
-
190
- # ---------------------------------------------------------------------------
191
- # Sources
192
- # ---------------------------------------------------------------------------
193
-
194
-
195
- @dataclass
196
- class SourceItem:
197
- key: str # stable identifier per image (used for dedup/resume)
198
- image: Optional[Image.Image] # None for an unreadable row (placeholder)
199
- extras: Dict[str, Any] # original row fields (only populated for dataset source)
200
-
201
-
202
- def iter_dataset_images(
203
- dataset_id: str,
204
- image_column: str,
205
- split: str,
206
- shuffle: bool,
207
- seed: int,
208
- max_samples: Optional[int],
209
- ):
210
- """Iterate (key, PIL) pairs from an HF dataset repo.
211
-
212
- Returns: (iterator, total, dataset_reference). The dataset reference is the
213
- post-shuffle/post-select Dataset, kept around so the dataset-repo sink can
214
- `add_column("layout", ...)` and preserve the original schema (especially
215
- Image-type columns).
216
- """
217
- from datasets import load_dataset
218
-
219
- logger.info(f"Loading dataset: {dataset_id} (split={split})")
220
- ds = load_dataset(dataset_id, split=split)
221
-
222
- if image_column not in ds.column_names:
223
- raise ValueError(
224
- f"Column '{image_column}' not found. Available: {ds.column_names}"
225
- )
226
-
227
- if shuffle:
228
- logger.info(f"Shuffling with seed {seed}")
229
- ds = ds.shuffle(seed=seed)
230
- if max_samples:
231
- ds = ds.select(range(min(max_samples, len(ds))))
232
- logger.info(f"Limited to {len(ds)} samples")
233
-
234
- total = len(ds)
235
-
236
- def gen() -> Iterator[SourceItem]:
237
- failed = 0
238
- for i in range(total):
239
- try:
240
- row = ds[i]
241
- image = to_pil(row[image_column])
242
- except (UnidentifiedImageError, OSError) as e:
243
- # Still yield a placeholder so the output row stays aligned with
244
- # the source row (the dataset sink writes layouts positionally).
245
- failed += 1
246
- logger.warning(
247
- f"Unreadable image at row {i}: "
248
- f"{type(e).__name__}: {e} — writing empty layout"
249
- )
250
- yield SourceItem(
251
- key=f"row-{i:08d}",
252
- image=None,
253
- extras={"failed": True},
254
- )
255
- continue
256
- yield SourceItem(
257
- key=f"row-{i:08d}",
258
- image=image,
259
- extras={}, # original schema is preserved by the sink via the dataset ref
260
- )
261
- if failed:
262
- logger.info(f"{failed} unreadable image(s) written as empty layouts")
263
-
264
- return gen(), total, ds
265
-
266
-
267
- SOURCE_PATHS_SNAPSHOT = "_source_paths.json"
268
-
269
-
270
- def _bucket_snapshot_path(output_url: str) -> Tuple[str, str]:
271
- """Return (bucket_id, key) for the source-paths snapshot inside an output bucket."""
272
- out_bucket_id, out_prefix = parse_bucket_url(output_url)
273
- snapshot_key = (
274
- f"{out_prefix}/{SOURCE_PATHS_SNAPSHOT}".lstrip("/")
275
- if out_prefix
276
- else SOURCE_PATHS_SNAPSHOT
277
- )
278
- return out_bucket_id, snapshot_key
279
-
280
-
281
- def iter_bucket_images(
282
- bucket_url: str,
283
- shuffle: bool,
284
- seed: int,
285
- max_samples: Optional[int],
286
- hf_token: Optional[str],
287
- output_url: Optional[str] = None,
288
- ) -> Tuple[Iterator[SourceItem], int]:
289
- """Glob image files under a bucket prefix and stream them via HfFileSystem.
290
-
291
- If `output_url` is a bucket, the resolved source-path list is snapshotted to
292
- `<output>/_source_paths.json` on first run. Subsequent runs against the same
293
- output prefix reuse that snapshot, so resume stays consistent even if the
294
- source bucket grows or `--shuffle`/`--max-samples` would otherwise pick a
295
- different subset on the second run.
296
- """
297
- from huggingface_hub import HfApi, HfFileSystem
298
-
299
- bucket_id, prefix = parse_bucket_url(bucket_url)
300
- fs = HfFileSystem(token=hf_token)
301
- base = f"{BUCKET_PREFIX}{bucket_id}/{prefix}".rstrip("/")
302
-
303
- snapshot_bucket_id: Optional[str] = None
304
- snapshot_key: Optional[str] = None
305
- cached_paths: Optional[List[str]] = None
306
-
307
- if output_url and is_bucket_url(output_url):
308
- snapshot_bucket_id, snapshot_key = _bucket_snapshot_path(output_url)
309
- snapshot_url = f"{BUCKET_PREFIX}{snapshot_bucket_id}/{snapshot_key}"
310
- try:
311
- with fs.open(snapshot_url, "rb") as f:
312
- snapshot = json.load(f)
313
- if snapshot.get("source_url") != bucket_url:
314
- logger.warning(
315
- f"Output prefix already has a snapshot referencing a "
316
- f"different source ({snapshot.get('source_url')!r} vs "
317
- f"{bucket_url!r}). Ignoring and re-listing."
318
- )
319
- else:
320
- cached_paths = snapshot["paths"]
321
- logger.info(
322
- f"Reusing existing snapshot of {len(cached_paths)} source paths "
323
- f"(written {snapshot.get('created_at', 'unknown')})"
324
- )
325
- except FileNotFoundError:
326
- pass
327
- except Exception as e:
328
- logger.warning(f"Could not read existing snapshot ({e}); re-listing.")
329
-
330
- if cached_paths is not None:
331
- all_paths = cached_paths
332
- else:
333
- logger.info(f"Listing images under {base}")
334
- all_paths = []
335
- try:
336
- for entry in fs.find(base, detail=False):
337
- ext = Path(entry).suffix.lower()
338
- if ext in IMAGE_EXTENSIONS:
339
- all_paths.append(entry)
340
- except FileNotFoundError as e:
341
- raise ValueError(f"Bucket prefix not found: {base}") from e
342
-
343
- if not all_paths:
344
- raise ValueError(
345
- f"No image files (any of {sorted(IMAGE_EXTENSIONS)}) under {base}"
346
- )
347
-
348
- all_paths.sort()
349
- if shuffle:
350
- rng = np.random.default_rng(seed)
351
- rng.shuffle(all_paths)
352
- if max_samples:
353
- all_paths = all_paths[:max_samples]
354
-
355
- # Persist the chosen list so resume runs see exactly this set.
356
- if snapshot_bucket_id is not None and snapshot_key is not None:
357
- api = HfApi(token=hf_token)
358
- payload = {
359
- "source_url": bucket_url,
360
- "shuffle": shuffle,
361
- "seed": seed,
362
- "max_samples": max_samples,
363
- "created_at": datetime.now(timezone.utc).isoformat(),
364
- "paths": all_paths,
365
- }
366
- api.batch_bucket_files(
367
- snapshot_bucket_id,
368
- add=[(json.dumps(payload).encode(), snapshot_key)],
369
- token=hf_token,
370
- )
371
- logger.info(
372
- f"Wrote source-path snapshot ({len(all_paths)} paths) to "
373
- f"hf://buckets/{snapshot_bucket_id}/{snapshot_key}"
374
- )
375
-
376
- total = len(all_paths)
377
- logger.info(f"Found {total} images in bucket")
378
-
379
- def key_for(path: str) -> str:
380
- # Use the full bucket path (`buckets/<id>/<rel>`) as returned by
381
- # fs.find. This is stable across reruns (so resume works), and the
382
- # stored value in `source_path` is fully addressable — open via
383
- # HfFileSystem directly with `hf://` re-prepended.
384
- return path
385
-
386
- def gen() -> Iterator[SourceItem]:
387
- skipped = 0
388
- for path in all_paths:
389
- try:
390
- with fs.open(path, "rb") as f:
391
- data = f.read()
392
- image = to_pil(data)
393
- except (UnidentifiedImageError, OSError) as e:
394
- skipped += 1
395
- logger.warning(
396
- f"Skipping unreadable image {path}: "
397
- f"{type(e).__name__}: {e}"
398
- )
399
- continue
400
- yield SourceItem(
401
- key=key_for(path),
402
- image=image,
403
- extras={"__source_path": key_for(path)},
404
- )
405
- if skipped:
406
- logger.info(f"Skipped {skipped} unreadable image(s) total")
407
-
408
- return gen(), total
409
-
410
-
411
- # ---------------------------------------------------------------------------
412
- # Sinks
413
- # ---------------------------------------------------------------------------
414
-
415
-
416
- class DatasetRepoSink:
417
- """Buffer all results in memory, push once at end with dataset card + inference_info.
418
-
419
- Two modes:
420
- - `original_dataset` provided (dataset-repo source): preserve the source
421
- schema (including Image-type columns) and just `add_column("layout", ...)`.
422
- - `original_dataset` is None (bucket-image source): build a Dataset from
423
- collected rows containing __source_path + layout.
424
- """
425
-
426
- def __init__(
427
- self,
428
- repo_id: str,
429
- *,
430
- hf_token: Optional[str],
431
- private: bool,
432
- config: Optional[str],
433
- create_pr: bool,
434
- source_id: str,
435
- original_dataset=None,
436
- overwrite: bool = False,
437
- ):
438
- self.repo_id = repo_id
439
- self.hf_token = hf_token
440
- self.private = private
441
- self.config = config
442
- self.create_pr = create_pr
443
- self.source_id = source_id
444
- self.original_dataset = original_dataset
445
- self.overwrite = overwrite
446
- # Used when original_dataset is None: row-by-row buffer.
447
- self._rows: List[Dict[str, Any]] = []
448
- # Used when original_dataset is set: ordered layouts aligned with dataset rows.
449
- self._layouts: List[str] = []
450
-
451
- @property
452
- def kind(self) -> str:
453
- return "dataset"
454
-
455
- def already_done(self) -> set:
456
- return set() # dataset sink does a single push, no resume
457
-
458
- def write(self, key: str, layout: List[Dict[str, Any]], extras: Dict[str, Any]) -> None:
459
- layout_json = json.dumps(layout, ensure_ascii=False)
460
- if self.original_dataset is not None:
461
- self._layouts.append(layout_json)
462
- return
463
- row = {"__source_key": key, "layout": layout_json}
464
- for k, v in extras.items():
465
- if isinstance(v, (str, int, float, bool)) or v is None:
466
- row[k] = v
467
- self._rows.append(row)
468
-
469
- def finalize(self, model_id: str, args_dict: Dict[str, Any]) -> None:
470
- from datasets import Dataset
471
-
472
- if self.original_dataset is not None:
473
- if len(self._layouts) != len(self.original_dataset):
474
- logger.warning(
475
- f"Layout count ({len(self._layouts)}) != dataset rows "
476
- f"({len(self.original_dataset)}); padding with empty layouts."
477
- )
478
- # Pad to keep add_column happy.
479
- while len(self._layouts) < len(self.original_dataset):
480
- self._layouts.append("[]")
481
- base = self.original_dataset
482
- if "layout" in base.column_names:
483
- if not self.overwrite:
484
- raise ValueError(
485
- "Output column 'layout' already exists in the input dataset; "
486
- "pass --overwrite to replace it."
487
- )
488
- logger.warning("--overwrite: replacing existing column 'layout'")
489
- base = base.remove_columns("layout")
490
- ds = base.add_column("layout", self._layouts)
491
- else:
492
- if not self._rows:
493
- logger.warning("No rows produced; nothing to push.")
494
- return
495
- ds = Dataset.from_list(self._rows)
496
- if "__source_key" in ds.column_names:
497
- ds = ds.rename_column("__source_key", "source_path")
498
-
499
- inference_entry = build_inference_entry(model_id, args_dict)
500
-
501
- if "inference_info" in ds.column_names:
502
- logger.info("Updating existing inference_info column")
503
-
504
- def _update(example):
505
- try:
506
- existing = (
507
- json.loads(example["inference_info"])
508
- if example["inference_info"]
509
- else []
510
- )
511
- except (json.JSONDecodeError, TypeError):
512
- existing = []
513
- existing.append(inference_entry)
514
- return {"inference_info": json.dumps(existing)}
515
-
516
- ds = ds.map(_update)
517
- else:
518
- ds = ds.add_column(
519
- "inference_info", [json.dumps([inference_entry])] * len(ds)
520
- )
521
-
522
- logger.info(f"Pushing {len(ds)} rows to {self.repo_id}")
523
- push_kwargs = {
524
- "private": self.private,
525
- "token": self.hf_token,
526
- "max_shard_size": "500MB",
527
- "create_pr": self.create_pr,
528
- "commit_message": f"Add PP-DocLayout layout predictions ({len(ds)} samples)"
529
- + (f" [{self.config}]" if self.config else ""),
530
- }
531
- if self.config:
532
- push_kwargs["config_name"] = self.config
533
-
534
- max_retries = 3
535
- for attempt in range(1, max_retries + 1):
536
- try:
537
- if attempt > 1:
538
- logger.warning("Disabling XET (fallback to HTTP upload)")
539
- os.environ["HF_HUB_DISABLE_XET"] = "1"
540
- ds.push_to_hub(self.repo_id, **push_kwargs)
541
- break
542
- except Exception as e:
543
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
544
- if attempt == max_retries:
545
- logger.error("All upload attempts failed.")
546
- raise
547
- time.sleep(30 * (2 ** (attempt - 1)))
548
-
549
- # Dataset card
550
- from huggingface_hub import DatasetCard
551
-
552
- card = DatasetCard(
553
- create_dataset_card(
554
- source=self.source_id,
555
- model_name=args_dict["model_name"],
556
- num_samples=len(ds),
557
- processing_time=args_dict["processing_time"],
558
- output_column="layout",
559
- threshold=args_dict["threshold"],
560
- layout_nms=args_dict["layout_nms"],
561
- )
562
- )
563
- card.push_to_hub(self.repo_id, token=self.hf_token)
564
- logger.info(
565
- f"Done: https://huggingface.co/datasets/{self.repo_id}"
566
- )
567
-
568
-
569
- class BucketShardSink:
570
- """Write incremental parquet shards to a bucket prefix. Resumable."""
571
-
572
- METADATA_FILE = "_metadata.json"
573
- SHARD_PATTERN = "shard-{:05d}.parquet"
574
-
575
- def __init__(
576
- self,
577
- bucket_url: str,
578
- *,
579
- hf_token: Optional[str],
580
- shard_size: int,
581
- include_images: bool,
582
- resume: bool,
583
- source_id: str,
584
- ):
585
- from huggingface_hub import HfApi, HfFileSystem, create_bucket
586
-
587
- self.bucket_url = bucket_url
588
- self.bucket_id, self.prefix = parse_bucket_url(bucket_url)
589
- self.hf_token = hf_token
590
- self.shard_size = shard_size
591
- self.include_images = include_images
592
- self.resume = resume
593
- self.source_id = source_id
594
-
595
- self._api = HfApi(token=hf_token)
596
- self._fs = HfFileSystem(token=hf_token)
597
-
598
- # Make sure the bucket exists. Path inside the bucket is created lazily on first write.
599
- try:
600
- create_bucket(self.bucket_id, exist_ok=True, token=hf_token)
601
- except Exception as e:
602
- # If we don't have create rights but the bucket already exists, that's fine.
603
- logger.warning(f"create_bucket('{self.bucket_id}') warning: {e}")
604
-
605
- self._buffer: List[Dict[str, Any]] = []
606
- self._next_shard_idx = self._discover_next_shard_idx()
607
- self._completed_keys = self._discover_completed_keys() if resume else set()
608
- if self._completed_keys:
609
- logger.info(
610
- f"Resume: found {len(self._completed_keys)} already-processed keys, will skip them"
611
- )
612
-
613
- @property
614
- def kind(self) -> str:
615
- return "bucket"
616
-
617
- def already_done(self) -> set:
618
- return self._completed_keys
619
-
620
- # --- internal helpers ---
621
-
622
- def _shard_path(self, idx: int) -> str:
623
- return self._join(self.SHARD_PATTERN.format(idx))
624
-
625
- def _join(self, name: str) -> str:
626
- return f"{self.prefix}/{name}".lstrip("/") if self.prefix else name
627
-
628
- def _list_existing_shards(self) -> List[str]:
629
- try:
630
- tree = self._api.list_bucket_tree(
631
- self.bucket_id, prefix=self.prefix or None, recursive=True
632
- )
633
- except Exception:
634
- return []
635
- shards: List[str] = []
636
- for item in tree:
637
- path = getattr(item, "path", None)
638
- ftype = getattr(item, "type", None)
639
- if not path or ftype not in (None, "file"):
640
- continue
641
- base = Path(path).name
642
- if base.startswith("shard-") and base.endswith(".parquet"):
643
- shards.append(path)
644
- return sorted(shards)
645
-
646
- def _discover_next_shard_idx(self) -> int:
647
- shards = self._list_existing_shards()
648
- max_idx = -1
649
- for s in shards:
650
- stem = Path(s).stem # shard-00007
651
- try:
652
- max_idx = max(max_idx, int(stem.split("-")[-1]))
653
- except ValueError:
654
- continue
655
- return max_idx + 1
656
-
657
- def _discover_completed_keys(self) -> set:
658
- import pyarrow.parquet as pq
659
-
660
- keys: set = set()
661
- for shard_path in self._list_existing_shards():
662
- full = f"{BUCKET_PREFIX}{self.bucket_id}/{shard_path}"
663
- try:
664
- with self._fs.open(full, "rb") as f:
665
- table = pq.read_table(f, columns=["__source_key"])
666
- keys.update(table.column("__source_key").to_pylist())
667
- except Exception as e:
668
- logger.warning(f"Could not read keys from {shard_path}: {e}")
669
- return keys
670
-
671
- def _flush(self) -> None:
672
- if not self._buffer:
673
- return
674
- import pyarrow as pa
675
- import pyarrow.parquet as pq
676
-
677
- # Build a stable schema. Skip the image column if not requested.
678
- columns = ["__source_key", "layout"]
679
- if self.include_images:
680
- columns.append("__image_bytes")
681
- # Carry through any extra string-coercible fields (e.g. __source_path).
682
- extra_keys = sorted(
683
- {k for row in self._buffer for k in row.keys() if k not in columns}
684
- )
685
- columns.extend(extra_keys)
686
-
687
- table_dict = {c: [row.get(c) for row in self._buffer] for c in columns}
688
- # pyarrow infers types from python objects; strings/bytes/lists handled fine.
689
- table = pa.Table.from_pydict(table_dict)
690
-
691
- buf = io.BytesIO()
692
- pq.write_table(table, buf, compression="zstd")
693
- data = buf.getvalue()
694
-
695
- shard_remote = self._shard_path(self._next_shard_idx)
696
- logger.info(
697
- f"Writing shard {self._next_shard_idx} ({len(self._buffer)} rows, "
698
- f"{len(data) / 1024 / 1024:.1f} MiB) to {shard_remote}"
699
- )
700
- self._api.batch_bucket_files(
701
- self.bucket_id, add=[(data, shard_remote)], token=self.hf_token
702
- )
703
- self._next_shard_idx += 1
704
- self._buffer.clear()
705
-
706
- def write(self, key: str, layout: List[Dict[str, Any]], extras: Dict[str, Any]) -> None:
707
- row: Dict[str, Any] = {
708
- "__source_key": key,
709
- "layout": json.dumps(layout, ensure_ascii=False),
710
- }
711
- if self.include_images and "__image_bytes" in extras:
712
- row["__image_bytes"] = extras["__image_bytes"]
713
- # Pass through string/numeric extras (skip raw PIL Image objects which
714
- # the dataset source never injects directly into extras anyway).
715
- for k, v in extras.items():
716
- if k in row or k == "__image_bytes":
717
- continue
718
- if isinstance(v, (str, int, float, bool)) or v is None:
719
- row[k] = v
720
- self._buffer.append(row)
721
- if len(self._buffer) >= self.shard_size:
722
- self._flush()
723
-
724
- def finalize(self, model_id: str, args_dict: Dict[str, Any]) -> None:
725
- # Flush trailing rows.
726
- self._flush()
727
- # Write/update the metadata file alongside the shards.
728
- meta = {
729
- "model_id": model_id,
730
- "model_name": args_dict["model_name"],
731
- "task_mode": "layout-detection",
732
- "source": self.source_id,
733
- "threshold": args_dict["threshold"],
734
- "layout_nms": args_dict["layout_nms"],
735
- "shard_size": args_dict["shard_size"],
736
- "include_images": self.include_images,
737
- "last_run_at": datetime.now(timezone.utc).isoformat(),
738
- "processing_time": args_dict.get("processing_time"),
739
- }
740
- meta_bytes = json.dumps(meta, indent=2).encode("utf-8")
741
- meta_path = self._join(self.METADATA_FILE)
742
- self._api.batch_bucket_files(
743
- self.bucket_id, add=[(meta_bytes, meta_path)], token=self.hf_token
744
- )
745
- logger.info(
746
- f"Done: https://huggingface.co/buckets/{self.bucket_id}"
747
- + (f"/{self.prefix}" if self.prefix else "")
748
- )
749
-
750
-
751
- # ---------------------------------------------------------------------------
752
- # inference_info + dataset card
753
- # ---------------------------------------------------------------------------
754
-
755
-
756
- def build_inference_entry(model_id: str, args_dict: Dict[str, Any]) -> Dict[str, Any]:
757
- return {
758
- "model_id": "PaddlePaddle/" + args_dict["model_name"],
759
- "model_name": args_dict["model_name"],
760
- "model_size": MODEL_SIZES.get(args_dict["model_name"], "unknown"),
761
- "task_mode": "layout-detection",
762
- "column_name": "layout",
763
- "timestamp": datetime.now(timezone.utc).isoformat(),
764
- "threshold": args_dict["threshold"],
765
- "layout_nms": args_dict["layout_nms"],
766
- "backend": "paddleocr",
767
- }
768
-
769
-
770
- def create_dataset_card(
771
- source: str,
772
- model_name: str,
773
- num_samples: int,
774
- processing_time: str,
775
- output_column: str,
776
- threshold: float,
777
- layout_nms: bool,
778
- ) -> str:
779
- """Render the dataset card markdown for the dataset-repo sink."""
780
- if is_bucket_url(source):
781
- source_link = f"[{source}]({source})"
782
- else:
783
- source_link = f"[{source}](https://huggingface.co/datasets/{source})"
784
-
785
- return f"""---
786
- tags:
787
- - layout-detection
788
- - document-processing
789
- - paddleocr
790
- - pp-doclayout
791
- - uv-script
792
- - generated
793
- viewer: false
794
- ---
795
-
796
- # Layout detection with {model_name}
797
-
798
- Bounding-box layout predictions for images from {source_link}, produced by
799
- PaddleOCR's [{model_name}](https://huggingface.co/PaddlePaddle/{model_name}).
800
-
801
- ## Processing details
802
-
803
- - **Source**: {source_link}
804
- - **Model**: PaddlePaddle/{model_name} ({MODEL_SIZES.get(model_name, "unknown")})
805
- - **Samples**: {num_samples:,}
806
- - **Processing time**: {processing_time}
807
- - **Processing date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
808
- - **Confidence threshold**: {threshold}
809
- - **Layout NMS**: {"on" if layout_nms else "off"}
810
- - **Output column**: `{output_column}` (JSON-encoded list of detections)
811
-
812
- ## Schema
813
-
814
- Each row contains the original columns plus:
815
-
816
- - `{output_column}`: JSON string. List of detections:
817
- ```json
818
- [
819
- {{"bbox": [x1, y1, x2, y2], "label": "text", "score": 0.97, "cls_id": 2}},
820
- {{"bbox": [x1, y1, x2, y2], "label": "table", "score": 0.92, "cls_id": 5}}
821
- ]
822
- ```
823
- Coordinates are in **original input-image pixel space** (top-left origin,
824
- `[xmin, ymin, xmax, ymax]`).
825
- - `inference_info`: JSON list tracking every model that has been applied to
826
- this dataset (appended on each run).
827
-
828
- ## Usage
829
-
830
- ```python
831
- import json
832
- from datasets import load_dataset
833
-
834
- ds = load_dataset("{{output_dataset_id}}", split="train")
835
- detections = json.loads(ds[0]["{output_column}"])
836
- for det in detections:
837
- print(det["label"], det["score"], det["bbox"])
838
- ```
839
-
840
- ## Reproduction
841
-
842
- ```bash
843
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \\
844
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-doclayout.py \\
845
- {source} <output> --model-name {model_name}
846
- ```
847
-
848
- Generated with [UV Scripts](https://huggingface.co/uv-scripts).
849
- """
850
-
851
-
852
- # ---------------------------------------------------------------------------
853
- # Main
854
- # ---------------------------------------------------------------------------
855
-
856
-
857
- def resolve_device(device: str) -> str:
858
- if device == "gpu":
859
- try:
860
- import paddle # noqa: F401
861
-
862
- if paddle.device.is_compiled_with_cuda() and paddle.device.cuda.device_count() > 0:
863
- logger.info(
864
- f"GPU available: {paddle.device.cuda.device_count()} device(s)"
865
- )
866
- return "gpu"
867
- logger.warning("No CUDA GPU detected; falling back to CPU.")
868
- return "cpu"
869
- except Exception as e:
870
- logger.warning(f"GPU check failed ({e}); falling back to CPU.")
871
- return "cpu"
872
- return device
873
-
874
-
875
- def main(args: argparse.Namespace) -> None:
876
- from huggingface_hub import login
877
-
878
- start_time = datetime.now()
879
- hf_token = args.hf_token or os.environ.get("HF_TOKEN")
880
- if hf_token:
881
- login(token=hf_token)
882
-
883
- device = resolve_device(args.device)
884
-
885
- # ---------- source ----------
886
- original_dataset = None
887
- if is_bucket_url(args.input_source):
888
- src_iter, total = iter_bucket_images(
889
- args.input_source,
890
- shuffle=args.shuffle,
891
- seed=args.seed,
892
- max_samples=args.max_samples,
893
- hf_token=hf_token,
894
- output_url=args.output_target,
895
- )
896
- else:
897
- src_iter, total, original_dataset = iter_dataset_images(
898
- args.input_source,
899
- image_column=args.image_column,
900
- split=args.split,
901
- shuffle=args.shuffle,
902
- seed=args.seed,
903
- max_samples=args.max_samples,
904
- )
905
- # Fail fast, before minutes of inference, if the 'layout' output column would
906
- # collide with an existing input column. --overwrite opts in to replacing it.
907
- if original_dataset is not None and "layout" in original_dataset.column_names:
908
- if not args.overwrite:
909
- logger.error(
910
- "Output column 'layout' already exists in the input dataset "
911
- f"(columns: {original_dataset.column_names})."
912
- )
913
- logger.error("Pass --overwrite to replace it.")
914
- sys.exit(1)
915
- logger.warning("--overwrite: will replace existing column 'layout'")
916
-
917
- # ---------- sink ----------
918
- if is_bucket_url(args.output_target):
919
- sink: Union[BucketShardSink, DatasetRepoSink] = BucketShardSink(
920
- args.output_target,
921
- hf_token=hf_token,
922
- shard_size=args.shard_size,
923
- include_images=args.include_images,
924
- resume=not args.no_resume,
925
- source_id=args.input_source,
926
- )
927
- else:
928
- sink = DatasetRepoSink(
929
- args.output_target,
930
- hf_token=hf_token,
931
- private=args.private,
932
- config=args.config,
933
- create_pr=args.create_pr,
934
- source_id=args.input_source,
935
- original_dataset=original_dataset,
936
- overwrite=args.overwrite,
937
- )
938
-
939
- completed = sink.already_done()
940
-
941
- # ---------- model ----------
942
- if args.model_name not in VALID_MODELS:
943
- raise ValueError(
944
- f"Invalid model {args.model_name!r}. Choose from: {VALID_MODELS}"
945
- )
946
- logger.info(f"Loading PaddleOCR LayoutDetection model: {args.model_name} on {device}")
947
- # PaddleX gates `import cv2` at module load time on
948
- # `is_dep_available("opencv-contrib-python")`, which checks
949
- # `importlib.metadata.version(...)`. We ship `opencv-contrib-python-headless`
950
- # (same `cv2`, no system libGL.so.1 needed) — but that's a different
951
- # distribution name, so the gate fails and `cv2` is never bound, causing
952
- # NameErrors deep inside paddlex modules. Patch the metadata lookup to
953
- # alias the GUI cv2 distros to the headless variant before importing
954
- # paddleocr; this lets paddlex's own `import cv2` succeed naturally.
955
- import importlib.metadata as _metadata
956
-
957
- _orig_metadata_version = _metadata.version
958
-
959
- def _patched_metadata_version(dep_name):
960
- if dep_name in ("opencv-contrib-python", "opencv-python"):
961
- for headless_alias in (
962
- "opencv-contrib-python-headless",
963
- "opencv-python-headless",
964
- ):
965
- try:
966
- return _orig_metadata_version(headless_alias)
967
- except _metadata.PackageNotFoundError:
968
- continue
969
- return _orig_metadata_version(dep_name)
970
-
971
- _metadata.version = _patched_metadata_version
972
-
973
- from paddleocr import LayoutDetection
974
-
975
- model = LayoutDetection(model_name=args.model_name, device=device)
976
-
977
- # ---------- loop ----------
978
- processed = 0
979
- skipped = 0
980
- errors = 0
981
- pbar = tqdm(src_iter, total=total, desc=f"Layout {args.model_name}")
982
- for item in pbar:
983
- if item.key in completed:
984
- skipped += 1
985
- continue
986
- if item.extras.get("failed") or item.image is None:
987
- # Unreadable source image — write an empty layout in position so the
988
- # output stays row-aligned with the source dataset.
989
- sink.write(item.key, [], {})
990
- errors += 1
991
- processed += 1
992
- continue
993
- try:
994
- arr = pil_to_array(item.image)
995
- results = model.predict(
996
- arr,
997
- batch_size=args.batch_size,
998
- layout_nms=args.layout_nms,
999
- )
1000
- if not results:
1001
- detections: List[Dict[str, Any]] = []
1002
- else:
1003
- detections = extract_detections(results[0])
1004
- if args.threshold and args.threshold > 0:
1005
- detections = [d for d in detections if d["score"] >= args.threshold]
1006
- except Exception as e:
1007
- logger.error(f"Error on {item.key}: {e}")
1008
- detections = []
1009
- errors += 1
1010
-
1011
- extras = dict(item.extras)
1012
- if isinstance(sink, BucketShardSink) and args.include_images:
1013
- buf = io.BytesIO()
1014
- item.image.save(buf, format="PNG")
1015
- extras["__image_bytes"] = buf.getvalue()
1016
-
1017
- sink.write(item.key, detections, extras)
1018
- processed += 1
1019
-
1020
- duration = datetime.now() - start_time
1021
- processing_time_str = f"{duration.total_seconds() / 60:.2f} min"
1022
- logger.info(
1023
- f"Processed {processed} (skipped {skipped}, errors {errors}) in {processing_time_str}"
1024
- )
1025
-
1026
- args_dict = {
1027
- "model_name": args.model_name,
1028
- "threshold": args.threshold,
1029
- "layout_nms": args.layout_nms,
1030
- "shard_size": args.shard_size,
1031
- "processing_time": processing_time_str,
1032
- }
1033
- sink.finalize(model_id=f"PaddlePaddle/{args.model_name}", args_dict=args_dict)
1034
-
1035
- if args.verbose:
1036
- import importlib.metadata
1037
-
1038
- logger.info("--- Resolved package versions ---")
1039
- for pkg in [
1040
- "paddlepaddle",
1041
- "paddlepaddle-gpu",
1042
- "paddleocr",
1043
- "huggingface-hub",
1044
- "datasets",
1045
- "pyarrow",
1046
- "pillow",
1047
- "numpy",
1048
- ]:
1049
- try:
1050
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
1051
- except importlib.metadata.PackageNotFoundError:
1052
- logger.info(f" {pkg}: not installed")
1053
- logger.info("--- End versions ---")
1054
-
1055
-
1056
- # ---------------------------------------------------------------------------
1057
- # CLI
1058
- # ---------------------------------------------------------------------------
1059
-
1060
-
1061
- def _print_usage_banner() -> None:
1062
- print("=" * 80)
1063
- print("PP-DocLayout layout detection")
1064
- print("=" * 80)
1065
- print(
1066
- "\nDetect document layout regions (text/title/table/figure/formula/...)"
1067
- )
1068
- print("with PaddleOCR's PP-DocLayout-L (or M / S / plus-L variant).")
1069
- print("\nModels:")
1070
- for m in VALID_MODELS:
1071
- print(f" {m:24s} {MODEL_SIZES.get(m, '')}")
1072
- print("\nSources:")
1073
- print(" - HF dataset repo: namespace/dataset")
1074
- print(" - HF bucket of images: hf://buckets/namespace/bucket[/prefix]")
1075
- print("\nSinks:")
1076
- print(" - HF dataset repo (one push + dataset card):")
1077
- print(" namespace/dataset")
1078
- print(" - HF bucket (incremental shards, resumable):")
1079
- print(" hf://buckets/namespace/bucket/run-name")
1080
- print("\nExamples:")
1081
- print("\n # Smoke test on L4 (dataset -> dataset)")
1082
- print(" hf jobs uv run --flavor l4x1 -s HF_TOKEN https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-doclayout.py \\")
1083
- print(" davanstrien/ufo-ColPali pp-doclayout-smoke \\")
1084
- print(" --max-samples 3 --shuffle --seed 42 --private")
1085
- print("\n # Dataset -> bucket (incremental shards)")
1086
- print(
1087
- " hf jobs uv run --flavor l4x1 -s HF_TOKEN https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-doclayout.py \\"
1088
- )
1089
- print(" davanstrien/ufo-ColPali \\")
1090
- print(
1091
- " hf://buckets/davanstrien/pp-doclayout-scratch/run1 \\"
1092
- )
1093
- print(" --max-samples 20 --shard-size 5")
1094
- print("\n # Bucket of images -> dataset")
1095
- print(
1096
- " hf jobs uv run --flavor l4x1 -s HF_TOKEN https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-doclayout.py \\"
1097
- )
1098
- print(
1099
- " hf://buckets/davanstrien/pp-doclayout-images \\"
1100
- )
1101
- print(" pp-doclayout-from-bucket --private")
1102
- print("\nFor full help, run: uv run pp-doclayout.py --help")
1103
- print("=" * 80)
1104
-
1105
-
1106
- def build_parser() -> argparse.ArgumentParser:
1107
- p = argparse.ArgumentParser(
1108
- description="PP-DocLayout layout detection over an HF dataset or bucket.",
1109
- formatter_class=argparse.RawDescriptionHelpFormatter,
1110
- )
1111
- p.add_argument(
1112
- "input_source",
1113
- help="HF dataset id (namespace/dataset) OR hf://buckets/ns/bucket[/prefix]",
1114
- )
1115
- p.add_argument(
1116
- "output_target",
1117
- help="HF dataset id (namespace/dataset) OR hf://buckets/ns/bucket/run-name",
1118
- )
1119
- p.add_argument(
1120
- "--model-name",
1121
- default="PP-DocLayout-L",
1122
- choices=VALID_MODELS,
1123
- help="PaddleOCR layout model variant (default: PP-DocLayout-L)",
1124
- )
1125
- p.add_argument(
1126
- "--device",
1127
- default="gpu",
1128
- choices=["gpu", "cpu"],
1129
- help="Device for inference (default: gpu, falls back to cpu if CUDA missing)",
1130
- )
1131
- p.add_argument(
1132
- "--batch-size",
1133
- type=int,
1134
- default=1,
1135
- help="Per-image batch size passed to model.predict (default: 1)",
1136
- )
1137
- p.add_argument(
1138
- "--threshold",
1139
- type=float,
1140
- default=0.5,
1141
- help="Drop detections below this confidence (default: 0.5; 0 disables)",
1142
- )
1143
- p.add_argument(
1144
- "--layout-nms",
1145
- dest="layout_nms",
1146
- action="store_true",
1147
- default=True,
1148
- help="Enable layout NMS (default: on)",
1149
- )
1150
- p.add_argument(
1151
- "--no-layout-nms",
1152
- dest="layout_nms",
1153
- action="store_false",
1154
- help="Disable layout NMS",
1155
- )
1156
- # Dataset-source-specific
1157
- p.add_argument(
1158
- "--image-column",
1159
- default="image",
1160
- help="Column containing images (dataset-repo source only, default: image)",
1161
- )
1162
- p.add_argument(
1163
- "--split",
1164
- default="train",
1165
- help="Dataset split (dataset-repo source only, default: train)",
1166
- )
1167
- p.add_argument(
1168
- "--max-samples", type=int, help="Limit number of samples (for testing)"
1169
- )
1170
- p.add_argument(
1171
- "--shuffle", action="store_true", help="Shuffle source before processing"
1172
- )
1173
- p.add_argument(
1174
- "--seed", type=int, default=42, help="Random seed for shuffle (default: 42)"
1175
- )
1176
- # Dataset-sink-specific
1177
- p.add_argument(
1178
- "--private", action="store_true", help="Private dataset output (dataset sink only)"
1179
- )
1180
- p.add_argument(
1181
- "--config",
1182
- help="Config/subset name when pushing to Hub (dataset sink only)",
1183
- )
1184
- p.add_argument(
1185
- "--create-pr",
1186
- action="store_true",
1187
- help="Create PR instead of direct push (dataset sink only)",
1188
- )
1189
- p.add_argument(
1190
- "--overwrite",
1191
- action="store_true",
1192
- help="Replace the output column if it already exists in the input dataset "
1193
- "(default: error out to avoid clobbering an existing column).",
1194
- )
1195
- # Bucket-sink-specific
1196
- p.add_argument(
1197
- "--shard-size",
1198
- type=int,
1199
- default=256,
1200
- help="Rows per parquet shard for bucket sink (default: 256)",
1201
- )
1202
- p.add_argument(
1203
- "--include-images",
1204
- action="store_true",
1205
- help="Embed source image bytes in bucket output shards (off by default)",
1206
- )
1207
- p.add_argument(
1208
- "--no-resume",
1209
- action="store_true",
1210
- help="Disable resume scan when writing to a bucket sink",
1211
- )
1212
- # Auth + diagnostics
1213
- p.add_argument("--hf-token", help="Hugging Face API token (else uses HF_TOKEN env)")
1214
- p.add_argument(
1215
- "--verbose",
1216
- action="store_true",
1217
- help="Log resolved package versions at the end",
1218
- )
1219
- return p
1220
-
1221
-
1222
- if __name__ == "__main__":
1223
- if len(sys.argv) == 1:
1224
- _print_usage_banner()
1225
- sys.exit(0)
1226
- main(build_parser().parse_args())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pp-ocrv6.py DELETED
@@ -1,1102 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.10"
3
- # dependencies = [
4
- # "paddlepaddle-gpu>=3.0.0",
5
- # "paddleocr>=3.7.0",
6
- # "paddlex[ocr]>=3.7.0",
7
- # "opencv-contrib-python-headless",
8
- # "datasets>=3.1.0",
9
- # "huggingface-hub",
10
- # "pillow",
11
- # "numpy",
12
- # "tqdm",
13
- # ]
14
- #
15
- # [tool.uv]
16
- # # PaddleOCR/PaddleX pull in opencv-contrib-python (full) which needs system
17
- # # libGL.so.1 — not present in the slim uv-on-bookworm image used by HF Jobs.
18
- # # Swap to the headless cv2 variant (same `import cv2`, no GUI deps). A matching
19
- # # importlib.metadata patch in main() makes paddlex recognise the headless name.
20
- # override-dependencies = [
21
- # "opencv-contrib-python ; python_version < '0'",
22
- # "opencv-python ; python_version < '0'",
23
- # ]
24
- #
25
- # [[tool.uv.index]]
26
- # name = "paddle"
27
- # url = "https://www.paddlepaddle.org.cn/packages/stable/cu126/"
28
- # explicit = true
29
- #
30
- # [tool.uv.sources]
31
- # paddlepaddle-gpu = { index = "paddle" }
32
- # ///
33
- """
34
- OCR images with PP-OCRv6 — a lightweight detection+recognition pipeline from
35
- PaddlePaddle. Three tiers from **1.5M to 34.5M parameters**.
36
-
37
- Unlike the VLM-based OCR recipes here, PP-OCRv6 is a **classical det+rec pipeline**
38
- that outputs **plain text** (not markdown). At 1.5M-34.5M params it's far smaller
39
- than the VLM OCRs and runs on a cheap t4-small GPU.
40
-
41
- Model tiers (pick with `--model-tier`):
42
- tiny 1.5M params (0.4M det + 1.1M rec) 49 languages, ~73% recognition
43
- small 7.7M params (2.5M det + 5.3M rec) 50 languages, ~81% recognition
44
- medium 34.5M params (22M det + 19M rec) 50 languages, ~83% recognition
45
-
46
- All tiers are Apache 2.0 licensed. Runs via PaddleOCR's default Paddle engine
47
- (`paddle_static`) — same proven header pattern as `pp-doclayout.py`.
48
-
49
- HF Jobs examples:
50
-
51
- # Tiny on a cheap GPU
52
- hf jobs uv run --flavor t4-small -s HF_TOKEN \\
53
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-ocrv6.py \\
54
- INPUT_DATASET OUTPUT_DATASET \\
55
- --model-tier tiny --max-samples 5
56
-
57
- # Medium on a small GPU (recommended for quality)
58
- hf jobs uv run --flavor t4-small -s HF_TOKEN \\
59
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-ocrv6.py \\
60
- INPUT_DATASET OUTPUT_DATASET \\
61
- --model-tier medium --max-samples 10
62
-
63
- Models: PaddlePaddle/PP-OCRv6_<tier>_det + PP-OCRv6_<tier>_rec
64
- Blog: https://huggingface.co/blog/PaddlePaddle/pp-ocrv6
65
- """
66
-
67
- import argparse
68
- import io
69
- import json
70
- import logging
71
- import os
72
- import sys
73
- import time
74
- from dataclasses import dataclass
75
- from datetime import datetime, timezone
76
- from pathlib import Path
77
- from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
78
-
79
- import numpy as np
80
- from PIL import Image, UnidentifiedImageError
81
- from tqdm.auto import tqdm
82
-
83
- logging.basicConfig(level=logging.INFO)
84
- logger = logging.getLogger(__name__)
85
-
86
-
87
- # ---------------------------------------------------------------------------
88
- # Constants
89
- # ---------------------------------------------------------------------------
90
-
91
- TIER_MODELS = {
92
- "tiny": ("PP-OCRv6_tiny_det", "PP-OCRv6_tiny_rec"),
93
- "small": ("PP-OCRv6_small_det", "PP-OCRv6_small_rec"),
94
- "medium": ("PP-OCRv6_medium_det", "PP-OCRv6_medium_rec"),
95
- }
96
-
97
- TIER_PARAMS = {
98
- "tiny": "1.5M (0.4M det + 1.1M rec)",
99
- "small": "7.7M (2.5M det + 5.3M rec)",
100
- "medium": "34.5M (22M det + 19M rec)",
101
- }
102
-
103
- TIER_LANGUAGES = {
104
- "tiny": "49 languages (zh, zh-Hant, en + 46 Latin-script — no Japanese)",
105
- "small": "50 languages (zh, zh-Hant, en, ja + 46 Latin-script)",
106
- "medium": "50 languages (zh, zh-Hant, en, ja + 46 Latin-script)",
107
- }
108
-
109
- TIER_REC = {
110
- "tiny": 73.5,
111
- "small": 81.3,
112
- "medium": 83.2,
113
- }
114
-
115
- BUCKET_PREFIX = "hf://buckets/"
116
-
117
- IMAGE_EXTENSIONS = {
118
- ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp", ".jp2", ".j2k",
119
- }
120
-
121
-
122
- # ---------------------------------------------------------------------------
123
- # URL helpers
124
- # ---------------------------------------------------------------------------
125
-
126
- def is_bucket_url(s: str) -> bool:
127
- return s.startswith(BUCKET_PREFIX)
128
-
129
-
130
- def parse_bucket_url(url: str) -> Tuple[str, str]:
131
- if not is_bucket_url(url):
132
- raise ValueError(f"Not a bucket URL: {url}")
133
- rest = url[len(BUCKET_PREFIX):].strip("/")
134
- parts = rest.split("/", 2)
135
- if len(parts) < 2:
136
- raise ValueError(f"Bucket URL must include namespace and bucket name: {url}")
137
- bucket_id = f"{parts[0]}/{parts[1]}"
138
- prefix = parts[2] if len(parts) > 2 else ""
139
- return bucket_id, prefix
140
-
141
-
142
- # ---------------------------------------------------------------------------
143
- # Image helpers
144
- # ---------------------------------------------------------------------------
145
-
146
- def to_pil(image: Union[Image.Image, Dict[str, Any], str, bytes]) -> Image.Image:
147
- if isinstance(image, Image.Image):
148
- return image.convert("RGB")
149
- if isinstance(image, dict) and "bytes" in image:
150
- return Image.open(io.BytesIO(image["bytes"])).convert("RGB")
151
- if isinstance(image, (bytes, bytearray)):
152
- return Image.open(io.BytesIO(image)).convert("RGB")
153
- if isinstance(image, str):
154
- return Image.open(image).convert("RGB")
155
- raise ValueError(f"Unsupported image type: {type(image)}")
156
-
157
-
158
- def pil_to_array(pil_img: Image.Image) -> np.ndarray:
159
- return np.asarray(pil_img, dtype=np.uint8)
160
-
161
-
162
- # ---------------------------------------------------------------------------
163
- # Result extraction
164
- # ---------------------------------------------------------------------------
165
-
166
- def extract_text(result: Any) -> Tuple[str, List[Dict[str, Any]]]:
167
- """Pull text and per-line details from a PaddleOCR predict result.
168
-
169
- Returns (concatenated_text, per_line_details) where per_line_details is
170
- a list of dicts with keys: text, score, bbox (4-point detection polygon as
171
- [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] in input-image pixel coordinates).
172
- """
173
- payload = result.json if hasattr(result, "json") else result
174
- res = payload.get("res", payload) if isinstance(payload, dict) else {}
175
- rec_texts = res.get("rec_texts", []) or []
176
- rec_scores = res.get("rec_scores", []) or []
177
- dt_polys = res.get("dt_polys", []) or []
178
-
179
- # Concatenate reading-order text lines (PaddleOCR returns them in order)
180
- text = "\n".join(rec_texts)
181
-
182
- per_line = []
183
- for i, t in enumerate(rec_texts):
184
- entry = {"text": t}
185
- if i < len(rec_scores):
186
- entry["score"] = float(rec_scores[i])
187
- if i < len(dt_polys):
188
- entry["bbox"] = [[float(c) for c in point] for point in dt_polys[i]]
189
- per_line.append(entry)
190
-
191
- return text, per_line
192
-
193
-
194
- # ---------------------------------------------------------------------------
195
- # Sources
196
- # ---------------------------------------------------------------------------
197
-
198
- @dataclass
199
- class SourceItem:
200
- key: str
201
- image: Optional[Image.Image]
202
- extras: Dict[str, Any]
203
-
204
-
205
- def iter_dataset_images(
206
- dataset_id: str,
207
- image_column: str,
208
- split: str,
209
- shuffle: bool,
210
- seed: int,
211
- max_samples: Optional[int],
212
- ):
213
- from datasets import load_dataset
214
-
215
- logger.info(f"Loading dataset: {dataset_id} (split={split})")
216
- ds = load_dataset(dataset_id, split=split)
217
-
218
- if image_column not in ds.column_names:
219
- raise ValueError(
220
- f"Column '{image_column}' not found. Available: {ds.column_names}"
221
- )
222
-
223
- if shuffle:
224
- logger.info(f"Shuffling with seed {seed}")
225
- ds = ds.shuffle(seed=seed)
226
- if max_samples:
227
- ds = ds.select(range(min(max_samples, len(ds))))
228
- logger.info(f"Limited to {len(ds)} samples")
229
-
230
- total = len(ds)
231
-
232
- def gen() -> Iterator[SourceItem]:
233
- failed = 0
234
- for i in range(total):
235
- try:
236
- row = ds[i]
237
- image = to_pil(row[image_column])
238
- except (UnidentifiedImageError, OSError) as e:
239
- # Still yield a placeholder so the output row stays aligned with
240
- # the source row (the dataset sink writes results positionally).
241
- failed += 1
242
- logger.warning(
243
- f"Unreadable image at row {i}: {type(e).__name__}: {e} "
244
- f"— writing empty result"
245
- )
246
- yield SourceItem(key=f"row-{i:08d}", image=None, extras={"failed": True})
247
- continue
248
- yield SourceItem(key=f"row-{i:08d}", image=image, extras={})
249
- if failed:
250
- logger.info(f"{failed} unreadable image(s) written as empty results")
251
-
252
- return gen(), total, ds
253
-
254
-
255
- SOURCE_PATHS_SNAPSHOT = "_source_paths.json"
256
-
257
-
258
- def _bucket_snapshot_path(output_url: str) -> Tuple[str, str]:
259
- out_bucket_id, out_prefix = parse_bucket_url(output_url)
260
- snapshot_key = (
261
- f"{out_prefix}/{SOURCE_PATHS_SNAPSHOT}".lstrip("/")
262
- if out_prefix
263
- else SOURCE_PATHS_SNAPSHOT
264
- )
265
- return out_bucket_id, snapshot_key
266
-
267
-
268
- def iter_bucket_images(
269
- bucket_url: str,
270
- shuffle: bool,
271
- seed: int,
272
- max_samples: Optional[int],
273
- hf_token: Optional[str],
274
- output_url: Optional[str] = None,
275
- ) -> Tuple[Iterator[SourceItem], int]:
276
- from huggingface_hub import HfApi, HfFileSystem
277
-
278
- bucket_id, prefix = parse_bucket_url(bucket_url)
279
- fs = HfFileSystem(token=hf_token)
280
- base = f"{BUCKET_PREFIX}{bucket_id}/{prefix}".rstrip("/")
281
-
282
- snapshot_bucket_id: Optional[str] = None
283
- snapshot_key: Optional[str] = None
284
- cached_paths: Optional[List[str]] = None
285
-
286
- if output_url and is_bucket_url(output_url):
287
- snapshot_bucket_id, snapshot_key = _bucket_snapshot_path(output_url)
288
- snapshot_url = f"{BUCKET_PREFIX}{snapshot_bucket_id}/{snapshot_key}"
289
- try:
290
- with fs.open(snapshot_url, "rb") as f:
291
- snapshot = json.load(f)
292
- mismatches = []
293
- if snapshot.get("source_url") != bucket_url:
294
- mismatches.append(
295
- f"source_url ({snapshot.get('source_url')!r} vs {bucket_url!r})"
296
- )
297
- if snapshot.get("shuffle") != shuffle:
298
- mismatches.append(f"shuffle ({snapshot.get('shuffle')} vs {shuffle})")
299
- if shuffle and snapshot.get("seed") != seed:
300
- mismatches.append(f"seed ({snapshot.get('seed')} vs {seed})")
301
- if snapshot.get("max_samples") != max_samples:
302
- mismatches.append(
303
- f"max_samples ({snapshot.get('max_samples')} vs {max_samples})"
304
- )
305
- if mismatches:
306
- logger.warning(
307
- "Existing snapshot params differ from this run ("
308
- + "; ".join(mismatches)
309
- + "); ignoring snapshot and re-listing."
310
- )
311
- else:
312
- cached_paths = snapshot["paths"]
313
- logger.info(
314
- f"Reusing existing snapshot of {len(cached_paths)} source paths "
315
- f"(written {snapshot.get('created_at', 'unknown')})"
316
- )
317
- except FileNotFoundError:
318
- pass
319
- except Exception as e:
320
- logger.warning(f"Could not read existing snapshot ({e}); re-listing.")
321
-
322
- if cached_paths is not None:
323
- all_paths = cached_paths
324
- else:
325
- logger.info(f"Listing images under {base}")
326
- all_paths = []
327
- try:
328
- for entry in fs.find(base, detail=False):
329
- ext = Path(entry).suffix.lower()
330
- if ext in IMAGE_EXTENSIONS:
331
- all_paths.append(entry)
332
- except FileNotFoundError as e:
333
- raise ValueError(f"Bucket prefix not found: {base}") from e
334
-
335
- if not all_paths:
336
- raise ValueError(
337
- f"No image files (any of {sorted(IMAGE_EXTENSIONS)}) under {base}"
338
- )
339
-
340
- all_paths.sort()
341
- if shuffle:
342
- rng = np.random.default_rng(seed)
343
- rng.shuffle(all_paths)
344
- if max_samples:
345
- all_paths = all_paths[:max_samples]
346
-
347
- if snapshot_bucket_id is not None and snapshot_key is not None:
348
- api = HfApi(token=hf_token)
349
- payload = {
350
- "source_url": bucket_url,
351
- "shuffle": shuffle,
352
- "seed": seed,
353
- "max_samples": max_samples,
354
- "created_at": datetime.now(timezone.utc).isoformat(),
355
- "paths": all_paths,
356
- }
357
- api.batch_bucket_files(
358
- snapshot_bucket_id,
359
- add=[(json.dumps(payload).encode(), snapshot_key)],
360
- token=hf_token,
361
- )
362
- logger.info(
363
- f"Wrote source-path snapshot ({len(all_paths)} paths) to "
364
- f"hf://buckets/{snapshot_bucket_id}/{snapshot_key}"
365
- )
366
-
367
- total = len(all_paths)
368
- logger.info(f"Found {total} images in bucket")
369
-
370
- def key_for(path: str) -> str:
371
- return path
372
-
373
- def gen() -> Iterator[SourceItem]:
374
- skipped = 0
375
- for path in all_paths:
376
- try:
377
- with fs.open(path, "rb") as f:
378
- data = f.read()
379
- image = to_pil(data)
380
- except (UnidentifiedImageError, OSError) as e:
381
- skipped += 1
382
- logger.warning(
383
- f"Skipping unreadable image {path}: {type(e).__name__}: {e}"
384
- )
385
- continue
386
- yield SourceItem(key=key_for(path), image=image, extras={})
387
- if skipped:
388
- logger.info(f"Skipped {skipped} unreadable image(s) total")
389
-
390
- return gen(), total
391
-
392
-
393
- # ---------------------------------------------------------------------------
394
- # Sinks
395
- # ---------------------------------------------------------------------------
396
-
397
- class DatasetRepoSink:
398
- def __init__(
399
- self,
400
- repo_id: str,
401
- *,
402
- hf_token: Optional[str],
403
- private: bool,
404
- config: Optional[str],
405
- create_pr: bool,
406
- source_id: str,
407
- original_dataset=None,
408
- output_column: str = "markdown",
409
- overwrite: bool = False,
410
- ):
411
- self.repo_id = repo_id
412
- self.hf_token = hf_token
413
- self.private = private
414
- self.config = config
415
- self.create_pr = create_pr
416
- self.source_id = source_id
417
- self.original_dataset = original_dataset
418
- self.output_column = output_column
419
- self.overwrite = overwrite
420
- self._texts: List[str] = []
421
- self._blocks: List[str] = []
422
-
423
- @property
424
- def kind(self) -> str:
425
- return "dataset"
426
-
427
- def already_done(self) -> set:
428
- return set()
429
-
430
- def write(self, key: str, text: str, blocks: List[Dict[str, Any]]) -> None:
431
- self._texts.append(text)
432
- self._blocks.append(json.dumps(blocks, ensure_ascii=False))
433
-
434
- def finalize(self, tier: str, det_model: str, rec_model: str, args_dict: Dict[str, Any]) -> None:
435
- from datasets import Dataset
436
-
437
- if self.original_dataset is not None:
438
- if len(self._texts) != len(self.original_dataset):
439
- logger.warning(
440
- f"Text count ({len(self._texts)}) != dataset rows "
441
- f"({len(self.original_dataset)}); padding with empty strings."
442
- )
443
- while len(self._texts) < len(self.original_dataset):
444
- self._texts.append("")
445
- self._blocks.append("[]")
446
- # Guard again at save time in case the input column set changed under us.
447
- base = self.original_dataset
448
- clash = [c for c in (self.output_column, "pp_ocr_blocks") if c in base.column_names]
449
- if clash:
450
- if not self.overwrite:
451
- raise ValueError(
452
- f"Output column(s) {clash} already exist in the input dataset; "
453
- f"pass a different --output-column, or --overwrite to replace them."
454
- )
455
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
456
- base = base.remove_columns(clash)
457
- ds = base.add_column(self.output_column, self._texts)
458
- ds = ds.add_column("pp_ocr_blocks", self._blocks)
459
- else:
460
- if not self._texts:
461
- logger.warning("No rows produced; nothing to push.")
462
- return
463
- ds = Dataset.from_list([
464
- {"source_path": None, self.output_column: t, "pp_ocr_blocks": b}
465
- for t, b in zip(self._texts, self._blocks)
466
- ])
467
-
468
- inference_entry = build_inference_entry(tier, det_model, rec_model, args_dict)
469
-
470
- if "inference_info" in ds.column_names:
471
- logger.info("Updating existing inference_info column")
472
-
473
- def _update(example):
474
- try:
475
- existing = (
476
- json.loads(example["inference_info"])
477
- if example["inference_info"]
478
- else []
479
- )
480
- except (json.JSONDecodeError, TypeError):
481
- existing = []
482
- existing.append(inference_entry)
483
- return {"inference_info": json.dumps(existing)}
484
-
485
- ds = ds.map(_update)
486
- else:
487
- ds = ds.add_column(
488
- "inference_info", [json.dumps([inference_entry])] * len(ds)
489
- )
490
-
491
- logger.info(f"Pushing {len(ds)} rows to {self.repo_id}")
492
- push_kwargs = {
493
- "private": self.private,
494
- "token": self.hf_token,
495
- "max_shard_size": "500MB",
496
- "create_pr": self.create_pr,
497
- "commit_message": f"Add PP-OCRv6-{tier} OCR results ({len(ds)} samples)"
498
- + (f" [{self.config}]" if self.config else ""),
499
- }
500
- if self.config:
501
- push_kwargs["config_name"] = self.config
502
-
503
- max_retries = 3
504
- for attempt in range(1, max_retries + 1):
505
- try:
506
- if attempt > 1:
507
- logger.warning("Disabling XET (fallback to HTTP upload)")
508
- os.environ["HF_HUB_DISABLE_XET"] = "1"
509
- ds.push_to_hub(self.repo_id, **push_kwargs)
510
- break
511
- except Exception as e:
512
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
513
- if attempt == max_retries:
514
- logger.error("All upload attempts failed.")
515
- raise
516
- time.sleep(30 * (2 ** (attempt - 1)))
517
-
518
- from huggingface_hub import DatasetCard
519
-
520
- card = DatasetCard(
521
- create_dataset_card(
522
- source=self.source_id,
523
- tier=tier,
524
- det_model=det_model,
525
- rec_model=rec_model,
526
- num_samples=len(ds),
527
- processing_time=args_dict["processing_time"],
528
- engine=args_dict.get("engine", "paddle_static"),
529
- output_id=self.repo_id,
530
- output_column=self.output_column,
531
- )
532
- )
533
- card.push_to_hub(self.repo_id, token=self.hf_token)
534
- logger.info(f"Done: https://huggingface.co/datasets/{self.repo_id}")
535
-
536
-
537
- class BucketShardSink:
538
- METADATA_FILE = "_metadata.json"
539
- SHARD_PATTERN = "shard-{:05d}.parquet"
540
-
541
- def __init__(
542
- self,
543
- bucket_url: str,
544
- *,
545
- hf_token: Optional[str],
546
- shard_size: int,
547
- resume: bool,
548
- source_id: str,
549
- ):
550
- from huggingface_hub import HfApi, HfFileSystem, create_bucket
551
-
552
- self.bucket_url = bucket_url
553
- self.bucket_id, self.prefix = parse_bucket_url(bucket_url)
554
- self.hf_token = hf_token
555
- self.shard_size = shard_size
556
- self.resume = resume
557
- self.source_id = source_id
558
-
559
- self._api = HfApi(token=hf_token)
560
- self._fs = HfFileSystem(token=hf_token)
561
-
562
- try:
563
- create_bucket(self.bucket_id, exist_ok=True, token=hf_token)
564
- except Exception as e:
565
- logger.warning(f"create_bucket('{self.bucket_id}') warning: {e}")
566
-
567
- self._buffer: List[Dict[str, Any]] = []
568
- self._next_shard_idx = self._discover_next_shard_idx()
569
- self._completed_keys = self._discover_completed_keys() if resume else set()
570
- if self._completed_keys:
571
- logger.info(
572
- f"Resume: found {len(self._completed_keys)} already-processed keys, will skip them"
573
- )
574
-
575
- @property
576
- def kind(self) -> str:
577
- return "bucket"
578
-
579
- def already_done(self) -> set:
580
- return self._completed_keys
581
-
582
- def _shard_path(self, idx: int) -> str:
583
- return self._join(self.SHARD_PATTERN.format(idx))
584
-
585
- def _join(self, name: str) -> str:
586
- return f"{self.prefix}/{name}".lstrip("/") if self.prefix else name
587
-
588
- def _list_existing_shards(self) -> List[str]:
589
- try:
590
- tree = self._api.list_bucket_tree(
591
- self.bucket_id, prefix=self.prefix or None, recursive=True
592
- )
593
- except Exception:
594
- return []
595
- shards: List[str] = []
596
- for item in tree:
597
- path = getattr(item, "path", None)
598
- ftype = getattr(item, "type", None)
599
- if not path or ftype not in (None, "file"):
600
- continue
601
- base = Path(path).name
602
- if base.startswith("shard-") and base.endswith(".parquet"):
603
- shards.append(path)
604
- return sorted(shards)
605
-
606
- def _discover_next_shard_idx(self) -> int:
607
- shards = self._list_existing_shards()
608
- max_idx = -1
609
- for s in shards:
610
- stem = Path(s).stem
611
- try:
612
- max_idx = max(max_idx, int(stem.split("-")[-1]))
613
- except ValueError:
614
- continue
615
- return max_idx + 1
616
-
617
- def _discover_completed_keys(self) -> set:
618
- import pyarrow.parquet as pq
619
-
620
- keys: set = set()
621
- for shard_path in self._list_existing_shards():
622
- full = f"{BUCKET_PREFIX}{self.bucket_id}/{shard_path}"
623
- try:
624
- with self._fs.open(full, "rb") as f:
625
- table = pq.read_table(f, columns=["__source_key"])
626
- keys.update(table.column("__source_key").to_pylist())
627
- except Exception as e:
628
- logger.warning(f"Could not read keys from {shard_path}: {e}")
629
- return keys
630
-
631
- def _flush(self) -> None:
632
- if not self._buffer:
633
- return
634
- import pyarrow as pa
635
- import pyarrow.parquet as pq
636
-
637
- columns = ["__source_key", "text", "pp_ocr_blocks"]
638
- table_dict = {c: [row.get(c) for row in self._buffer] for c in columns}
639
- table = pa.Table.from_pydict(table_dict)
640
-
641
- buf = io.BytesIO()
642
- pq.write_table(table, buf, compression="zstd")
643
- data = buf.getvalue()
644
-
645
- shard_remote = self._shard_path(self._next_shard_idx)
646
- logger.info(
647
- f"Writing shard {self._next_shard_idx} ({len(self._buffer)} rows, "
648
- f"{len(data) / 1024 / 1024:.1f} MiB) to {shard_remote}"
649
- )
650
- self._api.batch_bucket_files(
651
- self.bucket_id, add=[(data, shard_remote)], token=self.hf_token
652
- )
653
- self._next_shard_idx += 1
654
- self._buffer.clear()
655
-
656
- def write(self, key: str, text: str, blocks: List[Dict[str, Any]]) -> None:
657
- row: Dict[str, Any] = {
658
- "__source_key": key,
659
- "text": text,
660
- "pp_ocr_blocks": json.dumps(blocks, ensure_ascii=False),
661
- }
662
- self._buffer.append(row)
663
- if len(self._buffer) >= self.shard_size:
664
- self._flush()
665
-
666
- def finalize(self, tier: str, det_model: str, rec_model: str, args_dict: Dict[str, Any]) -> None:
667
- self._flush()
668
- meta = {
669
- "model": f"PP-OCRv6_{tier}",
670
- "det_model": det_model,
671
- "rec_model": rec_model,
672
- "tier": tier,
673
- "engine": "paddle_static",
674
- "source": self.source_id,
675
- "shard_size": args_dict["shard_size"],
676
- "last_run_at": datetime.now(timezone.utc).isoformat(),
677
- "processing_time": args_dict.get("processing_time"),
678
- }
679
- meta_bytes = json.dumps(meta, indent=2).encode("utf-8")
680
- meta_path = self._join(self.METADATA_FILE)
681
- self._api.batch_bucket_files(
682
- self.bucket_id, add=[(meta_bytes, meta_path)], token=self.hf_token
683
- )
684
- logger.info(
685
- f"Done: https://huggingface.co/buckets/{self.bucket_id}"
686
- + (f"/{self.prefix}" if self.prefix else "")
687
- )
688
-
689
-
690
- # ---------------------------------------------------------------------------
691
- # inference_info + dataset card
692
- # ---------------------------------------------------------------------------
693
-
694
- def build_inference_entry(tier: str, det_model: str, rec_model: str, args_dict: Dict[str, Any]) -> Dict[str, Any]:
695
- return {
696
- "model_id": f"PaddlePaddle/PP-OCRv6_{tier}",
697
- "det_model": det_model,
698
- "rec_model": rec_model,
699
- "tier": tier,
700
- "params": TIER_PARAMS.get(tier, "unknown"),
701
- "rec_accuracy_pct": TIER_REC.get(tier),
702
- "languages": TIER_LANGUAGES.get(tier, ""),
703
- "engine": "paddle_static",
704
- # column_name is the key ocr-bench's column discovery reads; keep
705
- # output_column too for backward compat with existing outputs.
706
- "column_name": args_dict.get("output_column", "markdown"),
707
- "output_column": args_dict.get("output_column", "markdown"),
708
- "blocks_column": "pp_ocr_blocks",
709
- "timestamp": datetime.now(timezone.utc).isoformat(),
710
- }
711
-
712
-
713
- def create_dataset_card(
714
- source: str,
715
- tier: str,
716
- det_model: str,
717
- rec_model: str,
718
- num_samples: int,
719
- processing_time: str,
720
- engine: str,
721
- output_id: str,
722
- output_column: str = "markdown",
723
- ) -> str:
724
- tier_display = tier.upper() if tier == "tiny" else tier.capitalize()
725
- if is_bucket_url(source):
726
- source_link = f"[{source}]({source})"
727
- else:
728
- source_link = f"[{source}](https://huggingface.co/datasets/{source})"
729
-
730
- return f"""---
731
- tags:
732
- - ocr
733
- - text-recognition
734
- - paddleocr
735
- - pp-ocrv6
736
- - uv-script
737
- - generated
738
- ---
739
-
740
- # OCR with PP-OCRv6 {tier_display}
741
-
742
- Plain-text OCR results for images from {source_link}, produced by
743
- PaddlePaddle's [PP-OCRv6](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6)
744
- {tier} pipeline ({TIER_PARAMS.get(tier, "unknown")}).
745
-
746
- ## Processing details
747
-
748
- - **Source**: {source_link}
749
- - **Model**: PP-OCRv6_{tier} ({det_model} + {rec_model})
750
- - **Tier**: {tier} ({TIER_PARAMS.get(tier, "unknown")})
751
- - **Recognition accuracy**: {TIER_REC.get(tier, "?"):.1f}%
752
- - **Languages**: {TIER_LANGUAGES.get(tier, "")}
753
- - **Engine**: {engine}
754
- - **Samples**: {num_samples:,}
755
- - **Processing time**: {processing_time}
756
- - **Processing date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
757
- - **License**: Apache 2.0 (models)
758
-
759
- ## Schema
760
-
761
- Each row contains the original columns plus:
762
-
763
- - `{output_column}`: Plain text extracted from the image (reading-order concatenation of
764
- detected text lines, newline-separated).
765
- - `pp_ocr_blocks`: JSON list, one dict per detected text line:
766
- ```json
767
- [
768
- {{
769
- "text": "recognized text",
770
- "score": 0.987,
771
- "bbox": [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]
772
- }}
773
- ]
774
- ```
775
- `score` is the recognition confidence and `bbox` is the detection polygon
776
- (4-point quadrilateral in input-image pixel coordinates).
777
- - `inference_info`: JSON list tracking every model applied to this dataset.
778
-
779
- > **Note:** PP-OCRv6 is a classical detection+recognition pipeline, not a VLM.
780
- > It outputs **plain text** rather than markdown. Per-line bounding boxes and
781
- > confidence scores are available in `pp_ocr_blocks`.
782
-
783
- ## Usage
784
-
785
- ```python
786
- import json
787
- from datasets import load_dataset
788
-
789
- ds = load_dataset("{output_id}", split="train")
790
- print(ds[0]["{output_column}"])
791
- for block in json.loads(ds[0]["pp_ocr_blocks"]):
792
- print(block["text"], block["score"])
793
- ```
794
-
795
- ## Reproduction
796
-
797
- ```bash
798
- hf jobs uv run --flavor t4-small -s HF_TOKEN \\
799
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-ocrv6.py \\
800
- {source} <output> --model-tier {tier}
801
- ```
802
-
803
- Generated with [UV Scripts](https://huggingface.co/uv-scripts).
804
- """
805
-
806
-
807
- # ---------------------------------------------------------------------------
808
- # Main
809
- # ---------------------------------------------------------------------------
810
-
811
- def main(args: argparse.Namespace) -> None:
812
- from huggingface_hub import login
813
-
814
- start_time = datetime.now()
815
- hf_token = args.hf_token or os.environ.get("HF_TOKEN")
816
- if hf_token:
817
- login(token=hf_token)
818
-
819
- # ---------- tier → model names ----------
820
- if args.model_tier not in TIER_MODELS:
821
- raise ValueError(
822
- f"Invalid tier {args.model_tier!r}. Choose from: {list(TIER_MODELS)}"
823
- )
824
- det_model, rec_model = TIER_MODELS[args.model_tier]
825
- tier = args.model_tier
826
- logger.info(f"PP-OCRv6 {tier}: {det_model} + {rec_model}")
827
-
828
- # ---------- source ----------
829
- original_dataset = None
830
- if is_bucket_url(args.input_source):
831
- src_iter, total = iter_bucket_images(
832
- args.input_source,
833
- shuffle=args.shuffle,
834
- seed=args.seed,
835
- max_samples=args.max_samples,
836
- hf_token=hf_token,
837
- output_url=args.output_target,
838
- )
839
- else:
840
- src_iter, total, original_dataset = iter_dataset_images(
841
- args.input_source,
842
- image_column=args.image_column,
843
- split=args.split,
844
- shuffle=args.shuffle,
845
- seed=args.seed,
846
- max_samples=args.max_samples,
847
- )
848
- # Fail fast, before minutes of inference, if the output column would collide
849
- # with an existing input column (e.g. a 'text' ground-truth column). Writing
850
- # into it would either crash on push or silently overwrite the input data.
851
- # --overwrite opts in to replacing the existing column(s) instead of erroring.
852
- if original_dataset is not None:
853
- clash = [
854
- col
855
- for col in (args.output_column, "pp_ocr_blocks")
856
- if col in original_dataset.column_names
857
- ]
858
- if clash and not args.overwrite:
859
- logger.error(
860
- f"Output column(s) {clash} already exist in the input dataset "
861
- f"(columns: {original_dataset.column_names})."
862
- )
863
- logger.error(
864
- "Choose a different --output-column, or pass --overwrite to replace them."
865
- )
866
- sys.exit(1)
867
- if clash:
868
- logger.warning(f"--overwrite: will replace existing column(s) {clash}")
869
-
870
- # ---------- sink ----------
871
- if is_bucket_url(args.output_target):
872
- sink: Union[BucketShardSink, DatasetRepoSink] = BucketShardSink(
873
- args.output_target,
874
- hf_token=hf_token,
875
- shard_size=args.shard_size,
876
- resume=not args.no_resume,
877
- source_id=args.input_source,
878
- )
879
- else:
880
- sink = DatasetRepoSink(
881
- args.output_target,
882
- hf_token=hf_token,
883
- private=args.private,
884
- config=args.config,
885
- create_pr=args.create_pr,
886
- source_id=args.input_source,
887
- original_dataset=original_dataset,
888
- output_column=args.output_column,
889
- overwrite=args.overwrite,
890
- )
891
-
892
- completed = sink.already_done()
893
-
894
- # ---------- model ----------
895
- # PaddleX gates `import cv2` at module load time on
896
- # `is_dep_available("opencv-contrib-python")`, which checks
897
- # `importlib.metadata.version(...)`. We ship `opencv-contrib-python-headless`
898
- # (same `cv2`, no system libGL.so.1 needed) — but that's a different
899
- # distribution name, so the gate fails and the OCR pipeline's `ocr` extra
900
- # check returns False. Patch the metadata lookup to alias the GUI cv2 distros
901
- # to the headless variant before importing paddleocr; this lets paddlex's own
902
- # `import cv2` succeed and `is_extra_available('ocr')` return True.
903
- import importlib.metadata as _metadata
904
-
905
- _orig_metadata_version = _metadata.version
906
-
907
- def _patched_metadata_version(dep_name):
908
- if dep_name in ("opencv-contrib-python", "opencv-python"):
909
- for headless_alias in (
910
- "opencv-contrib-python-headless",
911
- "opencv-python-headless",
912
- ):
913
- try:
914
- return _orig_metadata_version(headless_alias)
915
- except _metadata.PackageNotFoundError:
916
- continue
917
- return _orig_metadata_version(dep_name)
918
-
919
- _metadata.version = _patched_metadata_version
920
-
921
- # Silence the connectivity check for speed (not needed in a Job)
922
- os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
923
-
924
- from paddleocr import PaddleOCR
925
-
926
- ocr = PaddleOCR(
927
- text_detection_model_name=det_model,
928
- text_recognition_model_name=rec_model,
929
- use_doc_orientation_classify=False,
930
- use_doc_unwarping=False,
931
- use_textline_orientation=False,
932
- )
933
-
934
- # ---------- loop ----------
935
- processed = 0
936
- skipped = 0
937
- errors = 0
938
- pbar = tqdm(src_iter, total=total, desc=f"PP-OCRv6 {tier}")
939
- for item in pbar:
940
- if item.key in completed:
941
- skipped += 1
942
- continue
943
- if item.extras.get("failed") or item.image is None:
944
- # Unreadable source image — write an empty result in position so the
945
- # output stays row-aligned with the source dataset.
946
- sink.write(item.key, "", [])
947
- errors += 1
948
- processed += 1
949
- continue
950
- try:
951
- arr = pil_to_array(item.image)
952
- result = ocr.predict(arr)
953
- if result:
954
- text, blocks = extract_text(result[0])
955
- else:
956
- text, blocks = "", []
957
- except Exception as e:
958
- logger.error(f"Error on {item.key}: {e}")
959
- text, blocks = "", []
960
- errors += 1
961
-
962
- sink.write(item.key, text, blocks)
963
- processed += 1
964
-
965
- duration = datetime.now() - start_time
966
- processing_time_str = f"{duration.total_seconds() / 60:.2f} min"
967
- logger.info(
968
- f"Processed {processed} (skipped {skipped}, errors {errors}) in {processing_time_str}"
969
- )
970
-
971
- args_dict = {
972
- "tier": tier,
973
- "det_model": det_model,
974
- "rec_model": rec_model,
975
- "engine": "paddle_static",
976
- "shard_size": args.shard_size,
977
- "processing_time": processing_time_str,
978
- "output_column": args.output_column,
979
- }
980
- sink.finalize(
981
- tier=tier,
982
- det_model=det_model,
983
- rec_model=rec_model,
984
- args_dict=args_dict,
985
- )
986
-
987
- if args.verbose:
988
- import importlib.metadata
989
-
990
- logger.info("--- Resolved package versions ---")
991
- for pkg in [
992
- "paddleocr",
993
- "paddlex",
994
- "paddlepaddle-gpu",
995
- "huggingface-hub",
996
- "datasets",
997
- "pillow",
998
- "numpy",
999
- ]:
1000
- try:
1001
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
1002
- except importlib.metadata.PackageNotFoundError:
1003
- logger.info(f" {pkg}: not installed")
1004
- logger.info("--- End versions ---")
1005
-
1006
-
1007
- # ---------------------------------------------------------------------------
1008
- # CLI
1009
- # ---------------------------------------------------------------------------
1010
-
1011
- def build_parser() -> argparse.ArgumentParser:
1012
- p = argparse.ArgumentParser(
1013
- description="PP-OCRv6 OCR over an HF dataset or bucket of images.",
1014
- formatter_class=argparse.RawDescriptionHelpFormatter,
1015
- )
1016
- p.add_argument(
1017
- "input_source",
1018
- help="HF dataset id (namespace/dataset) OR hf://buckets/ns/bucket[/prefix]",
1019
- )
1020
- p.add_argument(
1021
- "output_target",
1022
- help="HF dataset id (namespace/dataset) OR hf://buckets/ns/bucket/run-name",
1023
- )
1024
- p.add_argument(
1025
- "--model-tier",
1026
- default="medium",
1027
- choices=list(TIER_MODELS),
1028
- help="PP-OCRv6 model tier: tiny (1.5M), small (7.7M), medium (34.5M). Default: medium.",
1029
- )
1030
- # Dataset-source-specific
1031
- p.add_argument(
1032
- "--image-column",
1033
- default="image",
1034
- help="Column containing images (dataset-repo source only, default: image)",
1035
- )
1036
- p.add_argument(
1037
- "--split",
1038
- default="train",
1039
- help="Dataset split (dataset-repo source only, default: train)",
1040
- )
1041
- p.add_argument(
1042
- "--max-samples", type=int, help="Limit number of samples (for testing)"
1043
- )
1044
- p.add_argument(
1045
- "--shuffle", action="store_true", help="Shuffle source before processing"
1046
- )
1047
- p.add_argument(
1048
- "--seed", type=int, default=42, help="Random seed for shuffle (default: 42)"
1049
- )
1050
- # Dataset-sink-specific
1051
- p.add_argument(
1052
- "--private", action="store_true", help="Private dataset output (dataset sink only)"
1053
- )
1054
- p.add_argument(
1055
- "--config",
1056
- help="Config/subset name when pushing to Hub (dataset sink only)",
1057
- )
1058
- p.add_argument(
1059
- "--create-pr",
1060
- action="store_true",
1061
- help="Create PR instead of direct push (dataset sink only)",
1062
- )
1063
- p.add_argument(
1064
- "--output-column",
1065
- default="markdown",
1066
- help=(
1067
- "Column name for the recognized text (dataset sink only, default: markdown). "
1068
- "Must not collide with an existing input column — many corpora already ship a "
1069
- "'text' ground-truth column, so 'text' would fail on push. Blocks always go to "
1070
- "'pp_ocr_blocks'."
1071
- ),
1072
- )
1073
- p.add_argument(
1074
- "--overwrite",
1075
- action="store_true",
1076
- help="Replace the output column(s) if they already exist in the input dataset "
1077
- "(default: error out to avoid clobbering an existing column).",
1078
- )
1079
- # Bucket-sink-specific
1080
- p.add_argument(
1081
- "--shard-size",
1082
- type=int,
1083
- default=256,
1084
- help="Rows per parquet shard for bucket sink (default: 256)",
1085
- )
1086
- p.add_argument(
1087
- "--no-resume",
1088
- action="store_true",
1089
- help="Disable resume scan when writing to a bucket sink",
1090
- )
1091
- # Auth + diagnostics
1092
- p.add_argument("--hf-token", help="Hugging Face API token (else uses HF_TOKEN env)")
1093
- p.add_argument(
1094
- "--verbose",
1095
- action="store_true",
1096
- help="Log resolved package versions at the end",
1097
- )
1098
- return p
1099
-
1100
-
1101
- if __name__ == "__main__":
1102
- main(build_parser().parse_args())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
qianfan-ocr.py DELETED
@@ -1,665 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "vllm>=0.15.1",
8
- # "tqdm",
9
- # "toolz",
10
- # "torch",
11
- # ]
12
- # ///
13
-
14
- """
15
- Convert document images to markdown using Qianfan-OCR with vLLM.
16
-
17
- Qianfan-OCR is a 4.7B end-to-end document intelligence model from Baidu,
18
- built on InternVL architecture with Qianfan-ViT encoder + Qwen3-4B LLM.
19
-
20
- Features:
21
- - #1 end-to-end model on OmniDocBench v1.5 (93.12) and OlmOCR Bench (79.8)
22
- - Layout-as-Thought: optional reasoning phase for complex layouts via --think
23
- - 192 language support (Latin, CJK, Arabic, Cyrillic, and more)
24
- - Multiple task modes: OCR, table (HTML), formula (LaTeX), chart, scene text
25
- - Key information extraction with custom prompts
26
- - 1.024 PPS on A100 with W8A8 quantization
27
-
28
- Model: baidu/Qianfan-OCR
29
- License: Apache 2.0
30
- Paper: https://arxiv.org/abs/2603.13398
31
- """
32
-
33
- import argparse
34
- import base64
35
- import io
36
- import json
37
- import logging
38
- import os
39
- import sys
40
- import time
41
- from datetime import datetime
42
- from typing import Any, Dict, List, Union
43
-
44
- import torch
45
- from datasets import load_dataset
46
- from huggingface_hub import DatasetCard, login
47
- from PIL import Image
48
- from toolz import partition_all
49
- from tqdm.auto import tqdm
50
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
51
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
52
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
53
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
54
- from vllm import LLM, SamplingParams
55
-
56
- logging.basicConfig(level=logging.INFO)
57
- logger = logging.getLogger(__name__)
58
-
59
- MODEL = "baidu/Qianfan-OCR"
60
-
61
- PROMPT_TEMPLATES = {
62
- "ocr": "Parse this document to Markdown.",
63
- "table": "Extract tables to HTML format.",
64
- "formula": "Extract formulas to LaTeX.",
65
- "chart": "What trends are shown in this chart?",
66
- "scene": "Extract all visible text from the image.",
67
- "kie": None, # requires --custom-prompt
68
- }
69
-
70
-
71
- def check_cuda_availability():
72
- """Check if CUDA is available and exit if not."""
73
- if not torch.cuda.is_available():
74
- logger.error("CUDA is not available. This script requires a GPU.")
75
- sys.exit(1)
76
- else:
77
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
78
-
79
-
80
- def ensure_output_columns_free(dataset, columns, overwrite=False):
81
- """Fail fast if an output column would collide with an existing input column.
82
-
83
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
84
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
85
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
86
- column(s) here instead (logged) so the later add_column is clean.
87
- """
88
- clash = [c for c in columns if c in dataset.column_names]
89
- if not clash:
90
- return dataset
91
- if overwrite:
92
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
93
- return dataset.remove_columns(clash)
94
- logger.error(
95
- f"Output column(s) {clash} already exist in the input dataset "
96
- f"(columns: {dataset.column_names})."
97
- )
98
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
99
- sys.exit(1)
100
-
101
-
102
- def extract_content_from_thinking(text: str, include_thinking: bool = False) -> str:
103
- """
104
- Extract final content from Qianfan-OCR's Layout-as-Thought output.
105
-
106
- When --think is enabled, the model generates layout analysis inside
107
- <think>...</think> tags before the final markdown output.
108
- """
109
- if include_thinking:
110
- return text.strip()
111
-
112
- # If no thinking tags, return as-is
113
- if "<think>" not in text:
114
- return text.strip()
115
-
116
- # Extract everything after </think>
117
- think_end = text.find("</think>")
118
- if think_end != -1:
119
- return text[think_end + 8 :].strip()
120
-
121
- # Thinking started but never closed — return full text
122
- logger.warning("Found <think> but no </think>, returning full text")
123
- return text.strip()
124
-
125
-
126
- def make_ocr_message(
127
- image: Union[Image.Image, Dict[str, Any], str],
128
- prompt: str,
129
- ) -> List[Dict]:
130
- """Create vLLM chat message with image and prompt."""
131
- if isinstance(image, Image.Image):
132
- pil_img = image
133
- elif isinstance(image, dict) and "bytes" in image:
134
- pil_img = Image.open(io.BytesIO(image["bytes"]))
135
- elif isinstance(image, str):
136
- pil_img = Image.open(image)
137
- else:
138
- raise ValueError(f"Unsupported image type: {type(image)}")
139
-
140
- pil_img = pil_img.convert("RGB")
141
-
142
- buf = io.BytesIO()
143
- pil_img.save(buf, format="PNG")
144
- data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
145
-
146
- return [
147
- {
148
- "role": "user",
149
- "content": [
150
- {"type": "image_url", "image_url": {"url": data_uri}},
151
- {"type": "text", "text": prompt},
152
- ],
153
- }
154
- ]
155
-
156
-
157
- def create_dataset_card(
158
- source_dataset: str,
159
- model: str,
160
- num_samples: int,
161
- processing_time: str,
162
- batch_size: int,
163
- max_model_len: int,
164
- max_tokens: int,
165
- gpu_memory_utilization: float,
166
- prompt_mode: str,
167
- think: bool,
168
- include_thinking: bool,
169
- image_column: str = "image",
170
- split: str = "train",
171
- ) -> str:
172
- """Create a dataset card documenting the OCR process."""
173
- model_name = model.split("/")[-1]
174
-
175
- return f"""---
176
- tags:
177
- - ocr
178
- - document-processing
179
- - qianfan-ocr
180
- - markdown
181
- - uv-script
182
- - generated
183
- ---
184
-
185
- # Document OCR using {model_name}
186
-
187
- This dataset contains OCR results from [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using Qianfan-OCR, Baidu's 4.7B end-to-end document intelligence model.
188
-
189
- ## Processing Details
190
-
191
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
192
- - **Model**: [{model}](https://huggingface.co/{model})
193
- - **Number of Samples**: {num_samples:,}
194
- - **Processing Time**: {processing_time}
195
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
196
-
197
- ### Configuration
198
-
199
- - **Image Column**: `{image_column}`
200
- - **Output Column**: `markdown`
201
- - **Dataset Split**: `{split}`
202
- - **Batch Size**: {batch_size}
203
- - **Prompt Mode**: {prompt_mode}
204
- - **Layout-as-Thought**: {"Enabled" if think else "Disabled"}
205
- - **Thinking Traces**: {"Included" if include_thinking else "Excluded"}
206
- - **Max Model Length**: {max_model_len:,} tokens
207
- - **Max Output Tokens**: {max_tokens:,}
208
- - **GPU Memory Utilization**: {gpu_memory_utilization:.1%}
209
-
210
- ## Model Information
211
-
212
- Qianfan-OCR key capabilities:
213
- - #1 end-to-end model on OmniDocBench v1.5 (93.12)
214
- - #1 on OlmOCR Bench (79.8)
215
- - 192 language support
216
- - Layout-as-Thought reasoning for complex documents
217
- - Document parsing, table extraction, formula recognition, chart understanding
218
- - Key information extraction
219
-
220
- ## Dataset Structure
221
-
222
- The dataset contains all original columns plus:
223
- - `markdown`: The extracted text in markdown format
224
- - `inference_info`: JSON list tracking all OCR models applied
225
-
226
- ## Reproduction
227
-
228
- ```bash
229
- uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/qianfan-ocr.py \\
230
- {source_dataset} \\
231
- <output-dataset> \\
232
- --image-column {image_column} \\
233
- --prompt-mode {prompt_mode} \\
234
- --batch-size {batch_size}{" --think" if think else ""}
235
- ```
236
-
237
- Generated with [UV Scripts](https://huggingface.co/uv-scripts)
238
- """
239
-
240
-
241
- def main(
242
- input_dataset: str,
243
- output_dataset: str,
244
- image_column: str = "image",
245
- batch_size: int = 8,
246
- max_model_len: int = 16384,
247
- max_tokens: int = 8192,
248
- temperature: float = 0.0,
249
- top_p: float = 1.0,
250
- gpu_memory_utilization: float = 0.85,
251
- hf_token: str = None,
252
- split: str = "train",
253
- max_samples: int = None,
254
- private: bool = False,
255
- shuffle: bool = False,
256
- seed: int = 42,
257
- prompt_mode: str = "ocr",
258
- think: bool = False,
259
- include_thinking: bool = False,
260
- custom_prompt: str = None,
261
- output_column: str = "markdown",
262
- overwrite: bool = False,
263
- config: str = None,
264
- create_pr: bool = False,
265
- verbose: bool = False,
266
- ):
267
- """Process images from HF dataset through Qianfan-OCR model."""
268
-
269
- check_cuda_availability()
270
- start_time = datetime.now()
271
-
272
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
273
- if HF_TOKEN:
274
- login(token=HF_TOKEN)
275
-
276
- # Build prompt
277
- if custom_prompt:
278
- prompt = custom_prompt
279
- logger.info(f"Using custom prompt: {prompt[:80]}...")
280
- else:
281
- if prompt_mode == "kie":
282
- logger.error("--prompt-mode kie requires --custom-prompt")
283
- sys.exit(1)
284
- prompt = PROMPT_TEMPLATES[prompt_mode]
285
- logger.info(f"Using prompt mode: {prompt_mode}")
286
-
287
- if think:
288
- prompt = prompt + "<think>"
289
- logger.info("Layout-as-Thought enabled (appending <think> to prompt)")
290
-
291
- logger.info(f"Using model: {MODEL}")
292
-
293
- # Load dataset
294
- logger.info(f"Loading dataset: {input_dataset}")
295
- dataset = load_dataset(input_dataset, split=split)
296
-
297
- if image_column not in dataset.column_names:
298
- raise ValueError(
299
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
300
- )
301
-
302
- # Fail fast if the output column would collide with an existing input column
303
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
304
-
305
- if shuffle:
306
- logger.info(f"Shuffling dataset with seed {seed}")
307
- dataset = dataset.shuffle(seed=seed)
308
-
309
- if max_samples:
310
- dataset = dataset.select(range(min(max_samples, len(dataset))))
311
- logger.info(f"Limited to {len(dataset)} samples")
312
-
313
- # Initialize vLLM
314
- logger.info("Initializing vLLM with Qianfan-OCR")
315
- logger.info("This may take a few minutes on first run...")
316
- llm = LLM(
317
- model=MODEL,
318
- trust_remote_code=True,
319
- max_model_len=max_model_len,
320
- gpu_memory_utilization=gpu_memory_utilization,
321
- limit_mm_per_prompt={"image": 1},
322
- enforce_eager=False,
323
- )
324
-
325
- sampling_params = SamplingParams(
326
- temperature=temperature,
327
- top_p=top_p,
328
- max_tokens=max_tokens,
329
- )
330
-
331
- logger.info(f"Processing {len(dataset)} images in batches of {batch_size}")
332
- logger.info(f"Output will be written to column: {output_column}")
333
-
334
- # Process images in batches
335
- all_outputs = []
336
-
337
- for batch_indices in tqdm(
338
- partition_all(batch_size, range(len(dataset))),
339
- total=(len(dataset) + batch_size - 1) // batch_size,
340
- desc="Qianfan-OCR processing",
341
- ):
342
- batch_indices = list(batch_indices)
343
- batch_images = [dataset[i][image_column] for i in batch_indices]
344
-
345
- try:
346
- batch_messages = [make_ocr_message(img, prompt) for img in batch_images]
347
- outputs = llm.chat(batch_messages, sampling_params)
348
-
349
- for output in outputs:
350
- text = output.outputs[0].text.strip()
351
- if think:
352
- text = extract_content_from_thinking(text, include_thinking)
353
- all_outputs.append(text)
354
-
355
- except Exception as e:
356
- logger.error(f"Error processing batch: {e}")
357
- all_outputs.extend(["[OCR ERROR]"] * len(batch_images))
358
-
359
- # Calculate processing time
360
- processing_duration = datetime.now() - start_time
361
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
362
-
363
- # Add output column
364
- logger.info(f"Adding '{output_column}' column to dataset")
365
- dataset = dataset.add_column(output_column, all_outputs)
366
-
367
- # Handle inference_info tracking
368
- inference_entry = {
369
- "model_id": MODEL,
370
- "model_name": "Qianfan-OCR",
371
- "column_name": output_column,
372
- "timestamp": datetime.now().isoformat(),
373
- "prompt_mode": prompt_mode if not custom_prompt else "custom",
374
- "think": think,
375
- "temperature": temperature,
376
- "max_tokens": max_tokens,
377
- }
378
-
379
- if "inference_info" in dataset.column_names:
380
- logger.info("Updating existing inference_info column")
381
-
382
- def update_inference_info(example):
383
- try:
384
- existing_info = (
385
- json.loads(example["inference_info"])
386
- if example["inference_info"]
387
- else []
388
- )
389
- except (json.JSONDecodeError, TypeError):
390
- existing_info = []
391
- existing_info.append(inference_entry)
392
- return {"inference_info": json.dumps(existing_info)}
393
-
394
- dataset = dataset.map(update_inference_info)
395
- else:
396
- logger.info("Creating new inference_info column")
397
- inference_list = [json.dumps([inference_entry])] * len(dataset)
398
- dataset = dataset.add_column("inference_info", inference_list)
399
-
400
- # Push to hub with retry and XET fallback
401
- logger.info(f"Pushing to {output_dataset}")
402
- commit_msg = f"Add Qianfan-OCR results ({len(dataset)} samples)" + (
403
- f" [{config}]" if config else ""
404
- )
405
- max_retries = 3
406
- for attempt in range(1, max_retries + 1):
407
- try:
408
- if attempt > 1:
409
- logger.warning("Disabling XET (fallback to HTTP upload)")
410
- os.environ["HF_HUB_DISABLE_XET"] = "1"
411
- dataset.push_to_hub(
412
- output_dataset,
413
- private=private,
414
- token=HF_TOKEN,
415
- max_shard_size="500MB",
416
- **({"config_name": config} if config else {}),
417
- create_pr=create_pr,
418
- commit_message=commit_msg,
419
- )
420
- break
421
- except Exception as e:
422
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
423
- if attempt < max_retries:
424
- delay = 30 * (2 ** (attempt - 1))
425
- logger.info(f"Retrying in {delay}s...")
426
- time.sleep(delay)
427
- else:
428
- logger.error("All upload attempts failed. OCR results are lost.")
429
- sys.exit(1)
430
-
431
- # Create and push dataset card (skip when creating PR to avoid conflicts)
432
- if not create_pr:
433
- logger.info("Creating dataset card")
434
- card_content = create_dataset_card(
435
- source_dataset=input_dataset,
436
- model=MODEL,
437
- num_samples=len(dataset),
438
- processing_time=processing_time_str,
439
- batch_size=batch_size,
440
- max_model_len=max_model_len,
441
- max_tokens=max_tokens,
442
- gpu_memory_utilization=gpu_memory_utilization,
443
- prompt_mode=prompt_mode if not custom_prompt else "custom",
444
- think=think,
445
- include_thinking=include_thinking,
446
- image_column=image_column,
447
- split=split,
448
- )
449
- card = DatasetCard(card_content)
450
- card.push_to_hub(output_dataset, token=HF_TOKEN)
451
-
452
- logger.info("Qianfan-OCR processing complete!")
453
- logger.info(
454
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
455
- )
456
- logger.info(f"Processing time: {processing_time_str}")
457
- logger.info(
458
- f"Processing speed: {len(dataset) / processing_duration.total_seconds():.2f} images/sec"
459
- )
460
-
461
- if verbose:
462
- import importlib.metadata
463
-
464
- logger.info("--- Resolved package versions ---")
465
- for pkg in ["vllm", "transformers", "torch", "datasets", "pyarrow", "pillow"]:
466
- try:
467
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
468
- except importlib.metadata.PackageNotFoundError:
469
- logger.info(f" {pkg}: not installed")
470
- logger.info("--- End versions ---")
471
-
472
-
473
- if __name__ == "__main__":
474
- if len(sys.argv) == 1:
475
- print("=" * 80)
476
- print("Qianfan-OCR - End-to-End Document Intelligence")
477
- print("=" * 80)
478
- print("\n4.7B model from Baidu, #1 on OmniDocBench v1.5 (93.12)")
479
- print("\nFeatures:")
480
- print("- #1 end-to-end model on OmniDocBench v1.5 and OlmOCR Bench")
481
- print("- Layout-as-Thought reasoning for complex documents (--think)")
482
- print("- 192 language support")
483
- print("- Multiple modes: OCR, table (HTML), formula (LaTeX), chart, scene text")
484
- print("- Key information extraction with custom prompts")
485
- print("\nExample usage:")
486
- print("\n1. Basic OCR:")
487
- print(" uv run qianfan-ocr.py input-dataset output-dataset")
488
- print("\n2. With Layout-as-Thought (complex documents):")
489
- print(" uv run qianfan-ocr.py docs output --think")
490
- print("\n3. Table extraction:")
491
- print(" uv run qianfan-ocr.py docs output --prompt-mode table")
492
- print("\n4. Formula extraction:")
493
- print(" uv run qianfan-ocr.py docs output --prompt-mode formula")
494
- print("\n5. Key information extraction:")
495
- print(
496
- ' uv run qianfan-ocr.py invoices output --prompt-mode kie --custom-prompt "Extract: name, date, total. Output JSON."'
497
- )
498
- print("\n6. Running on HF Jobs:")
499
- print(" hf jobs uv run --flavor l4x1 \\")
500
- print(" -s HF_TOKEN \\")
501
- print(
502
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/qianfan-ocr.py \\"
503
- )
504
- print(" input-dataset output-dataset --max-samples 10")
505
- print("\nFor full help, run: uv run qianfan-ocr.py --help")
506
- sys.exit(0)
507
-
508
- parser = argparse.ArgumentParser(
509
- description="Document OCR using Qianfan-OCR (4.7B, #1 on OmniDocBench v1.5)",
510
- formatter_class=argparse.RawDescriptionHelpFormatter,
511
- epilog="""
512
- Prompt modes:
513
- ocr Document parsing to Markdown (default)
514
- table Table extraction to HTML format
515
- formula Formula recognition to LaTeX
516
- chart Chart understanding and analysis
517
- scene Scene text extraction
518
- kie Key information extraction (requires --custom-prompt)
519
-
520
- Examples:
521
- uv run qianfan-ocr.py my-docs analyzed-docs
522
- uv run qianfan-ocr.py docs output --think --max-samples 50
523
- uv run qianfan-ocr.py docs output --prompt-mode table
524
- uv run qianfan-ocr.py invoices data --prompt-mode kie --custom-prompt "Extract: name, date, total."
525
- """,
526
- )
527
-
528
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
529
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
530
- parser.add_argument(
531
- "--image-column",
532
- default="image",
533
- help="Column containing images (default: image)",
534
- )
535
- parser.add_argument(
536
- "--batch-size",
537
- type=int,
538
- default=8,
539
- help="Batch size for processing (default: 8)",
540
- )
541
- parser.add_argument(
542
- "--max-model-len",
543
- type=int,
544
- default=16384,
545
- help="Maximum model context length (default: 16384, reduce to 8192 if OOM on L4)",
546
- )
547
- parser.add_argument(
548
- "--max-tokens",
549
- type=int,
550
- default=8192,
551
- help="Maximum tokens to generate (default: 8192)",
552
- )
553
- parser.add_argument(
554
- "--temperature",
555
- type=float,
556
- default=0.0,
557
- help="Sampling temperature (default: 0.0, deterministic)",
558
- )
559
- parser.add_argument(
560
- "--top-p",
561
- type=float,
562
- default=1.0,
563
- help="Top-p sampling parameter (default: 1.0)",
564
- )
565
- parser.add_argument(
566
- "--gpu-memory-utilization",
567
- type=float,
568
- default=0.85,
569
- help="GPU memory utilization (default: 0.85)",
570
- )
571
- parser.add_argument("--hf-token", help="Hugging Face API token")
572
- parser.add_argument(
573
- "--split", default="train", help="Dataset split to use (default: train)"
574
- )
575
- parser.add_argument(
576
- "--max-samples",
577
- type=int,
578
- help="Maximum number of samples to process (for testing)",
579
- )
580
- parser.add_argument(
581
- "--private", action="store_true", help="Make output dataset private"
582
- )
583
- parser.add_argument(
584
- "--shuffle", action="store_true", help="Shuffle dataset before processing"
585
- )
586
- parser.add_argument(
587
- "--seed",
588
- type=int,
589
- default=42,
590
- help="Random seed for shuffling (default: 42)",
591
- )
592
- parser.add_argument(
593
- "--prompt-mode",
594
- choices=list(PROMPT_TEMPLATES.keys()),
595
- default="ocr",
596
- help="Prompt mode (default: ocr)",
597
- )
598
- parser.add_argument(
599
- "--think",
600
- action="store_true",
601
- help="Enable Layout-as-Thought reasoning (appends <think> to prompt)",
602
- )
603
- parser.add_argument(
604
- "--include-thinking",
605
- action="store_true",
606
- help="Include thinking traces in output (default: only final content)",
607
- )
608
- parser.add_argument(
609
- "--custom-prompt",
610
- help="Custom prompt text (overrides --prompt-mode)",
611
- )
612
- parser.add_argument(
613
- "--output-column",
614
- default="markdown",
615
- help="Column name for output text (default: markdown)",
616
- )
617
- parser.add_argument(
618
- "--overwrite",
619
- action="store_true",
620
- help="Replace the output column if it already exists in the input dataset "
621
- "(default: error out to avoid clobbering an existing column).",
622
- )
623
- parser.add_argument(
624
- "--config",
625
- help="Config/subset name when pushing to Hub (for benchmarking multiple models)",
626
- )
627
- parser.add_argument(
628
- "--create-pr",
629
- action="store_true",
630
- help="Create a pull request instead of pushing directly",
631
- )
632
- parser.add_argument(
633
- "--verbose",
634
- action="store_true",
635
- help="Log resolved package versions after processing",
636
- )
637
-
638
- args = parser.parse_args()
639
-
640
- main(
641
- input_dataset=args.input_dataset,
642
- output_dataset=args.output_dataset,
643
- image_column=args.image_column,
644
- batch_size=args.batch_size,
645
- max_model_len=args.max_model_len,
646
- max_tokens=args.max_tokens,
647
- temperature=args.temperature,
648
- top_p=args.top_p,
649
- gpu_memory_utilization=args.gpu_memory_utilization,
650
- hf_token=args.hf_token,
651
- split=args.split,
652
- max_samples=args.max_samples,
653
- private=args.private,
654
- shuffle=args.shuffle,
655
- seed=args.seed,
656
- prompt_mode=args.prompt_mode,
657
- think=args.think,
658
- include_thinking=args.include_thinking,
659
- custom_prompt=args.custom_prompt,
660
- output_column=args.output_column,
661
- overwrite=args.overwrite,
662
- config=args.config,
663
- create_pr=args.create_pr,
664
- verbose=args.verbose,
665
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
rolm-ocr.py CHANGED
@@ -40,10 +40,6 @@ from huggingface_hub import DatasetCard, login
40
  from PIL import Image
41
  from toolz import partition_all
42
  from tqdm.auto import tqdm
43
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
44
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
45
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
46
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
47
  from vllm import LLM, SamplingParams
48
  from datetime import datetime
49
 
@@ -61,28 +57,6 @@ def check_cuda_availability():
61
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
62
 
63
 
64
- def ensure_output_columns_free(dataset, columns, overwrite=False):
65
- """Fail fast if an output column would collide with an existing input column.
66
-
67
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
68
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
69
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
70
- column(s) here instead (logged) so the later add_column is clean.
71
- """
72
- clash = [c for c in columns if c in dataset.column_names]
73
- if not clash:
74
- return dataset
75
- if overwrite:
76
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
77
- return dataset.remove_columns(clash)
78
- logger.error(
79
- f"Output column(s) {clash} already exist in the input dataset "
80
- f"(columns: {dataset.column_names})."
81
- )
82
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
83
- sys.exit(1)
84
-
85
-
86
  def make_ocr_message(
87
  image: Union[Image.Image, Dict[str, Any], str],
88
  prompt: str = "Return the plain text representation of this document as if you were reading it naturally.\n",
@@ -131,15 +105,6 @@ def create_dataset_card(
131
  """Create a dataset card documenting the OCR process."""
132
  model_name = model.split("/")[-1]
133
 
134
- # Canonical provenance stamp (see AGENTS.md): Jobs claim gated on JOB_ID, set by HF Jobs in-container.
135
- on_jobs = os.environ.get("JOB_ID") is not None
136
- hw = os.environ.get("ACCELERATOR") or ""
137
- origin = (
138
- "Produced on [Hugging Face Jobs](https://huggingface.co/docs/huggingface_hub/guides/jobs)"
139
- + (f" (`{hw}`)" if hw else "")
140
- ) if on_jobs else "Generated"
141
- jobs_tag = "\n- hf-jobs" if on_jobs else ""
142
-
143
  return f"""---
144
  viewer: false
145
  tags:
@@ -147,7 +112,7 @@ tags:
147
  - text-extraction
148
  - rolmocr
149
  - uv-script
150
- - generated{jobs_tag}
151
  ---
152
 
153
  # OCR Text Extraction using {model_name}
@@ -204,10 +169,10 @@ for info in inference_info:
204
 
205
  ## Reproduction
206
 
207
- {origin} with the [`rolm-ocr.py`](https://huggingface.co/datasets/uv-scripts/ocr/raw/main/rolm-ocr.py) recipe from [uv-scripts](https://huggingface.co/uv-scripts). Run it yourself:
208
 
209
  ```bash
210
- hf jobs uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/rolm-ocr.py \\
211
  {source_dataset} \\
212
  <output-dataset> \\
213
  --image-column {image_column} \\
@@ -221,6 +186,8 @@ hf jobs uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/rolm-ocr.
221
 
222
  - **Processing Speed**: ~{num_samples / (float(processing_time.split()[0]) * 60):.1f} images/second
223
  - **GPU Configuration**: vLLM with {gpu_memory_utilization:.0%} GPU memory utilization
 
 
224
  """
225
 
226
 
@@ -238,7 +205,6 @@ def main(
238
  max_samples: int = None,
239
  private: bool = False,
240
  output_column: str = None,
241
- overwrite: bool = False,
242
  shuffle: bool = False,
243
  seed: int = 42,
244
  verbose: bool = False,
@@ -273,9 +239,6 @@ def main(
273
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
274
  )
275
 
276
- # Fail fast if the output column would collide with an existing input column
277
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
278
-
279
  # Shuffle if requested
280
  if shuffle:
281
  logger.info(f"Shuffling dataset with seed {seed}")
@@ -535,12 +498,6 @@ Examples:
535
  default=None,
536
  help="Name of the output column for extracted text (default: markdown)",
537
  )
538
- parser.add_argument(
539
- "--overwrite",
540
- action="store_true",
541
- help="Replace the output column if it already exists in the input dataset "
542
- "(default: error out to avoid clobbering an existing column).",
543
- )
544
  parser.add_argument(
545
  "--shuffle",
546
  action="store_true",
@@ -574,7 +531,6 @@ Examples:
574
  max_samples=args.max_samples,
575
  private=args.private,
576
  output_column=args.output_column,
577
- overwrite=args.overwrite,
578
  shuffle=args.shuffle,
579
  seed=args.seed,
580
  verbose=args.verbose,
 
40
  from PIL import Image
41
  from toolz import partition_all
42
  from tqdm.auto import tqdm
 
 
 
 
43
  from vllm import LLM, SamplingParams
44
  from datetime import datetime
45
 
 
57
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def make_ocr_message(
61
  image: Union[Image.Image, Dict[str, Any], str],
62
  prompt: str = "Return the plain text representation of this document as if you were reading it naturally.\n",
 
105
  """Create a dataset card documenting the OCR process."""
106
  model_name = model.split("/")[-1]
107
 
 
 
 
 
 
 
 
 
 
108
  return f"""---
109
  viewer: false
110
  tags:
 
112
  - text-extraction
113
  - rolmocr
114
  - uv-script
115
+ - generated
116
  ---
117
 
118
  # OCR Text Extraction using {model_name}
 
169
 
170
  ## Reproduction
171
 
172
+ This dataset was generated using the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) RolmOCR script:
173
 
174
  ```bash
175
+ uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/rolm-ocr.py \\
176
  {source_dataset} \\
177
  <output-dataset> \\
178
  --image-column {image_column} \\
 
186
 
187
  - **Processing Speed**: ~{num_samples / (float(processing_time.split()[0]) * 60):.1f} images/second
188
  - **GPU Configuration**: vLLM with {gpu_memory_utilization:.0%} GPU memory utilization
189
+
190
+ Generated with 🤖 [UV Scripts](https://huggingface.co/uv-scripts)
191
  """
192
 
193
 
 
205
  max_samples: int = None,
206
  private: bool = False,
207
  output_column: str = None,
 
208
  shuffle: bool = False,
209
  seed: int = 42,
210
  verbose: bool = False,
 
239
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
240
  )
241
 
 
 
 
242
  # Shuffle if requested
243
  if shuffle:
244
  logger.info(f"Shuffling dataset with seed {seed}")
 
498
  default=None,
499
  help="Name of the output column for extracted text (default: markdown)",
500
  )
 
 
 
 
 
 
501
  parser.add_argument(
502
  "--shuffle",
503
  action="store_true",
 
531
  max_samples=args.max_samples,
532
  private=args.private,
533
  output_column=args.output_column,
 
534
  shuffle=args.shuffle,
535
  seed=args.seed,
536
  verbose=args.verbose,
serving-unlimited-ocr.md DELETED
@@ -1,169 +0,0 @@
1
- # Serve Unlimited-OCR as a live endpoint on HF Jobs
2
-
3
- The OCR recipes in this folder run as batch jobs (dataset in → dataset out). To call a model
4
- interactively, from an agent, or with ad-hoc concurrent requests, you can instead run it as a
5
- temporary HTTP endpoint. [HF Jobs serving](https://huggingface.co/docs/hub/jobs-serving) exposes a
6
- port on a GPU Job, giving an OpenAI-compatible endpoint that runs until the job is cancelled or its
7
- `--timeout` is reached.
8
-
9
- This is a worked example for [baidu/Unlimited-OCR](https://huggingface.co/baidu/Unlimited-OCR)
10
- (3B, MIT, based on DeepSeek-OCR; supports multi-page parsing in a single request). Two server
11
- options below: **vLLM** on Baidu's official image (the newer official path, OpenAI-compatible), or
12
- **SGLang** on the stock image with the model's own wheel. Either gives an OpenAI-compatible endpoint.
13
-
14
- > **Single-image vs multi-page — pick the engine by task:**
15
- > - **Single-page** OCR (one image → markdown): both engines work. For a whole corpus, the batch
16
- > recipe [`unlimited-ocr-vllm.py`](unlimited-ocr-vllm.py) (offline vLLM, resumable, no network) is
17
- > the better fit than a client loop; for interactive/agent use, serve with **vLLM (Option A)**.
18
- > - **Multi-page / long-horizon** parsing (the model's headline feature): **both engines do it**
19
- > (validated 2026-06-28 — a clean 2-page doc read back both pages, `<PAGE>`-separated, on *both* vLLM
20
- > and SGLang). The difference is **robustness on hard inputs**: on degraded historical scans / newspaper
21
- > clippings, vLLM multi-page degraded to hallucination in our tests while **SGLang (Option B)** read
22
- > real content — so SGLang is the **more robust** multi-page path (it's also the authors' documented
23
- > one, via `images_config`). Use vLLM multi-page for clean docs; reach for SGLang for hard scans.
24
- > (vLLM's upstream PR [#46564](https://github.com/vllm-project/vllm/pull/46564) benchmarks single-page only.)
25
-
26
- ## 1. Start the server
27
-
28
- ### Option A — vLLM (official image)
29
-
30
- vLLM support landed upstream; Baidu ships a dedicated image (the architecture isn't in a stable pip
31
- wheel yet). Use the default `:unlimited-ocr` tag on L4/A100, or `:unlimited-ocr-cu129` on Hopper.
32
- Runs on `l4x1`, no fa3/Hopper requirement. **Single-image is validated**; **multi-page also works on
33
- clean docs** (both pages, `<PAGE>`-separated) but degraded to hallucination on hard scans in our tests
34
- — for hard/degraded inputs prefer Option B (SGLang). For multi-page on vLLM, the request takes one
35
- `<image>` per page in the text and `window_size=1024` in `vllm_xargs` (it has no `images_config`).
36
-
37
- ```bash
38
- hf jobs run --detach --expose 8000 --flavor l4x1 -s HF_TOKEN --timeout 30m \
39
- vllm/vllm-openai:unlimited-ocr -- \
40
- vllm serve baidu/Unlimited-OCR --served-model-name Unlimited-OCR \
41
- --trust-remote-code --max-model-len 32768 --host 0.0.0.0 --port 8000 \
42
- --logits_processors vllm.model_executor.models.unlimited_ocr:NGramPerReqLogitsProcessor \
43
- --no-enable-prefix-caching --mm-processor-cache-gb 0
44
- ```
45
-
46
- Per-request, vLLM takes the no-repeat n-gram knobs via `vllm_xargs` and needs `skip_special_tokens`
47
- off (it has no `images_config` — that's an SGLang param):
48
-
49
- ```python
50
- r = client.chat.completions.create(
51
- model="Unlimited-OCR",
52
- messages=[{"role": "user", "content": [
53
- {"type": "text", "text": "<image>document parsing."}, # literal <image> prefix is required
54
- {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}},
55
- ]}],
56
- temperature=0,
57
- extra_body={"skip_special_tokens": False, "vllm_xargs": {"ngram_size": 35, "window_size": 128}},
58
- )
59
- ```
60
-
61
- ### Option B — SGLang (model's own build) · supports multi-page
62
-
63
- The model also ships its own SGLang build, installed at startup from a 12 MB wheel. **This is the
64
- more robust path for multi-page / long-horizon parsing** (§3) — the model authors' documented route
65
- (`images_config`), and the one that held up on hard scans where vLLM multi-page hallucinated. Two
66
- pins matter (both learned the hard way, 2026-06-28):
67
- - **Pin the image to `lmsysorg/sglang:v0.5.10.post1`** — *not* `:latest`. `:latest` drifted to torch
68
- 2.11 / cu130, incompatible with the wheel (torch 2.9.1 / cuda-python 12.9); v0.5.10.post1 is the last
69
- release that matches the wheel exactly.
70
- - **Run on `a100-large` with `--attention-backend flashinfer`, not `h200`/`fa3`.** `fa3` needs a Hopper
71
- GPU, but HF's `h200` nodes currently fail GPU init with `CUDA error 802: system not yet initialized`
72
- (3/3 attempts) — an infra issue, not the model. `a100` + `flashinfer` sidesteps it and works.
73
-
74
- ```bash
75
- hf jobs run --detach --expose 10000 --flavor a100-large -s HF_TOKEN --timeout 30m \
76
- lmsysorg/sglang:v0.5.10.post1 -- \
77
- bash -lc 'pip install --no-deps https://github.com/baidu/Unlimited-OCR/raw/main/wheel/sglang-0.0.0.dev11416+g92e8bb79e-py3-none-any.whl \
78
- && pip install -q kernels==0.11.7 \
79
- && python -m sglang.launch_server --model baidu/Unlimited-OCR --served-model-name Unlimited-OCR \
80
- --attention-backend flashinfer --page-size 1 --mem-fraction-static 0.85 --context-length 32768 \
81
- --enable-custom-logit-processor --disable-overlap-schedule --skip-server-warmup \
82
- --host 0.0.0.0 --port 10000'
83
- ```
84
-
85
- Notes:
86
- - `--` before `bash` is required, or the CLI parses `-lc` as its own flags.
87
- - `--timeout` stops the endpoint (and billing) at the deadline; `hf jobs cancel <id>` stops it earlier.
88
- - **Validated 2026-06-28** on `a100-large`: server came up, single-image and multi-page both read
89
- correctly (a clean 2-page doc returned both pages verbatim, `<PAGE>`-separated). The model card's
90
- "official" backend is `fa3` on Hopper for exact R-SWA — switch back to `--attention-backend fa3
91
- --flavor h200` once the h200 `802` infra issue clears; `flashinfer` on `a100` is the working fallback.
92
- - Follow startup with `hf jobs logs -f <id>`; ready at `The server is fired up` / `Application startup
93
- complete` (a few minutes cold; the wheel + model download dominate).
94
-
95
- The client examples below use the **SGLang** request format (`images_config` in `extra_body`,
96
- port 10000). The single-image call (§2) also works on the vLLM server — just use the Option A
97
- `extra_body` and your exposed port. **Multi-page (§3) is SGLang-only.**
98
-
99
- ## 2. Call it (OpenAI client; HF token as the API key)
100
-
101
- The exposed port is at `https://<job_id>--10000.hf.jobs`; the OpenAI base URL is that plus `/v1`.
102
-
103
- ```python
104
- import base64, os
105
- from openai import OpenAI
106
-
107
- client = OpenAI(base_url="https://<job_id>--10000.hf.jobs/v1", api_key=os.environ["HF_TOKEN"])
108
- img = base64.b64encode(open("page.jpg", "rb").read()).decode()
109
-
110
- r = client.chat.completions.create(
111
- model="Unlimited-OCR",
112
- messages=[{"role": "user", "content": [
113
- {"type": "text", "text": "document parsing."},
114
- {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}},
115
- ]}],
116
- temperature=0,
117
- extra_body={"images_config": {"image_mode": "gundam"}}, # "gundam" (crop-tiling) or "base"
118
- )
119
- print(r.choices[0].message.content)
120
- ```
121
-
122
- Output is layout-grounded markdown: each block is tagged `<|det|>type [x1,y1,x2,y2]<|/det|> text`,
123
- with coordinates normalized to 0–1000. Remove the tags for plain text
124
- (`re.sub(r'<\|det\|>.*?<\|/det\|>', '', text)`) or keep them for structure.
125
-
126
- ## 3. Multi-page / PDF (SGLang shown; vLLM also works on clean docs)
127
-
128
- > ✅ This **SGLang** flow (Option B) is **validated working 2026-06-28** (a clean 2-page doc read back
129
- > both pages verbatim, `<PAGE>`-separated) and follows the model card's multi-page example. The
130
- > `images_config`/`image_mode` param is SGLang-specific — **vLLM ignores it**; on vLLM, do multi-page
131
- > with one `<image>` per page in the text + `window_size=1024` in `vllm_xargs` (no `images_config`).
132
- > Both engines read clean multi-page docs; **SGLang was the more robust on hard/degraded scans**, where
133
- > vLLM multi-page hallucinated in our tests. (vLLM's upstream
134
- > [PR #46564](https://github.com/vllm-project/vllm/pull/46564) benchmarks single-page only.)
135
-
136
- Send multiple page images in one request with the `Multi page parsing.` prompt and `image_mode="base"`:
137
-
138
- ```python
139
- parts = [{"type": "text", "text": "Multi page parsing."}]
140
- for page_png in page_images: # e.g. PDF pages rendered with pymupdf at ~150 dpi
141
- b64 = base64.b64encode(open(page_png, "rb").read()).decode()
142
- parts.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}})
143
-
144
- r = client.chat.completions.create(
145
- model="Unlimited-OCR",
146
- messages=[{"role": "user", "content": parts}],
147
- temperature=0, max_tokens=16384,
148
- extra_body={"images_config": {"image_mode": "base"}},
149
- )
150
- ```
151
-
152
- Pages are separated by `<PAGE>`; tables are returned as HTML and equations as LaTeX, with reading
153
- order preserved across pages. The context length is 32k tokens, so split longer documents.
154
-
155
- ## 4. Concurrency
156
-
157
- SGLang batches concurrent requests, so a client can send many requests in parallel to one endpoint;
158
- the upstream [`infer.py`](https://github.com/baidu/Unlimited-OCR/blob/main/infer.py) uses a
159
- `ThreadPoolExecutor` at `concurrency=8`. For a large corpus, a batch job that runs next to the data
160
- (resumable, no network transfer) is usually a better fit than a client-to-endpoint loop.
161
-
162
- ## 5. Stop it
163
-
164
- ```bash
165
- hf jobs cancel <job_id>
166
- ```
167
-
168
- Billing is per-minute for the GPU flavor plus a small flat fee for the exposed port; scheduling time
169
- is not billed. Run `hf jobs hardware` for current flavors and prices.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
smoldocling-ocr.py CHANGED
@@ -1,7 +1,7 @@
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
- # "datasets>=4.0.0",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm",
@@ -42,13 +42,9 @@ from typing import Any, Dict, Union
42
  import torch
43
  from datasets import load_dataset
44
  from huggingface_hub import DatasetCard, login
45
- from PIL import Image, UnidentifiedImageError
46
  from toolz import partition_all
47
  from tqdm.auto import tqdm
48
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc, which the
49
- # default uv-script image lacks (engine init then crashes). Greedy OCR doesn't use it; this
50
- # lets the plain default-image command work. On the vllm/vllm-openai image it's a harmless no-op.
51
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
52
  from vllm import LLM, SamplingParams
53
 
54
  logging.basicConfig(level=logging.INFO)
@@ -65,28 +61,6 @@ def check_cuda_availability():
65
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
66
 
67
 
68
- def ensure_output_columns_free(dataset, columns, overwrite=False):
69
- """Fail fast if an output column would collide with an existing input column.
70
-
71
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
72
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
73
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
74
- column(s) here instead (logged) so the later add_column is clean.
75
- """
76
- clash = [c for c in columns if c in dataset.column_names]
77
- if not clash:
78
- return dataset
79
- if overwrite:
80
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
81
- return dataset.remove_columns(clash)
82
- logger.error(
83
- f"Output column(s) {clash} already exist in the input dataset "
84
- f"(columns: {dataset.column_names})."
85
- )
86
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
87
- sys.exit(1)
88
-
89
-
90
  def prepare_llm_input(
91
  image: Union[Image.Image, Dict[str, Any], str],
92
  prompt_text: str = "Convert page to Docling.",
@@ -250,7 +224,6 @@ def main(
250
  max_samples: int = None,
251
  private: bool = False,
252
  output_column: str = "markdown",
253
- overwrite: bool = False,
254
  output_format: str = "markdown",
255
  shuffle: bool = False,
256
  seed: int = 42,
@@ -282,9 +255,6 @@ def main(
282
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
283
  )
284
 
285
- # Fail fast if the output column would collide with an existing input column
286
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
287
-
288
  # Validate output format
289
  if output_format not in ["markdown", "doctags"]:
290
  raise ValueError(
@@ -329,21 +299,7 @@ def main(
329
  desc="OCR processing",
330
  ):
331
  batch_indices = list(batch_indices)
332
-
333
- # Fetch images first, with per-batch fallback for unreadable files.
334
- # One corrupt image used to take down the entire run via the list
335
- # comprehension; now we mark the whole batch as skipped and continue.
336
- try:
337
- batch_images = [dataset[i][image_column] for i in batch_indices]
338
- except (UnidentifiedImageError, OSError) as e:
339
- logger.warning(
340
- f"Skipping batch of {len(batch_indices)} — unreadable image "
341
- f"in batch: {type(e).__name__}: {e}"
342
- )
343
- all_output.extend(
344
- ["[OCR SKIPPED — UNREADABLE IMAGE]"] * len(batch_indices)
345
- )
346
- continue
347
 
348
  try:
349
  # Prepare inputs for batch
@@ -597,12 +553,6 @@ Examples:
597
  default="markdown",
598
  help="Column name for output text (default: markdown)",
599
  )
600
- parser.add_argument(
601
- "--overwrite",
602
- action="store_true",
603
- help="Replace the output column if it already exists in the input dataset "
604
- "(default: error out to avoid clobbering an existing column).",
605
- )
606
  parser.add_argument(
607
  "--output-format",
608
  default="markdown",
@@ -656,7 +606,6 @@ Examples:
656
  max_samples=args.max_samples,
657
  private=args.private,
658
  output_column=args.output_column,
659
- overwrite=args.overwrite,
660
  output_format=args.output_format,
661
  shuffle=args.shuffle,
662
  seed=args.seed,
 
1
  # /// script
2
  # requires-python = ">=3.11"
3
  # dependencies = [
4
+ # "datasets",
5
  # "huggingface-hub",
6
  # "pillow",
7
  # "vllm",
 
42
  import torch
43
  from datasets import load_dataset
44
  from huggingface_hub import DatasetCard, login
45
+ from PIL import Image
46
  from toolz import partition_all
47
  from tqdm.auto import tqdm
 
 
 
 
48
  from vllm import LLM, SamplingParams
49
 
50
  logging.basicConfig(level=logging.INFO)
 
61
  logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  def prepare_llm_input(
65
  image: Union[Image.Image, Dict[str, Any], str],
66
  prompt_text: str = "Convert page to Docling.",
 
224
  max_samples: int = None,
225
  private: bool = False,
226
  output_column: str = "markdown",
 
227
  output_format: str = "markdown",
228
  shuffle: bool = False,
229
  seed: int = 42,
 
255
  f"Column '{image_column}' not found. Available: {dataset.column_names}"
256
  )
257
 
 
 
 
258
  # Validate output format
259
  if output_format not in ["markdown", "doctags"]:
260
  raise ValueError(
 
299
  desc="OCR processing",
300
  ):
301
  batch_indices = list(batch_indices)
302
+ batch_images = [dataset[i][image_column] for i in batch_indices]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
  try:
305
  # Prepare inputs for batch
 
553
  default="markdown",
554
  help="Column name for output text (default: markdown)",
555
  )
 
 
 
 
 
 
556
  parser.add_argument(
557
  "--output-format",
558
  default="markdown",
 
606
  max_samples=args.max_samples,
607
  private=args.private,
608
  output_column=args.output_column,
 
609
  output_format=args.output_format,
610
  shuffle=args.shuffle,
611
  seed=args.seed,
surya-ocr-bucket.py DELETED
@@ -1,1389 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.10"
3
- # dependencies = [
4
- # "surya-ocr==0.20.0",
5
- # "datasets>=3.1.0",
6
- # "huggingface-hub",
7
- # "pillow",
8
- # "imagecodecs",
9
- # "toolz",
10
- # "tqdm",
11
- # ]
12
- #
13
- # # Pin surya-ocr to the known-good build (has the `surya.inference` engine layout
14
- # # this recipe injects into); an unpinned/loosened resolve backtracks to an ancient
15
- # # surya without it. huggingface-hub is left unpinned: at runtime PYTHONPATH puts the
16
- # # pinned image's hub (with the buckets API) ahead of the venv, so no version tension.
17
- # ///
18
- """
19
- Structured OCR over a **bucket of document files** (images + PDFs) with Datalab's
20
- **Surya OCR 2** (`datalab-to/surya-ocr-2`, 650M, Qwen3.5-style) — no dataset
21
- round-trip. This is the bucket-native sibling of `surya-ocr.py` (which reads a Hub
22
- dataset column). Point it straight at an HF bucket of `.jp2`/`.png`/`.pdf`/... files.
23
-
24
- Like the parent it produces *structured* OCR: per-block HTML + bounding boxes +
25
- reading order + confidence. `--task` switches between `ocr` (full-page text),
26
- `layout` (labelled regions), and `table` (HTML / rows-cols-cells).
27
-
28
- INPUT — two interchangeable I/O strategies (`--io-mode`, default `auto`):
29
- mount bucket mounted read-only at /in via `-v hf://buckets/<id>:/in:ro`; files
30
- are read straight off the FUSE mount. Zero ephemeral disk.
31
- copy take a bucket id directly; the huggingface_hub library LISTs then batch-
32
- DOWNLOADS each `--batch-size` chunk to local temp, OCRs it, writes output,
33
- then deletes the temp batch. Avoids the known FUSE bulk-read stall; peak
34
- disk = one batch. `auto` picks copy for an `hf://buckets/...` input, mount
35
- for a local dir.
36
-
37
- OUTPUT — one or both (>=1 required):
38
- --output-bucket per page a `.md` (flattened reading-order text) AND a `.json`
39
- (that page's structured `surya_blocks`), mirroring the input dir
40
- structure, into a mounted dir OR an `hf://buckets/...` URL.
41
- Streaming / O(1) memory, with resume-by-skip (a file whose
42
- `.json` already exists is skipped) — the scalable path.
43
- --output-dataset a parquet dataset pushed to the Hub (one row per file:
44
- file_name / markdown / surya_blocks / inference_info), like the
45
- parent recipe. Convenient; buffered in memory (no image bytes by
46
- default — use `--include-images` to embed page images).
47
-
48
- ENGINE: Surya normally spawns a vLLM **server** (Docker), which can't run inside an
49
- HF Job. This injects a custom in-process backend into Surya's `SuryaInferenceManager`
50
- that runs vLLM's offline `LLM().chat()` engine (no server). Surya still owns all the
51
- prompting, image preprocessing, and HTML/bbox parsing — we only swap the transport.
52
-
53
- LICENSE NOTE: Surya's *code* is Apache-2.0 but the *weights* are a modified
54
- OpenRAIL-M license — free for research, personal use, and startups under $5M
55
- funding/revenue, restricted from competitive use against Datalab's API. Confirm you
56
- are within those terms. https://huggingface.co/datalab-to/surya-ocr-2
57
-
58
- HF Jobs — MUST use the pinned vLLM image + the site-packages python path (the model
59
- is the recent, version-sensitive `qwen3_5` architecture; v0.20.1 is Surya's
60
- known-good build, and it puts python/vLLM under /usr/local, NOT /usr/bin):
61
-
62
- # copy input -> dataset output
63
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \\
64
- --image vllm/vllm-openai:v0.20.1 --python /usr/local/bin/python3 \\
65
- -e PYTHONPATH=/usr/local/lib/python3.12/site-packages \\
66
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/surya-ocr-bucket.py \\
67
- hf://buckets/<ns>/<bucket> --io-mode copy --glob "*.jp2" \\
68
- --output-dataset <ns>/<out> --private
69
-
70
- # mount input -> per-file bucket output (mirrors dir structure)
71
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \\
72
- --image vllm/vllm-openai:v0.20.1 --python /usr/local/bin/python3 \\
73
- -e PYTHONPATH=/usr/local/lib/python3.12/site-packages \\
74
- -v hf://buckets/<ns>/<bucket>:/in:ro \\
75
- -v hf://buckets/<ns>/<out-bucket>:/out \\
76
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/surya-ocr-bucket.py \\
77
- /in --io-mode mount --glob "*.jp2" --output-bucket /out
78
-
79
- Model: datalab-to/surya-ocr-2 (package: surya-ocr, https://github.com/datalab-to/surya)
80
- """
81
-
82
- import argparse
83
- import json
84
- import logging
85
- import math
86
- import os
87
- import shutil
88
- import sys
89
- import tempfile
90
- import time
91
- from contextlib import contextmanager
92
- from dataclasses import dataclass
93
- from datetime import datetime, timezone
94
- from fnmatch import fnmatch
95
- from pathlib import Path, PurePosixPath
96
- from typing import Any, Dict, Iterator, List, Optional, Tuple
97
-
98
- from PIL import Image, UnidentifiedImageError
99
- from toolz import partition_all
100
- from tqdm import tqdm
101
-
102
- logging.basicConfig(level=logging.INFO)
103
- logger = logging.getLogger(__name__)
104
-
105
- DEFAULT_MODEL = "datalab-to/surya-ocr-2"
106
- # Surya's own vision-tiling bounds (from its vLLM backend), applied to the
107
- # offline engine too so preprocessing matches the server path exactly.
108
- MM_PROCESSOR_KWARGS = {"min_pixels": 3136, "max_pixels": 6291456}
109
- TASKS = ("ocr", "layout", "table")
110
- # Extensions read by default. `.jp2`/`.j2k` are first-class: the canonical test
111
- # corpus (Library of Congress / Chronicling America) is all JPEG-2000.
112
- DEFAULT_EXTENSIONS = ".jp2,.j2k,.png,.jpg,.jpeg,.tiff,.tif,.bmp,.webp,.pdf"
113
- JP2_EXTENSIONS = {".jp2", ".j2k"}
114
- PDF_EXTENSION = ".pdf"
115
- BUCKET_PREFIX = "hf://buckets/"
116
-
117
-
118
- # ---------------------------------------------------------------------------
119
- # GPU / page-range helpers (verbatim from surya-ocr.py)
120
- # ---------------------------------------------------------------------------
121
-
122
-
123
- def check_cuda_availability() -> None:
124
- """Exit early with a clear message if there's no GPU."""
125
- import torch
126
-
127
- if not torch.cuda.is_available():
128
- logger.error("CUDA is not available. This script requires a GPU.")
129
- logger.error(
130
- "Run on Hugging Face Jobs with: hf jobs uv run --flavor l4x1 "
131
- "--image vllm/vllm-openai:v0.20.1 ..."
132
- )
133
- sys.exit(1)
134
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
135
-
136
-
137
- def parse_page_range(spec: Optional[str]) -> Optional[List[int]]:
138
- """Turn '0-3,5' into [0,1,2,3,5]. None/empty -> None (all pages)."""
139
- if not spec:
140
- return None
141
- pages: List[int] = []
142
- for part in spec.split(","):
143
- part = part.strip()
144
- if not part:
145
- continue
146
- if "-" in part:
147
- lo, hi = part.split("-", 1)
148
- pages.extend(range(int(lo), int(hi) + 1))
149
- else:
150
- pages.append(int(part))
151
- return pages or None
152
-
153
-
154
- # --- structured-output shim (vLLM API moved between versions) ---
155
- def build_structured_outputs(schema: Dict[str, Any]) -> Dict[str, Any]:
156
- """SamplingParams kwargs for guided JSON, across vLLM versions (layout uses this)."""
157
- try:
158
- from vllm.sampling_params import StructuredOutputsParams # vLLM >= 0.12
159
-
160
- return {"structured_outputs": StructuredOutputsParams(json=schema)}
161
- except (ImportError, TypeError):
162
- pass
163
- try:
164
- from vllm.sampling_params import GuidedDecodingParams # older vLLM
165
-
166
- return {"guided_decoding": GuidedDecodingParams(json=schema)}
167
- except (ImportError, TypeError):
168
- pass
169
- logger.warning(
170
- "Guided JSON unavailable in this vLLM version; relying on the model."
171
- )
172
- return {}
173
-
174
-
175
- def _mean_token_prob(completion_output) -> Optional[float]:
176
- """Mean exp(logprob) of the sampled tokens -> Surya's per-block `confidence`."""
177
- lps = getattr(completion_output, "logprobs", None)
178
- if not lps:
179
- return None
180
- probs: List[float] = []
181
- for tid, lp_dict in zip(completion_output.token_ids, lps):
182
- if not lp_dict:
183
- continue
184
- entry = lp_dict.get(tid)
185
- if (
186
- entry is None
187
- ): # sampled token not in the returned top-k; use the best we have
188
- entry = max(lp_dict.values(), key=lambda e: e.logprob)
189
- probs.append(math.exp(entry.logprob))
190
- return sum(probs) / len(probs) if probs else None
191
-
192
-
193
- # ---------------------------------------------------------------------------
194
- # Offline vLLM backend + Surya manager (verbatim from surya-ocr.py)
195
- # ---------------------------------------------------------------------------
196
-
197
-
198
- class OfflineVLLMBackend:
199
- """Surya `Backend` (duck-typed) that runs vLLM's offline `LLM().chat()` engine.
200
-
201
- Surya's predictors call `manager.generate(batch)` -> `backend.generate(batch)`;
202
- we satisfy that contract in-process (no server). Surya keeps ownership of the
203
- prompts (`PROMPT_MAPPING`), image scaling (`scale_to_fit`), and output parsing.
204
- """
205
-
206
- name = "offline-vllm"
207
-
208
- def __init__(
209
- self,
210
- model: str,
211
- max_model_len: int,
212
- gpu_memory_utilization: float,
213
- dtype: str = "bfloat16",
214
- max_tokens_default: int = 2048,
215
- logprobs_default: bool = True,
216
- ):
217
- self.model = model
218
- self.max_model_len = max_model_len
219
- self.gpu_memory_utilization = gpu_memory_utilization
220
- self.dtype = dtype
221
- self.max_tokens_default = max_tokens_default
222
- self.logprobs_default = logprobs_default
223
- self.llm = None
224
- self._build_messages = None
225
- self._scale_to_fit = None
226
- self._prompt_mapping = None
227
-
228
- def start(self):
229
- from vllm import LLM
230
-
231
- logger.info(
232
- f"Loading {self.model} into vLLM offline engine (dtype={self.dtype})..."
233
- )
234
- self.llm = LLM(
235
- model=self.model,
236
- dtype=self.dtype,
237
- max_model_len=self.max_model_len,
238
- gpu_memory_utilization=self.gpu_memory_utilization,
239
- mm_processor_kwargs=MM_PROCESSOR_KWARGS,
240
- limit_mm_per_prompt={"image": 1},
241
- )
242
- # Reuse Surya's exact request shaping so the offline path matches the server.
243
- from surya.inference.backends.openai_client import _build_messages
244
- from surya.inference.prompts import PROMPT_MAPPING
245
- from surya.inference.util import scale_to_fit
246
-
247
- self._build_messages = _build_messages
248
- self._scale_to_fit = scale_to_fit
249
- self._prompt_mapping = PROMPT_MAPPING
250
- return None
251
-
252
- def stop(self) -> None:
253
- self.llm = None
254
-
255
- def _sampling_params(self, item):
256
- from vllm import SamplingParams
257
-
258
- max_tokens = item.max_tokens or self.max_tokens_default
259
- want_logprobs = item.request_logprobs or self.logprobs_default
260
- kwargs: Dict[str, Any] = dict(temperature=0.0, top_p=0.1, max_tokens=max_tokens)
261
- if want_logprobs:
262
- kwargs["logprobs"] = 1
263
- if item.guided_json is not None:
264
- kwargs.update(build_structured_outputs(item.guided_json))
265
- return SamplingParams(**kwargs)
266
-
267
- def generate(self, batch):
268
- from surya.inference.schema import BatchOutputItem
269
-
270
- if self.llm is None:
271
- self.start()
272
- if not batch:
273
- return []
274
-
275
- conversations = []
276
- sampling_params = []
277
- for item in batch:
278
- prompt = item.prompt or self._prompt_mapping[item.prompt_type]
279
- image = self._scale_to_fit(item.image)
280
- conversations.append(self._build_messages(image, prompt))
281
- sampling_params.append(self._sampling_params(item))
282
-
283
- outputs = self.llm.chat(
284
- conversations,
285
- sampling_params,
286
- chat_template_content_format="openai",
287
- use_tqdm=False,
288
- )
289
-
290
- results = []
291
- for item, out in zip(batch, outputs):
292
- comp = out.outputs[0]
293
- results.append(
294
- BatchOutputItem(
295
- raw=comp.text,
296
- token_count=len(comp.token_ids),
297
- error=False,
298
- mean_token_prob=_mean_token_prob(comp),
299
- logprobs=None,
300
- metadata=item.metadata, # carries page_idx/block_idx — must round-trip
301
- )
302
- )
303
- return results
304
-
305
-
306
- def make_manager(backend: OfflineVLLMBackend):
307
- """A SuryaInferenceManager wired to our offline backend (bypassing autodetect)."""
308
- from surya.inference import SuryaInferenceManager
309
-
310
- manager = SuryaInferenceManager.__new__(SuryaInferenceManager)
311
- manager.method = backend.name
312
- manager.backend = backend
313
- return manager
314
-
315
-
316
- # ---------------------------------------------------------------------------
317
- # Result serialization (verbatim from surya-ocr.py)
318
- # ---------------------------------------------------------------------------
319
-
320
-
321
- def _html_to_text(html: str) -> str:
322
- from bs4 import BeautifulSoup
323
-
324
- return BeautifulSoup(html, "html.parser").get_text(" ", strip=True)
325
-
326
-
327
- def serialize_pages(task: str, pages: List[Any]) -> Tuple[str, List[Dict[str, Any]]]:
328
- """(text, structured-per-page) for one document's page results."""
329
- structured = [p.model_dump(mode="json") for p in pages]
330
- page_texts: List[str] = []
331
- for page in pages:
332
- if task == "ocr":
333
- parts = []
334
- for b in sorted(page.blocks, key=lambda b: b.reading_order):
335
- if b.skipped or not b.html:
336
- continue
337
- txt = _html_to_text(b.html)
338
- if txt:
339
- parts.append(txt)
340
- page_texts.append("\n".join(parts))
341
- elif task == "layout":
342
- # No OCR text in layout mode — emit a reading-order outline of labels.
343
- page_texts.append(
344
- "\n".join(
345
- f"{b.position}: {b.label}"
346
- for b in sorted(page.bboxes, key=lambda b: b.position)
347
- )
348
- )
349
- else: # table
350
- if page.html: # mode="full"
351
- page_texts.append(page.html)
352
- else: # mode="simple"
353
- page_texts.append(f"{len(page.rows)} rows x {len(page.cols)} cols")
354
- return "\n\n".join(page_texts), structured
355
-
356
-
357
- def serialize_per_page(task: str, pages: List[Any]) -> List[Tuple[str, Dict[str, Any]]]:
358
- """Per-page (text, structured-dict). Reuses `serialize_pages` one page at a time
359
- so the per-file dataset row and the per-page bucket files share one code path."""
360
- out: List[Tuple[str, Dict[str, Any]]] = []
361
- for page in pages:
362
- text, structured = serialize_pages(task, [page])
363
- out.append((text, structured[0]))
364
- return out
365
-
366
-
367
- # ---------------------------------------------------------------------------
368
- # Bucket-URL helpers (verbatim from pp-doclayout.py)
369
- # ---------------------------------------------------------------------------
370
-
371
-
372
- def is_bucket_url(s: str) -> bool:
373
- return s.startswith(BUCKET_PREFIX)
374
-
375
-
376
- def parse_bucket_url(url: str) -> Tuple[str, str]:
377
- """Split `hf://buckets/ns/bucket/path/in/bucket` into (`ns/bucket`, `path/in/bucket`)."""
378
- if not is_bucket_url(url):
379
- raise ValueError(f"Not a bucket URL: {url}")
380
- rest = url[len(BUCKET_PREFIX) :].strip("/")
381
- parts = rest.split("/", 2)
382
- if len(parts) < 2:
383
- raise ValueError(f"Bucket URL must include namespace and bucket name: {url}")
384
- bucket_id = f"{parts[0]}/{parts[1]}"
385
- prefix = parts[2] if len(parts) > 2 else ""
386
- return bucket_id, prefix
387
-
388
-
389
- # ---------------------------------------------------------------------------
390
- # Image / PDF loading
391
- # ---------------------------------------------------------------------------
392
-
393
-
394
- def open_image(path: Path) -> Image.Image:
395
- """Open one image as RGB. Falls back to imagecodecs for JPEG-2000, which the
396
- image's bundled Pillow may not decode (no OpenJPEG)."""
397
- try:
398
- return Image.open(path).convert("RGB")
399
- except (UnidentifiedImageError, OSError):
400
- if path.suffix.lower() in JP2_EXTENSIONS:
401
- import imagecodecs
402
-
403
- arr = imagecodecs.imread(str(path))
404
- logger.debug(f"Decoded {path.name} via imagecodecs (Pillow fallback)")
405
- return Image.fromarray(arr).convert("RGB")
406
- raise
407
-
408
-
409
- def load_pages(
410
- kind: str,
411
- local_path: Path,
412
- load_pdf,
413
- page_indices: Optional[List[int]],
414
- pdf_dpi: int,
415
- ) -> List[Image.Image]:
416
- """A local document file -> list of RGB page images (1 for an image, N for a PDF)."""
417
- if kind == "pdf":
418
- images, _ = load_pdf(str(local_path), page_indices, dpi=pdf_dpi)
419
- return [im.convert("RGB") for im in images]
420
- return [open_image(local_path)]
421
-
422
-
423
- # ---------------------------------------------------------------------------
424
- # File listing + sources (mount vs copy)
425
- # ---------------------------------------------------------------------------
426
-
427
-
428
- @dataclass
429
- class FileRef:
430
- """One input document. `key`/`rel` are the source-relative POSIX path (stable
431
- across runs -> resume) and drive output mirroring. `local_path` is set in mount
432
- mode; `bucket_file`/`bucket_path` in copy mode."""
433
-
434
- key: str
435
- rel: PurePosixPath
436
- kind: str # "image" | "pdf"
437
- local_path: Optional[Path] = None
438
- bucket_file: Any = None
439
- bucket_path: Optional[str] = None
440
-
441
-
442
- def classify(path_str: str, exts: set) -> Optional[str]:
443
- """Map a path to "pdf"/"image"/None using the allowed-extension set."""
444
- ext = PurePosixPath(path_str).suffix.lower()
445
- if ext == PDF_EXTENSION and PDF_EXTENSION in exts:
446
- return "pdf"
447
- if ext in exts:
448
- return "image"
449
- return None
450
-
451
-
452
- def _shuffle_slice(
453
- refs: List[FileRef], shuffle: bool, seed: int, max_samples: Optional[int]
454
- ) -> List[FileRef]:
455
- refs.sort(key=lambda r: r.key)
456
- if shuffle:
457
- import random
458
-
459
- random.Random(seed).shuffle(refs)
460
- if max_samples:
461
- refs = refs[:max_samples]
462
- return refs
463
-
464
-
465
- class MountSource:
466
- """Read files straight off a directory (a bucket mounted read-only at /in)."""
467
-
468
- mode = "mount"
469
-
470
- def __init__(self, root: Path, glob: str, exts: set):
471
- self.root = root
472
- self.glob = glob
473
- self.exts = exts
474
-
475
- def list_refs(
476
- self, shuffle: bool, seed: int, max_samples: Optional[int]
477
- ) -> List[FileRef]:
478
- refs: List[FileRef] = []
479
- for path in self.root.rglob("*"):
480
- if not path.is_file():
481
- continue
482
- rel = path.relative_to(self.root)
483
- rel_posix = rel.as_posix()
484
- kind = classify(rel_posix, self.exts)
485
- if kind is None or not fnmatch(rel_posix, self.glob):
486
- continue
487
- refs.append(
488
- FileRef(
489
- key=rel_posix,
490
- rel=PurePosixPath(rel_posix),
491
- kind=kind,
492
- local_path=path,
493
- )
494
- )
495
- return _shuffle_slice(refs, shuffle, seed, max_samples)
496
-
497
- @contextmanager
498
- def materialize(
499
- self, chunk: List[FileRef], load_pdf, page_indices, pdf_dpi
500
- ) -> Iterator[List[Tuple[FileRef, Optional[List[Image.Image]]]]]:
501
- loaded: List[Tuple[FileRef, Optional[List[Image.Image]]]] = []
502
- for ref in chunk:
503
- loaded.append(
504
- (
505
- ref,
506
- _safe_load(
507
- ref.kind, ref.local_path, load_pdf, page_indices, pdf_dpi
508
- ),
509
- )
510
- )
511
- yield loaded # nothing to clean up — reads are off the mount
512
-
513
-
514
- class CopySource:
515
- """List + batch-download bucket files via huggingface_hub to local temp, then
516
- delete the batch. The non-FUSE path (sidesteps the bulk-read stall)."""
517
-
518
- mode = "copy"
519
-
520
- def __init__(self, bucket_url: str, glob: str, exts: set, hf_token: Optional[str]):
521
- from huggingface_hub import HfApi
522
-
523
- self.bucket_id, self.prefix = parse_bucket_url(bucket_url)
524
- self.glob = glob
525
- self.exts = exts
526
- self.hf_token = hf_token
527
- self.api = HfApi(token=hf_token)
528
-
529
- def list_refs(
530
- self, shuffle: bool, seed: int, max_samples: Optional[int]
531
- ) -> List[FileRef]:
532
- logger.info(
533
- f"Listing bucket {self.bucket_id}"
534
- + (f"/{self.prefix}" if self.prefix else "")
535
- )
536
- refs: List[FileRef] = []
537
- for item in self.api.list_bucket_tree(
538
- self.bucket_id, prefix=self.prefix or None, recursive=True
539
- ):
540
- path = getattr(item, "path", None)
541
- if not path:
542
- continue
543
- kind = classify(path, self.exts)
544
- if kind is None:
545
- continue
546
- rel = path[len(self.prefix) :].lstrip("/") if self.prefix else path
547
- if not fnmatch(rel, self.glob):
548
- continue
549
- refs.append(
550
- FileRef(
551
- key=rel,
552
- rel=PurePosixPath(rel),
553
- kind=kind,
554
- bucket_file=item,
555
- bucket_path=path,
556
- )
557
- )
558
- logger.info(f"Found {len(refs)} matching file(s) in bucket")
559
- return _shuffle_slice(refs, shuffle, seed, max_samples)
560
-
561
- @contextmanager
562
- def materialize(
563
- self, chunk: List[FileRef], load_pdf, page_indices, pdf_dpi
564
- ) -> Iterator[List[Tuple[FileRef, Optional[List[Image.Image]]]]]:
565
- tmp = Path(tempfile.mkdtemp(prefix="surya-copy-"))
566
- try:
567
- # Pass the BucketFile objects from list_bucket_tree so download skips the
568
- # per-file metadata HEAD. Local names are index-keyed to avoid collisions.
569
- files = []
570
- locals_: List[Path] = []
571
- for i, ref in enumerate(chunk):
572
- local = tmp / f"{i:05d}{PurePosixPath(ref.bucket_path).suffix}"
573
- files.append((ref.bucket_file, str(local)))
574
- locals_.append(local)
575
- self.api.download_bucket_files(
576
- self.bucket_id, files=files, token=self.hf_token
577
- )
578
- loaded: List[Tuple[FileRef, Optional[List[Image.Image]]]] = []
579
- for ref, local in zip(chunk, locals_):
580
- if not local.exists():
581
- logger.warning(f"Download missing for {ref.key}; skipping")
582
- loaded.append((ref, None))
583
- continue
584
- loaded.append(
585
- (ref, _safe_load(ref.kind, local, load_pdf, page_indices, pdf_dpi))
586
- )
587
- yield loaded
588
- finally:
589
- shutil.rmtree(tmp, ignore_errors=True)
590
-
591
-
592
- def _safe_load(
593
- kind: str, local_path: Path, load_pdf, page_indices, pdf_dpi
594
- ) -> Optional[List[Image.Image]]:
595
- try:
596
- return load_pages(kind, local_path, load_pdf, page_indices, pdf_dpi)
597
- except Exception as e: # noqa: BLE001 — a single bad file shouldn't kill the run
598
- logger.warning(f"Failed to load {local_path.name}: {type(e).__name__}: {e}")
599
- return None
600
-
601
-
602
- # ---------------------------------------------------------------------------
603
- # Sinks
604
- # ---------------------------------------------------------------------------
605
-
606
-
607
- class BucketFilesSink:
608
- """Per page, write `<rel>.md` + `<rel>.json` (PDFs: `<stem>/page_NNN.{md,json}`),
609
- mirroring the input structure, to a mounted dir OR an `hf://buckets/...` URL.
610
- Streaming / O(1) memory. Resume-by-skip keys on the `.json` (written last)."""
611
-
612
- def __init__(self, output_target: str, hf_token: Optional[str], resume: bool):
613
- self.resume = resume
614
- self.api_mode = is_bucket_url(output_target)
615
- if self.api_mode:
616
- from huggingface_hub import HfApi
617
-
618
- self.bucket_id, self.prefix = parse_bucket_url(output_target)
619
- self.api = HfApi(token=hf_token)
620
- self.token = hf_token
621
- self._buffer: List[Tuple[bytes, str]] = []
622
- self._existing = self._load_existing() if resume else set()
623
- else:
624
- self.root = Path(output_target)
625
- self.root.mkdir(parents=True, exist_ok=True)
626
-
627
- @property
628
- def label(self) -> str:
629
- return (
630
- f"hf://buckets/{self.bucket_id}/{self.prefix}".rstrip("/")
631
- if self.api_mode
632
- else str(self.root)
633
- )
634
-
635
- def _join(self, rel: str) -> str:
636
- return f"{self.prefix}/{rel}".lstrip("/") if self.prefix else rel
637
-
638
- def _load_existing(self) -> set:
639
- existing = set()
640
- try:
641
- for item in self.api.list_bucket_tree(
642
- self.bucket_id, prefix=self.prefix or None, recursive=True
643
- ):
644
- p = getattr(item, "path", None)
645
- if p and p.endswith(".json"):
646
- existing.add(p)
647
- except Exception as e: # noqa: BLE001
648
- logger.warning(f"Could not pre-list output bucket for resume: {e}")
649
- if existing:
650
- logger.info(f"Resume: {len(existing)} output file(s) already present")
651
- return existing
652
-
653
- def _page_targets(self, ref: FileRef, n_pages: int) -> List[Tuple[str, str]]:
654
- if ref.kind == "pdf":
655
- stem = ref.rel.with_suffix("")
656
- return [
657
- (
658
- str(stem / f"page_{i + 1:03d}.md"),
659
- str(stem / f"page_{i + 1:03d}.json"),
660
- )
661
- for i in range(n_pages)
662
- ]
663
- return [(str(ref.rel.with_suffix(".md")), str(ref.rel.with_suffix(".json")))]
664
-
665
- def is_done(self, ref: FileRef) -> bool:
666
- # Resume applies to single-image files only; PDFs are re-rendered (idempotent
667
- # overwrite) since page count isn't known without opening them.
668
- if not self.resume or ref.kind == "pdf":
669
- return False
670
- json_rel = str(ref.rel.with_suffix(".json"))
671
- if self.api_mode:
672
- return self._join(json_rel) in self._existing
673
- return (self.root / json_rel).exists()
674
-
675
- def write_pages(
676
- self,
677
- ref: FileRef,
678
- per_page: List[Tuple[str, Dict[str, Any]]],
679
- pages: Optional[List[Image.Image]],
680
- ) -> None:
681
- targets = self._page_targets(ref, len(per_page))
682
- for (text, struct), (md_rel, json_rel) in zip(per_page, targets):
683
- md_bytes = text.encode("utf-8")
684
- json_bytes = json.dumps(struct, ensure_ascii=False).encode("utf-8")
685
- if self.api_mode:
686
- # .md first, .json last so a present .json marks the page complete.
687
- self._buffer.append((md_bytes, self._join(md_rel)))
688
- self._buffer.append((json_bytes, self._join(json_rel)))
689
- else:
690
- mp = self.root / md_rel
691
- mp.parent.mkdir(parents=True, exist_ok=True)
692
- mp.write_bytes(md_bytes)
693
- (self.root / json_rel).write_bytes(json_bytes)
694
-
695
- def write_error(self, ref: FileRef) -> None:
696
- # Write nothing on error so the file is retried on the next (resumed) run.
697
- pass
698
-
699
- def flush(self) -> None:
700
- if self.api_mode and self._buffer:
701
- self.api.batch_bucket_files(
702
- self.bucket_id, add=self._buffer, token=self.token
703
- )
704
- self._buffer = []
705
-
706
- def finalize(self, summary: Dict[str, Any]) -> None:
707
- self.flush()
708
- logger.info(f"Bucket files written to {self.label}")
709
-
710
-
711
- class DatasetSink:
712
- """Buffer one row per file, push a parquet dataset at the end (like surya-ocr.py)."""
713
-
714
- def __init__(
715
- self,
716
- repo_id: str,
717
- *,
718
- hf_token: Optional[str],
719
- private: bool,
720
- config: Optional[str],
721
- create_pr: bool,
722
- include_images: bool,
723
- output_column: str,
724
- blocks_column: str,
725
- ):
726
- self.repo_id = repo_id
727
- self.hf_token = hf_token
728
- self.private = private
729
- self.config = config
730
- self.create_pr = create_pr
731
- self.include_images = include_images
732
- self.output_column = output_column
733
- self.blocks_column = blocks_column
734
- self._rows: List[Dict[str, Any]] = []
735
-
736
- def is_done(self, ref: FileRef) -> bool:
737
- return False # single push at the end; no per-file resume
738
-
739
- def write_pages(
740
- self,
741
- ref: FileRef,
742
- per_page: List[Tuple[str, Dict[str, Any]]],
743
- pages: Optional[List[Image.Image]],
744
- ) -> None:
745
- row = {
746
- "file_name": ref.key,
747
- "num_pages": len(per_page),
748
- self.output_column: "\n\n".join(t for t, _ in per_page),
749
- self.blocks_column: json.dumps(
750
- [s for _, s in per_page], ensure_ascii=False
751
- ),
752
- }
753
- if self.include_images and pages:
754
- # First page only (keeps a single Image column); documented limitation.
755
- row["image"] = pages[0]
756
- self._rows.append(row)
757
-
758
- def write_error(self, ref: FileRef) -> None:
759
- self._rows.append(
760
- {
761
- "file_name": ref.key,
762
- "num_pages": 0,
763
- self.output_column: "[SURYA ERROR]",
764
- self.blocks_column: None,
765
- }
766
- )
767
-
768
- def flush(self) -> None:
769
- pass # single push at finalize
770
-
771
- def finalize(self, summary: Dict[str, Any]) -> None:
772
- from datasets import Dataset
773
-
774
- if not self._rows:
775
- logger.warning("No rows produced; nothing to push to the dataset.")
776
- return
777
-
778
- inference_entry = {
779
- "model": summary["model"],
780
- "model_name": "surya-ocr-2",
781
- "column_name": self.output_column,
782
- "blocks_column": self.blocks_column,
783
- "task": summary["task"],
784
- "table_mode": summary["table_mode"] if summary["task"] == "table" else None,
785
- "backend": "vllm-offline",
786
- "source": summary["source"],
787
- "io_mode": summary["io_mode"],
788
- "glob": summary["glob"],
789
- "page_range": summary["page_range"],
790
- "error_rate": summary["error_rate"],
791
- "timestamp": datetime.now(timezone.utc).isoformat(),
792
- "script": "surya-ocr-bucket.py",
793
- }
794
- for row in self._rows:
795
- row["inference_info"] = json.dumps([inference_entry])
796
-
797
- ds = Dataset.from_list(self._rows)
798
- if self.include_images and "image" in ds.column_names:
799
- try:
800
- from datasets import Image as HFImage
801
-
802
- ds = ds.cast_column("image", HFImage())
803
- except Exception as e: # noqa: BLE001
804
- logger.warning(f"Could not cast image column: {e}")
805
-
806
- logger.info(f"Pushing {len(ds)} rows to {self.repo_id}")
807
- push_kwargs = {
808
- "private": self.private,
809
- "token": self.hf_token,
810
- "max_shard_size": "500MB",
811
- "create_pr": self.create_pr,
812
- "commit_message": f"Add Surya OCR 2 {summary['task']} results ({len(ds)} files)"
813
- + (f" [{self.config}]" if self.config else ""),
814
- }
815
- if self.config:
816
- push_kwargs["config_name"] = self.config
817
-
818
- for attempt in range(1, 4):
819
- try:
820
- if attempt > 1:
821
- logger.warning("Disabling XET (fallback to HTTP upload)")
822
- os.environ["HF_HUB_DISABLE_XET"] = "1"
823
- ds.push_to_hub(self.repo_id, **push_kwargs)
824
- break
825
- except Exception as e: # noqa: BLE001
826
- logger.error(f"Upload attempt {attempt}/3 failed: {e}")
827
- if attempt == 3:
828
- logger.error("All upload attempts failed.")
829
- raise
830
- time.sleep(30 * (2 ** (attempt - 1)))
831
-
832
- self._push_card(summary, len(ds))
833
- logger.info(f"Dataset: https://huggingface.co/datasets/{self.repo_id}")
834
-
835
- def _push_card(self, summary: Dict[str, Any], n_rows: int) -> None:
836
- try:
837
- from huggingface_hub import DatasetCard
838
-
839
- card = DatasetCard(
840
- _dataset_card(
841
- source=summary["source"],
842
- model=summary["model"],
843
- task=summary["task"],
844
- table_mode=summary["table_mode"],
845
- io_mode=summary["io_mode"],
846
- n_files=n_rows,
847
- n_ok=summary["n_ok"],
848
- output_column=self.output_column,
849
- blocks_column=self.blocks_column,
850
- processing_time=summary["processing_time"],
851
- )
852
- )
853
- card.push_to_hub(self.repo_id, token=self.hf_token)
854
- except Exception as e: # noqa: BLE001
855
- logger.warning(f"Could not push dataset card: {e}")
856
-
857
-
858
- def _dataset_card(
859
- source: str,
860
- model: str,
861
- task: str,
862
- table_mode: str,
863
- io_mode: str,
864
- n_files: int,
865
- n_ok: int,
866
- output_column: str,
867
- blocks_column: str,
868
- processing_time: str,
869
- ) -> str:
870
- task_desc = {
871
- "ocr": "full-page OCR (structured HTML + bounding boxes)",
872
- "layout": "layout analysis (labelled regions + reading order)",
873
- "table": f"table recognition (mode `{table_mode}`)",
874
- }[task]
875
- return f"""---
876
- tags:
877
- - ocr
878
- - document-processing
879
- - surya
880
- - structured
881
- - uv-script
882
- - generated
883
- ---
884
-
885
- # Surya OCR 2 ({task}) on {source}
886
-
887
- {task_desc.capitalize()} over document files in the HF bucket
888
- `{source}`, using [Surya OCR 2](https://huggingface.co/{model}) (650M, Qwen3.5-based)
889
- by Datalab, via the [`surya-ocr`](https://github.com/datalab-to/surya) package, run
890
- as **offline vLLM batch inference** on Hugging Face Jobs (`surya-ocr-bucket.py`).
891
-
892
- ## Processing Details
893
-
894
- - **Source bucket**: `{source}`
895
- - **Model**: [{model}](https://huggingface.co/{model})
896
- - **Task**: `{task}`{f" (table mode `{table_mode}`)" if task == "table" else ""}
897
- - **I/O mode**: `{io_mode}`
898
- - **Text column**: `{output_column}` (flattened, reading-order text per file)
899
- - **Structured column**: `{blocks_column}` (JSON: per-page blocks with bbox / polygon / label / reading_order / confidence / html)
900
- - **Files**: {n_files:,}
901
- - **Processed OK**: {n_ok:,} / {n_files:,}
902
- - **Processing time**: {processing_time}
903
- - **Date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
904
-
905
- ## License note
906
-
907
- Surya's code is Apache-2.0, but the model **weights** use a modified OpenRAIL-M
908
- license: free for research, personal use, and startups under $5M funding/revenue,
909
- restricted from competitive use against Datalab's API. See the
910
- [model card](https://huggingface.co/{model}).
911
-
912
- ## Dataset Structure
913
-
914
- One row per source file:
915
- - `file_name`: source-relative path in the bucket
916
- - `num_pages`: pages OCR'd (1 for an image, N for a PDF)
917
- - `{output_column}`: flattened text (OCR), label outline (layout), or table HTML (table)
918
- - `{blocks_column}`: structured result as a JSON string (one entry per page)
919
- - `inference_info`: JSON list tracking models applied
920
-
921
- Generated with [UV Scripts](https://huggingface.co/uv-scripts).
922
- """
923
-
924
-
925
- # ---------------------------------------------------------------------------
926
- # Predictor + processing loop
927
- # ---------------------------------------------------------------------------
928
-
929
-
930
- def build_predictor(task: str, table_mode: str, manager):
931
- """Return a `run(images) -> page_results` closure (verbatim dispatch from parent)."""
932
- if task == "ocr":
933
- from surya.recognition import RecognitionPredictor
934
-
935
- predictor = RecognitionPredictor(manager)
936
-
937
- def run(images):
938
- return predictor(images, full_page=True)
939
- elif task == "layout":
940
- from surya.layout import LayoutPredictor
941
-
942
- predictor = LayoutPredictor(manager)
943
-
944
- def run(images):
945
- return predictor(images)
946
- else: # table
947
- from surya.table_rec import TableRecPredictor
948
-
949
- predictor = TableRecPredictor(manager)
950
-
951
- def run(images):
952
- return predictor(images, mode=table_mode)
953
-
954
- return run
955
-
956
-
957
- def process(
958
- refs: List[FileRef],
959
- source,
960
- run,
961
- task: str,
962
- sinks: List[Any],
963
- batch_size: int,
964
- load_pdf,
965
- page_indices: Optional[List[int]],
966
- pdf_dpi: int,
967
- ) -> Tuple[int, int, int, float, float]:
968
- """Resume-filter, then OCR file-by-file in batches.
969
-
970
- Returns (processed, ok, errors, io_secs, inf_secs). `io_secs` is time spent
971
- materializing batches (FUSE reads in mount mode; list-skip + batch download in
972
- copy mode); `inf_secs` is engine time (incl. one-time model load on the first
973
- batch). The split lets the mount-vs-copy benchmark isolate I/O from inference."""
974
- pending = [r for r in refs if not all(s.is_done(r) for s in sinks)]
975
- skipped = len(refs) - len(pending)
976
- if skipped:
977
- logger.info(f"Resume: skipping {skipped} already-complete file(s)")
978
- logger.info(f"Processing {len(pending)} file(s)")
979
-
980
- processed = ok = errors = 0
981
- io_secs = inf_secs = 0.0
982
- pbar = tqdm(total=len(pending), desc=f"Surya {task}")
983
- for chunk in partition_all(batch_size, pending):
984
- chunk = list(chunk)
985
- t_io = time.monotonic()
986
- with source.materialize(chunk, load_pdf, page_indices, pdf_dpi) as loaded:
987
- io_secs += time.monotonic() - t_io
988
- entries: List[Tuple[FileRef, List[Image.Image], int, int]] = []
989
- flat: List[Image.Image] = []
990
- for ref, pages in loaded:
991
- if not pages:
992
- for s in sinks:
993
- s.write_error(ref)
994
- errors += 1
995
- processed += 1
996
- pbar.update(1)
997
- continue
998
- entries.append((ref, pages, len(flat), len(pages)))
999
- flat.extend(pages)
1000
-
1001
- if flat:
1002
- t_inf = time.monotonic()
1003
- try:
1004
- results = run(flat)
1005
- except Exception as e: # noqa: BLE001
1006
- logger.error(f"Batch generate failed: {e}")
1007
- results = None
1008
- inf_secs += time.monotonic() - t_inf
1009
-
1010
- if results is None:
1011
- for ref, _pages, _start, _count in entries:
1012
- for s in sinks:
1013
- s.write_error(ref)
1014
- errors += 1
1015
- processed += 1
1016
- pbar.update(1)
1017
- else:
1018
- for ref, pages, start, count in entries:
1019
- per_page = serialize_per_page(
1020
- task, results[start : start + count]
1021
- )
1022
- for s in sinks:
1023
- s.write_pages(ref, per_page, pages)
1024
- ok += 1
1025
- processed += 1
1026
- pbar.update(1)
1027
-
1028
- for s in sinks:
1029
- s.flush()
1030
- pbar.close()
1031
- return processed, ok, errors, io_secs, inf_secs
1032
-
1033
-
1034
- # ---------------------------------------------------------------------------
1035
- # Main
1036
- # ---------------------------------------------------------------------------
1037
-
1038
-
1039
- def resolve_io_mode(io_mode: str, input_source: str) -> str:
1040
- if io_mode == "auto":
1041
- return "copy" if is_bucket_url(input_source) else "mount"
1042
- return io_mode
1043
-
1044
-
1045
- def main(args: argparse.Namespace) -> None:
1046
- # Unlock full Xet bandwidth for the model download (repo convention).
1047
- os.environ["HF_XET_HIGH_PERFORMANCE"] = "1"
1048
- # Surya reads settings from env at import; pin the checkpoint and forbid any
1049
- # server autostart (we inject our own offline backend instead).
1050
- os.environ["SURYA_MODEL_CHECKPOINT"] = args.model
1051
- os.environ["SURYA_INFERENCE_AUTOSTART"] = "False"
1052
-
1053
- check_cuda_availability()
1054
- start_time = datetime.now(timezone.utc)
1055
-
1056
- hf_token = args.hf_token or os.environ.get("HF_TOKEN")
1057
- if hf_token:
1058
- from huggingface_hub import login
1059
-
1060
- login(token=hf_token)
1061
-
1062
- exts = {e.strip().lower() for e in args.extensions.split(",") if e.strip()}
1063
- io_mode = resolve_io_mode(args.io_mode, args.input_source)
1064
-
1065
- # ---------- source ----------
1066
- if io_mode == "copy":
1067
- if not is_bucket_url(args.input_source):
1068
- logger.error("--io-mode copy requires an hf://buckets/... input.")
1069
- sys.exit(1)
1070
- source = CopySource(args.input_source, args.glob, exts, hf_token)
1071
- else:
1072
- root = Path(args.input_source)
1073
- if not root.is_dir():
1074
- logger.error(
1075
- f"--io-mode mount requires an existing directory (got {root}). "
1076
- "Mount the bucket with -v hf://buckets/<id>:/in:ro and pass /in."
1077
- )
1078
- sys.exit(1)
1079
- source = MountSource(root, args.glob, exts)
1080
- logger.info(f"I/O mode: {io_mode} Input: {args.input_source}")
1081
-
1082
- # ---------- sinks ----------
1083
- sinks: List[Any] = []
1084
- if args.output_bucket:
1085
- sinks.append(
1086
- BucketFilesSink(args.output_bucket, hf_token, resume=not args.no_resume)
1087
- )
1088
- if args.output_dataset:
1089
- sinks.append(
1090
- DatasetSink(
1091
- args.output_dataset,
1092
- hf_token=hf_token,
1093
- private=args.private,
1094
- config=args.config,
1095
- create_pr=args.create_pr,
1096
- include_images=args.include_images,
1097
- output_column=args.output_column,
1098
- blocks_column=args.blocks_column,
1099
- )
1100
- )
1101
-
1102
- # ---------- import Surya only after env is set ----------
1103
- from surya.input.load import load_pdf
1104
- from surya.settings import settings
1105
-
1106
- page_indices = parse_page_range(args.page_range)
1107
- pdf_dpi = args.pdf_dpi if args.pdf_dpi else settings.IMAGE_DPI_HIGHRES
1108
-
1109
- t_list = time.monotonic()
1110
- refs = source.list_refs(args.shuffle, args.seed, args.max_samples)
1111
- list_secs = time.monotonic() - t_list
1112
- if not refs:
1113
- logger.error("No matching files found. Check --glob / --extensions / input.")
1114
- sys.exit(1)
1115
- logger.info(
1116
- f"{len(refs)} file(s) listed in {list_secs:.1f}s | Model: {args.model} "
1117
- f"Task: {args.task}"
1118
- + (f" (mode {args.table_mode})" if args.task == "table" else "")
1119
- )
1120
-
1121
- # ---------- engine ----------
1122
- backend = OfflineVLLMBackend(
1123
- model=args.model,
1124
- max_model_len=args.max_model_len,
1125
- gpu_memory_utilization=args.gpu_memory_utilization,
1126
- dtype=args.dtype,
1127
- )
1128
- manager = make_manager(backend)
1129
- run = build_predictor(args.task, args.table_mode, manager)
1130
-
1131
- processed, ok, errors, io_secs, inf_secs = process(
1132
- refs,
1133
- source,
1134
- run,
1135
- args.task,
1136
- sinks,
1137
- args.batch_size,
1138
- load_pdf,
1139
- page_indices,
1140
- pdf_dpi,
1141
- )
1142
-
1143
- processing_time = (
1144
- f"{(datetime.now(timezone.utc) - start_time).total_seconds() / 60:.1f} min"
1145
- )
1146
- logger.info(
1147
- f"Processed {processed} (ok {ok}, errors {errors}) in {processing_time}"
1148
- )
1149
- # Benchmark breakdown: separate listing + per-batch I/O from engine time so the
1150
- # mount-vs-copy comparison isn't swamped by (identical) inference + model load.
1151
- pages_per_sec = ok / io_secs if io_secs else 0.0
1152
- logger.info(
1153
- f"[timing] io_mode={io_mode} list={list_secs:.1f}s io={io_secs:.1f}s "
1154
- f"inference={inf_secs:.1f}s files={ok} io_files_per_sec={pages_per_sec:.2f}"
1155
- )
1156
-
1157
- summary = {
1158
- "model": args.model,
1159
- "task": args.task,
1160
- "table_mode": args.table_mode,
1161
- "source": args.input_source,
1162
- "io_mode": io_mode,
1163
- "glob": args.glob,
1164
- "page_range": args.page_range,
1165
- "n_ok": ok,
1166
- "error_rate": (processed - ok) / processed if processed else 0.0,
1167
- "processing_time": processing_time,
1168
- }
1169
- for s in sinks:
1170
- s.finalize(summary)
1171
-
1172
- logger.info("Done! Surya OCR 2 (bucket) complete.")
1173
-
1174
- if args.verbose:
1175
- import importlib.metadata
1176
-
1177
- logger.info("--- Resolved package versions ---")
1178
- for pkg in [
1179
- "surya-ocr",
1180
- "vllm",
1181
- "transformers",
1182
- "torch",
1183
- "datasets",
1184
- "huggingface-hub",
1185
- "pillow",
1186
- "imagecodecs",
1187
- ]:
1188
- try:
1189
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
1190
- except importlib.metadata.PackageNotFoundError:
1191
- logger.info(f" {pkg}: not installed")
1192
-
1193
-
1194
- # ---------------------------------------------------------------------------
1195
- # CLI
1196
- # ---------------------------------------------------------------------------
1197
-
1198
-
1199
- def build_parser() -> argparse.ArgumentParser:
1200
- parser = argparse.ArgumentParser(
1201
- description="Surya OCR 2 (650M): structured OCR / layout / tables over a bucket of files",
1202
- formatter_class=argparse.RawDescriptionHelpFormatter,
1203
- epilog="""
1204
- I/O modes (--io-mode):
1205
- auto copy for an hf://buckets/... input, mount for a local dir (default)
1206
- mount read off a bucket mounted read-only at /in (-v hf://buckets/<id>:/in:ro)
1207
- copy list + batch-download via huggingface_hub to temp, OCR, delete the batch
1208
-
1209
- Outputs (at least one required):
1210
- --output-bucket per-page .md + .json mirroring input structure (mounted dir or
1211
- hf://buckets/... URL); resumable, O(1) memory
1212
- --output-dataset parquet dataset push (one row per file)
1213
-
1214
- Run on the vllm/vllm-openai:v0.20.1 image (offline vLLM batch; qwen3_5 is
1215
- version-sensitive — the site-packages python path is load-bearing):
1216
- --image vllm/vllm-openai:v0.20.1 --python /usr/local/bin/python3 \\
1217
- -e PYTHONPATH=/usr/local/lib/python3.12/site-packages
1218
- """,
1219
- )
1220
- parser.add_argument(
1221
- "input_source",
1222
- help="Mounted dir (e.g. /in) OR hf://buckets/<ns>/<bucket>[/prefix]",
1223
- )
1224
- parser.add_argument(
1225
- "--io-mode",
1226
- choices=["auto", "mount", "copy"],
1227
- default="auto",
1228
- help="Input I/O strategy (default: auto)",
1229
- )
1230
- parser.add_argument(
1231
- "--glob",
1232
- default="*",
1233
- help="fnmatch pattern over the source-relative path (default: '*'; "
1234
- "e.g. '*.jp2'). Applied on top of --extensions.",
1235
- )
1236
- parser.add_argument(
1237
- "--extensions",
1238
- default=DEFAULT_EXTENSIONS,
1239
- help=f"Comma-separated file extensions to read (default: {DEFAULT_EXTENSIONS})",
1240
- )
1241
- parser.add_argument(
1242
- "--output-bucket",
1243
- default=None,
1244
- help="Per-file .md + .json output: a mounted dir OR hf://buckets/<id>[/prefix]",
1245
- )
1246
- parser.add_argument(
1247
- "--output-dataset",
1248
- default=None,
1249
- help="Output dataset repo ID (parquet, one row per file)",
1250
- )
1251
- parser.add_argument(
1252
- "--no-resume",
1253
- action="store_true",
1254
- help="Disable resume-by-skip for --output-bucket (re-OCR everything)",
1255
- )
1256
- parser.add_argument(
1257
- "--task", choices=TASKS, default="ocr", help="Task (default: ocr)"
1258
- )
1259
- parser.add_argument(
1260
- "--table-mode",
1261
- choices=["full", "simple"],
1262
- default="full",
1263
- help="Table task: 'full' = HTML, 'simple' = rows/cols/cells (default: full)",
1264
- )
1265
- parser.add_argument(
1266
- "--page-range",
1267
- default=None,
1268
- help="Pages from PDFs, e.g. '0-5,7' (PDFs only)",
1269
- )
1270
- parser.add_argument(
1271
- "--pdf-dpi",
1272
- type=int,
1273
- default=None,
1274
- help="DPI for PDF rendering (default: Surya's IMAGE_DPI_HIGHRES)",
1275
- )
1276
- parser.add_argument(
1277
- "--max-samples", type=int, help="Limit number of files (for testing)"
1278
- )
1279
- parser.add_argument(
1280
- "--shuffle", action="store_true", help="Shuffle before sampling"
1281
- )
1282
- parser.add_argument(
1283
- "--seed", type=int, default=42, help="Shuffle seed (default: 42)"
1284
- )
1285
- parser.add_argument(
1286
- "--batch-size",
1287
- type=int,
1288
- default=16,
1289
- help="Images per offline llm.chat batch AND per copy-mode download/cleanup unit (default: 16)",
1290
- )
1291
- parser.add_argument(
1292
- "--max-model-len",
1293
- type=int,
1294
- default=18000,
1295
- help="vLLM context length (default: 18000)",
1296
- )
1297
- parser.add_argument(
1298
- "--gpu-memory-utilization",
1299
- type=float,
1300
- default=0.85,
1301
- help="vLLM GPU memory fraction (default: 0.85)",
1302
- )
1303
- parser.add_argument(
1304
- "--dtype",
1305
- default="bfloat16",
1306
- help="vLLM dtype (default: bfloat16; use float16 on T4/Turing)",
1307
- )
1308
- parser.add_argument(
1309
- "--model", default=DEFAULT_MODEL, help=f"Model ID (default: {DEFAULT_MODEL})"
1310
- )
1311
- parser.add_argument(
1312
- "--output-column",
1313
- default="markdown",
1314
- help="Dataset text column (default: markdown)",
1315
- )
1316
- parser.add_argument(
1317
- "--blocks-column",
1318
- default="surya_blocks",
1319
- help="Dataset structured JSON column (default: surya_blocks)",
1320
- )
1321
- parser.add_argument(
1322
- "--include-images",
1323
- action="store_true",
1324
- help="Embed the first page image in --output-dataset (memory-heavy)",
1325
- )
1326
- parser.add_argument(
1327
- "--private", action="store_true", help="Make output dataset private"
1328
- )
1329
- parser.add_argument(
1330
- "--config",
1331
- default=None,
1332
- help="Config/subset name when pushing (for benchmarking in one repo)",
1333
- )
1334
- parser.add_argument(
1335
- "--create-pr",
1336
- action="store_true",
1337
- help="Push dataset as a pull request instead of directly",
1338
- )
1339
- parser.add_argument("--hf-token", help="Hugging Face API token (or set HF_TOKEN)")
1340
- parser.add_argument(
1341
- "--verbose",
1342
- action="store_true",
1343
- help="Log resolved package versions after processing",
1344
- )
1345
- return parser
1346
-
1347
-
1348
- def _print_banner() -> None:
1349
- print(
1350
- "Surya OCR 2 (bucket) — structured OCR / layout / tables over a bucket of files (650M)"
1351
- )
1352
- print("\nUsage:")
1353
- print(
1354
- " uv run surya-ocr-bucket.py INPUT [--output-bucket ... | --output-dataset ...] [options]"
1355
- )
1356
- print("\nExamples:")
1357
- print(" # copy a bucket of .jp2 -> a dataset")
1358
- print(" uv run surya-ocr-bucket.py hf://buckets/me/news --io-mode copy \\")
1359
- print(" --glob '*.jp2' --output-dataset me/news-ocr --private")
1360
- print("\n # mount a bucket -> per-file .md + .json in an output bucket")
1361
- print(" uv run surya-ocr-bucket.py /in --io-mode mount --output-bucket /out")
1362
- print("\nRun on the vllm/vllm-openai:v0.20.1 image (offline vLLM batch):")
1363
- print(" hf jobs uv run --flavor l4x1 -s HF_TOKEN \\")
1364
- print(" --image vllm/vllm-openai:v0.20.1 --python /usr/local/bin/python3 \\")
1365
- print(" -e PYTHONPATH=/usr/local/lib/python3.12/site-packages \\")
1366
- print(" -v hf://buckets/me/news:/in:ro -v hf://buckets/me/news-ocr:/out \\")
1367
- print(
1368
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/surya-ocr-bucket.py \\"
1369
- )
1370
- print(" /in --io-mode mount --glob '*.jp2' --output-bucket /out")
1371
- print("\nFor full help: uv run surya-ocr-bucket.py --help")
1372
-
1373
-
1374
- if __name__ == "__main__":
1375
- if len(sys.argv) == 1:
1376
- _print_banner()
1377
- sys.exit(0)
1378
-
1379
- args = build_parser().parse_args()
1380
- if not args.output_bucket and not args.output_dataset:
1381
- build_parser().error(
1382
- "at least one of --output-bucket or --output-dataset is required"
1383
- )
1384
- if args.no_resume and not args.output_bucket:
1385
- logger.warning("--no-resume has no effect without --output-bucket")
1386
- if args.include_images and not args.output_dataset:
1387
- logger.warning("--include-images has no effect without --output-dataset")
1388
-
1389
- main(args)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
surya-ocr.py DELETED
@@ -1,915 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.10"
3
- # dependencies = [
4
- # "surya-ocr",
5
- # "datasets>=3.1.0",
6
- # "huggingface-hub",
7
- # "pillow",
8
- # "toolz",
9
- # "tqdm",
10
- # ]
11
- # ///
12
- """
13
- Document intelligence on images OR multi-page PDFs with Datalab's **Surya OCR 2**
14
- (`datalab-to/surya-ocr-2`, 650M, Qwen3.5-style).
15
-
16
- Surya is *structured* OCR: instead of a flat markdown blob, it returns per-block
17
- HTML with bounding boxes, reading order, and labels (equations in `<math>`). This
18
- recipe writes **both**:
19
-
20
- --output-column (default `markdown`) flattened, reading-order text per row
21
- surya_blocks the full structured result as JSON
22
- (bbox / polygon / label / reading_order /
23
- confidence / html per block), one entry
24
- per page.
25
-
26
- Three tasks via `--task`:
27
- ocr (default) full-page OCR -> text + per-block HTML/bboxes
28
- layout layout regions -> labelled boxes + reading order
29
- table table structure -> HTML (mode `full`) or rows/cols/cells
30
- (mode `simple`, via --table-mode)
31
-
32
- Input is one document per row:
33
- --image-column COL (default `image`) one image per row
34
- --pdf-column COL PDF bytes per row (multi-page; honors
35
- --page-range). Pages are concatenated in
36
- the text column and kept per-page in
37
- `surya_blocks`.
38
-
39
- ENGINE: Surya normally spawns a vLLM **server** (Docker) — which can't run inside
40
- an HF Job. This script instead does **offline batch inference**: it injects a
41
- custom in-process backend into Surya's `SuryaInferenceManager` that runs vLLM's
42
- offline `LLM().chat()` engine (no server, no HTTP). Surya still owns all the
43
- prompting, image preprocessing, and HTML/bbox parsing — we only swap the
44
- transport. Run on the **`vllm/vllm-openai:v0.20.1`** image (Surya's known-good
45
- vLLM build; the model is the recent, version-sensitive `qwen3_5` architecture).
46
-
47
- LICENSE NOTE: Surya's *code* is Apache-2.0 but the *weights* are a modified
48
- OpenRAIL-M license — free for research, personal use, and startups under $5M
49
- funding/revenue, but restricted from competitive use against Datalab's API.
50
- Confirm you are within those terms. https://huggingface.co/datalab-to/surya-ocr-2
51
-
52
- HF Jobs (use the pinned vLLM image so vLLM + qwen3_5 support are present):
53
-
54
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \\
55
- --image vllm/vllm-openai:v0.20.1 --python /usr/local/bin/python3 \\
56
- -e PYTHONPATH=/usr/local/lib/python3.12/site-packages \\
57
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/surya-ocr.py \\
58
- INPUT_DATASET OUTPUT_DATASET \\
59
- --max-samples 5 --shuffle --seed 42
60
-
61
- Model: datalab-to/surya-ocr-2 (package: surya-ocr, https://github.com/datalab-to/surya)
62
- """
63
-
64
- import argparse
65
- import io
66
- import json
67
- import logging
68
- import math
69
- import os
70
- import sys
71
- import tempfile
72
- import time
73
- from datetime import datetime, timezone
74
- from typing import Any, Dict, List, Optional, Tuple
75
- from urllib.request import urlopen
76
-
77
- from datasets import load_dataset
78
- from huggingface_hub import DatasetCard, login
79
- from PIL import Image
80
- from toolz import partition_all
81
- from tqdm import tqdm
82
-
83
- logging.basicConfig(level=logging.INFO)
84
- logger = logging.getLogger(__name__)
85
-
86
- DEFAULT_MODEL = "datalab-to/surya-ocr-2"
87
- # Surya's own vision-tiling bounds (from its vLLM backend), applied to the
88
- # offline engine too so preprocessing matches the server path exactly.
89
- MM_PROCESSOR_KWARGS = {"min_pixels": 3136, "max_pixels": 6291456}
90
- TASKS = ("ocr", "layout", "table")
91
-
92
-
93
- def check_cuda_availability() -> None:
94
- """Exit early with a clear message if there's no GPU."""
95
- import torch
96
-
97
- if not torch.cuda.is_available():
98
- logger.error("CUDA is not available. This script requires a GPU.")
99
- logger.error(
100
- "Run on Hugging Face Jobs with: hf jobs uv run --flavor l4x1 "
101
- "--image vllm/vllm-openai:v0.20.1 ..."
102
- )
103
- sys.exit(1)
104
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
105
-
106
-
107
- def check_vllm_available() -> None:
108
- """Fail fast (before loading 400 rows) if vLLM isn't importable.
109
-
110
- Surya-2 runs its VLM through vLLM's offline engine, but `vllm` is deliberately
111
- NOT a PEP723 dependency: the recent hybrid `qwen3_5` architecture is only in the
112
- pinned `vllm/vllm-openai:v0.20.1` image, which also provides torch/transformers via
113
- PYTHONPATH. Launched on the bare uv image (no `--image`), the import fails per-batch
114
- and every row silently gets "[SURYA GENERATE ERROR]". Detect that up front instead.
115
- """
116
- import importlib.util
117
-
118
- if importlib.util.find_spec("vllm") is None:
119
- logger.error("vLLM is not importable — this recipe cannot run on the bare uv image.")
120
- logger.error(
121
- "Surya-2 needs the pinned vLLM build; re-run with the image + interpreter flags:"
122
- )
123
- logger.error(
124
- " hf jobs uv run --flavor l4x1 -s HF_TOKEN \\\n"
125
- " --image vllm/vllm-openai:v0.20.1 --python /usr/local/bin/python3 \\\n"
126
- " -e PYTHONPATH=/usr/local/lib/python3.12/site-packages \\\n"
127
- " <script_url> INPUT_DATASET OUTPUT_DATASET ..."
128
- )
129
- sys.exit(1)
130
-
131
-
132
- def ensure_output_columns_free(dataset, columns, overwrite=False):
133
- """Fail fast if an output column would collide with an existing input column.
134
-
135
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
136
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
137
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
138
- column(s) here instead (logged) so the later add_column is clean.
139
- """
140
- clash = [c for c in columns if c in dataset.column_names]
141
- if not clash:
142
- return dataset
143
- if overwrite:
144
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
145
- return dataset.remove_columns(clash)
146
- logger.error(
147
- f"Output column(s) {clash} already exist in the input dataset "
148
- f"(columns: {dataset.column_names})."
149
- )
150
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
151
- sys.exit(1)
152
-
153
-
154
- def parse_page_range(spec: Optional[str]) -> Optional[List[int]]:
155
- """Turn '0-3,5' into [0,1,2,3,5]. None/empty -> None (all pages)."""
156
- if not spec:
157
- return None
158
- pages: List[int] = []
159
- for part in spec.split(","):
160
- part = part.strip()
161
- if not part:
162
- continue
163
- if "-" in part:
164
- lo, hi = part.split("-", 1)
165
- pages.extend(range(int(lo), int(hi) + 1))
166
- else:
167
- pages.append(int(part))
168
- return pages or None
169
-
170
-
171
- def cell_to_bytes(cell: Any) -> bytes:
172
- """Normalize an HF dataset cell (image or document) to raw file bytes."""
173
- if isinstance(cell, Image.Image):
174
- buf = io.BytesIO()
175
- cell.convert("RGB").save(buf, format="PNG")
176
- return buf.getvalue()
177
- if isinstance(cell, dict):
178
- if cell.get("bytes"):
179
- return cell["bytes"]
180
- if cell.get("path"):
181
- with open(cell["path"], "rb") as f:
182
- return f.read()
183
- raise ValueError(
184
- f"Unsupported image/document dict (no bytes/path): {list(cell)}"
185
- )
186
- if isinstance(cell, (bytes, bytearray)):
187
- return bytes(cell)
188
- if isinstance(cell, str):
189
- if cell.startswith(("http://", "https://")):
190
- return urlopen(cell).read() # noqa: S310
191
- with open(cell, "rb") as f:
192
- return f.read()
193
- raise ValueError(f"Unsupported cell type: {type(cell)}")
194
-
195
-
196
- def cell_to_pil(cell: Any) -> Image.Image:
197
- """One image cell -> RGB PIL image."""
198
- if isinstance(cell, Image.Image):
199
- return cell.convert("RGB")
200
- return Image.open(io.BytesIO(cell_to_bytes(cell))).convert("RGB")
201
-
202
-
203
- def load_pdf_images(
204
- load_pdf, cell: Any, page_indices: Optional[List[int]], dpi: int
205
- ) -> List[Image.Image]:
206
- """Render one PDF cell into page images via Surya's own pypdfium2 loader."""
207
- data = cell_to_bytes(cell)
208
- with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
209
- tmp.write(data)
210
- path = tmp.name
211
- try:
212
- images, _ = load_pdf(path, page_indices, dpi=dpi)
213
- return [im.convert("RGB") for im in images]
214
- finally:
215
- os.unlink(path)
216
-
217
-
218
- # --- structured-output shim (vLLM API moved between versions) ---
219
- def build_structured_outputs(schema: Dict[str, Any]) -> Dict[str, Any]:
220
- """SamplingParams kwargs for guided JSON, across vLLM versions (layout uses this)."""
221
- try:
222
- from vllm.sampling_params import StructuredOutputsParams # vLLM >= 0.12
223
-
224
- return {"structured_outputs": StructuredOutputsParams(json=schema)}
225
- except (ImportError, TypeError):
226
- pass
227
- try:
228
- from vllm.sampling_params import GuidedDecodingParams # older vLLM
229
-
230
- return {"guided_decoding": GuidedDecodingParams(json=schema)}
231
- except (ImportError, TypeError):
232
- pass
233
- logger.warning(
234
- "Guided JSON unavailable in this vLLM version; relying on the model."
235
- )
236
- return {}
237
-
238
-
239
- def _mean_token_prob(completion_output) -> Optional[float]:
240
- """Mean exp(logprob) of the sampled tokens -> Surya's per-block `confidence`."""
241
- lps = getattr(completion_output, "logprobs", None)
242
- if not lps:
243
- return None
244
- probs: List[float] = []
245
- for tid, lp_dict in zip(completion_output.token_ids, lps):
246
- if not lp_dict:
247
- continue
248
- entry = lp_dict.get(tid)
249
- if (
250
- entry is None
251
- ): # sampled token not in the returned top-k; use the best we have
252
- entry = max(lp_dict.values(), key=lambda e: e.logprob)
253
- probs.append(math.exp(entry.logprob))
254
- return sum(probs) / len(probs) if probs else None
255
-
256
-
257
- class OfflineVLLMBackend:
258
- """Surya `Backend` (duck-typed) that runs vLLM's offline `LLM().chat()` engine.
259
-
260
- Surya's predictors call `manager.generate(batch)` -> `backend.generate(batch)`;
261
- we satisfy that contract in-process (no server). Surya keeps ownership of the
262
- prompts (`PROMPT_MAPPING`), image scaling (`scale_to_fit`), and output parsing.
263
- """
264
-
265
- name = "offline-vllm"
266
-
267
- def __init__(
268
- self,
269
- model: str,
270
- max_model_len: int,
271
- gpu_memory_utilization: float,
272
- dtype: str = "bfloat16",
273
- max_tokens_default: int = 2048,
274
- logprobs_default: bool = True,
275
- ):
276
- self.model = model
277
- self.max_model_len = max_model_len
278
- self.gpu_memory_utilization = gpu_memory_utilization
279
- self.dtype = dtype
280
- self.max_tokens_default = max_tokens_default
281
- self.logprobs_default = logprobs_default
282
- self.llm = None
283
- self._build_messages = None
284
- self._scale_to_fit = None
285
- self._prompt_mapping = None
286
-
287
- def start(self):
288
- from vllm import LLM
289
-
290
- logger.info(
291
- f"Loading {self.model} into vLLM offline engine (dtype={self.dtype})..."
292
- )
293
- self.llm = LLM(
294
- model=self.model,
295
- dtype=self.dtype,
296
- max_model_len=self.max_model_len,
297
- gpu_memory_utilization=self.gpu_memory_utilization,
298
- mm_processor_kwargs=MM_PROCESSOR_KWARGS,
299
- limit_mm_per_prompt={"image": 1},
300
- )
301
- # Reuse Surya's exact request shaping so the offline path matches the server.
302
- from surya.inference.backends.openai_client import _build_messages
303
- from surya.inference.prompts import PROMPT_MAPPING
304
- from surya.inference.util import scale_to_fit
305
-
306
- self._build_messages = _build_messages
307
- self._scale_to_fit = scale_to_fit
308
- self._prompt_mapping = PROMPT_MAPPING
309
- return None
310
-
311
- def stop(self) -> None:
312
- self.llm = None
313
-
314
- def _sampling_params(self, item):
315
- from vllm import SamplingParams
316
-
317
- max_tokens = item.max_tokens or self.max_tokens_default
318
- want_logprobs = item.request_logprobs or self.logprobs_default
319
- kwargs: Dict[str, Any] = dict(temperature=0.0, top_p=0.1, max_tokens=max_tokens)
320
- if want_logprobs:
321
- kwargs["logprobs"] = 1
322
- if item.guided_json is not None:
323
- kwargs.update(build_structured_outputs(item.guided_json))
324
- return SamplingParams(**kwargs)
325
-
326
- def generate(self, batch):
327
- from surya.inference.schema import BatchOutputItem
328
-
329
- if self.llm is None:
330
- self.start()
331
- if not batch:
332
- return []
333
-
334
- conversations = []
335
- sampling_params = []
336
- for item in batch:
337
- prompt = item.prompt or self._prompt_mapping[item.prompt_type]
338
- image = self._scale_to_fit(item.image)
339
- conversations.append(self._build_messages(image, prompt))
340
- sampling_params.append(self._sampling_params(item))
341
-
342
- outputs = self.llm.chat(
343
- conversations,
344
- sampling_params,
345
- chat_template_content_format="openai",
346
- use_tqdm=False,
347
- )
348
-
349
- results = []
350
- for item, out in zip(batch, outputs):
351
- comp = out.outputs[0]
352
- results.append(
353
- BatchOutputItem(
354
- raw=comp.text,
355
- token_count=len(comp.token_ids),
356
- error=False,
357
- mean_token_prob=_mean_token_prob(comp),
358
- logprobs=None,
359
- metadata=item.metadata, # carries page_idx/block_idx — must round-trip
360
- )
361
- )
362
- return results
363
-
364
-
365
- def make_manager(backend: OfflineVLLMBackend):
366
- """A SuryaInferenceManager wired to our offline backend (bypassing autodetect)."""
367
- from surya.inference import SuryaInferenceManager
368
-
369
- manager = SuryaInferenceManager.__new__(SuryaInferenceManager)
370
- manager.method = backend.name
371
- manager.backend = backend
372
- return manager
373
-
374
-
375
- # --- result serialization (text column + structured surya_blocks) ---
376
- def _html_to_text(html: str) -> str:
377
- from bs4 import BeautifulSoup
378
-
379
- return BeautifulSoup(html, "html.parser").get_text(" ", strip=True)
380
-
381
-
382
- def serialize_pages(task: str, pages: List[Any]) -> Tuple[str, List[Dict[str, Any]]]:
383
- """(text, structured-per-page) for one row's page results."""
384
- structured = [p.model_dump(mode="json") for p in pages]
385
- page_texts: List[str] = []
386
- for page in pages:
387
- if task == "ocr":
388
- parts = []
389
- for b in sorted(page.blocks, key=lambda b: b.reading_order):
390
- if b.skipped or not b.html:
391
- continue
392
- txt = _html_to_text(b.html)
393
- if txt:
394
- parts.append(txt)
395
- page_texts.append("\n".join(parts))
396
- elif task == "layout":
397
- # No OCR text in layout mode — emit a reading-order outline of labels.
398
- page_texts.append(
399
- "\n".join(
400
- f"{b.position}: {b.label}"
401
- for b in sorted(page.bboxes, key=lambda b: b.position)
402
- )
403
- )
404
- else: # table
405
- if page.html: # mode="full"
406
- page_texts.append(page.html)
407
- else: # mode="simple"
408
- page_texts.append(f"{len(page.rows)} rows x {len(page.cols)} cols")
409
- return "\n\n".join(page_texts), structured
410
-
411
-
412
- def create_dataset_card(
413
- source_dataset: str,
414
- model: str,
415
- task: str,
416
- table_mode: str,
417
- num_samples: int,
418
- n_ok: int,
419
- source_column: str,
420
- is_pdf: bool,
421
- page_range: Optional[str],
422
- output_column: str,
423
- blocks_column: str,
424
- split: str,
425
- processing_time: str,
426
- ) -> str:
427
- input_kind = "PDF documents" if is_pdf else "images"
428
- col_desc = "PDF" if is_pdf else "image"
429
- if page_range:
430
- col_desc += f", pages {page_range}"
431
- task_desc = {
432
- "ocr": "full-page OCR (structured HTML + bounding boxes)",
433
- "layout": "layout analysis (labelled regions + reading order)",
434
- "table": f"table recognition (mode `{table_mode}`)",
435
- }[task]
436
- return f"""---
437
- tags:
438
- - ocr
439
- - document-processing
440
- - surya
441
- - structured
442
- - uv-script
443
- - generated
444
- ---
445
-
446
- # Surya OCR 2 ({task}) on {source_dataset}
447
-
448
- {task_desc.capitalize()} over {input_kind} in
449
- [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using
450
- [Surya OCR 2](https://huggingface.co/{model}) (650M, Qwen3.5-based) by Datalab, via the
451
- [`surya-ocr`](https://github.com/datalab-to/surya) package, run as **offline vLLM batch
452
- inference** on Hugging Face Jobs.
453
-
454
- ## Processing Details
455
-
456
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
457
- - **Model**: [{model}](https://huggingface.co/{model})
458
- - **Task**: `{task}`{f" (table mode `{table_mode}`)" if task == "table" else ""}
459
- - **Input column**: `{source_column}` ({col_desc})
460
- - **Text column**: `{output_column}` (flattened, reading-order text per row)
461
- - **Structured column**: `{blocks_column}` (JSON: per-page blocks with bbox / polygon / label / reading_order / confidence / html)
462
- - **Split**: `{split}`
463
- - **Samples**: {num_samples:,}
464
- - **Processed OK**: {n_ok:,} / {num_samples:,}
465
- - **Processing time**: {processing_time}
466
- - **Date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
467
-
468
- ## License note
469
-
470
- Surya's code is Apache-2.0, but the model **weights** use a modified OpenRAIL-M
471
- license: free for research, personal use, and startups under $5M funding/revenue,
472
- restricted from competitive use against Datalab's API. See the
473
- [model card](https://huggingface.co/{model}).
474
-
475
- ## Dataset Structure
476
-
477
- Original columns plus:
478
- - `{output_column}`: flattened text (OCR), label outline (layout), or table HTML (table)
479
- - `{blocks_column}`: structured result as a JSON string (one entry per page)
480
- - `inference_info`: JSON list tracking models applied to this dataset
481
-
482
- Generated with [UV Scripts](https://huggingface.co/uv-scripts).
483
- """
484
-
485
-
486
- def main(
487
- input_dataset: str,
488
- output_dataset: str,
489
- task: str = "ocr",
490
- table_mode: str = "full",
491
- image_column: str = "image",
492
- pdf_column: Optional[str] = None,
493
- output_column: str = "markdown",
494
- overwrite: bool = False,
495
- blocks_column: str = "surya_blocks",
496
- page_range: Optional[str] = None,
497
- split: str = "train",
498
- max_samples: Optional[int] = None,
499
- shuffle: bool = False,
500
- seed: int = 42,
501
- batch_size: int = 16,
502
- max_model_len: int = 18000,
503
- gpu_memory_utilization: float = 0.85,
504
- dtype: str = "bfloat16",
505
- model: str = DEFAULT_MODEL,
506
- private: bool = False,
507
- config: Optional[str] = None,
508
- create_pr: bool = False,
509
- hf_token: Optional[str] = None,
510
- verbose: bool = False,
511
- ) -> None:
512
- # Unlock full Xet bandwidth for the model download (repo convention).
513
- os.environ["HF_XET_HIGH_PERFORMANCE"] = "1"
514
- # Surya reads settings from env at import; pin the checkpoint and forbid any
515
- # server autostart (we inject our own offline backend instead).
516
- os.environ["SURYA_MODEL_CHECKPOINT"] = model
517
- os.environ["SURYA_INFERENCE_AUTOSTART"] = "False"
518
-
519
- check_cuda_availability()
520
- check_vllm_available()
521
- start_time = datetime.now(timezone.utc)
522
-
523
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
524
- if HF_TOKEN:
525
- login(token=HF_TOKEN)
526
-
527
- # Import Surya only after env is set.
528
- from surya.input.load import load_pdf
529
- from surya.settings import settings
530
-
531
- source_column = pdf_column or image_column
532
- is_pdf = pdf_column is not None
533
- page_indices = parse_page_range(page_range)
534
- pdf_dpi = settings.IMAGE_DPI_HIGHRES
535
-
536
- logger.info(
537
- f"Model: {model} Task: {task}"
538
- + (f" (mode {table_mode})" if task == "table" else "")
539
- )
540
- logger.info(f"Loading dataset: {input_dataset} (split={split})")
541
- dataset = load_dataset(input_dataset, split=split)
542
- if source_column not in dataset.column_names:
543
- logger.error(
544
- f"Column '{source_column}' not found. Available: {dataset.column_names}"
545
- )
546
- sys.exit(1)
547
- # Fail fast if the output column would collide with an existing input column
548
- dataset = ensure_output_columns_free(
549
- dataset, [output_column, blocks_column], overwrite=overwrite
550
- )
551
- if shuffle:
552
- dataset = dataset.shuffle(seed=seed)
553
- if max_samples:
554
- dataset = dataset.select(range(min(max_samples, len(dataset))))
555
- n = len(dataset)
556
- logger.info(f"Processing {n} documents from column '{source_column}'")
557
-
558
- # Build the offline engine + inject it into a Surya manager, then pick the predictor.
559
- backend = OfflineVLLMBackend(
560
- model=model,
561
- max_model_len=max_model_len,
562
- gpu_memory_utilization=gpu_memory_utilization,
563
- dtype=dtype,
564
- )
565
- manager = make_manager(backend)
566
-
567
- if task == "ocr":
568
- from surya.recognition import RecognitionPredictor
569
-
570
- predictor = RecognitionPredictor(manager)
571
-
572
- def run(images):
573
- return predictor(images, full_page=True)
574
- elif task == "layout":
575
- from surya.layout import LayoutPredictor
576
-
577
- predictor = LayoutPredictor(manager)
578
-
579
- def run(images):
580
- return predictor(images)
581
- else: # table
582
- from surya.table_rec import TableRecPredictor
583
-
584
- predictor = TableRecPredictor(manager)
585
-
586
- def run(images):
587
- return predictor(images, mode=table_mode)
588
-
589
- texts: List[Optional[str]] = [None] * n
590
- blocks: List[Optional[str]] = [None] * n
591
- error_flags: List[bool] = [True] * n
592
-
593
- for chunk in tqdm(list(partition_all(batch_size, range(n))), desc=f"Surya {task}"):
594
- chunk = list(chunk)
595
- flat_images: List[Image.Image] = []
596
- spans: List[Tuple[int, int, int]] = [] # (row_idx, start, count)
597
- for i in chunk:
598
- try:
599
- if is_pdf:
600
- imgs = load_pdf_images(
601
- load_pdf, dataset[i][source_column], page_indices, pdf_dpi
602
- )
603
- else:
604
- imgs = [cell_to_pil(dataset[i][source_column])]
605
- except Exception as e:
606
- logger.warning(f"Row {i}: failed to load document: {e}")
607
- texts[i] = f"[SURYA LOAD ERROR] {e}"
608
- blocks[i] = None
609
- continue
610
- if not imgs:
611
- texts[i] = "[SURYA EMPTY DOCUMENT]"
612
- continue
613
- spans.append((i, len(flat_images), len(imgs)))
614
- flat_images.extend(imgs)
615
-
616
- if not flat_images:
617
- continue
618
- try:
619
- results = run(flat_images)
620
- except Exception as e:
621
- logger.error(f"Batch generate failed: {e}")
622
- for i, _, _ in spans:
623
- texts[i] = "[SURYA GENERATE ERROR]"
624
- blocks[i] = None
625
- continue
626
-
627
- for i, start, count in spans:
628
- page_results = results[start : start + count]
629
- text, structured = serialize_pages(task, page_results)
630
- texts[i] = text
631
- blocks[i] = json.dumps(structured, ensure_ascii=False)
632
- error_flags[i] = False
633
-
634
- n_ok = sum(not f for f in error_flags)
635
- logger.info(f"Processed OK: {n_ok}/{n}")
636
-
637
- dataset = dataset.add_column(output_column, texts)
638
- dataset = dataset.add_column(blocks_column, blocks)
639
-
640
- inference_entry = {
641
- "model": model,
642
- "model_name": "surya-ocr-2",
643
- "column_name": output_column,
644
- "blocks_column": blocks_column,
645
- "task": task,
646
- "table_mode": table_mode if task == "table" else None,
647
- "backend": "vllm-offline",
648
- "page_range": page_range,
649
- "error_rate": (n - n_ok) / n if n else 0.0,
650
- "timestamp": datetime.now(timezone.utc).isoformat(),
651
- "script": "surya-ocr.py",
652
- }
653
- if "inference_info" in dataset.column_names:
654
-
655
- def update_info(example):
656
- try:
657
- existing = (
658
- json.loads(example["inference_info"])
659
- if example["inference_info"]
660
- else []
661
- )
662
- except (json.JSONDecodeError, TypeError):
663
- existing = []
664
- existing.append(inference_entry)
665
- return {"inference_info": json.dumps(existing)}
666
-
667
- dataset = dataset.map(update_info)
668
- else:
669
- dataset = dataset.add_column(
670
- "inference_info", [json.dumps([inference_entry])] * n
671
- )
672
-
673
- processing_time = (
674
- f"{(datetime.now(timezone.utc) - start_time).total_seconds() / 60:.1f} min"
675
- )
676
-
677
- logger.info(f"Pushing to {output_dataset}")
678
- max_retries = 3
679
- for attempt in range(1, max_retries + 1):
680
- try:
681
- if attempt > 1:
682
- logger.warning("Disabling XET (fallback to HTTP upload)")
683
- os.environ["HF_HUB_DISABLE_XET"] = "1"
684
- dataset.push_to_hub(
685
- output_dataset,
686
- private=private,
687
- token=HF_TOKEN,
688
- max_shard_size="500MB",
689
- create_pr=create_pr,
690
- **({"config_name": config} if config else {}),
691
- commit_message=f"Add Surya OCR 2 {task} results ({n} samples)"
692
- + (f" [{config}]" if config else ""),
693
- )
694
- break
695
- except Exception as e:
696
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
697
- if attempt < max_retries:
698
- delay = 30 * (2 ** (attempt - 1))
699
- logger.info(f"Retrying in {delay}s...")
700
- time.sleep(delay)
701
- else:
702
- logger.error("All upload attempts failed. Results are lost.")
703
- sys.exit(1)
704
-
705
- try:
706
- card = DatasetCard(
707
- create_dataset_card(
708
- source_dataset=input_dataset,
709
- model=model,
710
- task=task,
711
- table_mode=table_mode,
712
- num_samples=n,
713
- n_ok=n_ok,
714
- source_column=source_column,
715
- is_pdf=is_pdf,
716
- page_range=page_range,
717
- output_column=output_column,
718
- blocks_column=blocks_column,
719
- split=split,
720
- processing_time=processing_time,
721
- )
722
- )
723
- card.push_to_hub(output_dataset, token=HF_TOKEN)
724
- except Exception as e:
725
- logger.warning(f"Could not push dataset card: {e}")
726
-
727
- logger.info("Done! Surya OCR 2 complete.")
728
- logger.info(f"Dataset: https://huggingface.co/datasets/{output_dataset}")
729
- logger.info(f"Processing time: {processing_time}")
730
-
731
- if verbose:
732
- import importlib.metadata
733
-
734
- logger.info("--- Resolved package versions ---")
735
- for pkg in ["surya-ocr", "vllm", "transformers", "torch", "datasets", "pillow"]:
736
- try:
737
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
738
- except importlib.metadata.PackageNotFoundError:
739
- logger.info(f" {pkg}: not installed")
740
-
741
-
742
- if __name__ == "__main__":
743
- if len(sys.argv) == 1:
744
- print(
745
- "Surya OCR 2 — structured OCR / layout / tables from images & PDFs (650M)"
746
- )
747
- print("\nUsage:")
748
- print(" uv run surya-ocr.py INPUT OUTPUT [--task ocr|layout|table] [options]")
749
- print("\nExamples:")
750
- print(" # full-page OCR -> text + structured surya_blocks")
751
- print(" uv run surya-ocr.py my-images my-ocr")
752
- print("\n # layout regions / table structure")
753
- print(" uv run surya-ocr.py my-images my-layout --task layout")
754
- print(" uv run surya-ocr.py my-tables my-tables-out --task table")
755
- print("\n # multi-page PDFs")
756
- print(" uv run surya-ocr.py my-pdfs my-ocr --pdf-column pdf --page-range 0-5")
757
- print("\nRun on the vllm/vllm-openai:v0.20.1 image (offline vLLM batch).")
758
- print("For full help: uv run surya-ocr.py --help")
759
- sys.exit(0)
760
-
761
- parser = argparse.ArgumentParser(
762
- description="Surya OCR 2 (650M): structured OCR / layout / tables, offline vLLM batch",
763
- formatter_class=argparse.RawDescriptionHelpFormatter,
764
- epilog="""
765
- Tasks (--task):
766
- ocr full-page OCR -> reading-order text + per-block HTML/bboxes (default)
767
- layout layout regions -> labelled boxes + reading order
768
- table table structure -> HTML (--table-mode full) or rows/cols/cells (simple)
769
-
770
- Output columns:
771
- --output-column flattened text per row (default: markdown)
772
- surya_blocks structured JSON per row (bbox/label/reading_order/confidence/html)
773
-
774
- Input (one document per row):
775
- --image-column COL one image per row (default: image)
776
- --pdf-column COL PDF bytes per row (multi-page; honors --page-range)
777
-
778
- Run on the vllm/vllm-openai:v0.20.1 image:
779
- --image vllm/vllm-openai:v0.20.1 --python /usr/local/bin/python3 \\
780
- -e PYTHONPATH=/usr/local/lib/python3.12/site-packages
781
- """,
782
- )
783
- parser.add_argument(
784
- "input_dataset", help="Input dataset ID from the Hugging Face Hub"
785
- )
786
- parser.add_argument(
787
- "output_dataset", help="Output dataset ID for the Hugging Face Hub"
788
- )
789
- parser.add_argument(
790
- "--task", choices=TASKS, default="ocr", help="Task (default: ocr)"
791
- )
792
- parser.add_argument(
793
- "--table-mode",
794
- choices=["full", "simple"],
795
- default="full",
796
- help="Table task: 'full' = HTML, 'simple' = rows/cols/cells (default: full)",
797
- )
798
- parser.add_argument(
799
- "--image-column", default="image", help="Image column (default: image)"
800
- )
801
- parser.add_argument(
802
- "--pdf-column",
803
- default=None,
804
- help="PDF column (bytes/path). Mutually exclusive with --image-column.",
805
- )
806
- parser.add_argument(
807
- "--output-column",
808
- default="markdown",
809
- help="Text output column (default: markdown)",
810
- )
811
- parser.add_argument(
812
- "--overwrite",
813
- action="store_true",
814
- help="Replace the output column if it already exists in the input dataset "
815
- "(default: error out to avoid clobbering an existing column).",
816
- )
817
- parser.add_argument(
818
- "--blocks-column",
819
- default="surya_blocks",
820
- help="Structured JSON output column (default: surya_blocks)",
821
- )
822
- parser.add_argument(
823
- "--page-range",
824
- default=None,
825
- help="Pages from PDFs, e.g. '0-5,7' (PDF column only)",
826
- )
827
- parser.add_argument(
828
- "--split", default="train", help="Dataset split (default: train)"
829
- )
830
- parser.add_argument(
831
- "--max-samples", type=int, help="Limit number of documents (for testing)"
832
- )
833
- parser.add_argument(
834
- "--shuffle", action="store_true", help="Shuffle before sampling"
835
- )
836
- parser.add_argument(
837
- "--seed", type=int, default=42, help="Shuffle seed (default: 42)"
838
- )
839
- parser.add_argument(
840
- "--batch-size",
841
- type=int,
842
- default=16,
843
- help="Rows (images) per offline llm.chat batch (default: 16)",
844
- )
845
- parser.add_argument(
846
- "--max-model-len",
847
- type=int,
848
- default=18000,
849
- help="vLLM context length (default: 18000)",
850
- )
851
- parser.add_argument(
852
- "--gpu-memory-utilization",
853
- type=float,
854
- default=0.85,
855
- help="vLLM GPU memory fraction (default: 0.85)",
856
- )
857
- parser.add_argument(
858
- "--dtype",
859
- default="bfloat16",
860
- help="vLLM dtype (default: bfloat16; use float16 on T4/Turing)",
861
- )
862
- parser.add_argument(
863
- "--model", default=DEFAULT_MODEL, help=f"Model ID (default: {DEFAULT_MODEL})"
864
- )
865
- parser.add_argument(
866
- "--private", action="store_true", help="Make output dataset private"
867
- )
868
- parser.add_argument(
869
- "--config",
870
- default=None,
871
- help="Config/subset name when pushing (for benchmarking in one repo)",
872
- )
873
- parser.add_argument(
874
- "--create-pr",
875
- action="store_true",
876
- help="Push as a pull request instead of directly",
877
- )
878
- parser.add_argument("--hf-token", help="Hugging Face API token (or set HF_TOKEN)")
879
- parser.add_argument(
880
- "--verbose",
881
- action="store_true",
882
- help="Log resolved package versions after processing",
883
- )
884
-
885
- args = parser.parse_args()
886
-
887
- if args.pdf_column and args.image_column != "image":
888
- parser.error("--image-column and --pdf-column are mutually exclusive.")
889
-
890
- main(
891
- input_dataset=args.input_dataset,
892
- output_dataset=args.output_dataset,
893
- task=args.task,
894
- table_mode=args.table_mode,
895
- image_column=args.image_column,
896
- pdf_column=args.pdf_column,
897
- output_column=args.output_column,
898
- overwrite=args.overwrite,
899
- blocks_column=args.blocks_column,
900
- page_range=args.page_range,
901
- split=args.split,
902
- max_samples=args.max_samples,
903
- shuffle=args.shuffle,
904
- seed=args.seed,
905
- batch_size=args.batch_size,
906
- max_model_len=args.max_model_len,
907
- gpu_memory_utilization=args.gpu_memory_utilization,
908
- dtype=args.dtype,
909
- model=args.model,
910
- private=args.private,
911
- config=args.config,
912
- create_pr=args.create_pr,
913
- hf_token=args.hf_token,
914
- verbose=args.verbose,
915
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tesseract-ocr.py DELETED
@@ -1,646 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.10"
3
- # dependencies = [
4
- # "pytesseract",
5
- # "datasets>=3.1.0",
6
- # "huggingface-hub[hf_transfer]",
7
- # "pillow",
8
- # "tqdm",
9
- # ]
10
- # ///
11
- """
12
- Plain-text OCR with **Tesseract** — the classical CPU OCR engine, as a baseline.
13
-
14
- This is the odd one out in this collection: no GPU, no VLM. It runs Google's
15
- Tesseract (v5, LSTM) over an image dataset and writes the recognised text to a
16
- column, so you can put a cheap, fast, no-GPU baseline next to the VLM recipes
17
- (e.g. in a per-collection leaderboard like ocr-bench). Output is plain text, not
18
- markdown — Tesseract has no notion of tables/formulas/layout-as-markdown.
19
-
20
- CPU-ONLY: this recipe deliberately does not require a GPU. Run it on a
21
- `cpu-basic` / `cpu-upgrade` flavor. `--num-workers` fans OCR out across cores.
22
-
23
- SYSTEM DEPENDENCY: the `tesseract` binary is NOT in the default Jobs image. On
24
- startup this script installs it via apt (`tesseract-ocr`; Jobs containers run as
25
- root). For non-English languages it also tries to install the matching
26
- `tesseract-ocr-<lang>` data pack. If your Jobs image blocks apt, bake Tesseract
27
- into a custom `--image` instead.
28
-
29
- HF Jobs (CPU — no GPU needed):
30
-
31
- hf jobs uv run --flavor cpu-upgrade -s HF_TOKEN \\
32
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/tesseract-ocr.py \\
33
- input-dataset output-dataset \\
34
- --max-samples 100 --shuffle
35
-
36
- Language packs (apt codes are 3-letter ISO 639-2, same as Tesseract's `--lang`):
37
-
38
- --lang eng English (default; ships with the base package)
39
- --lang fra French (installs tesseract-ocr-fra)
40
- --lang eng+fra multiple languages, '+'-joined
41
-
42
- Engine: Tesseract OCR (https://github.com/tesseract-ocr/tesseract), Apache-2.0.
43
- """
44
-
45
- import argparse
46
- import io
47
- import json
48
- import logging
49
- import os
50
- import shutil
51
- import subprocess
52
- import sys
53
- import time
54
- from concurrent.futures import ThreadPoolExecutor
55
- from datetime import datetime, timezone
56
- from typing import Any, Dict, List, Union
57
-
58
- from datasets import load_dataset
59
- from huggingface_hub import DatasetCard, login
60
- from PIL import Image
61
- from tqdm import tqdm
62
-
63
- logging.basicConfig(level=logging.INFO)
64
- logger = logging.getLogger(__name__)
65
-
66
- # Stable leaderboard label. Tesseract is not on the Hub, so this is deliberately
67
- # NOT an org/name Hub id (faking one would produce broken links in dataset cards
68
- # and a misleading leaderboard row). The precise installed version is recorded
69
- # separately in inference_info as `tesseract_version`.
70
- MODEL_ID = "tesseract-5"
71
- MODEL_NAME = "Tesseract"
72
- PROJECT_URL = "https://github.com/tesseract-ocr/tesseract"
73
-
74
- # Error sentinel written to the output column when a single image fails, so one
75
- # bad page never sinks the whole run (matches the other recipes in this repo).
76
- OCR_ERROR = "[OCR ERROR]"
77
-
78
-
79
- def ensure_tesseract_installed(lang: str) -> None:
80
- """Install the `tesseract` binary (and language data) if it's missing.
81
-
82
- The default Jobs image has no `tesseract`. Containers run as root, so apt
83
- works. The base `tesseract-ocr` package ships English; other languages need
84
- their own `tesseract-ocr-<code>` data pack. Base install is fatal on
85
- failure (nothing to OCR with); language-pack installs are best-effort (a
86
- missing pack surfaces as a clear Tesseract error at OCR time anyway).
87
- """
88
- requested = [c.strip() for c in lang.split("+") if c.strip()]
89
-
90
- if shutil.which("tesseract") is None:
91
- logger.info("`tesseract` not found — installing via apt (Jobs runs as root)...")
92
- try:
93
- subprocess.run(["apt-get", "update", "-qq"], check=True)
94
- subprocess.run(
95
- ["apt-get", "install", "-y", "-qq", "tesseract-ocr"], check=True
96
- )
97
- except Exception as e:
98
- logger.error(f"Failed to apt-install tesseract-ocr: {e}")
99
- logger.error(
100
- "If this Jobs image blocks apt, run with a custom --image that has "
101
- "Tesseract preinstalled (apt package `tesseract-ocr`)."
102
- )
103
- sys.exit(1)
104
- if shutil.which("tesseract") is None:
105
- logger.error("tesseract still not on PATH after install — aborting.")
106
- sys.exit(1)
107
- logger.info("Installed tesseract.")
108
-
109
- # Install any missing non-English language data packs (best-effort).
110
- try:
111
- import pytesseract
112
-
113
- available = set(pytesseract.get_languages(config=""))
114
- except Exception:
115
- available = set()
116
- missing = [c for c in requested if c not in available and c not in ("osd",)]
117
- if missing:
118
- pkgs = [f"tesseract-ocr-{c}" for c in missing]
119
- logger.info(f"Installing language data pack(s): {pkgs}")
120
- try:
121
- subprocess.run(["apt-get", "update", "-qq"], check=True)
122
- subprocess.run(["apt-get", "install", "-y", "-qq", *pkgs], check=True)
123
- except Exception as e:
124
- logger.warning(
125
- f"Could not install language pack(s) {pkgs}: {e}. "
126
- f"OCR will fail for those languages if the data is absent."
127
- )
128
-
129
-
130
- def detect_tesseract_version() -> str:
131
- """Return the installed Tesseract version string (best-effort)."""
132
- try:
133
- import pytesseract
134
-
135
- return str(pytesseract.get_tesseract_version())
136
- except Exception:
137
- return "unknown"
138
-
139
-
140
- def ensure_output_columns_free(dataset, columns, overwrite=False):
141
- """Fail fast if an output column would collide with an existing input column.
142
-
143
- Adding a column that already exists silently overwrites it (e.g. a
144
- ground-truth `text`/`markdown` column) or crashes on push with a
145
- duplicate-column error only *after* OCR has run. Catch it up front. With
146
- overwrite=True, drop the clashing column(s) here instead (logged).
147
- """
148
- clash = [c for c in columns if c in dataset.column_names]
149
- if not clash:
150
- return dataset
151
- if overwrite:
152
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
153
- return dataset.remove_columns(clash)
154
- logger.error(
155
- f"Output column(s) {clash} already exist in the input dataset "
156
- f"(columns: {dataset.column_names})."
157
- )
158
- logger.error(
159
- "Choose a different --output-column, or pass --overwrite to replace them."
160
- )
161
- sys.exit(1)
162
-
163
-
164
- def to_pil(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
165
- """Coerce a datasets image cell to an RGB PIL image.
166
-
167
- Handles the three shapes a HF image column yields: a decoded PIL image, a
168
- `{"bytes": ...}` dict, or a file path string.
169
- """
170
- if isinstance(image, Image.Image):
171
- pil_img = image
172
- elif isinstance(image, dict) and "bytes" in image:
173
- pil_img = Image.open(io.BytesIO(image["bytes"]))
174
- elif isinstance(image, str):
175
- pil_img = Image.open(image)
176
- else:
177
- raise ValueError(f"Unsupported image type: {type(image)}")
178
- return pil_img.convert("RGB")
179
-
180
-
181
- def ocr_image(
182
- image: Union[Image.Image, Dict[str, Any], str],
183
- lang: str,
184
- config: str,
185
- ) -> str:
186
- """Run Tesseract on a single image, returning stripped text (or the error sentinel)."""
187
- import pytesseract
188
-
189
- try:
190
- pil_img = to_pil(image)
191
- return pytesseract.image_to_string(pil_img, lang=lang, config=config).strip()
192
- except Exception as e: # noqa: BLE001 — one bad page shouldn't sink the run
193
- logger.error(f"Error OCR'ing image: {e}")
194
- return OCR_ERROR
195
-
196
-
197
- def run_ocr(
198
- dataset,
199
- image_column: str,
200
- lang: str,
201
- config: str,
202
- num_workers: int,
203
- ) -> List[str]:
204
- """OCR every row's image, preserving dataset order.
205
-
206
- Tesseract shells out to a subprocess, so ThreadPoolExecutor gives real
207
- parallelism (the GIL is released during the call). We process in chunks so
208
- only a chunk's worth of decoded images is held in memory at once — the full
209
- dataset stays on disk.
210
- """
211
- n = len(dataset)
212
- results: List[str] = []
213
- chunk = max(num_workers * 4, 16)
214
-
215
- with tqdm(total=n, desc="OCR", unit="img") as pbar:
216
- if num_workers <= 1:
217
- for i in range(n):
218
- results.append(ocr_image(dataset[i][image_column], lang, config))
219
- pbar.update(1)
220
- else:
221
- with ThreadPoolExecutor(max_workers=num_workers) as pool:
222
- for start in range(0, n, chunk):
223
- stop = min(start + chunk, n)
224
- images = [dataset[i][image_column] for i in range(start, stop)]
225
- for text in pool.map(
226
- lambda img: ocr_image(img, lang, config), images
227
- ):
228
- results.append(text)
229
- pbar.update(1)
230
- return results
231
-
232
-
233
- def create_dataset_card(
234
- source_dataset: str,
235
- num_samples: int,
236
- processing_time: str,
237
- tesseract_version: str,
238
- lang: str,
239
- psm: int,
240
- oem: int,
241
- num_workers: int,
242
- output_column: str,
243
- image_column: str = "image",
244
- split: str = "train",
245
- ) -> str:
246
- """Create a dataset card documenting the Tesseract OCR run."""
247
- return f"""---
248
- tags:
249
- - ocr
250
- - text-recognition
251
- - tesseract
252
- - uv-script
253
- - generated
254
- ---
255
-
256
- # Document OCR using Tesseract
257
-
258
- This dataset contains OCR results from images in [{source_dataset}](https://huggingface.co/datasets/{source_dataset}) using [Tesseract]({PROJECT_URL}), the classical open-source CPU OCR engine — a cheap, no-GPU baseline alongside the VLM OCR recipes.
259
-
260
- ## Processing Details
261
-
262
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
263
- - **Engine**: [Tesseract]({PROJECT_URL}) `{tesseract_version}`
264
- - **Language(s)**: `{lang}`
265
- - **Number of Samples**: {num_samples:,}
266
- - **Processing Time**: {processing_time}
267
- - **Processing Date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}
268
-
269
- ### Configuration
270
-
271
- - **Image Column**: `{image_column}`
272
- - **Output Column**: `{output_column}`
273
- - **Dataset Split**: `{split}`
274
- - **Page Segmentation Mode (psm)**: {psm}
275
- - **OCR Engine Mode (oem)**: {oem}
276
- - **Workers**: {num_workers}
277
-
278
- ## Model Information
279
-
280
- Tesseract is a classical (non-VLM) OCR engine:
281
- - Runs on CPU — no GPU required
282
- - v4+ uses an LSTM-based recognition engine
283
- - 100+ languages via installable data packs
284
- - Plain-text output (no markdown / table / formula structure)
285
- - Apache-2.0 licensed
286
-
287
- ## Dataset Structure
288
-
289
- The dataset contains all original columns plus:
290
- - `{output_column}`: The recognised text (plain text)
291
- - `inference_info`: JSON list tracking all OCR models applied to this dataset
292
-
293
- ## Reproduction
294
-
295
- ```bash
296
- uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/tesseract-ocr.py \\
297
- {source_dataset} \\
298
- <output-dataset> \\
299
- --image-column {image_column} \\
300
- --lang {lang} \\
301
- --psm {psm}
302
- ```
303
-
304
- Generated with [UV Scripts](https://huggingface.co/uv-scripts)
305
- """
306
-
307
-
308
- def main(
309
- input_dataset: str,
310
- output_dataset: str,
311
- image_column: str = "image",
312
- lang: str = "eng",
313
- psm: int = 3,
314
- oem: int = 3,
315
- num_workers: int = 0,
316
- hf_token: str = None,
317
- split: str = "train",
318
- max_samples: int = None,
319
- private: bool = False,
320
- shuffle: bool = False,
321
- seed: int = 42,
322
- output_column: str = "markdown",
323
- overwrite: bool = False,
324
- dry_run: bool = False,
325
- verbose: bool = False,
326
- config: str = None,
327
- create_pr: bool = False,
328
- ):
329
- """Process images from an HF dataset through Tesseract OCR."""
330
-
331
- start_time = datetime.now(timezone.utc)
332
-
333
- # Fan-out defaults to all cores. When running >1 worker, cap Tesseract's own
334
- # OpenMP threads to 1 so N processes don't oversubscribe the CPU (per the
335
- # Tesseract FAQ). Must be set before the binary is invoked.
336
- if num_workers <= 0:
337
- num_workers = os.cpu_count() or 1
338
- if num_workers > 1:
339
- os.environ.setdefault("OMP_THREAD_LIMIT", "1")
340
-
341
- ensure_tesseract_installed(lang)
342
- tesseract_version = detect_tesseract_version()
343
- logger.info(
344
- f"Using Tesseract {tesseract_version} (lang={lang}, psm={psm}, oem={oem})"
345
- )
346
- logger.info(f"CPU-only run with {num_workers} worker(s)")
347
-
348
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
349
- if HF_TOKEN and not dry_run:
350
- login(token=HF_TOKEN)
351
-
352
- # Load dataset
353
- logger.info(f"Loading dataset: {input_dataset}")
354
- dataset = load_dataset(input_dataset, split=split)
355
-
356
- if image_column not in dataset.column_names:
357
- raise ValueError(
358
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
359
- )
360
-
361
- # Fail fast if the output column would collide with an existing input column
362
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
363
-
364
- if shuffle:
365
- logger.info(f"Shuffling dataset with seed {seed}")
366
- dataset = dataset.shuffle(seed=seed)
367
-
368
- if max_samples:
369
- dataset = dataset.select(range(min(max_samples, len(dataset))))
370
- logger.info(f"Limited to {len(dataset)} samples")
371
-
372
- tess_config = f"--psm {psm} --oem {oem}"
373
- logger.info(f"Processing {len(dataset)} images -> column '{output_column}'")
374
-
375
- all_outputs = run_ocr(dataset, image_column, lang, tess_config, num_workers)
376
-
377
- n_errors = sum(1 for t in all_outputs if t == OCR_ERROR)
378
- if n_errors:
379
- logger.warning(f"{n_errors}/{len(all_outputs)} images failed OCR ({OCR_ERROR})")
380
-
381
- processing_duration = datetime.now(timezone.utc) - start_time
382
- processing_time_str = f"{processing_duration.total_seconds() / 60:.1f} min"
383
-
384
- logger.info(f"Adding '{output_column}' column to dataset")
385
- dataset = dataset.add_column(output_column, all_outputs)
386
-
387
- # Inference info tracking. `model_id` + `column_name` are the fields consumers
388
- # (e.g. ocr-bench) read to find the text column and the leaderboard label.
389
- inference_entry = {
390
- "model_id": MODEL_ID,
391
- "model_name": MODEL_NAME,
392
- "column_name": output_column,
393
- "timestamp": datetime.now(timezone.utc).isoformat(),
394
- "engine": "tesseract",
395
- "tesseract_version": tesseract_version,
396
- "lang": lang,
397
- "psm": psm,
398
- "oem": oem,
399
- }
400
-
401
- if "inference_info" in dataset.column_names:
402
- logger.info("Updating existing inference_info column")
403
-
404
- def update_inference_info(example):
405
- try:
406
- existing_info = (
407
- json.loads(example["inference_info"])
408
- if example["inference_info"]
409
- else []
410
- )
411
- except (json.JSONDecodeError, TypeError):
412
- existing_info = []
413
- existing_info.append(inference_entry)
414
- return {"inference_info": json.dumps(existing_info)}
415
-
416
- dataset = dataset.map(update_inference_info)
417
- else:
418
- logger.info("Creating new inference_info column")
419
- inference_list = [json.dumps([inference_entry])] * len(dataset)
420
- dataset = dataset.add_column("inference_info", inference_list)
421
-
422
- if dry_run:
423
- logger.info("--dry-run: skipping push to Hub.")
424
- preview = next((t for t in all_outputs if t and t != OCR_ERROR), "")
425
- logger.info(f"Sample OCR output (first non-empty, truncated):\n{preview[:500]}")
426
- logger.info(
427
- f"Done (dry run). {len(dataset)} rows OCR'd in {processing_time_str}."
428
- )
429
- return
430
-
431
- # Push to hub with retry and XET fallback
432
- logger.info(f"Pushing to {output_dataset}")
433
- max_retries = 3
434
- for attempt in range(1, max_retries + 1):
435
- try:
436
- if attempt > 1:
437
- logger.warning("Disabling XET (fallback to HTTP upload)")
438
- os.environ["HF_HUB_DISABLE_XET"] = "1"
439
- dataset.push_to_hub(
440
- output_dataset,
441
- private=private,
442
- token=HF_TOKEN,
443
- max_shard_size="500MB",
444
- **({"config_name": config} if config else {}),
445
- create_pr=create_pr,
446
- commit_message=f"Add {MODEL_ID} OCR results ({len(dataset)} samples)"
447
- + (f" [{config}]" if config else ""),
448
- )
449
- break
450
- except Exception as e:
451
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
452
- if attempt < max_retries:
453
- delay = 30 * (2 ** (attempt - 1))
454
- logger.info(f"Retrying in {delay}s...")
455
- time.sleep(delay)
456
- else:
457
- logger.error("All upload attempts failed. OCR results are lost.")
458
- sys.exit(1)
459
-
460
- # Create and push dataset card
461
- logger.info("Creating dataset card")
462
- card_content = create_dataset_card(
463
- source_dataset=input_dataset,
464
- num_samples=len(dataset),
465
- processing_time=processing_time_str,
466
- tesseract_version=tesseract_version,
467
- lang=lang,
468
- psm=psm,
469
- oem=oem,
470
- num_workers=num_workers,
471
- output_column=output_column,
472
- image_column=image_column,
473
- split=split,
474
- )
475
- card = DatasetCard(card_content)
476
- card.push_to_hub(output_dataset, token=HF_TOKEN)
477
-
478
- logger.info("Done! Tesseract OCR processing complete.")
479
- logger.info(
480
- f"Dataset available at: https://huggingface.co/datasets/{output_dataset}"
481
- )
482
- logger.info(f"Processing time: {processing_time_str}")
483
- if processing_duration.total_seconds() > 0:
484
- logger.info(
485
- f"Processing speed: {len(dataset) / processing_duration.total_seconds():.2f} images/sec"
486
- )
487
-
488
- if verbose:
489
- import importlib.metadata
490
-
491
- logger.info("--- Resolved package versions ---")
492
- for pkg in ["pytesseract", "datasets", "pyarrow", "pillow"]:
493
- try:
494
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
495
- except importlib.metadata.PackageNotFoundError:
496
- logger.info(f" {pkg}: not installed")
497
- logger.info(f" tesseract=={tesseract_version}")
498
- logger.info("--- End versions ---")
499
-
500
-
501
- if __name__ == "__main__":
502
- if len(sys.argv) == 1:
503
- print("=" * 70)
504
- print("Tesseract OCR — CPU baseline")
505
- print("=" * 70)
506
- print("\nClassical LSTM OCR engine. No GPU. Plain-text output.")
507
- print("\nExamples:")
508
- print("\n1. Basic OCR (English):")
509
- print(" uv run tesseract-ocr.py input-dataset output-dataset")
510
- print("\n2. Another language (installs the data pack on Jobs):")
511
- print(" uv run tesseract-ocr.py docs results --lang fra")
512
- print("\n3. Test with a small sample, no push:")
513
- print(" uv run tesseract-ocr.py large-dataset out --max-samples 5 --dry-run")
514
- print("\n4. Running on HF Jobs (CPU flavor):")
515
- print(" hf jobs uv run --flavor cpu-upgrade \\")
516
- print(" -s HF_TOKEN \\")
517
- print(
518
- " https://huggingface.co/datasets/uv-scripts/ocr/raw/main/tesseract-ocr.py \\"
519
- )
520
- print(" input-dataset output-dataset --max-samples 100 --shuffle")
521
- print("\nFor full help: uv run tesseract-ocr.py --help")
522
- sys.exit(0)
523
-
524
- parser = argparse.ArgumentParser(
525
- description="CPU OCR baseline using Tesseract (classical LSTM engine, plain text)",
526
- formatter_class=argparse.RawDescriptionHelpFormatter,
527
- epilog="""
528
- Examples:
529
- uv run tesseract-ocr.py my-docs analyzed-docs
530
- uv run tesseract-ocr.py docs results --lang eng+fra --psm 4
531
- uv run tesseract-ocr.py large-dataset test --max-samples 50 --shuffle --dry-run
532
-
533
- Page segmentation modes (--psm), common ones:
534
- 3 Fully automatic page segmentation, no OSD (default; good for full pages)
535
- 4 Assume a single column of text of variable sizes
536
- 6 Assume a single uniform block of text
537
- 1 Automatic page segmentation with OSD
538
- """,
539
- )
540
-
541
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
542
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
543
- parser.add_argument(
544
- "--image-column",
545
- default="image",
546
- help="Column containing images (default: image)",
547
- )
548
- parser.add_argument(
549
- "--lang",
550
- default="eng",
551
- help="Tesseract language code(s), '+'-joined (default: eng). "
552
- "Non-English packs are apt-installed on Jobs.",
553
- )
554
- parser.add_argument(
555
- "--psm",
556
- type=int,
557
- default=3,
558
- help="Page segmentation mode (default: 3, full-page auto)",
559
- )
560
- parser.add_argument(
561
- "--oem",
562
- type=int,
563
- default=3,
564
- help="OCR engine mode (default: 3, based on what's available; 1=LSTM only)",
565
- )
566
- parser.add_argument(
567
- "--num-workers",
568
- type=int,
569
- default=0,
570
- help="Parallel OCR workers (default: 0 = all CPU cores)",
571
- )
572
- parser.add_argument("--hf-token", help="Hugging Face API token")
573
- parser.add_argument(
574
- "--split", default="train", help="Dataset split to use (default: train)"
575
- )
576
- parser.add_argument(
577
- "--max-samples",
578
- type=int,
579
- help="Maximum number of samples to process (for testing)",
580
- )
581
- parser.add_argument(
582
- "--private", action="store_true", help="Make output dataset private"
583
- )
584
- parser.add_argument(
585
- "--config",
586
- help="Config/subset name when pushing to Hub (for benchmarking multiple models in one repo)",
587
- )
588
- parser.add_argument(
589
- "--create-pr",
590
- action="store_true",
591
- help="Create a pull request instead of pushing directly (for parallel benchmarking)",
592
- )
593
- parser.add_argument(
594
- "--shuffle", action="store_true", help="Shuffle dataset before processing"
595
- )
596
- parser.add_argument(
597
- "--seed",
598
- type=int,
599
- default=42,
600
- help="Random seed for shuffling (default: 42)",
601
- )
602
- parser.add_argument(
603
- "--output-column",
604
- default="markdown",
605
- help="Column name for output text (default: markdown, for cross-model consistency)",
606
- )
607
- parser.add_argument(
608
- "--overwrite",
609
- action="store_true",
610
- help="Replace the output column if it already exists in the input dataset "
611
- "(default: error out to avoid clobbering an existing column).",
612
- )
613
- parser.add_argument(
614
- "--dry-run",
615
- action="store_true",
616
- help="Run OCR but do NOT push to the Hub (local smoke testing).",
617
- )
618
- parser.add_argument(
619
- "--verbose",
620
- action="store_true",
621
- help="Log resolved package versions after processing (useful for pinning deps)",
622
- )
623
-
624
- args = parser.parse_args()
625
-
626
- main(
627
- input_dataset=args.input_dataset,
628
- output_dataset=args.output_dataset,
629
- image_column=args.image_column,
630
- lang=args.lang,
631
- psm=args.psm,
632
- oem=args.oem,
633
- num_workers=args.num_workers,
634
- hf_token=args.hf_token,
635
- split=args.split,
636
- max_samples=args.max_samples,
637
- private=args.private,
638
- shuffle=args.shuffle,
639
- seed=args.seed,
640
- output_column=args.output_column,
641
- overwrite=args.overwrite,
642
- dry_run=args.dry_run,
643
- verbose=args.verbose,
644
- config=args.config,
645
- create_pr=args.create_pr,
646
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
unlimited-ocr-vllm.py DELETED
@@ -1,577 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = [
4
- # "datasets>=4.0.0",
5
- # "huggingface-hub",
6
- # "pillow",
7
- # "tqdm",
8
- # "toolz",
9
- # ]
10
- # ///
11
-
12
- """
13
- Convert document images to markdown using Baidu Unlimited-OCR with vLLM.
14
-
15
- Unlimited-OCR (baidu/Unlimited-OCR, 3.3B, MIT) is a DeepSeek-OCR / DeepSeek-OCR-2 descendant. This
16
- recipe runs it as an offline vLLM batch job (dataset in -> markdown out), mirroring the proven
17
- deepseek-ocr-vllm.py pattern: llm.generate() with PIL images and the model's
18
- NGramPerReqLogitsProcessor to stop coordinate-token loops on long documents.
19
-
20
- One image per row -> one markdown. Output is layout-grounded markdown: text spans are tagged
21
- <|ref|>...<|/ref|> with <|det|>...<|/det|> coordinate boxes (coords normalized 0-1000); tables come
22
- back as HTML and equations as LaTeX. Pass --strip-grounding to drop the tags and keep clean text;
23
- add --grounding-column to keep the raw grounded output (with bboxes) in a second column too.
24
-
25
- Multi-page / "long-horizon" parsing (the model's headline feature) is not in this single-image batch
26
- recipe — for multi-page, serve the model and send all pages in one request (see serving-unlimited-ocr.md).
27
- Multi-page *does* work via vLLM serving: on a clean 2-page doc it returned both pages, <PAGE>-separated.
28
- But on hard/degraded scans (dense historical pages, newspaper clippings) vLLM multi-page degraded to
29
- hallucination in our tests, where the model's own SGLang build held up better — so SGLang is the more
30
- robust multi-page path. (vLLM's upstream PR, vllm-project/vllm#46564, benchmarks single-page only.)
31
-
32
- IMPORTANT: Unlimited-OCR's architecture is not in a stable vLLM pip wheel, so this script MUST run on
33
- Baidu's dedicated vLLM image (vllm and torch come from the image, not the PEP 723 deps):
34
-
35
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \\
36
- --image vllm/vllm-openai:unlimited-ocr --python /usr/bin/python3 \\
37
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\
38
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/unlimited-ocr-vllm.py \\
39
- your-input-dataset your-output-dataset --max-samples 10
40
-
41
- Use the vllm/vllm-openai:unlimited-ocr-cu129 tag on Hopper GPUs (h100/h200).
42
-
43
- Model card: https://huggingface.co/baidu/Unlimited-OCR
44
- vLLM recipe: https://recipes.vllm.ai/baidu/Unlimited-OCR
45
- """
46
-
47
- import argparse
48
- import io
49
- import json
50
- import logging
51
- import os
52
- import re
53
- import sys
54
- import time
55
- from datetime import datetime
56
- from typing import Any, Dict, List, Optional, Union
57
-
58
- import torch
59
- from datasets import load_dataset
60
- from huggingface_hub import DatasetCard, login
61
- from PIL import Image
62
- from toolz import partition_all
63
- from tqdm.auto import tqdm
64
-
65
- # Disable vLLM's FlashInfer sampler: it JIT-compiles a CUDA kernel needing nvcc. Greedy OCR doesn't
66
- # use it; on the dedicated vllm/vllm-openai image it's a harmless no-op.
67
- os.environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", "0")
68
- from vllm import LLM, SamplingParams
69
- from vllm.model_executor.models.unlimited_ocr import NGramPerReqLogitsProcessor
70
-
71
- logging.basicConfig(level=logging.INFO)
72
- logger = logging.getLogger(__name__)
73
-
74
- MODEL = "baidu/Unlimited-OCR"
75
-
76
- # Prompt and no-repeat-ngram knobs straight from the model card / vLLM recipe (single image).
77
- PROMPT = "<image>document parsing."
78
- NGRAM_SIZE = 35
79
- WINDOW_SIZE = 128
80
-
81
- # Strip the model's grounding markup to recover clean text:
82
- # drop the <|det|>...<|/det|> coordinate boxes, then unwrap the <|ref|>...<|/ref|> spans.
83
- _DET_RE = re.compile(r"<\|det\|>.*?<\|/det\|>", re.DOTALL)
84
- _REF_RE = re.compile(r"<\|/?ref\|>")
85
-
86
-
87
- def strip_grounding(text: str) -> str:
88
- """Remove <|det|> boxes and <|ref|> wrappers, keeping the inner text."""
89
- text = _DET_RE.sub("", text)
90
- text = _REF_RE.sub("", text)
91
- # collapse the blank lines left behind by removed boxes
92
- return re.sub(r"\n{3,}", "\n\n", text).strip()
93
-
94
-
95
- def check_cuda_availability():
96
- """Check if CUDA is available and exit if not."""
97
- if not torch.cuda.is_available():
98
- logger.error("CUDA is not available. This script requires a GPU.")
99
- sys.exit(1)
100
- logger.info(f"CUDA is available. GPU: {torch.cuda.get_device_name(0)}")
101
-
102
-
103
- def ensure_output_columns_free(dataset, columns, overwrite=False):
104
- """Fail fast if an output column would collide with an existing input column.
105
-
106
- Adding a column that already exists silently overwrites it (e.g. a ground-truth
107
- `text`/`markdown` column) or crashes on push with a duplicate-column error only
108
- *after* inference has run. Catch it up front. With overwrite=True, drop the clashing
109
- column(s) here instead (logged) so the later add_column is clean.
110
- """
111
- clash = [c for c in columns if c in dataset.column_names]
112
- if not clash:
113
- return dataset
114
- if overwrite:
115
- logger.warning(f"--overwrite: replacing existing column(s) {clash}")
116
- return dataset.remove_columns(clash)
117
- logger.error(
118
- f"Output column(s) {clash} already exist in the input dataset "
119
- f"(columns: {dataset.column_names})."
120
- )
121
- logger.error("Choose a different --output-column, or pass --overwrite to replace them.")
122
- sys.exit(1)
123
-
124
-
125
- def to_pil(image: Union[Image.Image, Dict[str, Any], str]) -> Image.Image:
126
- """Convert various dataset image cell formats to an RGB PIL image."""
127
- if isinstance(image, Image.Image):
128
- return image.convert("RGB")
129
- if isinstance(image, dict) and "bytes" in image:
130
- return Image.open(io.BytesIO(image["bytes"])).convert("RGB")
131
- if isinstance(image, str):
132
- return Image.open(image).convert("RGB")
133
- raise ValueError(f"Unsupported image type: {type(image)}")
134
-
135
-
136
- def create_dataset_card(
137
- source_dataset: str,
138
- output_dataset: str,
139
- model: str,
140
- num_samples: int,
141
- processing_time: str,
142
- output_column: str,
143
- strip_grounding_enabled: bool,
144
- split: str,
145
- ) -> str:
146
- """Create a dataset card documenting the OCR run."""
147
- if strip_grounding_enabled:
148
- grounding = "Grounding markup was stripped (`--strip-grounding`); the column holds clean text."
149
- else:
150
- grounding = (
151
- "The column holds the model's raw layout-grounded markdown: text spans tagged "
152
- "`<|ref|>...<|/ref|>` with `<|det|>...<|/det|>` coordinate boxes (coords 0-1000). "
153
- "Strip them with "
154
- "`re.sub(r'<\\|det\\|>.*?<\\|/det\\|>', '', t)` then `re.sub(r'<\\|/?ref\\|>', '', t)`."
155
- )
156
- return f"""---
157
- tags:
158
- - ocr
159
- - document-processing
160
- - unlimited-ocr
161
- - baidu
162
- - markdown
163
- - uv-script
164
- - generated
165
- ---
166
-
167
- # Document OCR using Unlimited-OCR
168
-
169
- This dataset contains OCR results for [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
170
- produced by [{model}](https://huggingface.co/{model}) with vLLM.
171
-
172
- ## Processing Details
173
-
174
- - **Source Dataset**: [{source_dataset}](https://huggingface.co/datasets/{source_dataset})
175
- - **Model**: [{model}](https://huggingface.co/{model})
176
- - **Number of Samples**: {num_samples:,}
177
- - **Processing Time**: {processing_time}
178
- - **Processing Date**: {datetime.now().strftime("%Y-%m-%d %H:%M UTC")}
179
- - **Output Column**: `{output_column}`
180
- - **Split**: `{split}`
181
-
182
- ## Output
183
-
184
- {grounding}
185
-
186
- Tables are returned as HTML and equations as LaTeX.
187
-
188
- ## Usage
189
-
190
- ```python
191
- from datasets import load_dataset
192
-
193
- ds = load_dataset("{output_dataset}", split="{split}")
194
- print(ds[0]["{output_column}"])
195
- ```
196
-
197
- ## Reproduction
198
-
199
- Generated with the [uv-scripts/ocr](https://huggingface.co/datasets/uv-scripts/ocr) Unlimited-OCR
200
- vLLM recipe. Unlimited-OCR needs Baidu's dedicated vLLM image:
201
-
202
- ```bash
203
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \\
204
- --image vllm/vllm-openai:unlimited-ocr --python /usr/bin/python3 \\
205
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\
206
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/unlimited-ocr-vllm.py \\
207
- {source_dataset} <output-dataset>
208
- ```
209
-
210
- Generated with [UV Scripts](https://huggingface.co/uv-scripts)
211
- """
212
-
213
-
214
- def main(
215
- input_dataset: str,
216
- output_dataset: str,
217
- model: str = MODEL,
218
- image_column: str = "image",
219
- output_column: str = "markdown",
220
- overwrite: bool = False,
221
- grounding_column: Optional[str] = None,
222
- batch_size: int = 8,
223
- max_model_len: int = 32768,
224
- max_tokens: int = 8192,
225
- gpu_memory_utilization: float = 0.8,
226
- strip_grounding_enabled: bool = False,
227
- hf_token: Optional[str] = None,
228
- split: str = "train",
229
- max_samples: Optional[int] = None,
230
- private: bool = False,
231
- shuffle: bool = False,
232
- seed: int = 42,
233
- config: Optional[str] = None,
234
- create_pr: bool = False,
235
- verbose: bool = False,
236
- ):
237
- """Process images from an HF dataset through Unlimited-OCR with vLLM."""
238
- if grounding_column and grounding_column == output_column:
239
- raise ValueError("--grounding-column must differ from --output-column")
240
- check_cuda_availability()
241
- start_time = datetime.now()
242
-
243
- HF_TOKEN = hf_token or os.environ.get("HF_TOKEN")
244
- if HF_TOKEN:
245
- login(token=HF_TOKEN)
246
-
247
- logger.info(f"Loading dataset: {input_dataset}")
248
- dataset = load_dataset(input_dataset, split=split)
249
- if image_column not in dataset.column_names:
250
- raise ValueError(
251
- f"Column '{image_column}' not found. Available: {dataset.column_names}"
252
- )
253
-
254
- # Fail fast if the output column would collide with an existing input column
255
- dataset = ensure_output_columns_free(dataset, [output_column], overwrite=overwrite)
256
-
257
- if shuffle:
258
- logger.info(f"Shuffling dataset with seed {seed}")
259
- dataset = dataset.shuffle(seed=seed)
260
- if max_samples:
261
- dataset = dataset.select(range(min(max_samples, len(dataset))))
262
- logger.info(f"Limited to {len(dataset)} samples")
263
-
264
- logger.info(f"Initializing vLLM with model: {model}")
265
- logger.info("This may take a few minutes on first run...")
266
-
267
- llm = LLM(
268
- model=model,
269
- trust_remote_code=True,
270
- max_model_len=max_model_len,
271
- gpu_memory_utilization=gpu_memory_utilization,
272
- enable_prefix_caching=False,
273
- mm_processor_cache_gb=0,
274
- limit_mm_per_prompt={"image": 1},
275
- logits_processors=[NGramPerReqLogitsProcessor],
276
- )
277
-
278
- sampling_params = SamplingParams(
279
- temperature=0.0,
280
- max_tokens=max_tokens,
281
- skip_special_tokens=False,
282
- extra_args=dict(ngram_size=NGRAM_SIZE, window_size=WINDOW_SIZE),
283
- )
284
-
285
- logger.info(f"Processing {len(dataset)} images in batches of {batch_size}")
286
- all_outputs: List[str] = []
287
- all_grounded: List[
288
- str
289
- ] = [] # raw grounded text, only when --grounding-column is set
290
- for batch_indices in tqdm(
291
- partition_all(batch_size, range(len(dataset))),
292
- total=(len(dataset) + batch_size - 1) // batch_size,
293
- desc="Unlimited-OCR",
294
- ):
295
- batch_indices = list(batch_indices)
296
- try:
297
- model_inputs = [
298
- {
299
- "prompt": PROMPT,
300
- "multi_modal_data": {"image": to_pil(dataset[i][image_column])},
301
- }
302
- for i in batch_indices
303
- ]
304
- outputs = llm.generate(model_inputs, sampling_params)
305
- for output in outputs:
306
- raw = output.outputs[0].text.strip()
307
- all_outputs.append(
308
- strip_grounding(raw) if strip_grounding_enabled else raw
309
- )
310
- if grounding_column:
311
- all_grounded.append(raw)
312
- except Exception as e:
313
- logger.error(f"Error processing batch: {e}")
314
- all_outputs.extend(["[OCR FAILED]"] * len(batch_indices))
315
- if grounding_column:
316
- all_grounded.extend(["[OCR FAILED]"] * len(batch_indices))
317
-
318
- processing_time_str = (
319
- f"{(datetime.now() - start_time).total_seconds() / 60:.1f} min"
320
- )
321
-
322
- logger.info(f"Adding '{output_column}' column to dataset")
323
- if output_column in dataset.column_names:
324
- logger.warning(f"Column '{output_column}' already exists, replacing it")
325
- dataset = dataset.remove_columns([output_column])
326
- dataset = dataset.add_column(output_column, all_outputs)
327
- if grounding_column:
328
- logger.info(f"Adding '{grounding_column}' column (raw grounded output)")
329
- if grounding_column in dataset.column_names:
330
- logger.warning(f"Column '{grounding_column}' already exists, replacing it")
331
- dataset = dataset.remove_columns([grounding_column])
332
- dataset = dataset.add_column(grounding_column, all_grounded)
333
-
334
- # inference_info: append-only log so several models can write into one dataset and be compared.
335
- inference_entry = {
336
- "model_id": model,
337
- "model_name": "Unlimited-OCR",
338
- "column_name": output_column,
339
- "timestamp": datetime.now().isoformat(),
340
- "batch_size": batch_size,
341
- "max_tokens": max_tokens,
342
- "max_model_len": max_model_len,
343
- "gpu_memory_utilization": gpu_memory_utilization,
344
- "strip_grounding": strip_grounding_enabled,
345
- "grounding_column": grounding_column,
346
- "script": "unlimited-ocr-vllm.py",
347
- "script_url": "https://huggingface.co/datasets/uv-scripts/ocr/raw/main/unlimited-ocr-vllm.py",
348
- }
349
- if "inference_info" in dataset.column_names:
350
- logger.info("Updating existing inference_info column")
351
-
352
- def update_inference_info(example):
353
- try:
354
- existing = (
355
- json.loads(example["inference_info"])
356
- if example["inference_info"]
357
- else []
358
- )
359
- except (json.JSONDecodeError, TypeError):
360
- existing = []
361
- existing.append(inference_entry)
362
- return {"inference_info": json.dumps(existing)}
363
-
364
- dataset = dataset.map(update_inference_info)
365
- else:
366
- logger.info("Creating new inference_info column")
367
- dataset = dataset.add_column(
368
- "inference_info", [json.dumps([inference_entry])] * len(dataset)
369
- )
370
-
371
- logger.info(f"Pushing to {output_dataset}")
372
- max_retries = 3
373
- for attempt in range(1, max_retries + 1):
374
- try:
375
- if attempt > 1:
376
- logger.warning("Disabling XET (fallback to HTTP upload)")
377
- os.environ["HF_HUB_DISABLE_XET"] = "1"
378
- dataset.push_to_hub(
379
- output_dataset,
380
- private=private,
381
- token=HF_TOKEN,
382
- max_shard_size="500MB",
383
- **({"config_name": config} if config else {}),
384
- create_pr=create_pr,
385
- commit_message=f"Add {model} OCR results ({len(dataset)} samples)"
386
- + (f" [{config}]" if config else ""),
387
- )
388
- break
389
- except Exception as e:
390
- logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")
391
- if attempt < max_retries:
392
- delay = 30 * (2 ** (attempt - 1))
393
- logger.info(f"Retrying in {delay}s...")
394
- time.sleep(delay)
395
- else:
396
- logger.error("All upload attempts failed. OCR results are lost.")
397
- sys.exit(1)
398
-
399
- logger.info("Creating dataset card...")
400
- card = DatasetCard(
401
- create_dataset_card(
402
- source_dataset=input_dataset,
403
- output_dataset=output_dataset,
404
- model=model,
405
- num_samples=len(dataset),
406
- processing_time=processing_time_str,
407
- output_column=output_column,
408
- strip_grounding_enabled=strip_grounding_enabled,
409
- split=split,
410
- )
411
- )
412
- card.push_to_hub(output_dataset, token=HF_TOKEN)
413
-
414
- logger.info("✅ OCR conversion complete!")
415
- logger.info(f"Dataset: https://huggingface.co/datasets/{output_dataset}")
416
- logger.info(f"Processing time: {processing_time_str}")
417
-
418
- if verbose:
419
- import importlib.metadata
420
-
421
- logger.info("--- Resolved package versions ---")
422
- for pkg in ["vllm", "transformers", "torch", "datasets", "pillow"]:
423
- try:
424
- logger.info(f" {pkg}=={importlib.metadata.version(pkg)}")
425
- except importlib.metadata.PackageNotFoundError:
426
- logger.info(f" {pkg}: not installed")
427
- logger.info("--- End versions ---")
428
-
429
-
430
- if __name__ == "__main__":
431
- if len(sys.argv) == 1:
432
- print("=" * 80)
433
- print("Unlimited-OCR to Markdown Converter (vLLM)")
434
- print("=" * 80)
435
- print("\nBaidu Unlimited-OCR (3.3B, MIT) — one image per row -> markdown.")
436
- print("\nMUST run on the dedicated image: vllm/vllm-openai:unlimited-ocr")
437
- print("(use the -cu129 tag on Hopper GPUs).")
438
- print("\nExample:")
439
- print(" hf jobs uv run --flavor l4x1 -s HF_TOKEN \\")
440
- print(
441
- " --image vllm/vllm-openai:unlimited-ocr --python /usr/bin/python3 \\"
442
- )
443
- print(" -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\")
444
- print(" unlimited-ocr-vllm.py my-images my-markdown --max-samples 10")
445
- print(
446
- "\nMulti-page documents: serve the model instead (see serving-unlimited-ocr.md)."
447
- )
448
- print("\nFor full help, run with --help")
449
- sys.exit(0)
450
-
451
- parser = argparse.ArgumentParser(
452
- description="OCR images to markdown using Unlimited-OCR (vLLM)",
453
- formatter_class=argparse.RawDescriptionHelpFormatter,
454
- epilog="""
455
- Examples:
456
- # Basic usage
457
- uv run unlimited-ocr-vllm.py my-images ocr-results
458
-
459
- # Clean text (strip grounding tags)
460
- uv run unlimited-ocr-vllm.py my-images ocr-results --strip-grounding
461
-
462
- # On HF Jobs (dedicated image required)
463
- hf jobs uv run --flavor l4x1 -s HF_TOKEN \\
464
- --image vllm/vllm-openai:unlimited-ocr --python /usr/bin/python3 \\
465
- -e PYTHONPATH=/usr/local/lib/python3.12/dist-packages \\
466
- https://huggingface.co/datasets/uv-scripts/ocr/raw/main/unlimited-ocr-vllm.py \\
467
- my-dataset my-output --max-samples 10
468
- """,
469
- )
470
- parser.add_argument("input_dataset", help="Input dataset ID from Hugging Face Hub")
471
- parser.add_argument("output_dataset", help="Output dataset ID for Hugging Face Hub")
472
- parser.add_argument(
473
- "--model",
474
- default=MODEL,
475
- help=f"Model to use (default: {MODEL}). Override only for a same-architecture mirror.",
476
- )
477
- parser.add_argument(
478
- "--image-column", default="image", help="Column with images (default: image)"
479
- )
480
- parser.add_argument(
481
- "--output-column",
482
- default="markdown",
483
- help="Output column name (default: markdown)",
484
- )
485
- parser.add_argument(
486
- "--overwrite",
487
- action="store_true",
488
- help="Replace the output column if it already exists in the input dataset "
489
- "(default: error out to avoid clobbering an existing column).",
490
- )
491
- parser.add_argument(
492
- "--strip-grounding",
493
- action="store_true",
494
- help="Drop <|det|>/<|ref|> grounding tags from the output column, keeping clean text",
495
- )
496
- parser.add_argument(
497
- "--grounding-column",
498
- help="Also store the RAW grounded output (boxes + tags) in this extra column "
499
- "(pair with --strip-grounding to keep clean text AND the layout/bboxes)",
500
- )
501
- parser.add_argument(
502
- "--batch-size", type=int, default=8, help="Images per batch (default: 8)"
503
- )
504
- parser.add_argument(
505
- "--max-model-len",
506
- type=int,
507
- default=32768,
508
- help="Max context length (default: 32768)",
509
- )
510
- parser.add_argument(
511
- "--max-tokens",
512
- type=int,
513
- default=8192,
514
- help="Max output tokens (default: 8192)",
515
- )
516
- parser.add_argument(
517
- "--gpu-memory-utilization",
518
- type=float,
519
- default=0.8,
520
- help="GPU memory fraction (default: 0.8)",
521
- )
522
- parser.add_argument("--hf-token", help="Hugging Face API token")
523
- parser.add_argument(
524
- "--split", default="train", help="Dataset split (default: train)"
525
- )
526
- parser.add_argument(
527
- "--max-samples", type=int, help="Max samples to process (for testing)"
528
- )
529
- parser.add_argument(
530
- "--private", action="store_true", help="Make output dataset private"
531
- )
532
- parser.add_argument(
533
- "--shuffle", action="store_true", help="Shuffle before processing"
534
- )
535
- parser.add_argument(
536
- "--seed", type=int, default=42, help="Shuffle seed (default: 42)"
537
- )
538
- parser.add_argument(
539
- "--config",
540
- help="Config/subset name when pushing (for benchmarking multiple models)",
541
- )
542
- parser.add_argument(
543
- "--create-pr",
544
- action="store_true",
545
- help="Push as a PR instead of a direct commit",
546
- )
547
- parser.add_argument(
548
- "--verbose",
549
- action="store_true",
550
- help="Log resolved package versions after the run",
551
- )
552
-
553
- args = parser.parse_args()
554
-
555
- main(
556
- input_dataset=args.input_dataset,
557
- output_dataset=args.output_dataset,
558
- model=args.model,
559
- image_column=args.image_column,
560
- output_column=args.output_column,
561
- overwrite=args.overwrite,
562
- grounding_column=args.grounding_column,
563
- batch_size=args.batch_size,
564
- max_model_len=args.max_model_len,
565
- max_tokens=args.max_tokens,
566
- gpu_memory_utilization=args.gpu_memory_utilization,
567
- strip_grounding_enabled=args.strip_grounding,
568
- hf_token=args.hf_token,
569
- split=args.split,
570
- max_samples=args.max_samples,
571
- private=args.private,
572
- shuffle=args.shuffle,
573
- seed=args.seed,
574
- config=args.config,
575
- create_pr=args.create_pr,
576
- verbose=args.verbose,
577
- )