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 that (x, y) are within the image dimensions.
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 that floor(x), floor(x)+1, floor(y), floor(y)+1 are within bounds.
156        let row0 = unsafe { self.get_row_unchecked(y0) };
157        // SAFETY: Same as above.
158        let row1 = unsafe { self.get_row_unchecked(y1) };
159
160        // We can access x0/x1 directly from the row slice
161        // SAFETY: x0 and x1 are within bounds guaranteed by the caller.
162        unsafe {
163            let v00 = f64::from(*row0.get_unchecked(x0));
164            let v10 = f64::from(*row0.get_unchecked(x1));
165            let v01 = f64::from(*row1.get_unchecked(x0));
166            let v11 = f64::from(*row1.get_unchecked(x1));
167
168            let v0 = v00 * (1.0 - dx) + v10 * dx;
169            let v1 = v01 * (1.0 - dx) + v11 * dx;
170
171            v0 * (1.0 - dy) + v1 * dy
172        }
173    }
174
175    /// Compute the gradient [gx, gy] at sub-pixel coordinates using bilinear interpolation.
176    #[must_use]
177    pub fn sample_gradient_bilinear(&self, x: f64, y: f64) -> [f64; 2] {
178        let x = x - 0.5;
179        let y = y - 0.5;
180
181        // Optimization: Sample [gx, gy] directly using a 3x3 or 4x4 neighborhood
182        // instead of 4 separate bilinear samples.
183        // For a high-quality sub-pixel gradient, we sample the 4 nearest integer locations
184        // and interpolate their finite-difference gradients.
185
186        if x < 1.0 || x >= (self.width - 2) as f64 || y < 1.0 || y >= (self.height - 2) as f64 {
187            let gx = (self.sample_bilinear(x + 1.0, y) - self.sample_bilinear(x - 1.0, y)) * 0.5;
188            let gy = (self.sample_bilinear(x, y + 1.0) - self.sample_bilinear(x, y - 1.0)) * 0.5;
189            return [gx, gy];
190        }
191
192        let x0 = x.floor() as usize;
193        let y0 = y.floor() as usize;
194        let dx = x - x0 as f64;
195        let dy = y - y0 as f64;
196
197        // Fetch 4x4 neighborhood to compute central differences at 4 grid points
198        // (x0, y0), (x0+1, y0), (x0, y0+1), (x0+1, y0+1)
199        // Indices needed: x0-1..x0+2, y0-1..y0+2
200        let mut g00 = [0.0, 0.0];
201        let mut g10 = [0.0, 0.0];
202        let mut g01 = [0.0, 0.0];
203        let mut g11 = [0.0, 0.0];
204
205        // SAFETY: Bounds are checked above (x >= 1.0, y >= 1.0, etc.)
206        unsafe {
207            for j in 0..2 {
208                for i in 0..2 {
209                    let cx = x0 + i;
210                    let cy = y0 + j;
211
212                    let gx = (f64::from(self.get_pixel_unchecked(cx + 1, cy))
213                        - f64::from(self.get_pixel_unchecked(cx - 1, cy)))
214                        * 0.5;
215                    let gy = (f64::from(self.get_pixel_unchecked(cx, cy + 1))
216                        - f64::from(self.get_pixel_unchecked(cx, cy - 1)))
217                        * 0.5;
218
219                    match (i, j) {
220                        (0, 0) => g00 = [gx, gy],
221                        (1, 0) => g10 = [gx, gy],
222                        (0, 1) => g01 = [gx, gy],
223                        (1, 1) => g11 = [gx, gy],
224                        _ => unreachable!(),
225                    }
226                }
227            }
228        }
229
230        let gx = (g00[0] * (1.0 - dx) + g10[0] * dx) * (1.0 - dy)
231            + (g01[0] * (1.0 - dx) + g11[0] * dx) * dy;
232        let gy = (g00[1] * (1.0 - dx) + g10[1] * dx) * (1.0 - dy)
233            + (g01[1] * (1.0 - dx) + g11[1] * dx) * dy;
234
235        [gx, gy]
236    }
237
238    /// Unsafe accessor for a specific row.
239    #[inline(always)]
240    pub(crate) unsafe fn get_row_unchecked(&self, y: usize) -> &[u8] {
241        let start = y * self.stride;
242        // SAFETY: Caller guarantees y < height. Width and stride are invariants.
243        unsafe { &self.data.get_unchecked(start..start + self.width) }
244    }
245
246    /// Create a decimated copy of the image by subsampling every `factor` pixels.
247    ///
248    /// The `output` buffer must have size at least `(width/factor) * (height/factor)`.
249    pub fn decimate_to<'b>(
250        &self,
251        factor: usize,
252        output: &'b mut [u8],
253    ) -> Result<ImageView<'b>, String> {
254        let factor = factor.max(1);
255        if factor == 1 {
256            let len = self.data.len();
257            if output.len() < len {
258                return Err(format!(
259                    "Output buffer too small: {} < {}",
260                    output.len(),
261                    len
262                ));
263            }
264            output[..len].copy_from_slice(self.data);
265            return ImageView::new(&output[..len], self.width, self.height, self.width);
266        }
267
268        let new_w = self.width / factor;
269        let new_h = self.height / factor;
270
271        if output.len() < new_w * new_h {
272            return Err(format!(
273                "Output buffer too small for decimation: {} < {}",
274                output.len(),
275                new_w * new_h
276            ));
277        }
278
279        output
280            .par_chunks_exact_mut(new_w)
281            .enumerate()
282            .take(new_h)
283            .for_each(|(y, out_row)| {
284                let src_y = y * factor;
285                let src_row = self.get_row(src_y);
286                for x in 0..new_w {
287                    out_row[x] = src_row[x * factor];
288                }
289            });
290
291        ImageView::new(&output[..new_w * new_h], new_w, new_h, new_w)
292    }
293
294    /// Create an upscaled copy of the image using bilinear interpolation.
295    ///
296    /// The `output` buffer must have size at least `(width*factor) * (height*factor)`.
297    pub fn upscale_to<'b>(
298        &self,
299        factor: usize,
300        output: &'b mut [u8],
301    ) -> Result<ImageView<'b>, String> {
302        let factor = factor.max(1);
303        if factor == 1 {
304            let len = self.data.len();
305            if output.len() < len {
306                return Err(format!(
307                    "Output buffer too small: {} < {}",
308                    output.len(),
309                    len
310                ));
311            }
312            output[..len].copy_from_slice(self.data);
313            return ImageView::new(&output[..len], self.width, self.height, self.width);
314        }
315
316        let new_w = self.width * factor;
317        let new_h = self.height * factor;
318
319        if output.len() < new_w * new_h {
320            return Err(format!(
321                "Output buffer too small for upscaling: {} < {}",
322                output.len(),
323                new_w * new_h
324            ));
325        }
326
327        let scale = 1.0 / factor as f64;
328
329        output
330            .par_chunks_exact_mut(new_w)
331            .enumerate()
332            .take(new_h)
333            .for_each(|(y, out_row)| {
334                let src_y = y as f64 * scale;
335                for (x, val) in out_row.iter_mut().enumerate() {
336                    let src_x = x as f64 * scale;
337                    // We can use unchecked version for speed if we are confident,
338                    // but sample_bilinear handles bounds checks.
339                    // Given we are inside image bounds, it should be fine.
340                    // To maximize perf we might want a localized optimized loop here,
341                    // but for now reusing sample_bilinear is safe and clean.
342                    *val = self.sample_bilinear(src_x, src_y) as u8;
343                }
344            });
345
346        ImageView::new(&output[..new_w * new_h], new_w, new_h, new_w)
347    }
348}
349
350#[cfg(test)]
351#[allow(clippy::expect_used, clippy::unwrap_used)]
352mod tests {
353    use super::*;
354    use proptest::prelude::*;
355
356    #[test]
357    fn test_image_view_stride() {
358        let data = vec![
359            1, 2, 3, 0, // row 0 + padding
360            4, 5, 6, 0, // row 1 + padding
361        ];
362        let view = ImageView::new(&data, 3, 2, 4).expect("Valid image creation");
363        assert_eq!(view.get_row(0), &[1, 2, 3]);
364        assert_eq!(view.get_row(1), &[4, 5, 6]);
365        assert_eq!(view.get_pixel(1, 1), 5);
366    }
367
368    #[test]
369    fn test_invalid_buffer_size() {
370        let data = vec![1, 2, 3];
371        let result = ImageView::new(&data, 2, 2, 2);
372        assert!(result.is_err());
373    }
374
375    proptest! {
376        #[test]
377        fn prop_image_view_creation(
378            width in 0..1000usize,
379            height in 0..1000usize,
380            stride_extra in 0..100usize,
381            has_enough_data in prop::bool::ANY
382        ) {
383            let stride = width + stride_extra;
384            let required_size = if height > 0 {
385                (height - 1) * stride + width
386            } else {
387                0
388            };
389
390            let data_len = if has_enough_data {
391                required_size
392            } else {
393                required_size.saturating_sub(1)
394            };
395
396            let data = vec![0u8; data_len];
397            let result = ImageView::new(&data, width, height, stride);
398
399            if height > 0 && !has_enough_data {
400                assert!(result.is_err());
401            } else {
402                assert!(result.is_ok());
403            }
404        }
405
406        #[test]
407        fn prop_get_pixel_clamping(
408            width in 1..100usize,
409            height in 1..100usize,
410            x in 0..200usize,
411            y in 0..200usize
412        ) {
413            let data = vec![0u8; height * width];
414            let view = ImageView::new(&data, width, height, width).expect("valid creation");
415            let p = view.get_pixel(x, y);
416            // Clamping should prevent panic
417            assert_eq!(p, 0);
418        }
419
420        #[test]
421        fn prop_sample_bilinear_invariants(
422            width in 2..20usize,
423            height in 2..20usize,
424            data in prop::collection::vec(0..=255u8, 20*20),
425            x in 0.0..20.0f64,
426            y in 0.0..20.0f64
427        ) {
428            let real_width = width.min(20);
429            let real_height = height.min(20);
430            let slice = &data[..real_width * real_height];
431            let view = ImageView::new(slice, real_width, real_height, real_width).expect("valid creation");
432
433            let x = x % real_width as f64;
434            let y = y % real_height as f64;
435
436            let val = view.sample_bilinear(x, y);
437
438            // Result should be within [0, 255]
439            assert!((0.0..=255.0).contains(&val));
440
441            // If inside 2x2 neighborhood, val should be within min/max of those 4 pixels
442            // The pixels are centered at i + 0.5, so sample (x, y) is between
443            // floor(x-0.5) and floor(x-0.5)+1.
444            let x0 = (x - 0.5).max(0.0).floor() as usize;
445            let y0 = (y - 0.5).max(0.0).floor() as usize;
446            let x1 = x0 + 1;
447            let y1 = y0 + 1;
448
449            if x1 < real_width && y1 < real_height {
450                let v00 = view.get_pixel(x0, y0);
451                let v10 = view.get_pixel(x1, y0);
452                let v01 = view.get_pixel(x0, y1);
453                let v11 = view.get_pixel(x1, y1);
454
455                let min = f64::from(v00.min(v10).min(v01).min(v11));
456                let max = f64::from(v00.max(v10).max(v01).max(v11));
457
458                assert!(val >= min - 1e-9 && val <= max + 1e-9, "Value {val} not in [{min}, {max}] for x={x}, y={y}");
459            }
460        }
461    }
462}