1#[cfg(feature = "ml")]
9use image::imageops::FilterType;
10#[cfg(feature = "ml")]
11use image::RgbImage;
12#[cfg(feature = "ml")]
13use ort::session::Session;
14#[cfg(feature = "ml")]
15use ort::value::Tensor;
16
17pub const LABELS: [&str; 17] = [
20 "caption",
21 "footnote",
22 "formula",
23 "list_item",
24 "page_footer",
25 "page_header",
26 "picture",
27 "section_header",
28 "table",
29 "text",
30 "title",
31 "document_index",
32 "code",
33 "checkbox_selected",
34 "checkbox_unselected",
35 "form",
36 "key_value_region",
37];
38
39#[derive(Debug, Clone)]
41pub struct Region {
42 pub label: &'static str,
43 pub score: f32,
44 pub l: f32,
45 pub t: f32,
46 pub r: f32,
47 pub b: f32,
48}
49
50const THRESHOLD: f32 = 0.3;
54pub const SIDE: u32 = 640;
56
57pub fn label_threshold(label: &str) -> f32 {
64 match label {
65 "section_header"
66 | "title"
67 | "code"
68 | "checkbox_selected"
69 | "checkbox_unselected"
70 | "form"
71 | "key_value_region"
72 | "document_index" => 0.45,
73 _ => 0.5,
76 }
77}
78
79#[cfg(feature = "ml")]
80pub struct LayoutModel {
81 session: Session,
82 batch_unsupported: bool,
87 fp32_path: Option<String>,
95 fp32: Option<Session>,
97 intra: usize,
99}
100
101#[cfg(feature = "ml")]
102impl LayoutModel {
103 pub fn load() -> Result<Self, String> {
107 Self::load_with(crate::intra_threads())
108 }
109
110 pub fn load_with(intra: usize) -> Result<Self, String> {
114 let path = crate::model_path(
115 "DOCLING_LAYOUT_ONNX",
116 "models/layout_heron.onnx",
117 "models/layout_heron_int8.onnx",
118 );
119 if crate::timing::enabled() {
120 eprintln!("docling-pdf: layout model: {path}");
121 }
122 let fp32_path = if std::env::var("DOCLING_LAYOUT_ONNX").is_err() {
125 let fp32 = crate::resolve_asset("models/layout_heron.onnx");
126 (path != fp32 && std::path::Path::new(&fp32).exists()).then_some(fp32)
127 } else {
128 None
129 };
130 let session = Self::open_session(&path, intra)?;
131 Ok(Self {
132 session,
133 batch_unsupported: false,
134 fp32_path,
135 fp32: None,
136 intra,
137 })
138 }
139
140 fn open_session(path: &str, intra: usize) -> Result<Session, String> {
141 let builder = Session::builder()
142 .map_err(|e| format!("layout: builder: {e}"))?
143 .with_intra_threads(intra)
146 .map_err(|e| format!("layout: intra_threads: {e}"))?;
147 crate::ep::apply(builder)
148 .map_err(|e| format!("layout: {e}"))?
149 .commit_from_file(path)
150 .map_err(|e| format!("layout: load {path}: {e}"))
151 }
152
153 pub fn predict_fp32_fallback(
158 &mut self,
159 img: &RgbImage,
160 page_w: f32,
161 page_h: f32,
162 ) -> Result<Option<Vec<Region>>, String> {
163 let Some(path) = self.fp32_path.clone() else {
164 return Ok(None);
165 };
166 if self.fp32.is_none() {
167 if crate::timing::enabled() {
168 eprintln!("docling-pdf: loading fp32 layout fallback: {path}");
169 }
170 self.fp32 = Some(Self::open_session(&path, self.intra)?);
171 }
172 let session = self.fp32.as_mut().expect("just loaded");
173 Ok(Some(
174 Self::run_on(session, &[(img, page_w, page_h)])?
175 .pop()
176 .expect("one result per input page"),
177 ))
178 }
179
180 pub fn predict(
183 &mut self,
184 img: &RgbImage,
185 page_w: f32,
186 page_h: f32,
187 ) -> Result<Vec<Region>, String> {
188 Ok(self
189 .predict_batch(&[(img, page_w, page_h)])?
190 .pop()
191 .expect("one result per input page"))
192 }
193
194 pub fn predict_batch(
200 &mut self,
201 pages: &[(&RgbImage, f32, f32)],
202 ) -> Result<Vec<Vec<Region>>, String> {
203 if pages.len() > 1 && self.batch_unsupported {
204 return self.predict_singly(pages);
205 }
206 match self.run_batch(pages) {
207 Err(e) if pages.len() > 1 => {
208 static WARNED: std::sync::atomic::AtomicBool =
213 std::sync::atomic::AtomicBool::new(false);
214 if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
215 eprintln!(
216 "docling-pdf: layout model rejected a {}-page batch ({e}); \
217 falling back to per-page inference — re-export with \
218 scripts/install/export_layout.py for batched layout",
219 pages.len()
220 );
221 }
222 self.batch_unsupported = true;
223 self.predict_singly(pages)
224 }
225 other => other,
226 }
227 }
228
229 fn predict_singly(
230 &mut self,
231 pages: &[(&RgbImage, f32, f32)],
232 ) -> Result<Vec<Vec<Region>>, String> {
233 pages
234 .iter()
235 .map(|p| Ok(self.run_batch(&[*p])?.pop().expect("one result")))
236 .collect()
237 }
238
239 fn run_batch(&mut self, pages: &[(&RgbImage, f32, f32)]) -> Result<Vec<Vec<Region>>, String> {
240 Self::run_on(&mut self.session, pages)
241 }
242
243 fn run_on(
244 session: &mut Session,
245 pages: &[(&RgbImage, f32, f32)],
246 ) -> Result<Vec<Vec<Region>>, String> {
247 if pages.is_empty() {
248 return Ok(Vec::new());
249 }
250 let n = (SIDE * SIDE) as usize;
253 let batch = pages.len();
254 let mut data = vec![0f32; batch * 3 * n];
255 for (p, (img, _, _)) in pages.iter().enumerate() {
256 let resized = image::imageops::resize(*img, SIDE, SIDE, FilterType::Triangle);
257 let page_off = p * 3 * n;
258 for (i, px) in resized.pixels().enumerate() {
259 data[page_off + i] = px[0] as f32 / 255.0;
260 data[page_off + n + i] = px[1] as f32 / 255.0;
261 data[page_off + 2 * n + i] = px[2] as f32 / 255.0;
262 }
263 }
264 let input = Tensor::from_array(([batch, 3, SIDE as usize, SIDE as usize], data))
265 .map_err(|e| format!("layout: input tensor: {e}"))?;
266 let outputs = session
267 .run(ort::inputs!["pixel_values" => input])
268 .map_err(|e| format!("layout: inference: {e}"))?;
269 let (lshape, logits) = outputs["logits"]
270 .try_extract_tensor::<f32>()
271 .map_err(|e| format!("layout: extract logits: {e}"))?;
272 let (_, boxes) = outputs["pred_boxes"]
273 .try_extract_tensor::<f32>()
274 .map_err(|e| format!("layout: extract boxes: {e}"))?;
275
276 let num_queries = lshape[1] as usize;
277 let num_classes = lshape[2] as usize;
278
279 let mut all = Vec::with_capacity(batch);
280 for (p, (_, page_w, page_h)) in pages.iter().enumerate() {
281 let logits =
282 &logits[p * num_queries * num_classes..(p + 1) * num_queries * num_classes];
283 let boxes = &boxes[p * num_queries * 4..(p + 1) * num_queries * 4];
284 all.push(decode_layout(
285 logits,
286 boxes,
287 num_queries,
288 num_classes,
289 *page_w,
290 *page_h,
291 ));
292 }
293 Ok(all)
294 }
295}
296
297fn sigmoid(x: f32) -> f32 {
298 1.0 / (1.0 + (-x).exp())
299}
300
301#[cfg(feature = "ocr-prep")]
305pub fn layout_input(img: &image::RgbImage) -> Vec<f32> {
306 let n = (SIDE * SIDE) as usize;
307 let mut data = vec![0f32; 3 * n];
308 let resized = image::imageops::resize(img, SIDE, SIDE, image::imageops::FilterType::Triangle);
309 for (i, px) in resized.pixels().enumerate() {
310 data[i] = px[0] as f32 / 255.0;
311 data[n + i] = px[1] as f32 / 255.0;
312 data[2 * n + i] = px[2] as f32 / 255.0;
313 }
314 data
315}
316
317pub fn decode_layout(
322 logits: &[f32],
323 boxes: &[f32],
324 num_queries: usize,
325 num_classes: usize,
326 page_w: f32,
327 page_h: f32,
328) -> Vec<Region> {
329 let mut scored: Vec<(f32, usize)> = (0..num_queries * num_classes)
330 .map(|idx| (sigmoid(logits[idx]), idx))
331 .collect();
332 scored.sort_unstable_by(|a, b| b.0.total_cmp(&a.0));
333 scored.truncate(num_queries);
334
335 let mut regions = Vec::new();
336 for (score, idx) in scored {
337 if score <= THRESHOLD {
338 continue;
339 }
340 let label_id = idx % num_classes;
341 let q = idx / num_classes;
342 let cx = boxes[q * 4];
343 let cy = boxes[q * 4 + 1];
344 let w = boxes[q * 4 + 2];
345 let h = boxes[q * 4 + 3];
346 let l = (cx - w / 2.0) * page_w;
348 let t = (cy - h / 2.0) * page_h;
349 let r = (cx + w / 2.0) * page_w;
350 let b = (cy + h / 2.0) * page_h;
351 regions.push(Region {
352 label: LABELS.get(label_id).copied().unwrap_or("text"),
353 score,
354 l,
355 t,
356 r,
357 b,
358 });
359 }
360 regions
361}