#[cfg(feature = "ml")]
use image::imageops::FilterType;
#[cfg(feature = "ml")]
use image::RgbImage;
#[cfg(feature = "ml")]
use ort::session::Session;
#[cfg(feature = "ml")]
use ort::value::Tensor;
pub const LABELS: [&str; 17] = [
"caption",
"footnote",
"formula",
"list_item",
"page_footer",
"page_header",
"picture",
"section_header",
"table",
"text",
"title",
"document_index",
"code",
"checkbox_selected",
"checkbox_unselected",
"form",
"key_value_region",
];
#[derive(Debug, Clone)]
pub struct Region {
pub label: &'static str,
pub score: f32,
pub l: f32,
pub t: f32,
pub r: f32,
pub b: f32,
}
const THRESHOLD: f32 = 0.3;
pub const SIDE: u32 = 640;
pub fn label_threshold(label: &str) -> f32 {
match label {
"section_header"
| "title"
| "code"
| "checkbox_selected"
| "checkbox_unselected"
| "form"
| "key_value_region"
| "document_index" => 0.45,
_ => 0.5,
}
}
#[cfg(feature = "ml")]
pub struct LayoutModel {
session: Session,
batch_unsupported: bool,
fp32_path: Option<String>,
fp32: Option<Session>,
intra: usize,
}
#[cfg(feature = "ml")]
impl LayoutModel {
pub fn load() -> Result<Self, String> {
Self::load_with(crate::intra_threads())
}
pub fn load_with(intra: usize) -> Result<Self, String> {
let path = crate::model_path(
"DOCLING_LAYOUT_ONNX",
"models/layout_heron.onnx",
"models/layout_heron_int8.onnx",
);
if crate::timing::enabled() {
eprintln!("docling-pdf: layout model: {path}");
}
let fp32_path = if std::env::var("DOCLING_LAYOUT_ONNX").is_err() {
let fp32 = crate::resolve_asset("models/layout_heron.onnx");
(path != fp32 && std::path::Path::new(&fp32).exists()).then_some(fp32)
} else {
None
};
let session = Self::open_session(&path, intra)?;
Ok(Self {
session,
batch_unsupported: false,
fp32_path,
fp32: None,
intra,
})
}
fn open_session(path: &str, intra: usize) -> Result<Session, String> {
let builder = Session::builder()
.map_err(|e| format!("layout: builder: {e}"))?
.with_intra_threads(intra)
.map_err(|e| format!("layout: intra_threads: {e}"))?;
crate::ep::apply(builder)
.map_err(|e| format!("layout: {e}"))?
.commit_from_file(path)
.map_err(|e| format!("layout: load {path}: {e}"))
}
pub fn predict_fp32_fallback(
&mut self,
img: &RgbImage,
page_w: f32,
page_h: f32,
) -> Result<Option<Vec<Region>>, String> {
let Some(path) = self.fp32_path.clone() else {
return Ok(None);
};
if self.fp32.is_none() {
if crate::timing::enabled() {
eprintln!("docling-pdf: loading fp32 layout fallback: {path}");
}
self.fp32 = Some(Self::open_session(&path, self.intra)?);
}
let session = self.fp32.as_mut().expect("just loaded");
Ok(Some(
Self::run_on(session, &[(img, page_w, page_h)])?
.pop()
.expect("one result per input page"),
))
}
pub fn predict(
&mut self,
img: &RgbImage,
page_w: f32,
page_h: f32,
) -> Result<Vec<Region>, String> {
Ok(self
.predict_batch(&[(img, page_w, page_h)])?
.pop()
.expect("one result per input page"))
}
pub fn predict_batch(
&mut self,
pages: &[(&RgbImage, f32, f32)],
) -> Result<Vec<Vec<Region>>, String> {
if pages.len() > 1 && self.batch_unsupported {
return self.predict_singly(pages);
}
match self.run_batch(pages) {
Err(e) if pages.len() > 1 => {
static WARNED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
eprintln!(
"docling-pdf: layout model rejected a {}-page batch ({e}); \
falling back to per-page inference — re-export with \
scripts/install/export_layout.py for batched layout",
pages.len()
);
}
self.batch_unsupported = true;
self.predict_singly(pages)
}
other => other,
}
}
fn predict_singly(
&mut self,
pages: &[(&RgbImage, f32, f32)],
) -> Result<Vec<Vec<Region>>, String> {
pages
.iter()
.map(|p| Ok(self.run_batch(&[*p])?.pop().expect("one result")))
.collect()
}
fn run_batch(&mut self, pages: &[(&RgbImage, f32, f32)]) -> Result<Vec<Vec<Region>>, String> {
Self::run_on(&mut self.session, pages)
}
fn run_on(
session: &mut Session,
pages: &[(&RgbImage, f32, f32)],
) -> Result<Vec<Vec<Region>>, String> {
if pages.is_empty() {
return Ok(Vec::new());
}
let n = (SIDE * SIDE) as usize;
let batch = pages.len();
let mut data = vec![0f32; batch * 3 * n];
for (p, (img, _, _)) in pages.iter().enumerate() {
let resized = image::imageops::resize(*img, SIDE, SIDE, FilterType::Triangle);
let page_off = p * 3 * n;
for (i, px) in resized.pixels().enumerate() {
data[page_off + i] = px[0] as f32 / 255.0;
data[page_off + n + i] = px[1] as f32 / 255.0;
data[page_off + 2 * n + i] = px[2] as f32 / 255.0;
}
}
let input = Tensor::from_array(([batch, 3, SIDE as usize, SIDE as usize], data))
.map_err(|e| format!("layout: input tensor: {e}"))?;
let outputs = session
.run(ort::inputs!["pixel_values" => input])
.map_err(|e| format!("layout: inference: {e}"))?;
let (lshape, logits) = outputs["logits"]
.try_extract_tensor::<f32>()
.map_err(|e| format!("layout: extract logits: {e}"))?;
let (_, boxes) = outputs["pred_boxes"]
.try_extract_tensor::<f32>()
.map_err(|e| format!("layout: extract boxes: {e}"))?;
let num_queries = lshape[1] as usize;
let num_classes = lshape[2] as usize;
let mut all = Vec::with_capacity(batch);
for (p, (_, page_w, page_h)) in pages.iter().enumerate() {
let logits =
&logits[p * num_queries * num_classes..(p + 1) * num_queries * num_classes];
let boxes = &boxes[p * num_queries * 4..(p + 1) * num_queries * 4];
all.push(decode_layout(
logits,
boxes,
num_queries,
num_classes,
*page_w,
*page_h,
));
}
Ok(all)
}
}
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
#[cfg(feature = "ocr-prep")]
pub fn layout_input(img: &image::RgbImage) -> Vec<f32> {
let n = (SIDE * SIDE) as usize;
let mut data = vec![0f32; 3 * n];
let resized = image::imageops::resize(img, SIDE, SIDE, image::imageops::FilterType::Triangle);
for (i, px) in resized.pixels().enumerate() {
data[i] = px[0] as f32 / 255.0;
data[n + i] = px[1] as f32 / 255.0;
data[2 * n + i] = px[2] as f32 / 255.0;
}
data
}
pub fn decode_layout(
logits: &[f32],
boxes: &[f32],
num_queries: usize,
num_classes: usize,
page_w: f32,
page_h: f32,
) -> Vec<Region> {
let mut scored: Vec<(f32, usize)> = (0..num_queries * num_classes)
.map(|idx| (sigmoid(logits[idx]), idx))
.collect();
scored.sort_unstable_by(|a, b| b.0.total_cmp(&a.0));
scored.truncate(num_queries);
let mut regions = Vec::new();
for (score, idx) in scored {
if score <= THRESHOLD {
continue;
}
let label_id = idx % num_classes;
let q = idx / num_classes;
let cx = boxes[q * 4];
let cy = boxes[q * 4 + 1];
let w = boxes[q * 4 + 2];
let h = boxes[q * 4 + 3];
let l = (cx - w / 2.0) * page_w;
let t = (cy - h / 2.0) * page_h;
let r = (cx + w / 2.0) * page_w;
let b = (cy + h / 2.0) * page_h;
regions.push(Region {
label: LABELS.get(label_id).copied().unwrap_or("text"),
score,
l,
t,
r,
b,
});
}
regions
}