Skip to main content

pdf_engine/
vision.rs

1//! Optional vision layer: page raster plus ONNX runner (#636).
2//!
3//! Behind the `vision` cargo feature (off by default). No trained model ships;
4//! a separate team plugs models in later. This module is infrastructure only:
5//!
6//! * [`rasterize_page_with_dpi`] renders a page through the engine's existing
7//!   renderer ([`crate::PdfDocument::render_page`]) and converts the RGBA
8//!   output to grayscale or RGB bytes. No new renderer.
9//! * [`VisionRunner`] loads an ONNX model from bytes or a path with
10//!   `tract-onnx` (MIT OR Apache-2.0) and runs it on a [`VisionRaster`] or on
11//!   a crop named by a bbox in page points, returning the output tensor as
12//!   [`VisionOutput`].
13//!
14//! Input tensors are NCHW `f32` in 0.0..=1.0 (bytes divided by 255). Output is
15//! the model's first output, which must be `f32`.
16
17// Copyright (c) 2026 Innovation Trigger B.V.
18//
19// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
20// the PDFluent Commercial Licence. See the LICENSE file in this repository --
21// that file travels with the copy you received, which a URL does not.
22
23use std::path::Path;
24
25use tract_onnx::prelude::{tvec, Framework as _, IntoTValue as _};
26
27use crate::{PdfDocument, RenderOptions};
28
29/// Default rasterization resolution for vision input, in dots per inch.
30///
31/// 72 dpi is 1:1 with PDF points, so a US Letter page (612x792 pt) rasterizes
32/// to 612x792 pixels.
33pub const DEFAULT_VISION_DPI: u32 = 72;
34
35/// Pixel layout of a [`VisionRaster`].
36///
37/// `#[non_exhaustive]`: new layouts land in a published crate without
38/// breaking callers that match with a fallback arm.
39#[non_exhaustive]
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum VisionPixelFormat {
42    /// One byte per pixel, ITU-R BT.601 luma (0 = black, 255 = white).
43    Gray8,
44    /// Three bytes per pixel, interleaved RGB, row-major.
45    Rgb8,
46}
47
48impl VisionPixelFormat {
49    /// Bytes per pixel.
50    pub fn channels(self) -> usize {
51        match self {
52            Self::Gray8 => 1,
53            Self::Rgb8 => 3,
54        }
55    }
56}
57
58/// A page (or crop) rasterized for vision input, row-major.
59#[derive(Debug, Clone)]
60pub struct VisionRaster {
61    /// Width in pixels.
62    pub width: u32,
63    /// Height in pixels.
64    pub height: u32,
65    /// Pixel layout of [`Self::data`].
66    pub format: VisionPixelFormat,
67    /// Pixel bytes, row-major, top-left origin.
68    pub data: Vec<u8>,
69}
70
71impl VisionRaster {
72    /// Crop a pixel rectangle out of this raster (top-left origin).
73    ///
74    /// Returns `None` when the rectangle is empty or lies outside the raster.
75    pub fn crop_pixels(&self, x: u32, y: u32, width: u32, height: u32) -> Option<Self> {
76        if width == 0 || height == 0 {
77            return None;
78        }
79        let (x_end, y_end) = (x.checked_add(width)?, y.checked_add(height)?);
80        if x_end > self.width || y_end > self.height {
81            return None;
82        }
83        let ch = self.format.channels();
84        let stride = self.width as usize * ch;
85        let mut data = Vec::with_capacity(width as usize * height as usize * ch);
86        for row in y..y_end {
87            let start = row as usize * stride + x as usize * ch;
88            data.extend_from_slice(&self.data[start..start + width as usize * ch]);
89        }
90        Some(Self {
91            width,
92            height,
93            format: self.format,
94            data,
95        })
96    }
97
98    /// Crop a bbox in page points out of this raster.
99    ///
100    /// `bbox` is `[x0, y0, x1, y1]` in PDF points with the origin at the
101    /// bottom-left (the [`crate::PageBox`] convention). `page_size_points` is
102    /// the page's `(width, height)` in points, normally
103    /// [`crate::PageGeometry::effective_dimensions`]. The mapping is
104    /// `pixels = points * dpi / 72`, with the Y axis flipped to the raster's
105    /// top-left origin. The bbox is clamped to the page; an empty intersection
106    /// is an error.
107    pub fn crop_points(
108        &self,
109        bbox: [f64; 4],
110        page_size_points: (f64, f64),
111        dpi: u32,
112    ) -> VisionResult<Self> {
113        let scale = f64::from(dpi) / 72.0;
114        if !scale.is_finite() || scale <= 0.0 {
115            return Err(VisionError::InvalidCrop(format!(
116                "dpi must be positive, got {dpi}"
117            )));
118        }
119        let (page_w, page_h) = page_size_points;
120        let x0 = bbox[0].min(bbox[2]).clamp(0.0, page_w);
121        let x1 = bbox[0].max(bbox[2]).clamp(0.0, page_w);
122        let y0 = bbox[1].min(bbox[3]).clamp(0.0, page_h);
123        let y1 = bbox[1].max(bbox[3]).clamp(0.0, page_h);
124        let px0 = (x0 * scale).floor().max(0.0) as u32;
125        let px1 = (x1 * scale).ceil().max(0.0) as u32;
126        // Bottom-origin pixel rows, then flipped to the raster's top origin.
127        let py0 = (y0 * scale).floor().max(0.0) as u32;
128        let py1 = (y1 * scale).ceil().max(0.0) as u32;
129        let px1 = px1.min(self.width);
130        let py1 = py1.min(self.height);
131        if px1 <= px0 || py1 <= py0 {
132            return Err(VisionError::InvalidCrop(format!(
133                "bbox {bbox:?} covers no pixels at {dpi} dpi"
134            )));
135        }
136        let y_top = self.height - py1;
137        let height = py1 - py0;
138        self.crop_pixels(px0, y_top, px1 - px0, height)
139            .ok_or_else(|| {
140                VisionError::InvalidCrop(format!("bbox {bbox:?} lies outside the raster"))
141            })
142    }
143
144    /// Convert to an NCHW `f32` input tensor in 0.0..=1.0.
145    fn to_input_tensor(&self) -> tract_onnx::prelude::Tensor {
146        let (w, h) = (self.width as usize, self.height as usize);
147        let ch = self.format.channels();
148        let mut planar = vec![0.0f32; w * h * ch];
149        match self.format {
150            VisionPixelFormat::Gray8 => {
151                for (dst, src) in planar.iter_mut().zip(self.data.iter()) {
152                    *dst = f32::from(*src) / 255.0;
153                }
154            }
155            VisionPixelFormat::Rgb8 => {
156                // Interleaved RGB -> planar NCHW.
157                let plane = w * h;
158                for (i, px) in self.data.chunks_exact(3).enumerate() {
159                    planar[i] = f32::from(px[0]) / 255.0;
160                    planar[plane + i] = f32::from(px[1]) / 255.0;
161                    planar[2 * plane + i] = f32::from(px[2]) / 255.0;
162                }
163            }
164        }
165        tract_onnx::prelude::Tensor::from_shape(&[1, ch, h, w], &planar)
166            .expect("shape matches buffer length")
167    }
168}
169
170/// Errors from the vision layer.
171///
172/// `#[non_exhaustive]`: new failure modes land in a published crate
173/// without breaking callers that match with a fallback arm.
174#[non_exhaustive]
175#[derive(Debug, thiserror::Error)]
176pub enum VisionError {
177    /// The page could not be rendered.
178    #[error("vision render failed: {0}")]
179    Render(#[from] crate::EngineError),
180    /// The ONNX model could not be loaded.
181    #[error("vision model failed to load: {0}")]
182    Model(String),
183    /// Inference failed.
184    #[error("vision inference failed: {0}")]
185    Inference(String),
186    /// The model's output is missing or not `f32`.
187    #[error("vision output unsupported: {0}")]
188    Output(String),
189    /// The crop bbox covers no pixels.
190    #[error("vision crop invalid: {0}")]
191    InvalidCrop(String),
192}
193
194/// Convenience alias for vision operations.
195pub type VisionResult<T> = std::result::Result<T, VisionError>;
196
197/// Rasterize a page to a grayscale or RGB buffer at [`DEFAULT_VISION_DPI`].
198pub fn rasterize_page(
199    doc: &PdfDocument,
200    page: usize,
201    format: VisionPixelFormat,
202) -> VisionResult<VisionRaster> {
203    rasterize_page_with_dpi(doc, page, DEFAULT_VISION_DPI, format)
204}
205
206/// Rasterize a page to a grayscale or RGB buffer at the given dpi.
207///
208/// Uses the engine's existing renderer ([`PdfDocument::render_page`]); the
209/// RGBA output is composited over white and converted (BT.601 luma for
210/// [`VisionPixelFormat::Gray8`], via [`crate::document::gray_page`], the one
211/// conversion the dump's image arm also reads).
212pub fn rasterize_page_with_dpi(
213    doc: &PdfDocument,
214    page: usize,
215    dpi: u32,
216    format: VisionPixelFormat,
217) -> VisionResult<VisionRaster> {
218    if format == VisionPixelFormat::Gray8 {
219        let (width, height, data) = crate::document::gray_page(doc, page, dpi)?;
220        return Ok(VisionRaster {
221            width,
222            height,
223            format,
224            data,
225        });
226    }
227    let options = RenderOptions {
228        dpi: f64::from(dpi),
229        ..Default::default()
230    };
231    let rendered = doc.render_page(page, &options)?;
232    let mut data = Vec::with_capacity(rendered.width as usize * rendered.height as usize * 3);
233    for px in rendered.pixels.chunks_exact(4) {
234        let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
235        // Composite over white; the default background is opaque white so
236        // alpha is 255 in practice, but never trust it.
237        let alpha = f32::from(a) / 255.0;
238        let blend = |c: u8| (f32::from(c) * alpha + 255.0 * (1.0 - alpha)).round() as u8;
239        data.extend_from_slice(&[blend(r), blend(g), blend(b)]);
240    }
241    Ok(VisionRaster {
242        width: rendered.width,
243        height: rendered.height,
244        format,
245        data,
246    })
247}
248
249/// The output tensor of one model run: shape plus row-major `f32` data.
250#[derive(Debug, Clone)]
251pub struct VisionOutput {
252    /// Tensor shape, e.g. `[1, 1, 612, 792]` for an identity over NCHW input.
253    pub shape: Vec<usize>,
254    /// Tensor data, row-major.
255    pub data: Vec<f32>,
256}
257
258/// An ONNX model loaded from bytes or a path, runnable on a [`VisionRaster`].
259pub struct VisionRunner {
260    model: std::sync::Arc<tract_onnx::prelude::TypedRunnableModel>,
261}
262
263impl VisionRunner {
264    /// Load a model from ONNX bytes (e.g. `include_bytes!`).
265    pub fn from_bytes(bytes: &[u8]) -> VisionResult<Self> {
266        let mut cursor = std::io::Cursor::new(bytes);
267        let model = tract_onnx::onnx()
268            .model_for_read(&mut cursor)
269            .map_err(|e| VisionError::Model(e.to_string()))?;
270        Self::from_inference_model(model)
271    }
272
273    /// Load a model from an `.onnx` file path.
274    pub fn from_path(path: impl AsRef<Path>) -> VisionResult<Self> {
275        let model = tract_onnx::onnx()
276            .model_for_path(path.as_ref())
277            .map_err(|e| VisionError::Model(e.to_string()))?;
278        Self::from_inference_model(model)
279    }
280
281    fn from_inference_model(model: tract_onnx::prelude::InferenceModel) -> VisionResult<Self> {
282        use tract_onnx::prelude::{InferenceModelExt as _, IntoRunnable as _};
283        let typed = model
284            .into_optimized()
285            .map_err(|e| VisionError::Model(e.to_string()))?;
286        let runnable = typed
287            .into_runnable()
288            .map_err(|e| VisionError::Model(e.to_string()))?;
289        Ok(Self { model: runnable })
290    }
291
292    /// Run the model on a raster, returning the first output tensor.
293    pub fn run(&self, raster: &VisionRaster) -> VisionResult<VisionOutput> {
294        let input = raster.to_input_tensor().into_tvalue();
295        let outputs = self
296            .model
297            .run(tvec!(input))
298            .map_err(|e| VisionError::Inference(e.to_string()))?;
299        let tensor = outputs
300            .first()
301            .ok_or_else(|| VisionError::Output("model returned no outputs".into()))?;
302        let view = tensor
303            .to_plain_array_view::<f32>()
304            .map_err(|e| VisionError::Output(e.to_string()))?;
305        Ok(VisionOutput {
306            shape: view.shape().to_vec(),
307            data: view.iter().copied().collect(),
308        })
309    }
310
311    /// Rasterize a page, crop a bbox in page points, and run the model on it.
312    ///
313    /// `bbox` follows [`VisionRaster::crop_points`]; the page size comes from
314    /// the document's own geometry.
315    pub fn run_on_crop(
316        &self,
317        doc: &PdfDocument,
318        page: usize,
319        bbox: [f64; 4],
320        dpi: u32,
321        format: VisionPixelFormat,
322    ) -> VisionResult<VisionOutput> {
323        let raster = rasterize_page_with_dpi(doc, page, dpi, format)?;
324        let geometry = doc.page_geometry(page)?;
325        let (w, h) = geometry.effective_dimensions();
326        let crop = raster.crop_points(bbox, (w, h), dpi)?;
327        self.run(&crop)
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn channels_match_layout() {
337        assert_eq!(VisionPixelFormat::Gray8.channels(), 1);
338        assert_eq!(VisionPixelFormat::Rgb8.channels(), 3);
339    }
340
341    #[test]
342    fn default_dpi_is_72() {
343        assert_eq!(DEFAULT_VISION_DPI, 72);
344    }
345
346    #[test]
347    fn pixel_crop_copies_rows() {
348        let raster = VisionRaster {
349            width: 4,
350            height: 4,
351            format: VisionPixelFormat::Gray8,
352            data: (0..16u8).collect(),
353        };
354        let crop = raster.crop_pixels(1, 1, 2, 2).expect("inside");
355        assert_eq!(crop.width, 2);
356        assert_eq!(crop.height, 2);
357        assert_eq!(crop.data, vec![5, 6, 9, 10]);
358    }
359
360    #[test]
361    fn point_crops_name_the_pixels_the_image_arm_reads() {
362        // `learned::raster` reuses this arithmetic at 72 dpi without the vision
363        // feature; the two must name the same pixels (#574).
364        let raster = VisionRaster {
365            width: 100,
366            height: 40,
367            format: VisionPixelFormat::Gray8,
368            data: (0..4000u32).map(|i| (i % 251) as u8).collect(),
369        };
370        let gray = crate::learned::raster::GrayPage {
371            width: 100,
372            height: 40,
373            data: &raster.data,
374        };
375        for bbox in [
376            [10.0, 10.0, 50.0, 20.0],
377            [0.0, 0.0, 100.0, 40.0],
378            [10.5, 9.2, 49.7, 20.8],
379            [50.0, 20.0, 10.0, 10.0],
380            [-5.0, -5.0, 20.0, 20.0],
381        ] {
382            let crop = raster
383                .crop_points(bbox, (100.0, 40.0), 72)
384                .expect("an in-page box crops");
385            let (c0, c1, r0, r1) = crate::learned::raster::pixel_box(bbox, &gray, 40.0);
386            assert_eq!((crop.width, crop.height), (c1 - c0, r1 - r0), "{bbox:?}");
387            let mut want = Vec::with_capacity(crop.data.len());
388            for r in r0..r1 {
389                let row = r as usize * 100;
390                want.extend_from_slice(&raster.data[row + c0 as usize..row + c1 as usize]);
391            }
392            assert_eq!(crop.data, want, "{bbox:?}");
393        }
394        // Off the page: an error here, an empty rect there.
395        assert!(raster
396            .crop_points([200.0, 200.0, 300.0, 300.0], (100.0, 40.0), 72)
397            .is_err());
398        let (c0, c1, r0, r1) =
399            crate::learned::raster::pixel_box([200.0, 200.0, 300.0, 300.0], &gray, 40.0);
400        assert!(c0 == c1 || r0 == r1);
401    }
402
403    #[test]
404    fn pixel_crop_outside_is_none() {
405        let raster = VisionRaster {
406            width: 2,
407            height: 2,
408            format: VisionPixelFormat::Gray8,
409            data: vec![0; 4],
410        };
411        assert!(raster.crop_pixels(0, 0, 3, 1).is_none());
412        assert!(raster.crop_pixels(1, 1, 2, 2).is_none());
413        assert!(raster.crop_pixels(0, 0, 0, 1).is_none());
414    }
415}