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