I need pull
#6
by visssoftjsc - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- AGENTS.md +0 -109
- CLAUDE.md +188 -263
- LANGUAGES.md +0 -38
- OCR-BENCHMARK.md +0 -93
- README.md +552 -189
- SERVING.md +0 -93
- abot-ocr.py +0 -600
- deepseek-ocr-vllm.py +4 -48
- deepseek-ocr.py +9 -48
- deepseek-ocr2-vllm.py +0 -37
- demo.gif +0 -3
- dots-mocr.py → dots-ocr-1.5.py +63 -152
- dots-ocr.py +5 -67
- examples/nls-index-card-v2.json +0 -13
- examples/nls-index-card-verbose.json +0 -13
- falcon-ocr-bucket.py +0 -303
- falcon-ocr.py +0 -466
- firered-ocr.py +8 -48
- glm-ocr-bucket.py +0 -369
- glm-ocr-v2.py +0 -39
- glm-ocr.py +7 -93
- hunyuan-ocr-1.5.py +0 -884
- hunyuan-ocr.py +4 -81
- lfm2-extract.py +0 -327
- lfm2-vl-extract.py +0 -358
- lift-extract.py +0 -846
- lighton-ocr.py +1 -38
- lighton-ocr2-server.py +0 -651
- lighton-ocr2.py +2 -43
- models.json +0 -386
- nanonets-ocr.py +12 -65
- nanonets-ocr2.py +10 -106
- nuextract3.py +0 -786
- numarkdown-ocr.py +0 -37
- olmocr2-vllm.py +31 -103
- ovis-ocr2-server.py +0 -725
- ovis-ocr2.py +0 -682
- paddleocr-vl-1.5.py +1 -34
- paddleocr-vl-1.6.py +0 -831
- paddleocr-vl.py +0 -37
- pp-doclayout.py +0 -1226
- pp-ocrv6.py +0 -1102
- qianfan-ocr.py +0 -665
- rolm-ocr.py +5 -49
- serving-unlimited-ocr.md +0 -169
- smoldocling-ocr.py +3 -54
- surya-ocr-bucket.py +0 -1389
- surya-ocr.py +0 -915
- tesseract-ocr.py +0 -646
- unlimited-ocr-vllm.py +0 -577
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
|
| 2 |
|
| 3 |
-
|
| 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 |
-
-
|
| 8 |
-
|
| 9 |
-
-
|
|
|
|
|
|
|
| 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 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 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 |
-
|
| 202 |
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
-
|
| 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
|
| 211 |
-
|
| 212 |
-
uv
|
| 213 |
-
|
|
|
|
| 214 |
```
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
``
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
```
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
```
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
---
|
| 241 |
|
| 242 |
-
##
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
-
|
| 251 |
-
|
| 252 |
-
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
-
|
| 258 |
-
|
| 259 |
-
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
---
|
| 273 |
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
-
|
| 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,
|
| 4 |
---
|
| 5 |
|
| 6 |
# OCR UV Scripts
|
| 7 |
|
| 8 |
-
|
| 9 |
|
| 10 |
-
|
| 11 |
|
| 12 |
-
|
| 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 |
-
|
| 28 |
|
| 29 |
-
- Process
|
| 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 |
-
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
##
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
```bash
|
| 43 |
-
|
| 44 |
```
|
| 45 |
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
-
|
| 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 |
-
|
| 115 |
|
| 116 |
-
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
```bash
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
```
|
| 127 |
|
| 128 |
-
|
| 129 |
|
| 130 |
-
|
| 131 |
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
```bash
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
-
|
| 145 |
-
https://huggingface.co/datasets/uv-scripts/ocr/raw/main/
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
```
|
| 148 |
|
| 149 |
-
|
| 150 |
|
| 151 |
-
|
| 152 |
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
```bash
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
```
|
| 177 |
|
| 178 |
-
##
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
-
|
| 183 |
|
| 184 |
```bash
|
| 185 |
-
#
|
| 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/
|
| 192 |
-
|
|
|
|
|
|
|
| 193 |
|
| 194 |
-
#
|
| 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/
|
| 201 |
-
|
| 202 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
```
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 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 |
-
#
|
| 246 |
-
hf jobs uv run --flavor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
https://huggingface.co/datasets/uv-scripts/ocr/raw/main/deepseek-ocr-vllm.py \
|
| 248 |
-
NationalLibraryOfScotland/Britain-and-UK-Handbooks-Dataset
|
| 249 |
-
--
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
#
|
| 252 |
-
hf jobs uv run --flavor l4x1
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
-
#
|
| 257 |
-
hf jobs uv run --flavor
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
```
|
| 262 |
|
| 263 |
-
|
| 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 |
-
|
| 276 |
|
| 277 |
```bash
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
input-dataset output-dataset
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
```
|
| 281 |
|
| 282 |
-
|
| 283 |
|
| 284 |
-
Works with any
|
|
|
|
| 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
|
| 357 |
-
logger.info(
|
| 358 |
-
dataset = dataset.add_column(
|
| 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":
|
| 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
|
| 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
|
| 410 |
pass
|
| 411 |
|
| 412 |
-
# Add
|
| 413 |
-
logger.info(
|
| 414 |
-
dataset = dataset.add_column(
|
| 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":
|
| 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
|
dots-mocr.py → dots-ocr-1.5.py
RENAMED
|
@@ -1,42 +1,35 @@
|
|
| 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 |
-
# # 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
|
| 20 |
|
| 21 |
-
|
| 22 |
-
on 100+ languages.
|
| 23 |
-
|
| 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 (
|
| 34 |
-
|
| 35 |
-
Model: rednote-hilab/dots.
|
| 36 |
-
|
| 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
|
| 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 |
-
#
|
| 71 |
-
# Source: https://github.com/rednote-hilab/dots.
|
| 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-
|
| 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
|
| 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 |
-
|
| 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)
|
| 274 |
|
| 275 |
```bash
|
| 276 |
-
uv run https://huggingface.co/datasets/uv-scripts/ocr/raw/main/dots-
|
| 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.
|
| 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
|
| 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="
|
| 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
|
| 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": "
|
| 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("
|
| 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("
|
| 546 |
print("=" * 80)
|
| 547 |
-
print("\n3B multilingual OCR model
|
| 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
|
| 559 |
-
print(" layout-all
|
| 560 |
-
print(" layout-only
|
| 561 |
-
print(" web-parsing
|
| 562 |
print(" scene-spotting - Scene text detection")
|
| 563 |
-
print(" grounding-ocr
|
| 564 |
-
print("
|
| 565 |
-
print(" general - Free-form (use with --custom-prompt)")
|
| 566 |
print("\nExample usage:")
|
| 567 |
print("\n1. Basic OCR:")
|
| 568 |
-
print(" uv run dots-
|
| 569 |
-
print("\n2.
|
| 570 |
-
print(
|
| 571 |
-
|
| 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-
|
| 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-
|
| 582 |
)
|
| 583 |
print(" input-dataset output-dataset")
|
| 584 |
print("\n" + "=" * 80)
|
| 585 |
-
print("\nFor full help, run: uv run dots-
|
| 586 |
sys.exit(0)
|
| 587 |
|
| 588 |
parser = argparse.ArgumentParser(
|
| 589 |
-
description="Document OCR using
|
| 590 |
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 591 |
epilog="""
|
| 592 |
-
Prompt Modes (official
|
| 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 |
-
|
| 600 |
-
general - Free-form QA (use with --custom-prompt)
|
| 601 |
|
| 602 |
SVG Code Generation:
|
| 603 |
-
|
| 604 |
-
--
|
| 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-
|
| 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-
|
|
|
|
|
|
|
|
|
|
| 616 |
|
| 617 |
# Full layout analysis with structure
|
| 618 |
-
uv run dots-
|
| 619 |
|
| 620 |
# Random sampling for testing
|
| 621 |
-
uv run dots-
|
| 622 |
""",
|
| 623 |
)
|
| 624 |
|
|
@@ -637,8 +555,8 @@ Examples:
|
|
| 637 |
)
|
| 638 |
parser.add_argument(
|
| 639 |
"--model",
|
| 640 |
-
default="rednote-hilab/dots.
|
| 641 |
-
help="Model to use (default: rednote-hilab/dots.
|
| 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
|
| 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 =
|
| 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 |
-
|
| 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=
|
| 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
|
| 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
|
| 134 |
buf = io.BytesIO()
|
| 135 |
-
pil_img.save(buf, format="
|
| 136 |
-
data_uri = f"data:image/
|
| 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 =
|
| 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
|
| 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=
|
| 517 |
-
help="Maximum model context length (default:
|
| 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,
|
| 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
|
| 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 |
-
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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=
|
| 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
|
| 151 |
---
|
| 152 |
|
| 153 |
# Document OCR using {model_name}
|
|
@@ -211,10 +176,10 @@ for info in inference_info:
|
|
| 211 |
|
| 212 |
## Reproduction
|
| 213 |
|
| 214 |
-
|
| 215 |
|
| 216 |
```bash
|
| 217 |
-
|
| 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 =
|
| 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
|
| 339 |
-
logger.info(
|
| 340 |
-
dataset = dataset.add_column(
|
| 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":
|
| 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=
|
| 504 |
-
help="Maximum model context length (default:
|
| 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 =
|
| 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
|
| 369 |
-
logger.info(
|
| 370 |
-
dataset = dataset.add_column(
|
| 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":
|
| 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 |
-
|
| 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=
|
| 548 |
-
help="Maximum model context length (default:
|
| 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
|
| 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 |
-
|
| 406 |
-
|
| 407 |
-
|
| 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
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
}
|
| 479 |
|
| 480 |
-
# Handle existing inference_info column
|
| 481 |
if inference_info_column in ds.column_names:
|
| 482 |
-
|
| 483 |
-
|
| 484 |
def update_inference_info(example):
|
| 485 |
try:
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
return {inference_info_column: json.dumps(existing_info)}
|
| 495 |
|
| 496 |
ds = ds.map(update_inference_info)
|
| 497 |
else:
|
| 498 |
-
|
| 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 |
-
|
| 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
|
| 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
|
| 151 |
---
|
| 152 |
|
| 153 |
# OCR Text Extraction using {model_name}
|
|
@@ -204,10 +169,10 @@ for info in inference_info:
|
|
| 204 |
|
| 205 |
## Reproduction
|
| 206 |
|
| 207 |
-
|
| 208 |
|
| 209 |
```bash
|
| 210 |
-
|
| 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
|
| 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
|
| 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 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|