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