Skip to main content

locus_core/
image.rs

1//! Stride-aware image view for zero-copy ingestion.
2#![allow(clippy::inline_always)]
3#![allow(clippy::cast_sign_loss)]
4#![allow(clippy::missing_errors_doc)]
5#![allow(clippy::missing_panics_doc)]
6#![allow(unsafe_code)]
7
8use rayon::prelude::*;
9
10/// A view into an image buffer with explicit stride support.
11/// This allows handling NumPy arrays with padding or non-standard layouts.
12#[derive(Copy, Clone)]
13pub struct ImageView<'a> {
14    /// The raw image data slice.
15    pub data: &'a [u8],
16    /// The width of the image in pixels.
17    pub width: usize,
18    /// The height of the image in pixels.
19    pub height: usize,
20    /// The stride (bytes per row) of the image.
21    pub stride: usize,
22}
23
24impl<'a> ImageView<'a> {
25    /// Create a new ImageView after validating that the buffer size matches the dimensions and stride.
26    pub fn new(data: &'a [u8], width: usize, height: usize, stride: usize) -> Result<Self, String> {
27        if stride < width {
28            return Err(format!(
29                "Stride ({stride}) cannot be less than width ({width})"
30            ));
31        }
32        let required_size = if height > 0 {
33            (height - 1) * stride + width
34        } else {
35            0
36        };
37        if data.len() < required_size {
38            return Err(format!(
39                "Buffer size ({}) is too small for {}x{} image with stride {} (required: {})",
40                data.len(),
41                width,
42                height,
43                stride,
44                required_size
45            ));
46        }
47        Ok(Self {
48            data,
49            width,
50            height,
51            stride,
52        })
53    }
54
55    /// Safe accessor for a specific row.
56    #[inline(always)]
57    #[must_use]
58    pub fn get_row(&self, y: usize) -> &[u8] {
59        assert!(y < self.height, "Row index {y} out of bounds");
60        let start = y * self.stride;
61        &self.data[start..start + self.width]
62    }
63
64    /// Get a pixel value at (x, y) with boundary clamping.
65    #[must_use]
66    pub fn get_pixel(&self, x: usize, y: usize) -> u8 {
67        let x = x.min(self.width - 1);
68        let y = y.min(self.height - 1);
69        // SAFETY: clamping ensures bounds
70        unsafe { *self.data.get_unchecked(y * self.stride + x) }
71    }
72
73    /// Get a pixel value at (x, y) without bounds checking.
74    ///
75    /// # Safety
76    /// Caller must ensure `x < width` and `y < height`.
77    #[inline(always)]
78    #[must_use]
79    pub unsafe fn get_pixel_unchecked(&self, x: usize, y: usize) -> u8 {
80        // debug_assert ensures we catch violations in debug mode
81        debug_assert!(x < self.width, "x {} out of bounds {}", x, self.width);
82        debug_assert!(y < self.height, "y {} out of bounds {}", y, self.height);
83        // SAFETY: Caller guarantees bounds
84        unsafe { *self.data.get_unchecked(y * self.stride + x) }
85    }
86
87    /// Sample pixel value with bilinear interpolation at sub-pixel coordinates.
88    #[must_use]
89    pub fn sample_bilinear(&self, x: f64, y: f64) -> f64 {
90        if x < 0.0 || x >= (self.width - 1) as f64 || y < 0.0 || y >= (self.height - 1) as f64 {
91            return f64::from(self.get_pixel(x.round() as usize, y.round() as usize));
92        }
93
94        let x0 = x.floor() as usize;
95        let y0 = y.floor() as usize;
96        let x1 = x0 + 1;
97        let y1 = y0 + 1;
98
99        let dx = x - x0 as f64;
100        let dy = y - y0 as f64;
101
102        let v00 = f64::from(self.get_pixel(x0, y0));
103        let v10 = f64::from(self.get_pixel(x1, y0));
104        let v01 = f64::from(self.get_pixel(x0, y1));
105        let v11 = f64::from(self.get_pixel(x1, y1));
106
107        let v0 = v00 * (1.0 - dx) + v10 * dx;
108        let v1 = v01 * (1.0 - dx) + v11 * dx;
109
110        v0 * (1.0 - dy) + v1 * dy
111    }
112
113    /// Sample pixel value with bilinear interpolation at sub-pixel coordinates without bounds checking.
114    ///
115    /// # Safety
116    /// Caller must ensure `0.0 <= x <= width - 1.001` and `0.0 <= y <= height - 1.001`
117    /// such that floor(x), floor(x)+1, floor(y), floor(y)+1 are all valid indices.
118    #[inline(always)]
119    #[must_use]
120    pub unsafe fn sample_bilinear_unchecked(&self, x: f64, y: f64) -> f64 {
121        let x0 = x as usize; // Truncate is effectively floor for positive numbers
122        let y0 = y as usize;
123        let x1 = x0 + 1;
124        let y1 = y0 + 1;
125
126        debug_assert!(x1 < self.width, "x1 {} out of bounds {}", x1, self.width);
127        debug_assert!(y1 < self.height, "y1 {} out of bounds {}", y1, self.height);
128
129        let dx = x - x0 as f64;
130        let dy = y - y0 as f64;
131
132        // Use unchecked pixel access
133        // We know strides and offsets are valid because of the assertions (in debug) and caller contract (in release)
134        // SAFETY: Caller guarantees checks.
135        let row0 = unsafe { self.get_row_unchecked(y0) };
136        let row1 = unsafe { self.get_row_unchecked(y1) };
137
138        // We can access x0/x1 directly from the row slice
139        // SAFETY: Caller guarantees checks.
140        unsafe {
141            let v00 = f64::from(*row0.get_unchecked(x0));
142            let v10 = f64::from(*row0.get_unchecked(x1));
143            let v01 = f64::from(*row1.get_unchecked(x0));
144            let v11 = f64::from(*row1.get_unchecked(x1));
145
146            let v0 = v00 * (1.0 - dx) + v10 * dx;
147            let v1 = v01 * (1.0 - dx) + v11 * dx;
148
149            v0 * (1.0 - dy) + v1 * dy
150        }
151    }
152
153    /// Compute the gradient [gx, gy] at sub-pixel coordinates using bilinear interpolation.
154    #[must_use]
155    pub fn sample_gradient_bilinear(&self, x: f64, y: f64) -> [f64; 2] {
156        // Optimization: Sample [gx, gy] directly using a 3x3 or 4x4 neighborhood
157        // instead of 4 separate bilinear samples.
158        // For a high-quality sub-pixel gradient, we sample the 4 nearest integer locations
159        // and interpolate their finite-difference gradients.
160
161        if x < 1.0 || x >= (self.width - 2) as f64 || y < 1.0 || y >= (self.height - 2) as f64 {
162            let gx = (self.sample_bilinear(x + 1.0, y) - self.sample_bilinear(x - 1.0, y)) * 0.5;
163            let gy = (self.sample_bilinear(x, y + 1.0) - self.sample_bilinear(x, y - 1.0)) * 0.5;
164            return [gx, gy];
165        }
166
167        let x0 = x.floor() as usize;
168        let y0 = y.floor() as usize;
169        let dx = x - x0 as f64;
170        let dy = y - y0 as f64;
171
172        // Fetch 4x4 neighborhood to compute central differences at 4 grid points
173        // (x0, y0), (x0+1, y0), (x0, y0+1), (x0+1, y0+1)
174        // Indices needed: x0-1..x0+2, y0-1..y0+2
175        let mut g00 = [0.0, 0.0];
176        let mut g10 = [0.0, 0.0];
177        let mut g01 = [0.0, 0.0];
178        let mut g11 = [0.0, 0.0];
179
180        unsafe {
181            for j in 0..2 {
182                for i in 0..2 {
183                    let cx = x0 + i;
184                    let cy = y0 + j;
185
186                    let gx = (f64::from(self.get_pixel_unchecked(cx + 1, cy))
187                        - f64::from(self.get_pixel_unchecked(cx - 1, cy)))
188                        * 0.5;
189                    let gy = (f64::from(self.get_pixel_unchecked(cx, cy + 1))
190                        - f64::from(self.get_pixel_unchecked(cx, cy - 1)))
191                        * 0.5;
192
193                    match (i, j) {
194                        (0, 0) => g00 = [gx, gy],
195                        (1, 0) => g10 = [gx, gy],
196                        (0, 1) => g01 = [gx, gy],
197                        (1, 1) => g11 = [gx, gy],
198                        _ => unreachable!(),
199                    }
200                }
201            }
202        }
203
204        let gx = (g00[0] * (1.0 - dx) + g10[0] * dx) * (1.0 - dy)
205            + (g01[0] * (1.0 - dx) + g11[0] * dx) * dy;
206        let gy = (g00[1] * (1.0 - dx) + g10[1] * dx) * (1.0 - dy)
207            + (g01[1] * (1.0 - dx) + g11[1] * dx) * dy;
208
209        [gx, gy]
210    }
211
212    /// Unsafe accessor for a specific row.
213    #[inline(always)]
214    pub(crate) unsafe fn get_row_unchecked(&self, y: usize) -> &[u8] {
215        let start = y * self.stride;
216        // SAFETY: Caller guarantees y < height. Width and stride are invariants.
217        unsafe { &self.data.get_unchecked(start..start + self.width) }
218    }
219
220    /// Create a decimated copy of the image by subsampling every `factor` pixels.
221    ///
222    /// The `output` buffer must have size at least `(width/factor) * (height/factor)`.
223    pub fn decimate_to<'b>(
224        &self,
225        factor: usize,
226        output: &'b mut [u8],
227    ) -> Result<ImageView<'b>, String> {
228        let factor = factor.max(1);
229        if factor == 1 {
230            let len = self.data.len();
231            if output.len() < len {
232                return Err(format!(
233                    "Output buffer too small: {} < {}",
234                    output.len(),
235                    len
236                ));
237            }
238            output[..len].copy_from_slice(self.data);
239            return ImageView::new(&output[..len], self.width, self.height, self.width);
240        }
241
242        let new_w = self.width / factor;
243        let new_h = self.height / factor;
244
245        if output.len() < new_w * new_h {
246            return Err(format!(
247                "Output buffer too small for decimation: {} < {}",
248                output.len(),
249                new_w * new_h
250            ));
251        }
252
253        output
254            .par_chunks_exact_mut(new_w)
255            .enumerate()
256            .take(new_h)
257            .for_each(|(y, out_row)| {
258                let src_y = y * factor;
259                let src_row = self.get_row(src_y);
260                for x in 0..new_w {
261                    out_row[x] = src_row[x * factor];
262                }
263            });
264
265        ImageView::new(&output[..new_w * new_h], new_w, new_h, new_w)
266    }
267
268    /// Create an upscaled copy of the image using bilinear interpolation.
269    ///
270    /// The `output` buffer must have size at least `(width*factor) * (height*factor)`.
271    pub fn upscale_to<'b>(
272        &self,
273        factor: usize,
274        output: &'b mut [u8],
275    ) -> Result<ImageView<'b>, String> {
276        let factor = factor.max(1);
277        if factor == 1 {
278            let len = self.data.len();
279            if output.len() < len {
280                return Err(format!(
281                    "Output buffer too small: {} < {}",
282                    output.len(),
283                    len
284                ));
285            }
286            output[..len].copy_from_slice(self.data);
287            return ImageView::new(&output[..len], self.width, self.height, self.width);
288        }
289
290        let new_w = self.width * factor;
291        let new_h = self.height * factor;
292
293        if output.len() < new_w * new_h {
294            return Err(format!(
295                "Output buffer too small for upscaling: {} < {}",
296                output.len(),
297                new_w * new_h
298            ));
299        }
300
301        let scale = 1.0 / factor as f64;
302
303        output
304            .par_chunks_exact_mut(new_w)
305            .enumerate()
306            .take(new_h)
307            .for_each(|(y, out_row)| {
308                let src_y = y as f64 * scale;
309                for (x, val) in out_row.iter_mut().enumerate() {
310                    let src_x = x as f64 * scale;
311                    // We can use unchecked version for speed if we are confident,
312                    // but sample_bilinear handles bounds checks.
313                    // Given we are inside image bounds, it should be fine.
314                    // To maximize perf we might want a localized optimized loop here,
315                    // but for now reusing sample_bilinear is safe and clean.
316                    *val = self.sample_bilinear(src_x, src_y) as u8;
317                }
318            });
319
320        ImageView::new(&output[..new_w * new_h], new_w, new_h, new_w)
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use proptest::prelude::*;
328
329    #[test]
330    fn test_image_view_stride() {
331        let data = vec![
332            1, 2, 3, 0, // row 0 + padding
333            4, 5, 6, 0, // row 1 + padding
334        ];
335        let view = ImageView::new(&data, 3, 2, 4).expect("Valid image creation");
336        assert_eq!(view.get_row(0), &[1, 2, 3]);
337        assert_eq!(view.get_row(1), &[4, 5, 6]);
338        assert_eq!(view.get_pixel(1, 1), 5);
339    }
340
341    #[test]
342    fn test_invalid_buffer_size() {
343        let data = vec![1, 2, 3];
344        let result = ImageView::new(&data, 2, 2, 2);
345        assert!(result.is_err());
346    }
347
348    proptest! {
349        #[test]
350        fn prop_image_view_creation(
351            width in 0..1000usize,
352            height in 0..1000usize,
353            stride_extra in 0..100usize,
354            has_enough_data in prop::bool::ANY
355        ) {
356            let stride = width + stride_extra;
357            let required_size = if height > 0 {
358                (height - 1) * stride + width
359            } else {
360                0
361            };
362
363            let data_len = if has_enough_data {
364                required_size
365            } else {
366                required_size.saturating_sub(1)
367            };
368
369            let data = vec![0u8; data_len];
370            let result = ImageView::new(&data, width, height, stride);
371
372            if height > 0 && !has_enough_data {
373                assert!(result.is_err());
374            } else {
375                assert!(result.is_ok());
376            }
377        }
378
379        #[test]
380        fn prop_get_pixel_clamping(
381            width in 1..100usize,
382            height in 1..100usize,
383            x in 0..200usize,
384            y in 0..200usize
385        ) {
386            let data = vec![0u8; height * width];
387            let view = ImageView::new(&data, width, height, width).expect("valid creation");
388            let p = view.get_pixel(x, y);
389            // Clamping should prevent panic
390            assert_eq!(p, 0);
391        }
392
393        #[test]
394        fn prop_sample_bilinear_invariants(
395            width in 2..20usize,
396            height in 2..20usize,
397            data in prop::collection::vec(0..=255u8, 20*20),
398            x in 0.0..20.0f64,
399            y in 0.0..20.0f64
400        ) {
401            let real_width = width.min(20);
402            let real_height = height.min(20);
403            let slice = &data[..real_width * real_height];
404            let view = ImageView::new(slice, real_width, real_height, real_width).expect("valid creation");
405
406            let x = x % real_width as f64;
407            let y = y % real_height as f64;
408
409            let val = view.sample_bilinear(x, y);
410
411            // Result should be within [0, 255]
412            assert!((0.0..=255.0).contains(&val));
413
414            // If inside 2x2 neighborhood, val should be within min/max of those 4 pixels
415            let x0 = x.floor() as usize;
416            let y0 = y.floor() as usize;
417            let x1 = x0 + 1;
418            let y1 = y0 + 1;
419
420            if x1 < real_width && y1 < real_height {
421                let v00 = view.get_pixel(x0, y0);
422                let v10 = view.get_pixel(x1, y0);
423                let v01 = view.get_pixel(x0, y1);
424                let v11 = view.get_pixel(x1, y1);
425
426                let min = f64::from(v00.min(v10).min(v01).min(v11));
427                let max = f64::from(v00.max(v10).max(v01).max(v11));
428
429                assert!(val >= min - 1e-9 && val <= max + 1e-9, "Value {val} not in [{min}, {max}] for x={x}, y={y}");
430            }
431        }
432    }
433}