1use std::path::Path;
24
25use tract_onnx::prelude::{tvec, Framework as _, IntoTValue as _};
26
27use crate::{PdfDocument, RenderOptions};
28
29pub const DEFAULT_VISION_DPI: u32 = 72;
34
35#[non_exhaustive]
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum VisionPixelFormat {
42 Gray8,
44 Rgb8,
46}
47
48impl VisionPixelFormat {
49 pub fn channels(self) -> usize {
51 match self {
52 Self::Gray8 => 1,
53 Self::Rgb8 => 3,
54 }
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct VisionRaster {
61 pub width: u32,
63 pub height: u32,
65 pub format: VisionPixelFormat,
67 pub data: Vec<u8>,
69}
70
71impl VisionRaster {
72 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 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 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 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 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#[non_exhaustive]
175#[derive(Debug, thiserror::Error)]
176pub enum VisionError {
177 #[error("vision render failed: {0}")]
179 Render(#[from] crate::EngineError),
180 #[error("vision model failed to load: {0}")]
182 Model(String),
183 #[error("vision inference failed: {0}")]
185 Inference(String),
186 #[error("vision output unsupported: {0}")]
188 Output(String),
189 #[error("vision crop invalid: {0}")]
191 InvalidCrop(String),
192}
193
194pub type VisionResult<T> = std::result::Result<T, VisionError>;
196
197pub 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
206pub 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 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#[derive(Debug, Clone)]
251pub struct VisionOutput {
252 pub shape: Vec<usize>,
254 pub data: Vec<f32>,
256}
257
258pub struct VisionRunner {
260 model: std::sync::Arc<tract_onnx::prelude::TypedRunnableModel>,
261}
262
263impl VisionRunner {
264 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 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 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 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 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 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}