use std::path::Path;
use tract_onnx::prelude::{tvec, Framework as _, IntoTValue as _};
use crate::{PdfDocument, RenderOptions};
pub const DEFAULT_VISION_DPI: u32 = 72;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisionPixelFormat {
Gray8,
Rgb8,
}
impl VisionPixelFormat {
pub fn channels(self) -> usize {
match self {
Self::Gray8 => 1,
Self::Rgb8 => 3,
}
}
}
#[derive(Debug, Clone)]
pub struct VisionRaster {
pub width: u32,
pub height: u32,
pub format: VisionPixelFormat,
pub data: Vec<u8>,
}
impl VisionRaster {
pub fn crop_pixels(&self, x: u32, y: u32, width: u32, height: u32) -> Option<Self> {
if width == 0 || height == 0 {
return None;
}
let (x_end, y_end) = (x.checked_add(width)?, y.checked_add(height)?);
if x_end > self.width || y_end > self.height {
return None;
}
let ch = self.format.channels();
let stride = self.width as usize * ch;
let mut data = Vec::with_capacity(width as usize * height as usize * ch);
for row in y..y_end {
let start = row as usize * stride + x as usize * ch;
data.extend_from_slice(&self.data[start..start + width as usize * ch]);
}
Some(Self {
width,
height,
format: self.format,
data,
})
}
pub fn crop_points(
&self,
bbox: [f64; 4],
page_size_points: (f64, f64),
dpi: u32,
) -> VisionResult<Self> {
let scale = f64::from(dpi) / 72.0;
if !scale.is_finite() || scale <= 0.0 {
return Err(VisionError::InvalidCrop(format!(
"dpi must be positive, got {dpi}"
)));
}
let (page_w, page_h) = page_size_points;
let x0 = bbox[0].min(bbox[2]).clamp(0.0, page_w);
let x1 = bbox[0].max(bbox[2]).clamp(0.0, page_w);
let y0 = bbox[1].min(bbox[3]).clamp(0.0, page_h);
let y1 = bbox[1].max(bbox[3]).clamp(0.0, page_h);
let px0 = (x0 * scale).floor().max(0.0) as u32;
let px1 = (x1 * scale).ceil().max(0.0) as u32;
let py0 = (y0 * scale).floor().max(0.0) as u32;
let py1 = (y1 * scale).ceil().max(0.0) as u32;
let px1 = px1.min(self.width);
let py1 = py1.min(self.height);
if px1 <= px0 || py1 <= py0 {
return Err(VisionError::InvalidCrop(format!(
"bbox {bbox:?} covers no pixels at {dpi} dpi"
)));
}
let y_top = self.height - py1;
let height = py1 - py0;
self.crop_pixels(px0, y_top, px1 - px0, height)
.ok_or_else(|| {
VisionError::InvalidCrop(format!("bbox {bbox:?} lies outside the raster"))
})
}
fn to_input_tensor(&self) -> tract_onnx::prelude::Tensor {
let (w, h) = (self.width as usize, self.height as usize);
let ch = self.format.channels();
let mut planar = vec![0.0f32; w * h * ch];
match self.format {
VisionPixelFormat::Gray8 => {
for (dst, src) in planar.iter_mut().zip(self.data.iter()) {
*dst = f32::from(*src) / 255.0;
}
}
VisionPixelFormat::Rgb8 => {
let plane = w * h;
for (i, px) in self.data.chunks_exact(3).enumerate() {
planar[i] = f32::from(px[0]) / 255.0;
planar[plane + i] = f32::from(px[1]) / 255.0;
planar[2 * plane + i] = f32::from(px[2]) / 255.0;
}
}
}
tract_onnx::prelude::Tensor::from_shape(&[1, ch, h, w], &planar)
.expect("shape matches buffer length")
}
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum VisionError {
#[error("vision render failed: {0}")]
Render(#[from] crate::EngineError),
#[error("vision model failed to load: {0}")]
Model(String),
#[error("vision inference failed: {0}")]
Inference(String),
#[error("vision output unsupported: {0}")]
Output(String),
#[error("vision crop invalid: {0}")]
InvalidCrop(String),
}
pub type VisionResult<T> = std::result::Result<T, VisionError>;
pub fn rasterize_page(
doc: &PdfDocument,
page: usize,
format: VisionPixelFormat,
) -> VisionResult<VisionRaster> {
rasterize_page_with_dpi(doc, page, DEFAULT_VISION_DPI, format)
}
pub fn rasterize_page_with_dpi(
doc: &PdfDocument,
page: usize,
dpi: u32,
format: VisionPixelFormat,
) -> VisionResult<VisionRaster> {
if format == VisionPixelFormat::Gray8 {
let (width, height, data) = crate::document::gray_page(doc, page, dpi)?;
return Ok(VisionRaster {
width,
height,
format,
data,
});
}
let options = RenderOptions {
dpi: f64::from(dpi),
..Default::default()
};
let rendered = doc.render_page(page, &options)?;
let mut data = Vec::with_capacity(rendered.width as usize * rendered.height as usize * 3);
for px in rendered.pixels.chunks_exact(4) {
let (r, g, b, a) = (px[0], px[1], px[2], px[3]);
let alpha = f32::from(a) / 255.0;
let blend = |c: u8| (f32::from(c) * alpha + 255.0 * (1.0 - alpha)).round() as u8;
data.extend_from_slice(&[blend(r), blend(g), blend(b)]);
}
Ok(VisionRaster {
width: rendered.width,
height: rendered.height,
format,
data,
})
}
#[derive(Debug, Clone)]
pub struct VisionOutput {
pub shape: Vec<usize>,
pub data: Vec<f32>,
}
pub struct VisionRunner {
model: std::sync::Arc<tract_onnx::prelude::TypedRunnableModel>,
}
impl VisionRunner {
pub fn from_bytes(bytes: &[u8]) -> VisionResult<Self> {
let mut cursor = std::io::Cursor::new(bytes);
let model = tract_onnx::onnx()
.model_for_read(&mut cursor)
.map_err(|e| VisionError::Model(e.to_string()))?;
Self::from_inference_model(model)
}
pub fn from_path(path: impl AsRef<Path>) -> VisionResult<Self> {
let model = tract_onnx::onnx()
.model_for_path(path.as_ref())
.map_err(|e| VisionError::Model(e.to_string()))?;
Self::from_inference_model(model)
}
fn from_inference_model(model: tract_onnx::prelude::InferenceModel) -> VisionResult<Self> {
use tract_onnx::prelude::{InferenceModelExt as _, IntoRunnable as _};
let typed = model
.into_optimized()
.map_err(|e| VisionError::Model(e.to_string()))?;
let runnable = typed
.into_runnable()
.map_err(|e| VisionError::Model(e.to_string()))?;
Ok(Self { model: runnable })
}
pub fn run(&self, raster: &VisionRaster) -> VisionResult<VisionOutput> {
let input = raster.to_input_tensor().into_tvalue();
let outputs = self
.model
.run(tvec!(input))
.map_err(|e| VisionError::Inference(e.to_string()))?;
let tensor = outputs
.first()
.ok_or_else(|| VisionError::Output("model returned no outputs".into()))?;
let view = tensor
.to_plain_array_view::<f32>()
.map_err(|e| VisionError::Output(e.to_string()))?;
Ok(VisionOutput {
shape: view.shape().to_vec(),
data: view.iter().copied().collect(),
})
}
pub fn run_on_crop(
&self,
doc: &PdfDocument,
page: usize,
bbox: [f64; 4],
dpi: u32,
format: VisionPixelFormat,
) -> VisionResult<VisionOutput> {
let raster = rasterize_page_with_dpi(doc, page, dpi, format)?;
let geometry = doc.page_geometry(page)?;
let (w, h) = geometry.effective_dimensions();
let crop = raster.crop_points(bbox, (w, h), dpi)?;
self.run(&crop)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channels_match_layout() {
assert_eq!(VisionPixelFormat::Gray8.channels(), 1);
assert_eq!(VisionPixelFormat::Rgb8.channels(), 3);
}
#[test]
fn default_dpi_is_72() {
assert_eq!(DEFAULT_VISION_DPI, 72);
}
#[test]
fn pixel_crop_copies_rows() {
let raster = VisionRaster {
width: 4,
height: 4,
format: VisionPixelFormat::Gray8,
data: (0..16u8).collect(),
};
let crop = raster.crop_pixels(1, 1, 2, 2).expect("inside");
assert_eq!(crop.width, 2);
assert_eq!(crop.height, 2);
assert_eq!(crop.data, vec![5, 6, 9, 10]);
}
#[test]
fn point_crops_name_the_pixels_the_image_arm_reads() {
let raster = VisionRaster {
width: 100,
height: 40,
format: VisionPixelFormat::Gray8,
data: (0..4000u32).map(|i| (i % 251) as u8).collect(),
};
let gray = crate::learned::raster::GrayPage {
width: 100,
height: 40,
data: &raster.data,
};
for bbox in [
[10.0, 10.0, 50.0, 20.0],
[0.0, 0.0, 100.0, 40.0],
[10.5, 9.2, 49.7, 20.8],
[50.0, 20.0, 10.0, 10.0],
[-5.0, -5.0, 20.0, 20.0],
] {
let crop = raster
.crop_points(bbox, (100.0, 40.0), 72)
.expect("an in-page box crops");
let (c0, c1, r0, r1) = crate::learned::raster::pixel_box(bbox, &gray, 40.0);
assert_eq!((crop.width, crop.height), (c1 - c0, r1 - r0), "{bbox:?}");
let mut want = Vec::with_capacity(crop.data.len());
for r in r0..r1 {
let row = r as usize * 100;
want.extend_from_slice(&raster.data[row + c0 as usize..row + c1 as usize]);
}
assert_eq!(crop.data, want, "{bbox:?}");
}
assert!(raster
.crop_points([200.0, 200.0, 300.0, 300.0], (100.0, 40.0), 72)
.is_err());
let (c0, c1, r0, r1) =
crate::learned::raster::pixel_box([200.0, 200.0, 300.0, 300.0], &gray, 40.0);
assert!(c0 == c1 || r0 == r1);
}
#[test]
fn pixel_crop_outside_is_none() {
let raster = VisionRaster {
width: 2,
height: 2,
format: VisionPixelFormat::Gray8,
data: vec![0; 4],
};
assert!(raster.crop_pixels(0, 0, 3, 1).is_none());
assert!(raster.crop_pixels(1, 1, 2, 2).is_none());
assert!(raster.crop_pixels(0, 0, 0, 1).is_none());
}
}