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
50#[cfg(feature = "ml")]
51const THRESHOLD: f32 = 0.3;
55#[cfg(feature = "ml")]
56const SIDE: u32 = 640;
57
58pub fn label_threshold(label: &str) -> f32 {
65 match label {
66 "section_header"
67 | "title"
68 | "code"
69 | "checkbox_selected"
70 | "checkbox_unselected"
71 | "form"
72 | "key_value_region"
73 | "document_index" => 0.45,
74 _ => 0.5,
77 }
78}
79
80#[cfg(feature = "ml")]
81pub struct LayoutModel {
82 session: Session,
83 batch_unsupported: bool,
88}
89
90#[cfg(feature = "ml")]
91impl LayoutModel {
92 pub fn load() -> Result<Self, String> {
96 Self::load_with(crate::intra_threads())
97 }
98
99 pub fn load_with(intra: usize) -> Result<Self, String> {
103 let path = crate::model_path(
104 "DOCLING_LAYOUT_ONNX",
105 "models/layout_heron.onnx",
106 "models/layout_heron_int8.onnx",
107 );
108 if crate::timing::enabled() {
109 eprintln!("docling-pdf: layout model: {path}");
110 }
111 let builder = Session::builder()
112 .map_err(|e| format!("layout: builder: {e}"))?
113 .with_intra_threads(intra)
116 .map_err(|e| format!("layout: intra_threads: {e}"))?;
117 let session = crate::ep::apply(builder)
118 .map_err(|e| format!("layout: {e}"))?
119 .commit_from_file(&path)
120 .map_err(|e| format!("layout: load {path}: {e}"))?;
121 Ok(Self {
122 session,
123 batch_unsupported: false,
124 })
125 }
126
127 pub fn predict(
130 &mut self,
131 img: &RgbImage,
132 page_w: f32,
133 page_h: f32,
134 ) -> Result<Vec<Region>, String> {
135 Ok(self
136 .predict_batch(&[(img, page_w, page_h)])?
137 .pop()
138 .expect("one result per input page"))
139 }
140
141 pub fn predict_batch(
147 &mut self,
148 pages: &[(&RgbImage, f32, f32)],
149 ) -> Result<Vec<Vec<Region>>, String> {
150 if pages.len() > 1 && self.batch_unsupported {
151 return self.predict_singly(pages);
152 }
153 match self.run_batch(pages) {
154 Err(e) if pages.len() > 1 => {
155 static WARNED: std::sync::atomic::AtomicBool =
160 std::sync::atomic::AtomicBool::new(false);
161 if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
162 eprintln!(
163 "docling-pdf: layout model rejected a {}-page batch ({e}); \
164 falling back to per-page inference — re-export with \
165 scripts/install/export_layout.py for batched layout",
166 pages.len()
167 );
168 }
169 self.batch_unsupported = true;
170 self.predict_singly(pages)
171 }
172 other => other,
173 }
174 }
175
176 fn predict_singly(
177 &mut self,
178 pages: &[(&RgbImage, f32, f32)],
179 ) -> Result<Vec<Vec<Region>>, String> {
180 pages
181 .iter()
182 .map(|p| Ok(self.run_batch(&[*p])?.pop().expect("one result")))
183 .collect()
184 }
185
186 fn run_batch(&mut self, pages: &[(&RgbImage, f32, f32)]) -> Result<Vec<Vec<Region>>, String> {
187 if pages.is_empty() {
188 return Ok(Vec::new());
189 }
190 let n = (SIDE * SIDE) as usize;
193 let batch = pages.len();
194 let mut data = vec![0f32; batch * 3 * n];
195 for (p, (img, _, _)) in pages.iter().enumerate() {
196 let resized = image::imageops::resize(*img, SIDE, SIDE, FilterType::Triangle);
197 let page_off = p * 3 * n;
198 for (i, px) in resized.pixels().enumerate() {
199 data[page_off + i] = px[0] as f32 / 255.0;
200 data[page_off + n + i] = px[1] as f32 / 255.0;
201 data[page_off + 2 * n + i] = px[2] as f32 / 255.0;
202 }
203 }
204 let input = Tensor::from_array(([batch, 3, SIDE as usize, SIDE as usize], data))
205 .map_err(|e| format!("layout: input tensor: {e}"))?;
206 let outputs = self
207 .session
208 .run(ort::inputs!["pixel_values" => input])
209 .map_err(|e| format!("layout: inference: {e}"))?;
210 let (lshape, logits) = outputs["logits"]
211 .try_extract_tensor::<f32>()
212 .map_err(|e| format!("layout: extract logits: {e}"))?;
213 let (_, boxes) = outputs["pred_boxes"]
214 .try_extract_tensor::<f32>()
215 .map_err(|e| format!("layout: extract boxes: {e}"))?;
216
217 let num_queries = lshape[1] as usize;
218 let num_classes = lshape[2] as usize;
219
220 let mut all = Vec::with_capacity(batch);
221 for (p, (_, page_w, page_h)) in pages.iter().enumerate() {
222 let logits =
223 &logits[p * num_queries * num_classes..(p + 1) * num_queries * num_classes];
224 let boxes = &boxes[p * num_queries * 4..(p + 1) * num_queries * 4];
225
226 let mut scored: Vec<(f32, usize)> = (0..num_queries * num_classes)
228 .map(|idx| (sigmoid(logits[idx]), idx))
229 .collect();
230 scored.sort_unstable_by(|a, b| b.0.total_cmp(&a.0));
231 scored.truncate(num_queries);
232
233 let mut regions = Vec::new();
234 for (score, idx) in scored {
235 if score <= THRESHOLD {
236 continue;
237 }
238 let label_id = idx % num_classes;
239 let q = idx / num_classes;
240 let cx = boxes[q * 4];
241 let cy = boxes[q * 4 + 1];
242 let w = boxes[q * 4 + 2];
243 let h = boxes[q * 4 + 3];
244 let l = (cx - w / 2.0) * page_w;
246 let t = (cy - h / 2.0) * page_h;
247 let r = (cx + w / 2.0) * page_w;
248 let b = (cy + h / 2.0) * page_h;
249 regions.push(Region {
250 label: LABELS.get(label_id).copied().unwrap_or("text"),
251 score,
252 l,
253 t,
254 r,
255 b,
256 });
257 }
258 all.push(regions);
259 }
260 Ok(all)
261 }
262}
263
264#[cfg(feature = "ml")]
265fn sigmoid(x: f32) -> f32 {
266 1.0 / (1.0 + (-x).exp())
267}