Skip to main content

docling_pdf/
layout.rs

1//! Layout detection via the RT-DETR (`docling-layout-heron`) model exported to
2//! ONNX, run with `ort`. A port of docling-ibm-models' `LayoutPredictor`:
3//! resize the page image to 640×640 and rescale to `[0,1]` (the heron processor
4//! has `do_normalize=false`), run the model, then RT-DETR
5//! `post_process_object_detection` (sigmoid → top-k over query×class →
6//! center-to-corners boxes scaled to the page).
7
8#[cfg(feature = "ml")]
9use image::imageops::FilterType;
10#[cfg(feature = "ml")]
11use ort::session::Session;
12#[cfg(feature = "ml")]
13use ort::value::Tensor;
14
15/// The 17 canonical layout classes, indexed by the model's class id
16/// (`config.json` `id2label`).
17pub const LABELS: [&str; 17] = [
18    "caption",
19    "footnote",
20    "formula",
21    "list_item",
22    "page_footer",
23    "page_header",
24    "picture",
25    "section_header",
26    "table",
27    "text",
28    "title",
29    "document_index",
30    "code",
31    "checkbox_selected",
32    "checkbox_unselected",
33    "form",
34    "key_value_region",
35];
36
37/// One detected region, in page points (top-left origin).
38#[derive(Debug, Clone)]
39pub struct Region {
40    pub label: &'static str,
41    pub score: f32,
42    pub l: f32,
43    pub t: f32,
44    pub r: f32,
45    pub b: f32,
46}
47
48/// What a layout inference call receives per page — which resize kernel packs
49/// the 640×640 model input depends on it (docling parity, #58-branch):
50///
51/// docling's layout stage runs on `page.get_image(scale=1.0)` — the
52/// point-sized page image (pdfium at 1.5×, PIL-BICUBIC down) — which its
53/// RT-DETR processor then stretches to 640×640 with **PIL BILINEAR**
54/// (`preprocessor_config.json`: `do_pad: false`, `resample: 2`; no letterbox,
55/// no normalize beyond `/255`). [`PageImage`](LayoutSrc::PageImage) is that
56/// image and goes through the byte-exact PIL kernel. [`Raw`](LayoutSrc::Raw)
57/// is any other bitmap (the browser path's canvas render, METS/TIFF page
58/// scans) and keeps the legacy Triangle stretch.
59#[cfg(feature = "ocr-prep")]
60#[derive(Clone, Copy)]
61pub enum LayoutSrc<'a> {
62    /// The scale-1.0 page image (`PdfPage::image_layout`), docling-exact.
63    PageImage(&'a image::RgbImage),
64    /// Any other page bitmap — legacy stretch.
65    Raw(&'a image::RgbImage),
66}
67
68/// Base confidence threshold (docling-ibm-models `base_threshold`): the raw
69/// RT-DETR floor before docling's `LayoutPostprocessor` applies its stricter
70/// per-label thresholds ([`label_threshold`]).
71const THRESHOLD: f32 = 0.3;
72/// RT-DETR's fixed square input side.
73pub const SIDE: u32 = 640;
74
75/// Per-label confidence threshold, ported from docling's
76/// `LayoutPostprocessor.CONFIDENCE_THRESHOLDS`. The raw predictor keeps every
77/// detection above the 0.3 base; the postprocessor then drops a cluster whose
78/// score is below its label's threshold. Applying it here (equivalent, since
79/// every per-label threshold is ≥ the 0.3 base) keeps low-confidence pictures /
80/// tables / list-items out of the assembly, matching docling.
81pub fn label_threshold(label: &str) -> f32 {
82    match label {
83        "section_header"
84        | "title"
85        | "code"
86        | "checkbox_selected"
87        | "checkbox_unselected"
88        | "form"
89        | "key_value_region"
90        | "document_index" => 0.45,
91        // caption, footnote, formula, list_item, page_footer, page_header,
92        // picture, table, text — all 0.5 in docling.
93        _ => 0.5,
94    }
95}
96
97#[cfg(feature = "ml")]
98pub struct LayoutModel {
99    session: Session,
100    /// Set when a multi-page inference fails — e.g. a locally built pre-#73
101    /// static graph (fixed batch=1) via `DOCLING_LAYOUT_ONNX` or a stale
102    /// `layout_heron_int8.onnx`. Batched calls then fall back to per-page runs
103    /// instead of failing the conversion.
104    batch_unsupported: bool,
105    /// The fp32 graph to escalate a suspicious page to, set only when the
106    /// *auto-selected* int8 graph loaded (an explicit `DOCLING_LAYOUT_ONNX` /
107    /// `DOCLING_RS_FP32` choice is respected). Int8 confidences sit close
108    /// enough to the 0.5 label thresholds that a different CPU's quantized
109    /// kernels (AVX-VNNI vs AVX2, CUDA's fallback mix) can flip a whole page's
110    /// detections — observed as a bill page whose tables all dissolved into
111    /// orphan lines on one machine while converting perfectly on another.
112    fp32_path: Option<String>,
113    /// Lazily-loaded session over `fp32_path` — most documents never pay for it.
114    fp32: Option<Session>,
115    /// Intra-op threads, kept for the lazy fp32 load.
116    intra: usize,
117}
118
119#[cfg(feature = "ml")]
120impl LayoutModel {
121    /// Load the ONNX model from `DOCLING_LAYOUT_ONNX`. Without the override,
122    /// prefers `models/layout_heron_int8.onnx` when present (the quantized
123    /// default; `DOCLING_RS_FP32=1` opts out), else `models/layout_heron.onnx`.
124    pub fn load() -> Result<Self, String> {
125        Self::load_with(crate::intra_threads())
126    }
127
128    /// Like [`load`](Self::load) but with an explicit intra-op thread count. A
129    /// parallel page-worker pool loads its helper models on a single thread each
130    /// and gets its speed-up from running pages concurrently instead.
131    pub fn load_with(intra: usize) -> Result<Self, String> {
132        let path = crate::model_path(
133            "DOCLING_LAYOUT_ONNX",
134            "models/layout_heron.onnx",
135            "models/layout_heron_int8.onnx",
136        );
137        if crate::timing::enabled() {
138            eprintln!("docling-pdf: layout model: {path}");
139        }
140        // Escalation target for the quant-robustness guard: only when the
141        // int8 graph was picked automatically and the fp32 one is also there.
142        let fp32_path = if std::env::var("DOCLING_LAYOUT_ONNX").is_err() {
143            let fp32 = crate::resolve_asset("models/layout_heron.onnx");
144            (path != fp32 && std::path::Path::new(&fp32).exists()).then_some(fp32)
145        } else {
146            None
147        };
148        let session = Self::open_session(&path, intra)?;
149        Ok(Self {
150            session,
151            batch_unsupported: false,
152            fp32_path,
153            fp32: None,
154            intra,
155        })
156    }
157
158    fn open_session(path: &str, intra: usize) -> Result<Session, String> {
159        let builder = Session::builder()
160            .map_err(|e| format!("layout: builder: {e}"))?
161            // Let inference use the available cores (ort otherwise defaults low);
162            // a large PDF runs this model once per page.
163            .with_intra_threads(intra)
164            .map_err(|e| format!("layout: intra_threads: {e}"))?;
165        crate::ep::apply(builder)
166            .map_err(|e| format!("layout: {e}"))?
167            .commit_from_file(path)
168            .map_err(|e| format!("layout: load {path}: {e}"))
169    }
170
171    /// Re-run one page through the fp32 graph — the escape hatch for a page
172    /// whose int8 detections look implausible (see `fp32_path`). `Ok(None)`
173    /// when there is nothing to escalate to: fp32 already loaded, an explicit
174    /// model override, or no fp32 file on disk.
175    pub fn predict_fp32_fallback(
176        &mut self,
177        img: LayoutSrc<'_>,
178        page_w: f32,
179        page_h: f32,
180    ) -> Result<Option<Vec<Region>>, String> {
181        let Some(path) = self.fp32_path.clone() else {
182            return Ok(None);
183        };
184        if self.fp32.is_none() {
185            if crate::timing::enabled() {
186                eprintln!("docling-pdf: loading fp32 layout fallback: {path}");
187            }
188            self.fp32 = Some(Self::open_session(&path, self.intra)?);
189        }
190        let session = self.fp32.as_mut().expect("just loaded");
191        Ok(Some(
192            Self::run_on(session, &[(img, page_w, page_h)])?
193                .pop()
194                .expect("one result per input page"),
195        ))
196    }
197
198    /// Detect layout regions on a page image. `page_w`/`page_h` are the page size
199    /// in points; returned boxes are in those coordinates.
200    pub fn predict(
201        &mut self,
202        img: LayoutSrc<'_>,
203        page_w: f32,
204        page_h: f32,
205    ) -> Result<Vec<Region>, String> {
206        Ok(self
207            .predict_batch(&[(img, page_w, page_h)])?
208            .pop()
209            .expect("one result per input page"))
210    }
211
212    /// Detect layout regions on several page images with **one** inference call
213    /// (issue #73). The ONNX export has a dynamic batch dimension, so a worker
214    /// can amortize the per-run framework overhead and keep its cores busier on
215    /// multi-page documents. Results are per-image, index-aligned with `pages`,
216    /// and identical to calling [`predict`](Self::predict) per page.
217    pub fn predict_batch(
218        &mut self,
219        pages: &[(LayoutSrc<'_>, f32, f32)],
220    ) -> Result<Vec<Vec<Region>>, String> {
221        if pages.len() > 1 && self.batch_unsupported {
222            return self.predict_singly(pages);
223        }
224        match self.run_batch(pages) {
225            Err(e) if pages.len() > 1 => {
226                // A graph without the dynamic batch dim (pre-#73 export) fails
227                // only for batch > 1 — remember and recover per page. Warn once
228                // per process, not per worker: every worker owns a LayoutModel
229                // over the same graph file, so repeats carry no information.
230                static WARNED: std::sync::atomic::AtomicBool =
231                    std::sync::atomic::AtomicBool::new(false);
232                if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
233                    eprintln!(
234                        "docling-pdf: layout model rejected a {}-page batch ({e}); \
235                         falling back to per-page inference — re-export with \
236                         scripts/install/export_layout.py for batched layout",
237                        pages.len()
238                    );
239                }
240                self.batch_unsupported = true;
241                self.predict_singly(pages)
242            }
243            other => other,
244        }
245    }
246
247    fn predict_singly(
248        &mut self,
249        pages: &[(LayoutSrc<'_>, f32, f32)],
250    ) -> Result<Vec<Vec<Region>>, String> {
251        pages
252            .iter()
253            .map(|p| Ok(self.run_batch(&[*p])?.pop().expect("one result")))
254            .collect()
255    }
256
257    fn run_batch(
258        &mut self,
259        pages: &[(LayoutSrc<'_>, f32, f32)],
260    ) -> Result<Vec<Vec<Region>>, String> {
261        Self::run_on(&mut self.session, pages)
262    }
263
264    fn run_on(
265        session: &mut Session,
266        pages: &[(LayoutSrc<'_>, f32, f32)],
267    ) -> Result<Vec<Vec<Region>>, String> {
268        if pages.is_empty() {
269            return Ok(Vec::new());
270        }
271        // Resize each page to 640×640 (RT-DETR ignores aspect ratio), rescale to
272        // [0,1], lay out as NCHW. The kernel depends on the source (see
273        // [`LayoutSrc`]): the docling-exact page image goes through Pillow's
274        // BILINEAR (the RT-DETR processor's kernel, byte-for-byte), raw
275        // bitmaps keep the legacy Triangle stretch.
276        let n = (SIDE * SIDE) as usize;
277        let batch = pages.len();
278        let mut data = vec![0f32; batch * 3 * n];
279        for (p, (src, _, _)) in pages.iter().enumerate() {
280            let resized = match src {
281                LayoutSrc::PageImage(img) => crate::resample::pil_resize(
282                    img,
283                    SIDE,
284                    SIDE,
285                    crate::resample::PilFilter::Bilinear,
286                ),
287                LayoutSrc::Raw(img) => {
288                    image::imageops::resize(*img, SIDE, SIDE, FilterType::Triangle)
289                }
290            };
291            let page_off = p * 3 * n;
292            for (i, px) in resized.pixels().enumerate() {
293                data[page_off + i] = px[0] as f32 / 255.0;
294                data[page_off + n + i] = px[1] as f32 / 255.0;
295                data[page_off + 2 * n + i] = px[2] as f32 / 255.0;
296            }
297        }
298        let input = Tensor::from_array(([batch, 3, SIDE as usize, SIDE as usize], data))
299            .map_err(|e| format!("layout: input tensor: {e}"))?;
300        let outputs = session
301            .run(ort::inputs!["pixel_values" => input])
302            .map_err(|e| format!("layout: inference: {e}"))?;
303        let (lshape, logits) = outputs["logits"]
304            .try_extract_tensor::<f32>()
305            .map_err(|e| format!("layout: extract logits: {e}"))?;
306        let (_, boxes) = outputs["pred_boxes"]
307            .try_extract_tensor::<f32>()
308            .map_err(|e| format!("layout: extract boxes: {e}"))?;
309
310        let num_queries = lshape[1] as usize;
311        let num_classes = lshape[2] as usize;
312
313        let mut all = Vec::with_capacity(batch);
314        for (p, (_, page_w, page_h)) in pages.iter().enumerate() {
315            let logits =
316                &logits[p * num_queries * num_classes..(p + 1) * num_queries * num_classes];
317            let boxes = &boxes[p * num_queries * 4..(p + 1) * num_queries * 4];
318            all.push(decode_layout(
319                logits,
320                boxes,
321                num_queries,
322                num_classes,
323                *page_w,
324                *page_h,
325            ));
326        }
327        Ok(all)
328    }
329}
330
331fn sigmoid(x: f32) -> f32 {
332    1.0 / (1.0 + (-x).exp())
333}
334
335/// Pack one page image into the model's `(1, 3, SIDE, SIDE)` input: resize
336/// (aspect ignored, RT-DETR convention), rescale to `[0,1]`, CHW. Shared
337/// with the browser build (#157), which delegates only the session call.
338#[cfg(feature = "ocr-prep")]
339pub fn layout_input(img: &image::RgbImage) -> Vec<f32> {
340    let n = (SIDE * SIDE) as usize;
341    let mut data = vec![0f32; 3 * n];
342    let resized = image::imageops::resize(img, SIDE, SIDE, image::imageops::FilterType::Triangle);
343    for (i, px) in resized.pixels().enumerate() {
344        data[i] = px[0] as f32 / 255.0;
345        data[n + i] = px[1] as f32 / 255.0;
346        data[2 * n + i] = px[2] as f32 / 255.0;
347    }
348    data
349}
350
351/// Decode one page's raw RT-DETR outputs into scored [`Region`]s in page
352/// points — sigmoid over every (query, class), top-`num_queries` kept, boxes
353/// converted center→corners and scaled. Shared with the browser build; the
354/// native batch path calls it per page, so both decode identically.
355pub fn decode_layout(
356    logits: &[f32],
357    boxes: &[f32],
358    num_queries: usize,
359    num_classes: usize,
360    page_w: f32,
361    page_h: f32,
362) -> Vec<Region> {
363    let mut scored: Vec<(f32, usize)> = (0..num_queries * num_classes)
364        .map(|idx| (sigmoid(logits[idx]), idx))
365        .collect();
366    scored.sort_unstable_by(|a, b| b.0.total_cmp(&a.0));
367    scored.truncate(num_queries);
368
369    let mut regions = Vec::new();
370    for (score, idx) in scored {
371        if score <= THRESHOLD {
372            continue;
373        }
374        let label_id = idx % num_classes;
375        let q = idx / num_classes;
376        let cx = boxes[q * 4];
377        let cy = boxes[q * 4 + 1];
378        let w = boxes[q * 4 + 2];
379        let h = boxes[q * 4 + 3];
380        // center_to_corners, then scale normalized coords to page points.
381        let l = (cx - w / 2.0) * page_w;
382        let t = (cy - h / 2.0) * page_h;
383        let r = (cx + w / 2.0) * page_w;
384        let b = (cy + h / 2.0) * page_h;
385        regions.push(Region {
386            label: LABELS.get(label_id).copied().unwrap_or("text"),
387            score,
388            l,
389            t,
390            r,
391            b,
392        });
393    }
394    regions
395}