Skip to content

Commit 41e24d1

Browse files
authored
Merge pull request #111 from artiz/claude/py-models-kv
fix(py): local models outrank the cache; download every hosted variant
2 parents 3c24804 + 1f6f2a6 commit 41e24d1

2 files changed

Lines changed: 132 additions & 36 deletions

File tree

crates/docling-pdf/src/layout.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,19 @@ impl LayoutModel {
145145
match self.run_batch(pages) {
146146
Err(e) if pages.len() > 1 => {
147147
// A graph without the dynamic batch dim (pre-#73 export) fails
148-
// only for batch > 1 — remember and recover per page.
149-
eprintln!(
150-
"docling-pdf: layout model rejected a {}-page batch ({e}); \
151-
falling back to per-page inference — re-export with \
152-
scripts/install/export_layout.py for batched layout",
153-
pages.len()
154-
);
148+
// only for batch > 1 — remember and recover per page. Warn once
149+
// per process, not per worker: every worker owns a LayoutModel
150+
// over the same graph file, so repeats carry no information.
151+
static WARNED: std::sync::atomic::AtomicBool =
152+
std::sync::atomic::AtomicBool::new(false);
153+
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
154+
eprintln!(
155+
"docling-pdf: layout model rejected a {}-page batch ({e}); \
156+
falling back to per-page inference — re-export with \
157+
scripts/install/export_layout.py for batched layout",
158+
pages.len()
159+
);
160+
}
155161
self.batch_unsupported = true;
156162
self.predict_singly(pages)
157163
}

crates/docling-py/python/docling_rs/models.py

Lines changed: 119 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
docling_rs.download_models() # once; idempotent, skips present files
1515
1616
``DocumentConverter`` calls :func:`ensure_env` automatically, so after the
17-
one-time download no configuration is needed at all.
17+
one-time download no configuration is needed at all. Local assets outrank the
18+
cache: when a matching ``models/`` / ``.pdfium/`` asset exists in the working
19+
directory (e.g. a repo checkout with its own exports), the env var is left
20+
unset and the native pipeline resolves the local path itself, exactly like
21+
the Rust CLI. Re-published release assets are picked up with
22+
``download_models(force=True)`` — the cache has no version stamp.
1823
"""
1924

2025
from __future__ import annotations
@@ -43,6 +48,14 @@
4348
_OPTIONAL = {
4449
"layout_heron_int8.onnx": "models/layout_heron_int8.onnx",
4550
"decoder_int8.onnx": "models/tableformer/decoder_int8.onnx",
51+
# The #97 hoisted-KV TableFormer decoder — byte-exact vs the legacy graph
52+
# and the fastest variant on every machine measured; ensure_env prefers it.
53+
"decoder_kv.onnx": "models/tableformer/decoder_kv.onnx",
54+
"decoder_kv.onnx.data": "models/tableformer/decoder_kv.onnx.data",
55+
"decoder_kv_int8.onnx": "models/tableformer/decoder_kv_int8.onnx",
56+
# DocumentFigureClassifier-v2.5 (~17 MB) for do_picture_classification;
57+
# missing file just skips the enrichment with a one-time warning.
58+
"picture_classifier.onnx": "models/picture_classifier.onnx",
4659
"encoder.onnx.data": "models/tableformer/encoder.onnx.data",
4760
"decoder.onnx.data": "models/tableformer/decoder.onnx.data",
4861
"bbox.onnx.data": "models/tableformer/bbox.onnx.data",
@@ -51,12 +64,27 @@
5164
"chunk_tokenizer.json": "models/chunk/tokenizer.json",
5265
}
5366

67+
# CodeFormula (do_code_enrichment / do_formula_enrichment) — the int8 decoder
68+
# (~165 MB) makes the ~655 MB fp32 decoder unnecessary (same rule as
69+
# download_dependencies.sh), so the fp32 graph is fetched only when the int8
70+
# variant isn't hosted.
71+
_ENRICH = {
72+
"cf_vision.onnx": "models/code_formula/vision.onnx",
73+
"cf_embed.onnx": "models/code_formula/embed.onnx",
74+
"cf_decoder_kv_int8.onnx": "models/code_formula/decoder_kv_int8.onnx",
75+
"cf_tokenizer.json": "models/code_formula/tokenizer.json",
76+
}
77+
_ENRICH_FP32_DECODER = ("cf_decoder_kv.onnx", "models/code_formula/decoder_kv.onnx")
78+
5479
# Straight-from-upstream fallback for assets older release tags don't host:
5580
# cache path -> upstream URL.
5681
_FALLBACK_URLS = {
5782
"models/chunk/tokenizer.json": (
5883
"https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json"
5984
),
85+
"models/picture_classifier.onnx": (
86+
"https://huggingface.co/docling-project/DocumentFigureClassifier-v2.5/resolve/main/model.onnx"
87+
),
6088
}
6189
# pdfium rasterizer (Linux x64, matching what the release hosts).
6290
_PDFIUM = {"libpdfium.so": ".pdfium/lib/libpdfium.so"}
@@ -69,8 +97,8 @@ def cache_dir() -> Path:
6997
return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "docling.rs"
7098

7199

72-
def _fetch(url: str, dest: Path, optional: bool, progress: bool) -> bool:
73-
if dest.exists():
100+
def _fetch(url: str, dest: Path, optional: bool, progress: bool, force: bool = False) -> bool:
101+
if dest.exists() and not force:
74102
return True
75103
dest.parent.mkdir(parents=True, exist_ok=True)
76104
tmp = dest.with_suffix(dest.suffix + ".download")
@@ -89,55 +117,117 @@ def _fetch(url: str, dest: Path, optional: bool, progress: bool) -> bool:
89117
raise
90118

91119

92-
def download_models(dest: "str | Path | None" = None, progress: bool = True) -> Path:
120+
def download_models(
121+
dest: "str | Path | None" = None, progress: bool = True, force: bool = False
122+
) -> Path:
93123
"""Fetch the PDF/image pipeline's models + pdfium into the cache (idempotent).
94124
95125
Returns the cache root. Pass ``dest`` to use a custom directory (also set
96126
it as ``$DOCLING_RS_CACHE_DIR`` at runtime, or pass the same value as
97-
``DocumentConverter(artifacts_path=...)``).
127+
``DocumentConverter(artifacts_path=...)``). Pass ``force=True`` to
128+
re-download files that are already cached — the cache has no version
129+
stamp, so this is how a stale cache picks up re-published model assets
130+
(e.g. the dynamic-batch layout graph or the hoisted-KV TableFormer
131+
decoder).
98132
"""
99133
root = Path(dest) if dest else cache_dir()
100134
if progress:
101135
print(f"docling.rs: fetching models to {root}", file=sys.stderr, flush=True)
102136
for name, rel in {**_REQUIRED, **_PDFIUM}.items():
103-
_fetch(f"{BASE_URL}/{name}", root / rel, optional=False, progress=progress)
104-
for name, rel in _OPTIONAL.items():
105-
if not _fetch(f"{BASE_URL}/{name}", root / rel, optional=True, progress=progress):
137+
_fetch(f"{BASE_URL}/{name}", root / rel, optional=False, progress=progress, force=force)
138+
for name, rel in {**_OPTIONAL, **_ENRICH}.items():
139+
if not _fetch(
140+
f"{BASE_URL}/{name}", root / rel, optional=True, progress=progress, force=force
141+
):
106142
if fallback := _FALLBACK_URLS.get(rel):
107-
_fetch(fallback, root / rel, optional=True, progress=progress)
143+
_fetch(fallback, root / rel, optional=True, progress=progress, force=force)
144+
# The huge fp32 CodeFormula decoder only matters when its int8 variant
145+
# isn't hosted (or DOCLING_RS_FP32 users fetch it here as the fallback).
146+
name, rel = _ENRICH_FP32_DECODER
147+
if not (root / _ENRICH["cf_decoder_kv_int8.onnx"]).exists():
148+
_fetch(f"{BASE_URL}/{name}", root / rel, optional=True, progress=progress, force=force)
108149
return root
109150

110151

111-
def _setdefault_if_exists(var: str, path: Path) -> None:
112-
if var not in os.environ and path.exists():
113-
os.environ[var] = str(path)
152+
def _point_at(var: str, local: "list[str]", cached: Path) -> None:
153+
"""Set ``var`` to ``cached`` unless configuration already exists.
154+
155+
Two things outrank the cache: an env var the caller already set, and a
156+
matching asset in the working directory (any of the ``local`` relative
157+
paths) — the native pipeline resolves those CWD paths itself when the env
158+
var stays unset, exactly like the Rust CLI run from a checkout. The env
159+
is also left untouched when ``cached`` doesn't exist."""
160+
if var in os.environ:
161+
return
162+
if any(Path(rel).exists() for rel in local):
163+
return
164+
if cached.exists():
165+
os.environ[var] = str(cached)
114166

115167

116168
def ensure_env(dest: "str | Path | None" = None) -> Path:
117169
"""Point the native pipeline at the cached assets via the ``DOCLING_*`` /
118-
``PDFIUM_*`` env vars (only filling ones that are not already set, so
119-
explicit configuration always wins). Prefers the INT8 models when present,
120-
matching the Rust pipeline's default; ``DOCLING_RS_FP32=1`` opts out.
121-
Safe to call when nothing is downloaded yet — missing files simply leave
122-
the env untouched (and the converter will fail with its usual clear
170+
``PDFIUM_*`` env vars. Local assets win: a variable is only filled when it
171+
is not already set AND no matching ``models/`` / ``.pdfium/`` asset exists
172+
in the working directory (the native code resolves those itself, so a repo
173+
checkout keeps using its own exports). Prefers the INT8 models when
174+
present, matching the Rust pipeline's default; ``DOCLING_RS_FP32=1`` opts
175+
out. Safe to call when nothing is downloaded yet — missing files simply
176+
leave the env untouched (and the converter will fail with its usual clear
123177
"model not found" message)."""
124178
root = Path(dest) if dest else cache_dir()
125179
fp32 = os.environ.get("DOCLING_RS_FP32", "0") not in ("", "0")
126180
m = root / "models"
127181

182+
layout_chain = ["models/layout_heron.onnx"]
183+
if not fp32:
184+
layout_chain.insert(0, "models/layout_heron_int8.onnx")
128185
layout = m / "layout_heron_int8.onnx"
129186
if fp32 or not layout.exists():
130187
layout = m / "layout_heron.onnx"
131-
_setdefault_if_exists("DOCLING_LAYOUT_ONNX", layout)
132-
133-
decoder = m / "tableformer/decoder_int8.onnx"
134-
if fp32 or not decoder.exists():
135-
decoder = m / "tableformer/decoder.onnx"
136-
_setdefault_if_exists("DOCLING_TABLEFORMER_DECODER", decoder)
137-
138-
_setdefault_if_exists("DOCLING_OCR_REC_ONNX", m / "ocr_rec.onnx")
139-
_setdefault_if_exists("DOCLING_OCR_DICT", m / "ppocr_keys_v1.txt")
140-
_setdefault_if_exists("DOCLING_TABLEFORMER_ENCODER", m / "tableformer/encoder.onnx")
141-
_setdefault_if_exists("DOCLING_TABLEFORMER_BBOX", m / "tableformer/bbox.onnx")
142-
_setdefault_if_exists("PDFIUM_DYNAMIC_LIB_PATH", root / ".pdfium/lib")
188+
_point_at("DOCLING_LAYOUT_ONNX", layout_chain, layout)
189+
190+
# TableFormer decoder preference, mirroring the Rust pipeline's default
191+
# chain (tableformer.rs): the #97 hoisted-KV graph ranks ahead of the
192+
# legacy layer-output-cache graph within each precision, and decoder_kv
193+
# (fp32) ranks above the quantized *legacy* decoder — it is faster on
194+
# every machine measured and byte-exact.
195+
if fp32:
196+
chain = ["tableformer/decoder_kv.onnx", "tableformer/decoder.onnx"]
197+
else:
198+
chain = [
199+
"tableformer/decoder_kv_int8.onnx",
200+
"tableformer/decoder_kv.onnx",
201+
"tableformer/decoder_int8.onnx",
202+
"tableformer/decoder.onnx",
203+
]
204+
decoder = next((p for rel in chain if (p := m / rel).exists()), m / "tableformer/decoder.onnx")
205+
_point_at("DOCLING_TABLEFORMER_DECODER", [f"models/{rel}" for rel in chain], decoder)
206+
207+
classifier_chain = ["models/picture_classifier.onnx"]
208+
if not fp32:
209+
classifier_chain.insert(0, "models/picture_classifier_int8.onnx")
210+
classifier = m / "picture_classifier_int8.onnx"
211+
if fp32 or not classifier.exists():
212+
classifier = m / "picture_classifier.onnx"
213+
_point_at("DOCLING_PICTURE_CLASSIFIER_ONNX", classifier_chain, classifier)
214+
215+
_point_at("DOCLING_OCR_REC_ONNX", ["models/ocr_rec.onnx"], m / "ocr_rec.onnx")
216+
_point_at("DOCLING_OCR_DICT", ["models/ppocr_keys_v1.txt"], m / "ppocr_keys_v1.txt")
217+
_point_at(
218+
"DOCLING_TABLEFORMER_ENCODER",
219+
["models/tableformer/encoder.onnx"],
220+
m / "tableformer/encoder.onnx",
221+
)
222+
_point_at(
223+
"DOCLING_TABLEFORMER_BBOX",
224+
["models/tableformer/bbox.onnx"],
225+
m / "tableformer/bbox.onnx",
226+
)
227+
_point_at(
228+
"DOCLING_CODE_FORMULA_DIR",
229+
["models/code_formula"],
230+
m / "code_formula",
231+
)
232+
_point_at("PDFIUM_DYNAMIC_LIB_PATH", [".pdfium/lib"], root / ".pdfium/lib")
143233
return root

0 commit comments

Comments
 (0)