1use pdf_ocr::runtime::session;
39
40pub use pdf_ocr::runtime::RuntimeConfig;
41
42use crate::{EngineError, PdfDocument, PixelFormat, RenderOptions, Result};
43
44pub use crate::model::LAYOUT_EP03;
48
49const INPUT_WIDTH: u32 = 640;
51
52const INPUT_HEIGHT: u32 = 640;
54
55const RENDER_DPI: f64 = 96.0;
57
58const SCORE_THRESHOLD: f32 = 0.05;
63
64const TOP_K: usize = 300;
66
67const LABELS: [&str; 17] = [
71 "caption",
72 "footnote",
73 "formula",
74 "list_item",
75 "page_footer",
76 "page_header",
77 "picture",
78 "section_header",
79 "table",
80 "text",
81 "title",
82 "document_index",
83 "code",
84 "checkbox_selected",
85 "checkbox_unselected",
86 "form",
87 "key_value_region",
88];
89
90#[derive(Debug, Clone)]
92pub struct LayoutBox {
93 pub label: String,
95 pub score: f32,
97 pub bbox_px: [f32; 4],
99 pub render_w: u32,
101 pub render_h: u32,
103}
104
105#[derive(Debug, Clone)]
107pub struct RgbPage {
108 pub data: Vec<u8>,
110 pub width: u32,
112 pub height: u32,
114}
115
116pub fn render_page_rgb(doc: &PdfDocument, page_index: usize) -> Result<RgbPage> {
123 let opts = RenderOptions {
124 dpi: RENDER_DPI,
125 background: [1.0, 1.0, 1.0, 1.0],
126 ..Default::default()
127 };
128 let rendered = doc.render_page(page_index, &opts)?;
129 if rendered.pixel_format != PixelFormat::Rgba8 {
130 return Err(EngineError::RenderError(
131 "layout render: expected RGBA pixels".to_string(),
132 ));
133 }
134 let mut rgb = Vec::with_capacity(rendered.pixels.len() / 4 * 3);
138 for px in rendered.pixels.chunks_exact(4) {
139 let missing = 255 - px[3];
140 rgb.push(px[0].saturating_add(missing));
141 rgb.push(px[1].saturating_add(missing));
142 rgb.push(px[2].saturating_add(missing));
143 }
144 Ok(RgbPage {
145 data: rgb,
146 width: rendered.width,
147 height: rendered.height,
148 })
149}
150
151pub fn preprocess_to_nchw(page: &RgbPage) -> Vec<f32> {
157 let resized = resize_rgb_bilinear_pil(
158 &page.data,
159 page.width,
160 page.height,
161 INPUT_WIDTH,
162 INPUT_HEIGHT,
163 );
164 let plane = INPUT_WIDTH as usize * INPUT_HEIGHT as usize;
165 let mut nchw = vec![0.0f32; 3 * plane];
166 for (i, px) in resized.chunks_exact(3).enumerate() {
167 nchw[i] = f32::from(px[0]) / 255.0;
168 nchw[plane + i] = f32::from(px[1]) / 255.0;
169 nchw[2 * plane + i] = f32::from(px[2]) / 255.0;
170 }
171 nchw
172}
173
174pub fn layout_boxes(
185 doc: &PdfDocument,
186 page_index: usize,
187 config: &RuntimeConfig,
188) -> Result<Vec<LayoutBox>> {
189 let page = render_page_rgb(doc, page_index)?;
190 let nchw = preprocess_to_nchw(&page);
191 let shared = session(&LAYOUT_EP03, config).map_err(model_io_error)?;
192 let (logits, boxes_cxcywh) = shared
193 .run(|ort_session| {
194 let input_name = {
198 let graph_inputs = ort_session.inputs();
199 if graph_inputs.len() != 1 {
200 return Err(ort::Error::new(format!(
201 "layout model has {} inputs, want the single pixel_values input",
202 graph_inputs.len()
203 )));
204 }
205 graph_inputs[0].name().to_string()
206 };
207 {
208 let graph_outputs = ort_session.outputs();
209 for want in ["logits", "pred_boxes"] {
210 if !graph_outputs.iter().any(|o| o.name() == want) {
211 return Err(ort::Error::new(format!(
212 "layout model has no {want} output"
213 )));
214 }
215 }
216 }
217 let input = ort::value::Tensor::from_array((
218 [1usize, 3, INPUT_HEIGHT as usize, INPUT_WIDTH as usize],
219 nchw,
220 ))?;
221 let outputs = ort_session.run(ort::inputs![input_name => input])?;
222 let logits = outputs
223 .get("logits")
224 .ok_or_else(|| ort::Error::new("layout model returned no logits output"))?;
225 let pred_boxes = outputs
226 .get("pred_boxes")
227 .ok_or_else(|| ort::Error::new("layout model returned no pred_boxes output"))?;
228 let (logits_shape, logits_data) = logits.try_extract_tensor::<f32>()?;
229 let (boxes_shape, boxes_data) = pred_boxes.try_extract_tensor::<f32>()?;
230 if logits_shape.len() != 3
231 || logits_shape[0] != 1
232 || logits_shape[2] != LABELS.len() as i64
233 {
234 return Err(ort::Error::new(format!(
235 "layout logits have shape {logits_shape:?}, want [1, queries, 17]"
236 )));
237 }
238 let queries = logits_shape[1];
239 if boxes_shape.len() != 3
240 || boxes_shape[0] != 1
241 || boxes_shape[1] != queries
242 || boxes_shape[2] != 4
243 {
244 return Err(ort::Error::new(format!(
245 "layout pred_boxes have shape {boxes_shape:?}, want [1, {queries}, 4]"
246 )));
247 }
248 Ok((logits_data.to_vec(), boxes_data.to_vec()))
249 })
250 .map_err(model_io_error)?;
251 Ok(postprocess(&logits, &boxes_cxcywh, page.width, page.height))
252}
253
254fn model_io_error(e: pdf_ocr::runtime::ModelError) -> EngineError {
258 EngineError::Io(std::io::Error::from(e))
259}
260
261fn postprocess(
269 logits: &[f32],
270 boxes_cxcywh: &[f32],
271 render_w: u32,
272 render_h: u32,
273) -> Vec<LayoutBox> {
274 let classes = LABELS.len();
275 let queries = (boxes_cxcywh.len() / 4).min(logits.len() / classes);
276 let total = queries * classes;
277 let scores: Vec<f32> = logits[..total]
278 .iter()
279 .map(|&logit| 1.0 / (1.0 + (-logit).exp()))
280 .collect();
281 let mut order: Vec<usize> = (0..total).collect();
282 order.sort_by(|&a, &b| scores[b].total_cmp(&scores[a]));
283 let w = render_w as f32;
284 let h = render_h as f32;
285 let mut out = Vec::new();
286 for index in order.into_iter().take(TOP_K.min(total)) {
287 let score = scores[index];
288 if score < SCORE_THRESHOLD {
289 continue;
290 }
291 let label_id = index % classes;
292 let query = index / classes;
293 let base = query * 4;
294 let (cx, cy, bw, bh) = (
295 boxes_cxcywh[base],
296 boxes_cxcywh[base + 1],
297 boxes_cxcywh[base + 2],
298 boxes_cxcywh[base + 3],
299 );
300 out.push(LayoutBox {
301 label: LABELS[label_id].to_string(),
302 score,
303 bbox_px: [
304 (cx - 0.5 * bw) * w,
305 (cy - 0.5 * bh) * h,
306 (cx + 0.5 * bw) * w,
307 (cy + 0.5 * bh) * h,
308 ],
309 render_w,
310 render_h,
311 });
312 }
313 out
314}
315
316type AxisTaps = Vec<(usize, f32)>;
318
319fn axis_taps(src_len: u32, dst_len: u32) -> Vec<AxisTaps> {
324 let src_len = src_len as usize;
325 let scale = f64::from(src_len as u32) / f64::from(dst_len);
326 let support = if scale > 1.0 { scale } else { 1.0 };
327 (0..dst_len as usize)
328 .map(|d| {
329 let centre = (d as f64 + 0.5) * scale - 0.5;
330 let lo = (centre - support).floor() as i64 + 1;
331 let hi = (centre + support).ceil() as i64 - 1;
332 let mut taps = Vec::new();
333 let mut sum = 0.0f64;
334 for i in lo.max(0)..=hi.min(src_len as i64 - 1) {
335 let t = ((i as f64 - centre) / support).abs();
336 let weight = if t < 1.0 { 1.0 - t } else { 0.0 };
337 if weight > 0.0 {
338 taps.push((i as usize, weight));
339 sum += weight;
340 }
341 }
342 if sum > 0.0 {
343 taps.into_iter()
344 .map(|(i, weight)| (i, (weight / sum) as f32))
345 .collect()
346 } else {
347 let nearest = centre.round().clamp(0.0, f64::from(src_len as u32 - 1)) as usize;
351 vec![(nearest, 1.0)]
352 }
353 })
354 .collect()
355}
356
357fn resize_rgb_bilinear_pil(src: &[u8], src_w: u32, src_h: u32, dst_w: u32, dst_h: u32) -> Vec<u8> {
367 let src_w = src_w as usize;
368 let src_h = src_h as usize;
369 let dst_w = dst_w as usize;
370 let dst_h = dst_h as usize;
371 let horizontal = axis_taps(src_w as u32, dst_w as u32);
372 let mut mid = vec![0u8; dst_w * src_h * 3];
373 for y in 0..src_h {
374 for (x, taps) in horizontal.iter().enumerate() {
375 let mut acc = [0.0f32; 3];
376 for &(i, weight) in taps {
377 let base = (y * src_w + i) * 3;
378 acc[0] += f32::from(src[base]) * weight;
379 acc[1] += f32::from(src[base + 1]) * weight;
380 acc[2] += f32::from(src[base + 2]) * weight;
381 }
382 let base = (y * dst_w + x) * 3;
383 mid[base] = acc[0].round().clamp(0.0, 255.0) as u8;
384 mid[base + 1] = acc[1].round().clamp(0.0, 255.0) as u8;
385 mid[base + 2] = acc[2].round().clamp(0.0, 255.0) as u8;
386 }
387 }
388 let vertical = axis_taps(src_h as u32, dst_h as u32);
389 let mut dst = vec![0u8; dst_w * dst_h * 3];
390 for (y, taps) in vertical.iter().enumerate() {
391 for x in 0..dst_w {
392 let mut acc = [0.0f32; 3];
393 for &(j, weight) in taps {
394 let base = (j * dst_w + x) * 3;
395 acc[0] += f32::from(mid[base]) * weight;
396 acc[1] += f32::from(mid[base + 1]) * weight;
397 acc[2] += f32::from(mid[base + 2]) * weight;
398 }
399 let base = (y * dst_w + x) * 3;
400 dst[base] = acc[0].round().clamp(0.0, 255.0) as u8;
401 dst[base + 1] = acc[1].round().clamp(0.0, 255.0) as u8;
402 dst[base + 2] = acc[2].round().clamp(0.0, 255.0) as u8;
403 }
404 }
405 dst
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn sigmoid(x: f32) -> f32 {
413 1.0 / (1.0 + (-x).exp())
414 }
415
416 #[test]
417 fn the_layout_spec_names_the_ep03_package() {
418 assert_eq!(LAYOUT_EP03.name, "layout-heron-ep03");
419 assert_eq!(LAYOUT_EP03.file, "heron_ep03.onnx");
420 assert_eq!(
421 LAYOUT_EP03.sha256,
422 "b7cc5d31f3223c74cc477a1386b894793618aeb398ca59cf5eb7264ec61d8afa"
423 );
424 assert!(
425 LAYOUT_EP03.install_command.contains("layout"),
426 "the install hint names the model package: {}",
427 LAYOUT_EP03.install_command
428 );
429 assert_eq!(LABELS.len(), 17);
430 assert_eq!(LABELS[9], "text");
431 assert_eq!(LABELS[8], "table");
432 }
433
434 #[test]
435 fn postprocess_top_k_runs_across_classes_with_modulo_labels() {
436 let mut logits = vec![-10.0f32; 2 * 17];
440 logits[9] = 3.0; logits[3] = 2.0; logits[17] = 0.0; logits[22] = -3.0; let boxes_cxcywh = vec![
445 0.5, 0.5, 0.2, 0.4, 0.25, 0.75, 0.5, 0.5, ];
448 let boxes = postprocess(&logits, &boxes_cxcywh, 1000, 800);
449 assert_eq!(boxes.len(), 3, "the under-threshold class must not survive");
450 assert_eq!(boxes[0].label, "text");
453 assert_eq!(boxes[1].label, "list_item");
454 assert_eq!(boxes[2].label, "caption");
455 assert!((boxes[0].score - sigmoid(3.0)).abs() < 1e-6);
456 assert!((boxes[1].score - sigmoid(2.0)).abs() < 1e-6);
457 assert!((boxes[2].score - sigmoid(0.0)).abs() < 1e-6);
458 assert_eq!(boxes[0].bbox_px, boxes[1].bbox_px);
462 for (got, want) in boxes[0].bbox_px.iter().zip([400.0, 240.0, 600.0, 560.0]) {
463 assert!((got - want).abs() < 1e-3, "got {got}, want {want}");
464 }
465 for (got, want) in boxes[2].bbox_px.iter().zip([0.0, 400.0, 500.0, 800.0]) {
466 assert!((got - want).abs() < 1e-3, "got {got}, want {want}");
467 }
468 for b in &boxes {
469 assert_eq!((b.render_w, b.render_h), (1000, 800));
470 }
471 }
472
473 #[test]
474 fn postprocess_keeps_the_top_300_queries() {
475 let mut logits = vec![-10.0f32; 301 * 17];
478 for q in 0..301 {
479 logits[q * 17] = 0.0; }
481 let boxes_cxcywh = vec![0.5f32, 0.5, 0.1, 0.1]
482 .into_iter()
483 .cycle()
484 .take(301 * 4)
485 .collect::<Vec<_>>();
486 let boxes = postprocess(&logits, &boxes_cxcywh, 640, 640);
487 assert_eq!(boxes.len(), TOP_K);
488 }
489
490 #[test]
491 fn postprocess_reads_nothing_below_threshold() {
492 let logits = vec![-10.0f32; 4 * 17];
493 let boxes_cxcywh = vec![0.5f32; 4 * 4];
494 assert!(postprocess(&logits, &boxes_cxcywh, 640, 640).is_empty());
495 }
496
497 #[test]
503 fn resize_matches_pil_on_a_downscaled_ramp() {
504 let mut src = Vec::new();
505 for y in 0..4u8 {
506 for x in 0..4u8 {
507 src.extend_from_slice(&[x * 64, y * 64, 128]);
508 }
509 }
510 assert_eq!(
511 resize_rgb_bilinear_pil(&src, 4, 4, 2, 2),
512 vec![46, 46, 128, 146, 46, 128, 46, 146, 128, 146, 146, 128]
513 );
514 }
515
516 #[test]
517 fn resize_averages_a_downscaled_checkerboard_like_pil() {
518 let mut src = Vec::new();
519 for y in 0..4 {
520 for x in 0..4 {
521 let v = if (x + y) % 2 == 0 { 255 } else { 0 };
522 src.extend_from_slice(&[v, v, v]);
523 }
524 }
525 assert_eq!(
526 resize_rgb_bilinear_pil(&src, 4, 4, 2, 2),
527 vec![130, 130, 130, 125, 125, 125, 125, 125, 125, 130, 130, 130]
528 );
529 }
530
531 #[test]
532 fn resize_matches_pil_on_a_downscaled_edge() {
533 let src = [0u8, 0, 0, 0, 0, 0]
534 .into_iter()
535 .chain([255u8; 18])
536 .collect::<Vec<_>>();
537 assert_eq!(
538 resize_rgb_bilinear_pil(&src, 8, 1, 4, 1),
539 vec![36, 36, 36, 223, 223, 223, 255, 255, 255, 255, 255, 255]
540 );
541 }
542
543 #[test]
544 fn resize_matches_pil_on_an_upscaled_ramp() {
545 let mut src = Vec::new();
546 for y in 0..3u8 {
547 for x in 0..3u8 {
548 src.extend_from_slice(&[x * 100, y * 100, 50]);
549 }
550 }
551 let mut expected = Vec::new();
552 for g in [0, 40, 100, 160, 200] {
553 for r in [0, 40, 100, 160, 200] {
554 expected.extend_from_slice(&[r, g, 50]);
555 }
556 }
557 assert_eq!(resize_rgb_bilinear_pil(&src, 3, 3, 5, 5), expected);
558 }
559
560 #[test]
561 fn preprocess_lays_out_nchw_f32_in_unit_range() {
562 let page = RgbPage {
563 data: vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255],
564 width: 2,
565 height: 2,
566 };
567 let nchw = preprocess_to_nchw(&page);
568 assert_eq!(nchw.len(), 3 * 640 * 640);
569 assert!(nchw.iter().all(|&v| (0.0..=1.0).contains(&v)));
570 for c in 0..3 {
573 let plane = &nchw[c * 640 * 640..(c + 1) * 640 * 640];
574 let mean = plane.iter().sum::<f32>() / plane.len() as f32;
575 assert!(
576 (mean - 0.5).abs() < 0.02,
577 "channel {c} mean {mean} is not ~0.5"
578 );
579 }
580 }
581}