docling_pdf/ocr.rs
1//! OCR for scanned pages, via the PP-OCRv3 recognition model (CRNN/SVTR) run
2//! with `ort`. The layout model already locates text regions on the page image
3//! (it works without a text layer), so OCR only needs *recognition*: each text
4//! region is cropped, split into lines by horizontal projection, and each line
5//! is recognised and decoded with CTC — producing [`TextCell`]s the normal
6//! layout assembly then consumes. This avoids a separate text-detection model.
7
8use image::RgbImage;
9use ort::session::Session;
10use ort::value::Tensor;
11
12use crate::layout::Region;
13// The ONNX-free half (line prep, batching, CTC decode) lives in `ocr_prep`
14// so the wasm build shares it verbatim (#79 phase 2).
15use crate::ocr_prep::{
16 batch_input, decode_row_scored, dict_chars, prep_region_lines, prep_table_words, width_batches,
17 PrepLine, REC_HEIGHT,
18};
19use crate::pdfium_backend::TextCell;
20
21pub struct OcrModel {
22 rec: Session,
23 /// CTC classes: index 0 = blank, 1..=6623 = dictionary, 6624 = space.
24 chars: Vec<String>,
25}
26
27/// OCR recognition language: which PP-OCRv3 model + dictionary pair runs.
28///
29/// The default is **English** (`.models/ocr_rec_en.onnx` + `.models/en_dict.txt`):
30/// the multilingual `ch_` model reads Latin scripts with badly degraded word
31/// spacing (glued words on ordinary English scans), which is the common
32/// real-world case. `Ch` selects the `ch_` pair (`.models/ocr_rec.onnx` +
33/// `.models/ppocr_keys_v1.txt`) — that is what upstream docling conformance is
34/// measured with, and `scripts/conformance/pdf_*.sh` pin it explicitly (by
35/// path, which wins over this selector).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum OcrLang {
38 /// en_PP-OCRv3 — English-only, proper Latin word spacing.
39 #[default]
40 En,
41 /// ch_PP-OCRv3 — multilingual; the docling-conformance model.
42 Ch,
43}
44
45impl OcrLang {
46 /// Parse a user-supplied language id. `None` for anything but `en`/`ch`
47 /// (trimmed, case-insensitive) — callers surface their own error/warning.
48 pub fn parse(s: &str) -> Option<Self> {
49 match s.trim().to_ascii_lowercase().as_str() {
50 "en" => Some(Self::En),
51 "ch" => Some(Self::Ch),
52 _ => None,
53 }
54 }
55
56 /// The process-level choice from `DOCLING_RS_OCR_LANG` (empty/unset → the
57 /// English default; unknown values warn and use English).
58 pub fn from_env() -> Self {
59 let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_LANG") else {
60 return Self::default();
61 };
62 Self::parse(&raw).unwrap_or_else(|| {
63 eprintln!("docling-pdf: DOCLING_RS_OCR_LANG={raw:?} is not en|ch; using en");
64 Self::default()
65 })
66 }
67}
68
69/// Resolve the recognition model + dictionary pair for `lang`. An English
70/// default that isn't on disk (older model checkouts) degrades to the `ch_`
71/// pair with a warning rather than failing — the usual missing-optional-asset
72/// convention. Explicit `DOCLING_OCR_REC_ONNX` / `DOCLING_OCR_DICT` paths win
73/// over all of this; they are a pair, so set both together.
74pub(crate) fn resolve_rec_pair(lang: OcrLang) -> (String, String) {
75 const CH: (&str, &str) = (".models/ocr_rec.onnx", ".models/ppocr_keys_v1.txt");
76 const EN: (&str, &str) = (".models/ocr_rec_en.onnx", ".models/en_dict.txt");
77 let want_ch = lang == OcrLang::Ch;
78 let pick = if want_ch { CH } else { EN };
79 let (mut rec, mut dict) = (crate::resolve_asset(pick.0), crate::resolve_asset(pick.1));
80 if !want_ch && (!std::path::Path::new(&rec).exists() || !std::path::Path::new(&dict).exists()) {
81 let (ch_rec, ch_dict) = (crate::resolve_asset(CH.0), crate::resolve_asset(CH.1));
82 if std::path::Path::new(&ch_rec).exists() && std::path::Path::new(&ch_dict).exists() {
83 eprintln!(
84 "docling-pdf: English OCR model not found ({rec}); falling back to the \
85 multilingual ch_ model — expect weak Latin word spacing. Fetch it with \
86 scripts/install/download_dependencies.sh"
87 );
88 (rec, dict) = (ch_rec, ch_dict);
89 }
90 }
91 (
92 docling_core::env::nonempty("DOCLING_OCR_REC_ONNX").unwrap_or(rec),
93 docling_core::env::nonempty("DOCLING_OCR_DICT").unwrap_or(dict),
94 )
95}
96
97impl OcrModel {
98 /// Load the recognition model and its character dictionary for `lang` —
99 /// see [`resolve_rec_pair`] for the selection rules (explicit
100 /// `DOCLING_OCR_REC_ONNX`/`DOCLING_OCR_DICT` paths win).
101 pub fn load(lang: OcrLang) -> Result<Self, String> {
102 let (rec_path, dict_path) = resolve_rec_pair(lang);
103 // Single-threaded: ORT's multi-threaded float-reduction order varies
104 // across runs, which flips the CTC argmax on low-confidence characters
105 // (e.g. noisy faxes) and makes the snapshot output non-deterministic. The
106 // recognition inputs are tiny per-line crops, so the throughput cost is
107 // negligible.
108 let builder = Session::builder()
109 .map_err(|e| format!("ocr: builder: {e}"))?
110 .with_intra_threads(1)
111 .map_err(|e| format!("ocr: intra_threads: {e}"))?;
112 let rec = crate::ep::apply(builder)
113 .map_err(|e| format!("ocr: {e}"))?
114 .commit_from_file(&rec_path)
115 .map_err(|e| format!("ocr: load {rec_path}: {e}"))?;
116 let dict = std::fs::read_to_string(&dict_path)
117 .map_err(|e| format!("ocr: read dict {dict_path}: {e}"))?;
118 Ok(Self {
119 rec,
120 chars: dict_chars(&dict),
121 })
122 }
123
124 /// Recognise a batch of prepared *same-width* lines in one session run.
125 ///
126 /// Only equal widths ever share a run: same-width batching is
127 /// bit-identical to one-at-a-time recognition (each sample keeps its own
128 /// data and per-sample kernel reduction order — verified empirically on
129 /// the scanned corpus), whereas width-padding leaks into the real
130 /// timesteps through the model's global-attention blocks and measurably
131 /// changes low-confidence characters.
132 fn recognize_batch(
133 &mut self,
134 w: usize,
135 chunk: &[usize],
136 lines: &[PrepLine],
137 ) -> Result<Vec<(String, f32)>, String> {
138 let n = chunk.len();
139 let data = batch_input(w, chunk, lines);
140 let input = Tensor::from_array(([n, 3, REC_HEIGHT as usize, w], data))
141 .map_err(|e| format!("ocr: input tensor: {e}"))?;
142 let outputs = self
143 .rec
144 .run(ort::inputs!["x" => input])
145 .map_err(|e| format!("ocr: rec inference: {e}"))?;
146 let (shape, probs) = outputs[0]
147 .try_extract_tensor::<f32>()
148 .map_err(|e| format!("ocr: extract rec: {e}"))?;
149 let t_len = shape[1] as usize;
150 let nc = shape[2] as usize;
151 Ok((0..n)
152 .map(|i| {
153 decode_row_scored(
154 &self.chars,
155 &probs[i * t_len * nc..(i + 1) * t_len * nc],
156 nc,
157 )
158 })
159 .collect())
160 }
161
162 /// Recognize `lines` and reduce to orientation-probe evidence: the
163 /// confidence-weighted character count `Σ(conf × chars)` plus the raw
164 /// character total (#225). Same deterministic width-batching as page OCR.
165 pub(crate) fn score_lines(&mut self, lines: &[PrepLine]) -> Result<(f32, usize), String> {
166 let mut weighted = 0.0f32;
167 let mut chars = 0usize;
168 for (w, chunk) in width_batches(lines) {
169 for (text, conf) in self.recognize_batch(w, &chunk, lines)? {
170 let n = text.trim().chars().count();
171 weighted += conf * n as f32;
172 chars += n;
173 }
174 }
175 Ok((weighted, chars))
176 }
177
178 /// OCR a page: produce text cells (page points) for every line found inside
179 /// the text regions, each paired with its recognition confidence (mean
180 /// emitted-character probability — feeds the page `ocr_score`, #183).
181 /// `scale` is image-px per page-point.
182 pub fn ocr_page(
183 &mut self,
184 img: &RgbImage,
185 regions: &[Region],
186 scale: f32,
187 ) -> Result<Vec<(TextCell, f32)>, String> {
188 // Gather every line crop on the page first (shared with the browser
189 // path), so equal-width lines can share a recognition run regardless
190 // of which region they came from.
191 let (bboxes, lines) = prep_region_lines(img, regions, scale);
192
193 // Deterministic width-batching (shared with the wasm path).
194 let mut texts = vec![(String::new(), 0.0f32); lines.len()];
195 for (w, chunk) in width_batches(&lines) {
196 for (&i, text) in chunk.iter().zip(self.recognize_batch(w, &chunk, &lines)?) {
197 texts[i] = text;
198 }
199 }
200
201 // Emit cells in page order, exactly as the sequential walk did.
202 let mut cells = Vec::new();
203 for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
204 let text = text.trim().to_string();
205 if text.is_empty() {
206 continue;
207 }
208 cells.push((TextCell { text, l, t, r, b }, conf));
209 }
210 Ok(cells)
211 }
212
213 /// Recognize the *word* crops inside the page's table regions (mirroring
214 /// the browser scanned path): [`ocr_page`](Self::ocr_page) deliberately
215 /// skips table labels, so a scanned table would otherwise reach the cell
216 /// matcher with no words at all and dissolve (#173). Returns word-level
217 /// [`TextCell`]s in page points.
218 pub fn ocr_table_words(
219 &mut self,
220 img: &RgbImage,
221 regions: &[Region],
222 scale: f32,
223 ) -> Result<Vec<(TextCell, f32)>, String> {
224 let (bboxes, lines) = prep_table_words(img, regions, scale);
225 let mut texts = vec![(String::new(), 0.0f32); lines.len()];
226 for (w, chunk) in width_batches(&lines) {
227 for (&i, text) in chunk.iter().zip(self.recognize_batch(w, &chunk, &lines)?) {
228 texts[i] = text;
229 }
230 }
231 let mut cells = Vec::new();
232 for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
233 let text = text.trim().to_string();
234 if text.is_empty() {
235 continue;
236 }
237 cells.push((TextCell { text, l, t, r, b }, conf));
238 }
239 Ok(cells)
240 }
241}