docling_pdf/ocr.rs
1//! OCR for scanned pages, via the PP-OCRv3 recognition model (CRNN/SVTR) run
2//! with `ort`. The layout model locates the text regions on the page image
3//! (it works without a text layer); inside them the recognizer reads the
4//! lines the PP-OCR text detector found (`ocr_det`, #429/#570 — RapidOCR's
5//! own crops, each one text run with the detector's margin), falling back to
6//! a horizontal-projection split of the region crop where the detector saw
7//! nothing or is not installed; each line is recognised and decoded with CTC
8//! — producing [`TextCell`]s the normal layout assembly then consumes. A line
9//! under RapidOCR's `text_score` confidence is dropped (`ocr_prep::text_score`).
10
11use image::RgbImage;
12use ort::session::Session;
13use ort::value::Tensor;
14
15use crate::layout::Region;
16// The ONNX-free half (line prep, batching, CTC decode) lives in `ocr_prep`
17// so the wasm build shares it verbatim (#79 phase 2).
18use crate::ocr_prep::{
19 batch_input, decode_row_scored, dict_chars, prep_region_lines, prep_region_lines_det,
20 prep_table_words, prep_table_words_det, width_batches, PrepLine, REC_HEIGHT,
21};
22use crate::pdfium_backend::TextCell;
23
24pub struct OcrModel {
25 /// Single-threaded recognition sessions, one per parallel lane (see
26 /// [`Self::load_with`]); lines are dealt across them by batch index.
27 recs: Vec<Session>,
28 /// CTC classes: index 0 = blank, then the dictionary, then space.
29 chars: Vec<String>,
30 /// RapidOCR's line orientation handling (#666). See
31 /// [`crate::Pipeline::ocr_textline_orientation`].
32 orient: bool,
33 /// The text-line orientation classifier, loaded on the first oriented
34 /// page.
35 cls: ClsSlot,
36}
37
38/// [`OcrModel::cls`]: `Missing` records a failed load (warned once per
39/// process), so it is not retried per page.
40enum ClsSlot {
41 Unloaded,
42 Ready(crate::ocr_cls::TextlineCls),
43 Missing,
44}
45
46/// OCR recognition language: which PP-OCRv3 model + dictionary pair runs
47/// when the PP-OCRv6 recognizer is not installed.
48///
49/// With `.models/ocr_rec_v6.onnx` + `.models/ocr_rec_v6_dict.txt` on disk
50/// (#570; `download_dependencies.sh` fetches them) both languages run that
51/// one multilingual model — RapidOCR's `PP-OCRv6_rec_small`, the recognizer
52/// docling runs for English and Chinese alike, and the single largest factor
53/// in the FUNSD word-recall gap once the detector's lines are the crops
54/// (0.70 → 0.77 on 30 forms). Without it, the PP-OCRv3 pairs: the default is
55/// **English** (`.models/ocr_rec_en.onnx` + `.models/en_dict.txt`) — the
56/// multilingual `ch_` v3 model reads Latin scripts with badly degraded word
57/// spacing (glued words on ordinary English scans) — and `Ch` selects the
58/// `ch_` pair (`.models/ocr_rec.onnx` + `.models/ppocr_keys_v1.txt`), what
59/// the PDF conformance baselines were pinned against;
60/// `scripts/conformance/pdf_*.sh` pin it explicitly by path, which wins over
61/// this selector and over the v6 preference.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum OcrLang {
64 /// en_PP-OCRv3 — English-only, proper Latin word spacing.
65 #[default]
66 En,
67 /// ch_PP-OCRv3 — multilingual; the docling-conformance model.
68 Ch,
69}
70
71impl OcrLang {
72 /// Parse a user-supplied language id (#388): the engine's own codes
73 /// (`en`, `ch`) and BCP-47 tags naming a language one of the two
74 /// recognizers reads, trimmed and case-insensitive. `None` for anything
75 /// else — callers surface their own error/warning.
76 ///
77 /// docling canonicalizes OCR languages across its engines (docling#4075):
78 /// a bare value is the engine's native code, an `iso:`-prefixed value a
79 /// BCP-47 tag reduced to a language-script pair with the region dropped
80 /// (`zh-CN` and `zh-Hans` are the same recognizer, `en-GB` is `en`), and
81 /// the RapidOCR adapter maps `en` → its `en` model and `zh-Hans` → `ch`.
82 /// With only those two PP-OCRv3 pairs on board there is no ambiguity, so
83 /// the prefix is optional here: `en-US`, `eng`, `zh`, `zh-Hans`, `iso:zh-CN`
84 /// all resolve without a warning. Accepted primary subtags: English as
85 /// `en` / ISO 639-2/3 `eng` / docling's legacy `english`; Chinese as the
86 /// engine code `ch` (and RapidOCR's `chinese_cht`), `zh` / `zho` / `chi` /
87 /// `cmn` / legacy `chinese` / EasyOCR's `ch_sim` / `ch_tra`. Script,
88 /// region and variant subtags (`-Hans`, `-Hant`, `-CN`, `-TW`, `_US`) are
89 /// ignored: a traditional-script request (`zh-Hant`, `zh-TW`) gets the
90 /// multilingual `ch` recognizer too, the closest model shipped — upstream
91 /// would pick RapidOCR's separate `chinese_cht`, which this engine does
92 /// not carry. Genuinely unsupported languages (`de`, `fr`, `ja`, …) parse
93 /// to `None` and keep warning.
94 pub fn parse(s: &str) -> Option<Self> {
95 let token = s.trim().to_ascii_lowercase();
96 let tag = token.strip_prefix("iso:").unwrap_or(&token).trim();
97 let primary = tag.split(['-', '_']).next().unwrap_or_default();
98 match primary {
99 "en" | "eng" | "english" => Some(Self::En),
100 "ch" | "chinese_cht" | "zh" | "zho" | "chi" | "cmn" | "chinese" | "ch_sim"
101 | "ch_tra" => Some(Self::Ch),
102 _ => None,
103 }
104 }
105
106 /// The process-level choice from `DOCLING_RS_OCR_LANG` (empty/unset → the
107 /// English default; unknown values warn and use English).
108 pub fn from_env() -> Self {
109 let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_LANG") else {
110 return Self::default();
111 };
112 Self::parse(&raw).unwrap_or_else(|| {
113 eprintln!(
114 "docling-pdf: DOCLING_RS_OCR_LANG={raw:?} names no language the en/ch \
115 recognizers read ({}); using en",
116 Self::ACCEPTED
117 );
118 Self::default()
119 })
120 }
121
122 /// The accepted spellings, for error messages and docs.
123 pub const ACCEPTED: &'static str =
124 "en | ch, or a BCP-47 tag for English or Chinese such as en-US, eng, zh, zh-Hans, zh-TW";
125}
126
127/// Which document regions feed the OCR — docling 2.116's `OcrMode` (#254,
128/// upstream docling#3710). Upstream restructured its pipeline so OCR runs
129/// *after* layout, on layout regions filtered by the PDF text layer — the
130/// architecture this port has always had — and named the strategies:
131///
132/// - `PdfAwareLayoutRegions` (upstream's **default**): OCR only layout regions
133/// the embedded text layer can't cover. Exactly the standard path here —
134/// scanned pages OCR their regions, digital pages OCR only text-less bitmap
135/// areas.
136/// - `FullPage` / `LayoutRegions`: ignore the PDF text layer and OCR
137/// everything. Both map onto the [`force_full_page_ocr`] machinery (discard
138/// the text layer, OCR every layout region): the upstream distinction —
139/// whole-page vs per-region *detector* input — has no analogue in this
140/// engine, whose PP-OCR recognizer always consumes per-region line crops.
141///
142/// [`force_full_page_ocr`]: crate::Pipeline::force_full_page_ocr
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum OcrMode {
145 /// Upstream's `default`: currently wired to `PdfAwareLayoutRegions`.
146 #[default]
147 Default,
148 /// OCR the full page, text layer ignored (docling's `full_page`; the
149 /// mode-shaped spelling of `force_full_page_ocr`).
150 FullPage,
151 /// OCR every layout region, text layer ignored (docling's
152 /// `layout_regions`).
153 LayoutRegions,
154 /// OCR layout regions the text layer can't cover (docling's
155 /// `pdf_aware_layout_regions` — the default behavior).
156 PdfAwareLayoutRegions,
157}
158
159impl OcrMode {
160 /// Parse docling's mode ids. `None` for anything else — callers surface
161 /// their own error/warning.
162 pub fn parse(s: &str) -> Option<Self> {
163 match s.trim().to_ascii_lowercase().as_str() {
164 "default" => Some(Self::Default),
165 "full_page" => Some(Self::FullPage),
166 "layout_regions" => Some(Self::LayoutRegions),
167 "pdf_aware_layout_regions" => Some(Self::PdfAwareLayoutRegions),
168 _ => None,
169 }
170 }
171
172 /// The process-level choice from `DOCLING_RS_OCR_MODE` (empty/unset → the
173 /// default; unknown values warn and use the default).
174 pub fn from_env() -> Self {
175 let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_MODE") else {
176 return Self::default();
177 };
178 Self::parse(&raw).unwrap_or_else(|| {
179 eprintln!(
180 "docling-pdf: DOCLING_RS_OCR_MODE={raw:?} is not \
181 default|full_page|layout_regions|pdf_aware_layout_regions; using default"
182 );
183 Self::default()
184 })
185 }
186
187 /// Whether this mode discards the embedded text layer — the engine truth
188 /// both non-default modes reduce to.
189 pub fn forces_full_page(self) -> bool {
190 matches!(self, Self::FullPage | Self::LayoutRegions)
191 }
192}
193
194/// Which OCR engine recognizes text (#460): the built-in PP-OCRv3 recognizer
195/// (+ the RapidOCR text detector) — the default and the engine every
196/// conformance baseline is pinned against — or the system `tesseract` binary
197/// (see [`crate::tesseract`]), docling's `TesseractCliOcrOptions`
198/// counterpart. Both consume the same layout-region crops and produce the
199/// same cells; everything downstream is engine-agnostic.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum OcrEngine {
202 /// PP-OCRv3 recognition via ONNX Runtime (docling's `rapidocr` kind).
203 #[default]
204 PpOcr,
205 /// The `tesseract` CLI (docling's `tesseract` kind).
206 Tesseract,
207}
208
209impl OcrEngine {
210 /// Parse an engine id: `ppocr` (also `pp-ocr`, `rapidocr`, docling's
211 /// kind name) or `tesseract` (also `tesseract_cli`, `tesserocr`),
212 /// trimmed and case-insensitive. `None` for anything else.
213 pub fn parse(s: &str) -> Option<Self> {
214 match s.trim().to_ascii_lowercase().as_str() {
215 "ppocr" | "pp-ocr" | "pp_ocr" | "rapidocr" | "onnx" | "default" => Some(Self::PpOcr),
216 "tesseract" | "tesseract_cli" | "tesseract-cli" | "tesserocr" => Some(Self::Tesseract),
217 _ => None,
218 }
219 }
220
221 /// The process-level choice from `DOCLING_RS_OCR_ENGINE` (empty/unset →
222 /// PP-OCR; unknown values warn and use PP-OCR).
223 pub fn from_env() -> Self {
224 let Some(raw) = docling_core::env::nonempty("DOCLING_RS_OCR_ENGINE") else {
225 return Self::default();
226 };
227 Self::parse(&raw).unwrap_or_else(|| {
228 eprintln!(
229 "docling-pdf: DOCLING_RS_OCR_ENGINE={raw:?} is not ppocr|tesseract; using ppocr"
230 );
231 Self::default()
232 })
233 }
234
235 /// The accepted spellings, for error messages and docs.
236 pub const ACCEPTED: &'static str = "ppocr | tesseract";
237
238 /// Whether `raw` is an `ocr_lang` this engine can act on — what the
239 /// option surfaces validate up front: the en/ch model switch (or a
240 /// BCP-47 tag for either, [`OcrLang::parse`]) under PP-OCR; tessdata
241 /// stems and BCP-47 tags ([`crate::tesseract::lang_arg`]) under
242 /// Tesseract. The `Err` says what is accepted.
243 pub fn validate_lang(self, raw: &str) -> Result<(), String> {
244 match self {
245 Self::PpOcr => OcrLang::parse(raw).map(|_| ()).ok_or_else(|| {
246 format!(
247 "ocr_lang {raw:?} names no language the OCR models read ({})",
248 OcrLang::ACCEPTED
249 )
250 }),
251 Self::Tesseract => crate::tesseract::lang_arg(raw).map(|_| ()),
252 }
253 }
254}
255
256/// The process-level OCR render scale from `DOCLING_RS_OCR_SCALE` (#254,
257/// upstream docling#3877's `OcrOptions.scale`): pixels per PDF point fed to
258/// the recognizer. Unset/empty → `None` (OCR reads the pipeline's own page
259/// render, 2.0 px/pt); non-positive or unparsable values warn and are ignored.
260pub fn scale_from_env() -> Option<f32> {
261 positive_scale_env("DOCLING_RS_OCR_SCALE")
262}
263
264/// The process-level picture OCR scale from `DOCLING_RS_PICTURE_OCR_SCALE`
265/// (#666): pixels per picture pixel for the picture-OCR enrichment, before
266/// RapidOCR's 2000 px cap — see [`crate::Pipeline::picture_ocr_scale`].
267/// Unset/empty → `None`; non-positive or unparsable values warn and are
268/// ignored.
269pub fn picture_scale_from_env() -> Option<f32> {
270 positive_scale_env("DOCLING_RS_PICTURE_OCR_SCALE")
271}
272
273fn positive_scale_env(name: &str) -> Option<f32> {
274 let raw = docling_core::env::nonempty(name)?;
275 match raw.parse::<f32>() {
276 Ok(s) if s > 0.0 && s.is_finite() => Some(s),
277 _ => {
278 eprintln!("docling-pdf: {name}={raw:?} is not a positive number; ignored");
279 None
280 }
281 }
282}
283
284/// The process-level default of [`crate::Pipeline::ocr_textline_orientation`]
285/// from `DOCLING_RS_OCR_TEXTLINE_ORIENTATION` (#666; a `docling_core::env`
286/// flag). Off when unset.
287pub fn textline_orientation_from_env() -> bool {
288 docling_core::env::flag("DOCLING_RS_OCR_TEXTLINE_ORIENTATION")
289}
290
291/// Whether the text detector's boxes are the recognizer's line source inside
292/// layout regions (#570; `DOCLING_RS_OCR_LINES`: `det` default, `projection`
293/// = the ink-projection strips alone, the pre-#570 behavior). Cached.
294pub fn det_lines() -> bool {
295 static MODE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
296 *MODE.get_or_init(|| {
297 let raw = docling_core::env::nonempty("DOCLING_RS_OCR_LINES").unwrap_or_default();
298 match raw.trim().to_ascii_lowercase().as_str() {
299 "" | "det" | "detector" | "auto" => true,
300 "projection" | "proj" | "off" => false,
301 _ => {
302 eprintln!(
303 "docling-pdf: DOCLING_RS_OCR_LINES={raw:?} is not det|projection; using det"
304 );
305 true
306 }
307 }
308 })
309}
310
311/// Resolve the recognition model + dictionary pair for `lang`. An English
312/// default that isn't on disk (older model checkouts) degrades to the `ch_`
313/// pair with a warning rather than failing — the usual missing-optional-asset
314/// convention. Explicit `DOCLING_OCR_REC_ONNX` / `DOCLING_OCR_DICT` paths win
315/// over all of this; they are a pair, so set both together.
316pub(crate) fn resolve_rec_pair(lang: OcrLang) -> (String, String) {
317 const CH: (&str, &str) = (".models/ocr_rec.onnx", ".models/ppocr_keys_v1.txt");
318 const EN: (&str, &str) = (".models/ocr_rec_en.onnx", ".models/en_dict.txt");
319 const V6: (&str, &str) = (".models/ocr_rec_v6.onnx", ".models/ocr_rec_v6_dict.txt");
320 let exists = |p: &str| std::path::Path::new(p).exists();
321 // The multilingual PP-OCRv6 recognizer, when installed, serves both
322 // languages (see `OcrLang`); explicit paths below still win.
323 let (v6_rec, v6_dict) = (crate::resolve_asset(V6.0), crate::resolve_asset(V6.1));
324 let (mut rec, mut dict) = if exists(&v6_rec) && exists(&v6_dict) {
325 (v6_rec, v6_dict)
326 } else {
327 let pick = if lang == OcrLang::Ch { CH } else { EN };
328 (crate::resolve_asset(pick.0), crate::resolve_asset(pick.1))
329 };
330 let want_ch = lang == OcrLang::Ch;
331 if !want_ch && (!exists(&rec) || !exists(&dict)) {
332 let (ch_rec, ch_dict) = (crate::resolve_asset(CH.0), crate::resolve_asset(CH.1));
333 if std::path::Path::new(&ch_rec).exists() && std::path::Path::new(&ch_dict).exists() {
334 eprintln!(
335 "docling-pdf: English OCR model not found ({rec}); falling back to the \
336 multilingual ch_ model — expect weak Latin word spacing. Fetch it with \
337 scripts/install/download_dependencies.sh"
338 );
339 (rec, dict) = (ch_rec, ch_dict);
340 }
341 }
342 (
343 docling_core::env::nonempty("DOCLING_OCR_REC_ONNX").unwrap_or(rec),
344 docling_core::env::nonempty("DOCLING_OCR_DICT").unwrap_or(dict),
345 )
346}
347
348/// One recognised line: its text and mean emitted-character confidence.
349type Recognized = (String, f32);
350
351impl OcrModel {
352 /// Load the recognition model and its character dictionary for `lang` —
353 /// see [`resolve_rec_pair`] for the selection rules (explicit
354 /// `DOCLING_OCR_REC_ONNX`/`DOCLING_OCR_DICT` paths win) — with `lanes`
355 /// recognition sessions.
356 ///
357 /// Each session is pinned to one intra-op thread: ORT's multi-threaded
358 /// float-reduction order varies across runs, which flips the CTC argmax on
359 /// low-confidence characters (e.g. noisy faxes) and makes the snapshot
360 /// output non-deterministic. Recognition is linear in line width (~0.17 ms
361 /// per pixel column on one core) and on a scanned page it, plus the
362 /// orientation probe that reads the six widest lines, is ~35% of the wall
363 /// time while the other cores idle. Lines are independent, so `lanes`
364 /// sessions recognise disjoint same-width batches concurrently — each
365 /// line still sees exactly the single-thread kernel path, results are
366 /// placed by index, and the output is byte-identical to one lane.
367 /// `DOCLING_RS_OCR_SESSIONS` overrides the caller's lane count.
368 pub fn load_with(lang: OcrLang, lanes: usize) -> Result<Self, String> {
369 let (rec_path, dict_path) = resolve_rec_pair(lang);
370 let lanes = docling_core::env::parse::<usize>("DOCLING_RS_OCR_SESSIONS")
371 .filter(|&n| n > 0)
372 .unwrap_or(lanes)
373 .clamp(1, 8);
374 let open = || -> Result<Session, String> {
375 let builder = docling_onnx::session_builder()
376 .map_err(|e| format!("ocr: builder: {e}"))?
377 .with_intra_threads(1)
378 .map_err(|e| format!("ocr: intra_threads: {e}"))?;
379 let builder = docling_onnx::apply(builder).map_err(|e| format!("ocr: {e}"))?;
380 docling_onnx::commit(builder, &rec_path, "rec")
381 .map_err(|e| format!("ocr: load {rec_path}: {e}"))
382 };
383 // The lanes are independent sessions over the same file — open them
384 // concurrently so extra lanes cost no extra start-up latency.
385 let recs: Vec<Session> = std::thread::scope(|s| {
386 let handles: Vec<_> = (0..lanes).map(|_| s.spawn(open)).collect();
387 handles
388 .into_iter()
389 .map(|h| {
390 h.join()
391 .map_err(|_| "ocr: session thread panicked".to_string())?
392 })
393 .collect::<Result<Vec<_>, String>>()
394 })?;
395 let dict = std::fs::read_to_string(&dict_path)
396 .map_err(|e| format!("ocr: read dict {dict_path}: {e}"))?;
397 Ok(Self {
398 recs,
399 chars: dict_chars(&dict),
400 orient: false,
401 cls: ClsSlot::Unloaded,
402 })
403 }
404
405 /// Turn RapidOCR's line orientation handling on or off (#666): oriented
406 /// crops, the 90° turn, and the 0/180 classifier on detector lines.
407 pub fn set_textline_orientation(&mut self, enable: bool) {
408 self.orient = enable;
409 }
410
411 /// The orientation classifier, loading it on first use; `None` (warned
412 /// once) when the model is not installed or fails to load.
413 fn cls(&mut self) -> Option<&mut crate::ocr_cls::TextlineCls> {
414 if matches!(self.cls, ClsSlot::Unloaded) {
415 self.cls = match crate::ocr_cls::TextlineCls::load() {
416 Ok(m) => ClsSlot::Ready(m),
417 Err(e) => {
418 static WARNED: std::sync::Once = std::sync::Once::new();
419 WARNED.call_once(|| {
420 eprintln!(
421 "warning: {e}; text lines are cropped and turned upright but not \
422 checked for 180° (run scripts/install/download_dependencies.sh)"
423 );
424 });
425 ClsSlot::Missing
426 }
427 };
428 }
429 match &mut self.cls {
430 ClsSlot::Ready(m) => Some(m),
431 _ => None,
432 }
433 }
434
435 /// The oriented line prep (#666): RapidOCR's crops, the upside-down ones
436 /// turned 180° by the classifier when it is installed, then prepared
437 /// for recognition like any other line.
438 fn oriented_lines(
439 &mut self,
440 img: &RgbImage,
441 regions: &[Region],
442 scale: f32,
443 detected: &[crate::ocr_det::DetBox],
444 ) -> Result<(Vec<crate::ocr_prep::LineBox>, Vec<PrepLine>), String> {
445 use crate::ocr_prep::LineSrc;
446 let (bboxes, mut srcs) =
447 crate::ocr_prep::region_line_crops_det(img, regions, scale, detected);
448 let crop_idx: Vec<usize> = (0..srcs.len())
449 .filter(|&i| matches!(srcs[i], LineSrc::Crop(_)))
450 .collect();
451 if !crop_idx.is_empty() {
452 if let Some(cls) = self.cls() {
453 let crops: Vec<&RgbImage> = crop_idx
454 .iter()
455 .map(|&i| match &srcs[i] {
456 LineSrc::Crop(c) => c,
457 LineSrc::Prepared(_) => unreachable!("filtered to crops"),
458 })
459 .collect();
460 let flip = crate::timing::timed("ocr.cls", || cls.upside_down(&crops))?;
461 for (&i, flip) in crop_idx.iter().zip(flip) {
462 if let (true, LineSrc::Crop(c)) = (flip, &mut srcs[i]) {
463 image::imageops::rotate180_in_place(c);
464 }
465 }
466 }
467 }
468 let mut boxes = Vec::with_capacity(bboxes.len());
469 let mut lines = Vec::with_capacity(bboxes.len());
470 for (bb, src) in bboxes.into_iter().zip(srcs) {
471 let line = match src {
472 LineSrc::Crop(c) => crate::ocr_prep::prep_line(&c),
473 LineSrc::Prepared(p) => Some(p),
474 };
475 if let Some(line) = line {
476 boxes.push(bb);
477 lines.push(line);
478 }
479 }
480 Ok((boxes, lines))
481 }
482
483 /// Recognise every width batch of `lines`, dealt round-robin across the
484 /// lanes, and return `(line index, (text, confidence))` in batch order —
485 /// the same order the sequential loop produced, whatever the scheduling.
486 fn recognize_all(&mut self, lines: &[PrepLine]) -> Result<Vec<(usize, Recognized)>, String> {
487 let batches = width_batches(lines);
488 let lanes = self.recs.len().min(batches.len()).max(1);
489 let chars = &self.chars;
490 // One result slot per batch keeps the merge order independent of
491 // which lane finished first.
492 let mut per_batch: Vec<Option<Result<Vec<Recognized>, String>>> =
493 (0..batches.len()).map(|_| None).collect();
494 if lanes <= 1 {
495 for (slot, (w, chunk)) in per_batch.iter_mut().zip(&batches) {
496 *slot = Some(recognize_batch(&mut self.recs[0], chars, *w, chunk, lines));
497 }
498 } else {
499 std::thread::scope(|s| {
500 let handles: Vec<_> = self
501 .recs
502 .iter_mut()
503 .take(lanes)
504 .enumerate()
505 .map(|(lane, rec)| {
506 let batches = &batches;
507 s.spawn(move || {
508 batches
509 .iter()
510 .enumerate()
511 .filter(|(k, _)| k % lanes == lane)
512 .map(|(k, (w, chunk))| {
513 (k, recognize_batch(rec, chars, *w, chunk, lines))
514 })
515 .collect::<Vec<_>>()
516 })
517 })
518 .collect();
519 for h in handles {
520 for (k, r) in h.join().expect("ocr lane panicked") {
521 per_batch[k] = Some(r);
522 }
523 }
524 });
525 }
526 let mut out = Vec::with_capacity(lines.len());
527 for ((_, chunk), slot) in batches.iter().zip(per_batch) {
528 let texts = slot.expect("every batch is assigned a lane")?;
529 out.extend(chunk.iter().copied().zip(texts));
530 }
531 Ok(out)
532 }
533
534 /// Recognise a batch of prepared *same-width* lines in one session run.
535 ///
536 /// Only equal widths ever share a run: same-width batching is
537 /// bit-identical to one-at-a-time recognition (each sample keeps its own
538 /// data and per-sample kernel reduction order — verified empirically on
539 /// the scanned corpus), whereas width-padding leaks into the real
540 /// timesteps through the model's global-attention blocks and measurably
541 /// changes low-confidence characters.
542 /// Recognize `lines` and reduce to orientation-probe evidence: the
543 /// confidence-weighted character count `Σ(conf × chars)` plus the raw
544 /// character total (#225). Same deterministic width-batching as page OCR.
545 pub(crate) fn score_lines(&mut self, lines: &[PrepLine]) -> Result<(f32, usize), String> {
546 // Same accumulation order as the sequential loop (batch order), so
547 // the f32 sum is bit-identical regardless of lane scheduling.
548 let mut weighted = 0.0f32;
549 let mut chars = 0usize;
550 for (_, (text, conf)) in self.recognize_all(lines)? {
551 let n = text.trim().chars().count();
552 weighted += conf * n as f32;
553 chars += n;
554 }
555 Ok((weighted, chars))
556 }
557
558 /// OCR a page: produce text cells (page points) for every line found inside
559 /// the text regions, each paired with its recognition confidence (mean
560 /// emitted-character probability — feeds the page `ocr_score`, #183).
561 /// `scale` is image-px per page-point. `detected` — the text detector's
562 /// boxes, in image pixels of `img` — makes them the line source inside
563 /// the regions (#570, see [`prep_region_lines_det`]); `None` keeps the
564 /// projection segmentation.
565 pub fn ocr_page_with(
566 &mut self,
567 img: &RgbImage,
568 regions: &[Region],
569 scale: f32,
570 detected: Option<&[crate::ocr_det::DetBox]>,
571 ) -> Result<Vec<(TextCell, f32)>, String> {
572 // Gather every line crop on the page first (shared with the browser
573 // path), so equal-width lines can share a recognition run regardless
574 // of which region they came from.
575 let (bboxes, lines) = match detected {
576 Some(det) if self.orient => self.oriented_lines(img, regions, scale, det)?,
577 _ => crate::timing::timed("ocr.prep", || match detected {
578 Some(det) => prep_region_lines_det(img, regions, scale, det),
579 None => prep_region_lines(img, regions, scale),
580 }),
581 };
582
583 // Deterministic width-batching (shared with the wasm path), dealt
584 // across the recognition lanes.
585 let mut texts = vec![(String::new(), 0.0f32); lines.len()];
586 crate::timing::timed("ocr.rec", || -> Result<(), String> {
587 for (i, text) in self.recognize_all(&lines)? {
588 texts[i] = text;
589 }
590 Ok(())
591 })?;
592
593 // Emit cells in page order, exactly as the sequential walk did.
594 Ok(collect_cells(bboxes, texts))
595 }
596
597 /// Recognize the *word* crops inside the page's table regions (mirroring
598 /// the browser scanned path): [`ocr_page`](Self::ocr_page) deliberately
599 /// skips table labels, so a scanned table would otherwise reach the cell
600 /// matcher with no words at all and dissolve (#173). Returns word-level
601 /// [`TextCell`]s in page points.
602 pub fn ocr_table_words(
603 &mut self,
604 img: &RgbImage,
605 regions: &[Region],
606 scale: f32,
607 detected: Option<&[crate::ocr_det::DetBox]>,
608 ) -> Result<Vec<(TextCell, f32)>, String> {
609 let (bboxes, lines) = match detected {
610 Some(det) => prep_table_words_det(img, regions, scale, det),
611 None => prep_table_words(img, regions, scale),
612 };
613 let mut texts = vec![(String::new(), 0.0f32); lines.len()];
614 for (i, text) in self.recognize_all(&lines)? {
615 texts[i] = text;
616 }
617 Ok(collect_cells(bboxes, texts))
618 }
619}
620
621/// Pair recognized texts with their line boxes into page-point cells, in page
622/// order, dropping empty lines and those under [`text_score`].
623fn collect_cells(
624 bboxes: Vec<crate::ocr_prep::LineBox>,
625 texts: Vec<Recognized>,
626) -> Vec<(TextCell, f32)> {
627 let min_conf = crate::ocr_prep::text_score();
628 let mut cells = Vec::new();
629 for ((l, t, r, b), (text, conf)) in bboxes.into_iter().zip(texts) {
630 let text = text.trim().to_string();
631 if text.is_empty() || conf < min_conf {
632 continue;
633 }
634 cells.push((TextCell { text, l, t, r, b }, conf));
635 }
636 cells
637}
638
639/// Recognise a batch of prepared *same-width* lines in one run of `rec`.
640///
641/// Only equal widths ever share a run: same-width batching is bit-identical
642/// to one-at-a-time recognition (each sample keeps its own data and per-sample
643/// kernel reduction order — verified empirically on the scanned corpus),
644/// whereas width-padding leaks into the real timesteps through the model's
645/// global-attention blocks and measurably changes low-confidence characters.
646fn recognize_batch(
647 rec: &mut Session,
648 chars: &[String],
649 w: usize,
650 chunk: &[usize],
651 lines: &[PrepLine],
652) -> Result<Vec<(String, f32)>, String> {
653 let n = chunk.len();
654 let data = batch_input(w, chunk, lines);
655 let input = Tensor::from_array(([n, 3, REC_HEIGHT as usize, w], data))
656 .map_err(|e| format!("ocr: input tensor: {e}"))?;
657 let outputs = rec
658 .run(ort::inputs!["x" => input])
659 .map_err(|e| format!("ocr: rec inference: {e}"))?;
660 let (shape, probs) = outputs[0]
661 .try_extract_tensor::<f32>()
662 .map_err(|e| format!("ocr: extract rec: {e}"))?;
663 let t_len = shape[1] as usize;
664 let nc = shape[2] as usize;
665 Ok((0..n)
666 .map(|i| decode_row_scored(chars, &probs[i * t_len * nc..(i + 1) * t_len * nc], nc))
667 .collect())
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 /// #388: BCP-47 tags and the ISO 639-2/3 codes for English and Chinese
675 /// resolve to the two recognizers, with or without docling's `iso:`
676 /// prefix and whatever the script/region subtags; other languages and
677 /// nonsense stay `None`.
678 #[test]
679 fn ocr_lang_accepts_bcp47_tags_for_the_two_recognizers() {
680 for id in [
681 "en",
682 "EN",
683 " en ",
684 "en-US",
685 "en_GB",
686 "eng",
687 "english",
688 "iso:en",
689 "ISO:en-GB",
690 "en-Latn-US",
691 ] {
692 assert_eq!(OcrLang::parse(id), Some(OcrLang::En), "{id:?}");
693 }
694 for id in [
695 "ch",
696 "zh",
697 "zho",
698 "chi",
699 "cmn",
700 "chinese",
701 "ch_sim",
702 "ch_tra",
703 "chinese_cht",
704 "zh-Hans",
705 "zh-Hant",
706 "zh-CN",
707 "zh-TW",
708 "zh-Hant-HK",
709 "zh_SG",
710 "iso:zh-Hans",
711 ] {
712 assert_eq!(OcrLang::parse(id), Some(OcrLang::Ch), "{id:?}");
713 }
714 for id in [
715 "", "de", "fr-FR", "ja", "deu", "cn", "latin", "iso:", "iso:und", "e",
716 ] {
717 assert_eq!(OcrLang::parse(id), None, "{id:?}");
718 }
719 }
720
721 /// #254: docling's four `OcrMode` ids parse; `full_page`/`layout_regions`
722 /// reduce to the force-full-page machinery, the default/pdf-aware pair to
723 /// the standard text-layer-aware path. Unknown ids parse to nothing.
724 #[test]
725 fn ocr_mode_ids_parse_and_map_to_forcing() {
726 for (id, mode, forces) in [
727 ("default", OcrMode::Default, false),
728 ("full_page", OcrMode::FullPage, true),
729 ("layout_regions", OcrMode::LayoutRegions, true),
730 (
731 "pdf_aware_layout_regions",
732 OcrMode::PdfAwareLayoutRegions,
733 false,
734 ),
735 ] {
736 assert_eq!(OcrMode::parse(id), Some(mode));
737 assert_eq!(mode.forces_full_page(), forces, "{id}");
738 }
739 assert_eq!(OcrMode::parse(" Full_Page "), Some(OcrMode::FullPage));
740 assert_eq!(OcrMode::parse("easyocr"), None);
741 assert_eq!(OcrMode::parse(""), None);
742 }
743
744 /// #460: the engine ids, and engine-aware `ocr_lang` validation — `deu`
745 /// is a Tesseract stem, not a PP-OCR model; `en` works under both.
746 #[test]
747 fn ocr_engine_ids_and_lang_validation() {
748 for id in ["ppocr", "PP-OCR", " rapidocr ", "default"] {
749 assert_eq!(OcrEngine::parse(id), Some(OcrEngine::PpOcr), "{id:?}");
750 }
751 for id in ["tesseract", "Tesseract_CLI", "tesserocr"] {
752 assert_eq!(OcrEngine::parse(id), Some(OcrEngine::Tesseract), "{id:?}");
753 }
754 assert_eq!(OcrEngine::parse("easyocr"), None);
755 assert!(OcrEngine::PpOcr.validate_lang("en").is_ok());
756 assert!(OcrEngine::PpOcr.validate_lang("deu").is_err());
757 assert!(OcrEngine::Tesseract.validate_lang("en").is_ok());
758 assert!(OcrEngine::Tesseract.validate_lang("deu+fra").is_ok());
759 assert!(OcrEngine::Tesseract.validate_lang("xx").is_err());
760 }
761}