Skip to main content

oxigdal_algorithms/raster/
focal.rs

1//! Focal (neighborhood) statistics operations
2//!
3//! This module provides focal/neighborhood operations that compute statistics
4//! over moving windows. These are essential for many spatial analysis tasks.
5//!
6//! # Supported Operations
7//!
8//! - **Focal Mean**: Average value in neighborhood
9//! - **Focal Median**: Median value in neighborhood
10//! - **Focal Range**: Max - Min in neighborhood
11//! - **Focal Variety**: Count of unique values
12//! - **Focal Min/Max**: Minimum/Maximum in neighborhood
13//! - **Focal Sum**: Sum of values in neighborhood
14//! - **Focal Standard Deviation**: Standard deviation in neighborhood
15//!
16//! # Window Shapes
17//!
18//! - **Rectangular**: Standard rectangular window
19//! - **Circular**: Circular window (pixels within radius)
20//! - **Custom**: User-defined kernel mask
21//!
22//! # Example
23//!
24//! ```ignore
25//! use oxigdal_algorithms::raster::focal::{focal_mean, WindowShape, BoundaryMode};
26//! use oxigdal_core::buffer::RasterBuffer;
27//! use oxigdal_core::types::RasterDataType;
28//! # use oxigdal_algorithms::error::Result;
29//!
30//! # fn main() -> Result<()> {
31//! let src = RasterBuffer::zeros(100, 100, RasterDataType::Float32);
32//! let window = WindowShape::rectangular(3, 3)?;
33//! let boundary = BoundaryMode::Reflect;
34//!
35//! let result = focal_mean(&src, &window, &boundary)?;
36//! # Ok(())
37//! # }
38//! ```
39
40use crate::error::{AlgorithmError, Result};
41use oxigdal_core::buffer::RasterBuffer;
42use oxigdal_core::types::RasterDataType;
43
44#[cfg(feature = "parallel")]
45use rayon::prelude::*;
46
47/// Window shape for focal operations
48#[derive(Debug, Clone, PartialEq)]
49pub enum WindowShape {
50    /// Rectangular window with given width and height (must be odd)
51    Rectangular { width: usize, height: usize },
52
53    /// Circular window with given radius
54    Circular { radius: f64 },
55
56    /// Custom window defined by a binary mask (1 = include, 0 = exclude)
57    Custom {
58        mask: Vec<bool>,
59        width: usize,
60        height: usize,
61    },
62}
63
64impl WindowShape {
65    /// Creates a rectangular window
66    ///
67    /// # Arguments
68    ///
69    /// * `width` - Window width (must be odd)
70    /// * `height` - Window height (must be odd)
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if width or height is even or zero
75    pub fn rectangular(width: usize, height: usize) -> Result<Self> {
76        use oxigdal_core::OxiGdalError;
77
78        if width == 0 || height == 0 {
79            return Err(OxiGdalError::invalid_parameter_builder(
80                "window_size",
81                format!(
82                    "window dimensions must be greater than zero, got {}x{}",
83                    width, height
84                ),
85            )
86            .with_parameter("width", width.to_string())
87            .with_parameter("height", height.to_string())
88            .with_parameter("min", "1")
89            .with_operation("focal_operation")
90            .with_suggestion("Use positive window dimensions. Common values: 3, 5, 7, 9, 11")
91            .build()
92            .into());
93        }
94        if width % 2 == 0 || height % 2 == 0 {
95            let suggested_width = if width % 2 == 0 { width + 1 } else { width };
96            let suggested_height = if height % 2 == 0 { height + 1 } else { height };
97            return Err(OxiGdalError::invalid_parameter_builder(
98                "window_size",
99                format!("window dimensions must be odd, got {}x{}", width, height),
100            )
101            .with_parameter("width", width.to_string())
102            .with_parameter("height", height.to_string())
103            .with_operation("focal_operation")
104            .with_suggestion(format!(
105                "Use odd window dimensions for symmetric neighborhood. Try {}x{} instead",
106                suggested_width, suggested_height
107            ))
108            .build()
109            .into());
110        }
111        Ok(Self::Rectangular { width, height })
112    }
113
114    /// Creates a circular window
115    ///
116    /// # Arguments
117    ///
118    /// * `radius` - Window radius (must be positive)
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if radius is not positive
123    pub fn circular(radius: f64) -> Result<Self> {
124        use oxigdal_core::OxiGdalError;
125
126        if radius <= 0.0 {
127            return Err(OxiGdalError::invalid_parameter_builder(
128                "radius",
129                format!("window radius must be positive, got {}", radius),
130            )
131            .with_parameter("value", radius.to_string())
132            .with_parameter("min", "0.0")
133            .with_operation("focal_operation")
134            .with_suggestion(
135                "Use positive radius value. Common values: 1.0, 1.5, 2.0, 3.0 (in pixels)",
136            )
137            .build()
138            .into());
139        }
140        Ok(Self::Circular { radius })
141    }
142
143    /// Creates a custom window from a binary mask
144    ///
145    /// # Arguments
146    ///
147    /// * `mask` - Binary mask (true = include pixel)
148    /// * `width` - Mask width
149    /// * `height` - Mask height
150    ///
151    /// # Errors
152    ///
153    /// Returns an error if dimensions don't match mask size or are even
154    pub fn custom(mask: Vec<bool>, width: usize, height: usize) -> Result<Self> {
155        use oxigdal_core::OxiGdalError;
156
157        if mask.len() != width * height {
158            let expected = width * height;
159            return Err(OxiGdalError::invalid_parameter_builder(
160                "mask",
161                format!(
162                    "mask size must match width * height, got {} but expected {}",
163                    mask.len(),
164                    expected
165                ),
166            )
167            .with_parameter("mask_length", mask.len().to_string())
168            .with_parameter("width", width.to_string())
169            .with_parameter("height", height.to_string())
170            .with_parameter("expected_length", expected.to_string())
171            .with_operation("focal_operation")
172            .with_suggestion(format!(
173                "Provide a mask with exactly {} elements ({}x{})",
174                expected, width, height
175            ))
176            .build()
177            .into());
178        }
179        if width % 2 == 0 || height % 2 == 0 {
180            let suggested_width = if width % 2 == 0 { width + 1 } else { width };
181            let suggested_height = if height % 2 == 0 { height + 1 } else { height };
182            return Err(OxiGdalError::invalid_parameter_builder(
183                "window_size",
184                format!("window dimensions must be odd, got {}x{}", width, height),
185            )
186            .with_parameter("width", width.to_string())
187            .with_parameter("height", height.to_string())
188            .with_operation("focal_operation")
189            .with_suggestion(format!(
190                "Use odd window dimensions for symmetric neighborhood. Try {}x{} instead",
191                suggested_width, suggested_height
192            ))
193            .build()
194            .into());
195        }
196        Ok(Self::Custom {
197            mask,
198            width,
199            height,
200        })
201    }
202
203    /// Gets the window dimensions
204    #[must_use]
205    pub fn dimensions(&self) -> (usize, usize) {
206        match self {
207            Self::Rectangular { width, height } => (*width, *height),
208            Self::Circular { radius } => {
209                let size = (radius.ceil() * 2.0 + 1.0) as usize;
210                (size, size)
211            }
212            Self::Custom { width, height, .. } => (*width, *height),
213        }
214    }
215
216    /// Checks if a pixel at given offset is included in the window
217    #[must_use]
218    pub fn includes(&self, dx: i64, dy: i64) -> bool {
219        match self {
220            Self::Rectangular { width, height } => {
221                let hw = (*width / 2) as i64;
222                let hh = (*height / 2) as i64;
223                dx.abs() <= hw && dy.abs() <= hh
224            }
225            Self::Circular { radius } => {
226                let dist = ((dx * dx + dy * dy) as f64).sqrt();
227                dist <= *radius
228            }
229            Self::Custom {
230                mask,
231                width,
232                height,
233            } => {
234                let hw = (*width / 2) as i64;
235                let hh = (*height / 2) as i64;
236                if dx.abs() > hw || dy.abs() > hh {
237                    return false;
238                }
239                let x = (dx + hw) as usize;
240                let y = (dy + hh) as usize;
241                mask[y * width + x]
242            }
243        }
244    }
245
246    /// Returns all offsets included in the window
247    #[must_use]
248    pub fn offsets(&self) -> Vec<(i64, i64)> {
249        let (width, height) = self.dimensions();
250        let hw = (width / 2) as i64;
251        let hh = (height / 2) as i64;
252
253        let mut result = Vec::new();
254        for dy in -hh..=hh {
255            for dx in -hw..=hw {
256                if self.includes(dx, dy) {
257                    result.push((dx, dy));
258                }
259            }
260        }
261        result
262    }
263
264    /// Returns the number of cells in the window
265    #[must_use]
266    pub fn cell_count(&self) -> usize {
267        self.offsets().len()
268    }
269}
270
271/// Boundary handling mode for focal operations
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum BoundaryMode {
274    /// Ignore pixels outside boundary (smaller neighborhood at edges)
275    Ignore,
276
277    /// Use a constant value for pixels outside boundary
278    Constant(i64),
279
280    /// Reflect values at the boundary
281    Reflect,
282
283    /// Wrap around to opposite edge
284    Wrap,
285
286    /// Extend edge values
287    Edge,
288}
289
290/// Focal operation type
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum FocalOperation {
293    /// Mean (average) value
294    Mean,
295
296    /// Median value
297    Median,
298
299    /// Range (max - min)
300    Range,
301
302    /// Variety (count of unique values)
303    Variety,
304
305    /// Minimum value
306    Min,
307
308    /// Maximum value
309    Max,
310
311    /// Sum of values
312    Sum,
313
314    /// Standard deviation
315    StdDev,
316
317    /// Majority (most common value)
318    Majority,
319}
320
321/// Computes focal mean over a raster
322///
323/// # Arguments
324///
325/// * `src` - Source raster buffer
326/// * `window` - Window shape
327/// * `boundary` - Boundary handling mode
328///
329/// # Errors
330///
331/// Returns an error if the operation fails
332pub fn focal_mean(
333    src: &RasterBuffer,
334    window: &WindowShape,
335    boundary: &BoundaryMode,
336) -> Result<RasterBuffer> {
337    focal_operation(src, window, boundary, FocalOperation::Mean)
338}
339
340/// Computes focal median over a raster
341///
342/// # Arguments
343///
344/// * `src` - Source raster buffer
345/// * `window` - Window shape
346/// * `boundary` - Boundary handling mode
347///
348/// # Errors
349///
350/// Returns an error if the operation fails
351pub fn focal_median(
352    src: &RasterBuffer,
353    window: &WindowShape,
354    boundary: &BoundaryMode,
355) -> Result<RasterBuffer> {
356    focal_operation(src, window, boundary, FocalOperation::Median)
357}
358
359/// Computes focal range (max - min) over a raster
360///
361/// # Arguments
362///
363/// * `src` - Source raster buffer
364/// * `window` - Window shape
365/// * `boundary` - Boundary handling mode
366///
367/// # Errors
368///
369/// Returns an error if the operation fails
370pub fn focal_range(
371    src: &RasterBuffer,
372    window: &WindowShape,
373    boundary: &BoundaryMode,
374) -> Result<RasterBuffer> {
375    focal_operation(src, window, boundary, FocalOperation::Range)
376}
377
378/// Computes focal variety (unique value count) over a raster
379///
380/// # Arguments
381///
382/// * `src` - Source raster buffer
383/// * `window` - Window shape
384/// * `boundary` - Boundary handling mode
385///
386/// # Errors
387///
388/// Returns an error if the operation fails
389pub fn focal_variety(
390    src: &RasterBuffer,
391    window: &WindowShape,
392    boundary: &BoundaryMode,
393) -> Result<RasterBuffer> {
394    focal_operation(src, window, boundary, FocalOperation::Variety)
395}
396
397/// Computes focal minimum over a raster
398///
399/// # Arguments
400///
401/// * `src` - Source raster buffer
402/// * `window` - Window shape
403/// * `boundary` - Boundary handling mode
404///
405/// # Errors
406///
407/// Returns an error if the operation fails
408pub fn focal_min(
409    src: &RasterBuffer,
410    window: &WindowShape,
411    boundary: &BoundaryMode,
412) -> Result<RasterBuffer> {
413    focal_operation(src, window, boundary, FocalOperation::Min)
414}
415
416/// Computes focal maximum over a raster
417///
418/// # Arguments
419///
420/// * `src` - Source raster buffer
421/// * `window` - Window shape
422/// * `boundary` - Boundary handling mode
423///
424/// # Errors
425///
426/// Returns an error if the operation fails
427pub fn focal_max(
428    src: &RasterBuffer,
429    window: &WindowShape,
430    boundary: &BoundaryMode,
431) -> Result<RasterBuffer> {
432    focal_operation(src, window, boundary, FocalOperation::Max)
433}
434
435/// Computes focal sum over a raster
436///
437/// # Arguments
438///
439/// * `src` - Source raster buffer
440/// * `window` - Window shape
441/// * `boundary` - Boundary handling mode
442///
443/// # Errors
444///
445/// Returns an error if the operation fails
446pub fn focal_sum(
447    src: &RasterBuffer,
448    window: &WindowShape,
449    boundary: &BoundaryMode,
450) -> Result<RasterBuffer> {
451    focal_operation(src, window, boundary, FocalOperation::Sum)
452}
453
454/// Computes focal standard deviation over a raster
455///
456/// # Arguments
457///
458/// * `src` - Source raster buffer
459/// * `window` - Window shape
460/// * `boundary` - Boundary handling mode
461///
462/// # Errors
463///
464/// Returns an error if the operation fails
465pub fn focal_stddev(
466    src: &RasterBuffer,
467    window: &WindowShape,
468    boundary: &BoundaryMode,
469) -> Result<RasterBuffer> {
470    focal_operation(src, window, boundary, FocalOperation::StdDev)
471}
472
473/// Computes focal majority (most common value) over a raster
474///
475/// # Arguments
476///
477/// * `src` - Source raster buffer
478/// * `window` - Window shape
479/// * `boundary` - Boundary handling mode
480///
481/// # Errors
482///
483/// Returns an error if the operation fails
484pub fn focal_majority(
485    src: &RasterBuffer,
486    window: &WindowShape,
487    boundary: &BoundaryMode,
488) -> Result<RasterBuffer> {
489    focal_operation(src, window, boundary, FocalOperation::Majority)
490}
491
492/// Generic focal operation
493fn focal_operation(
494    src: &RasterBuffer,
495    window: &WindowShape,
496    boundary: &BoundaryMode,
497    operation: FocalOperation,
498) -> Result<RasterBuffer> {
499    let width = src.width();
500    let height = src.height();
501    let mut dst = RasterBuffer::zeros(width, height, src.data_type());
502
503    let offsets = window.offsets();
504
505    #[cfg(feature = "parallel")]
506    {
507        let results: Result<Vec<_>> = (0..height)
508            .into_par_iter()
509            .map(|y| {
510                let mut row_data = Vec::with_capacity(width as usize);
511                for x in 0..width {
512                    let value = compute_focal_value(src, x, y, &offsets, boundary, operation)?;
513                    row_data.push(value);
514                }
515                Ok((y, row_data))
516            })
517            .collect();
518
519        for (y, row_data) in results? {
520            for (x, value) in row_data.into_iter().enumerate() {
521                dst.set_pixel(x as u64, y, value)
522                    .map_err(AlgorithmError::Core)?;
523            }
524        }
525    }
526
527    #[cfg(not(feature = "parallel"))]
528    {
529        for y in 0..height {
530            for x in 0..width {
531                let value = compute_focal_value(src, x, y, &offsets, boundary, operation)?;
532                dst.set_pixel(x, y, value).map_err(AlgorithmError::Core)?;
533            }
534        }
535    }
536
537    Ok(dst)
538}
539
540/// Computes the focal value for a single pixel
541fn compute_focal_value(
542    src: &RasterBuffer,
543    x: u64,
544    y: u64,
545    offsets: &[(i64, i64)],
546    boundary: &BoundaryMode,
547    operation: FocalOperation,
548) -> Result<f64> {
549    let width = src.width() as i64;
550    let height = src.height() as i64;
551    let x_i64 = x as i64;
552    let y_i64 = y as i64;
553
554    let mut values = Vec::with_capacity(offsets.len());
555
556    for &(dx, dy) in offsets {
557        let nx = x_i64 + dx;
558        let ny = y_i64 + dy;
559
560        let value = if nx >= 0 && nx < width && ny >= 0 && ny < height {
561            src.get_pixel(nx as u64, ny as u64)
562                .map_err(AlgorithmError::Core)?
563        } else {
564            match boundary {
565                BoundaryMode::Ignore => continue,
566                BoundaryMode::Constant(c) => *c as f64,
567                BoundaryMode::Reflect => {
568                    let rx = if nx < 0 {
569                        -nx - 1
570                    } else if nx >= width {
571                        2 * width - nx - 1
572                    } else {
573                        nx
574                    };
575                    let ry = if ny < 0 {
576                        -ny - 1
577                    } else if ny >= height {
578                        2 * height - ny - 1
579                    } else {
580                        ny
581                    };
582                    src.get_pixel(rx as u64, ry as u64)
583                        .map_err(AlgorithmError::Core)?
584                }
585                BoundaryMode::Wrap => {
586                    let wx = ((nx % width) + width) % width;
587                    let wy = ((ny % height) + height) % height;
588                    src.get_pixel(wx as u64, wy as u64)
589                        .map_err(AlgorithmError::Core)?
590                }
591                BoundaryMode::Edge => {
592                    let ex = nx.clamp(0, width - 1);
593                    let ey = ny.clamp(0, height - 1);
594                    src.get_pixel(ex as u64, ey as u64)
595                        .map_err(AlgorithmError::Core)?
596                }
597            }
598        };
599
600        values.push(value);
601    }
602
603    if values.is_empty() {
604        return Ok(0.0);
605    }
606
607    let result = match operation {
608        FocalOperation::Mean => {
609            let sum: f64 = values.iter().sum();
610            sum / values.len() as f64
611        }
612        FocalOperation::Median => {
613            values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
614            let mid = values.len() / 2;
615            if values.len() % 2 == 0 {
616                (values[mid - 1] + values[mid]) / 2.0
617            } else {
618                values[mid]
619            }
620        }
621        FocalOperation::Range => {
622            // Compute min and max in a single pass using fold (panic-free)
623            // This is safe because values is guaranteed non-empty by check at line 590
624            let (min, max) = values.iter().copied().fold(
625                (f64::INFINITY, f64::NEG_INFINITY),
626                |(min_val, max_val), val| {
627                    let new_min = if val < min_val { val } else { min_val };
628                    let new_max = if val > max_val { val } else { max_val };
629                    (new_min, new_max)
630                },
631            );
632            max - min
633        }
634        FocalOperation::Variety => {
635            let mut unique = values.clone();
636            unique.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
637            unique.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
638            unique.len() as f64
639        }
640        FocalOperation::Min => {
641            // Use fold to find minimum (panic-free)
642            // This is safe because values is guaranteed non-empty by check at line 590
643            values
644                .iter()
645                .copied()
646                .fold(f64::INFINITY, |acc, val| if val < acc { val } else { acc })
647        }
648        FocalOperation::Max => {
649            // Use fold to find maximum (panic-free)
650            // This is safe because values is guaranteed non-empty by check at line 590
651            values.iter().copied().fold(
652                f64::NEG_INFINITY,
653                |acc, val| if val > acc { val } else { acc },
654            )
655        }
656        FocalOperation::Sum => values.iter().sum(),
657        FocalOperation::StdDev => {
658            let mean = values.iter().sum::<f64>() / values.len() as f64;
659            let variance = values
660                .iter()
661                .map(|v| {
662                    let diff = v - mean;
663                    diff * diff
664                })
665                .sum::<f64>()
666                / values.len() as f64;
667            variance.sqrt()
668        }
669        FocalOperation::Majority => {
670            // Count occurrences (with tolerance for floating point)
671            let mut counts: Vec<(f64, usize)> = Vec::new();
672            for &val in &values {
673                if let Some(entry) = counts.iter_mut().find(|(v, _)| (v - val).abs() < 1e-10) {
674                    entry.1 += 1;
675                } else {
676                    counts.push((val, 1));
677                }
678            }
679            // Use fold to find value with maximum count (panic-free)
680            // This is safe because counts is guaranteed non-empty since values is non-empty (checked at line 590)
681            let (majority_val, _) =
682                counts
683                    .into_iter()
684                    .fold((0.0, 0), |(acc_val, acc_count), (val, count)| {
685                        if count > acc_count {
686                            (val, count)
687                        } else {
688                            (acc_val, acc_count)
689                        }
690                    });
691            majority_val
692        }
693    };
694
695    Ok(result)
696}
697
698/// Optimized focal mean for rectangular windows using separable filtering
699///
700/// This is much faster than the generic implementation for large rectangular windows.
701///
702/// # Arguments
703///
704/// * `src` - Source raster buffer
705/// * `width` - Window width (must be odd)
706/// * `height` - Window height (must be odd)
707///
708/// # Errors
709///
710/// Returns an error if the operation fails
711pub fn focal_mean_separable(
712    src: &RasterBuffer,
713    width: usize,
714    height: usize,
715) -> Result<RasterBuffer> {
716    if width % 2 == 0 || height % 2 == 0 {
717        return Err(AlgorithmError::InvalidParameter {
718            parameter: "window_size",
719            message: "Window dimensions must be odd".to_string(),
720        });
721    }
722
723    let img_width = src.width();
724    let img_height = src.height();
725
726    // Horizontal pass
727    let mut temp = RasterBuffer::zeros(img_width, img_height, RasterDataType::Float64);
728    let hw = (width / 2) as i64;
729
730    for y in 0..img_height {
731        for x in 0..img_width {
732            let mut sum = 0.0;
733            let mut count = 0;
734
735            for dx in -hw..=hw {
736                let nx = (x as i64 + dx).clamp(0, img_width as i64 - 1);
737                sum += src.get_pixel(nx as u64, y).map_err(AlgorithmError::Core)?;
738                count += 1;
739            }
740
741            temp.set_pixel(x, y, sum / count as f64)
742                .map_err(AlgorithmError::Core)?;
743        }
744    }
745
746    // Vertical pass
747    let mut dst = RasterBuffer::zeros(img_width, img_height, src.data_type());
748    let hh = (height / 2) as i64;
749
750    for y in 0..img_height {
751        for x in 0..img_width {
752            let mut sum = 0.0;
753            let mut count = 0;
754
755            for dy in -hh..=hh {
756                let ny = (y as i64 + dy).clamp(0, img_height as i64 - 1);
757                sum += temp.get_pixel(x, ny as u64).map_err(AlgorithmError::Core)?;
758                count += 1;
759            }
760
761            dst.set_pixel(x, y, sum / count as f64)
762                .map_err(AlgorithmError::Core)?;
763        }
764    }
765
766    Ok(dst)
767}
768
769/// Applies a custom kernel convolution
770///
771/// # Arguments
772///
773/// * `src` - Source raster buffer
774/// * `kernel` - Convolution kernel weights
775/// * `width` - Kernel width
776/// * `height` - Kernel height
777/// * `normalize` - Whether to normalize by kernel sum
778///
779/// # Errors
780///
781/// Returns an error if the operation fails
782pub fn focal_convolve(
783    src: &RasterBuffer,
784    kernel: &[f64],
785    width: usize,
786    height: usize,
787    normalize: bool,
788) -> Result<RasterBuffer> {
789    if kernel.len() != width * height {
790        return Err(AlgorithmError::InvalidParameter {
791            parameter: "kernel",
792            message: "Kernel size must match width * height".to_string(),
793        });
794    }
795
796    let img_width = src.width();
797    let img_height = src.height();
798    let mut dst = RasterBuffer::zeros(img_width, img_height, src.data_type());
799
800    let hw = (width / 2) as i64;
801    let hh = (height / 2) as i64;
802
803    let kernel_sum: f64 = if normalize {
804        let sum: f64 = kernel.iter().sum();
805        if sum.abs() < 1e-10 { 1.0 } else { sum }
806    } else {
807        1.0
808    };
809
810    for y in 0..img_height {
811        for x in 0..img_width {
812            let mut sum = 0.0;
813
814            for ky in 0..height {
815                for kx in 0..width {
816                    let dx = kx as i64 - hw;
817                    let dy = ky as i64 - hh;
818                    let nx = (x as i64 + dx).clamp(0, img_width as i64 - 1);
819                    let ny = (y as i64 + dy).clamp(0, img_height as i64 - 1);
820
821                    let pixel_val = src
822                        .get_pixel(nx as u64, ny as u64)
823                        .map_err(AlgorithmError::Core)?;
824                    sum += pixel_val * kernel[ky * width + kx];
825                }
826            }
827
828            dst.set_pixel(x, y, sum / kernel_sum)
829                .map_err(AlgorithmError::Core)?;
830        }
831    }
832
833    Ok(dst)
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use approx::assert_abs_diff_eq;
840
841    #[test]
842    fn test_window_shape_rectangular() {
843        let window = WindowShape::rectangular(3, 3)
844            .expect("3x3 rectangular window creation should succeed in test");
845        assert_eq!(window.dimensions(), (3, 3));
846        assert_eq!(window.cell_count(), 9);
847        assert!(window.includes(0, 0));
848        assert!(window.includes(1, 1));
849        assert!(!window.includes(2, 2));
850    }
851
852    #[test]
853    fn test_window_shape_circular() {
854        let window = WindowShape::circular(1.5)
855            .expect("circular window with radius 1.5 should succeed in test");
856        assert!(window.includes(0, 0));
857        assert!(window.includes(1, 0));
858        assert!(window.includes(0, 1));
859        assert!(!window.includes(2, 2));
860    }
861
862    #[test]
863    fn test_window_shape_custom() {
864        let mask = vec![false, true, false, true, true, true, false, true, false];
865        let window = WindowShape::custom(mask, 3, 3)
866            .expect("custom 3x3 window creation should succeed in test");
867        assert_eq!(window.cell_count(), 5);
868        assert!(window.includes(0, 0));
869        assert!(!window.includes(1, 1));
870    }
871
872    #[test]
873    fn test_focal_mean() {
874        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
875        // Set center to 5, rest to 0
876        src.set_pixel(2, 2, 5.0)
877            .expect("setting pixel (2,2) should succeed in test");
878
879        let window = WindowShape::rectangular(3, 3)
880            .expect("3x3 rectangular window creation should succeed in test");
881        let result = focal_mean(&src, &window, &BoundaryMode::Edge)
882            .expect("focal_mean operation should succeed in test");
883
884        // Center should be ~0.55 (5/9)
885        let center = result
886            .get_pixel(2, 2)
887            .expect("getting pixel (2,2) from result should succeed in test");
888        assert_abs_diff_eq!(center, 5.0 / 9.0, epsilon = 0.01);
889    }
890
891    #[test]
892    fn test_focal_median() {
893        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
894        src.set_pixel(2, 2, 9.0)
895            .expect("setting pixel (2,2) should succeed in test");
896
897        let window = WindowShape::rectangular(3, 3)
898            .expect("3x3 rectangular window creation should succeed in test");
899        let result = focal_median(&src, &window, &BoundaryMode::Edge)
900            .expect("focal_median operation should succeed in test");
901
902        // Median should be 0 (8 zeros, 1 nine)
903        let center = result
904            .get_pixel(2, 2)
905            .expect("getting pixel (2,2) from result should succeed in test");
906        assert_abs_diff_eq!(center, 0.0, epsilon = 0.01);
907    }
908
909    #[test]
910    fn test_focal_range() {
911        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
912        src.set_pixel(2, 2, 10.0)
913            .expect("setting pixel (2,2) should succeed in test");
914
915        let window = WindowShape::rectangular(3, 3)
916            .expect("3x3 rectangular window creation should succeed in test");
917        let result = focal_range(&src, &window, &BoundaryMode::Edge)
918            .expect("focal_range operation should succeed in test");
919
920        // Range should be 10
921        let center = result
922            .get_pixel(2, 2)
923            .expect("getting pixel (2,2) from result should succeed in test");
924        assert_abs_diff_eq!(center, 10.0, epsilon = 0.01);
925    }
926
927    #[test]
928    fn test_focal_variety() {
929        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
930        src.set_pixel(2, 2, 1.0)
931            .expect("setting pixel (2,2) should succeed in test");
932        src.set_pixel(2, 3, 2.0)
933            .expect("setting pixel (2,3) should succeed in test");
934
935        let window = WindowShape::rectangular(3, 3)
936            .expect("3x3 rectangular window creation should succeed in test");
937        let result = focal_variety(&src, &window, &BoundaryMode::Edge)
938            .expect("focal_variety operation should succeed in test");
939
940        // Should have 3 unique values (0, 1, 2)
941        let center = result
942            .get_pixel(2, 2)
943            .expect("getting pixel (2,2) from result should succeed in test");
944        assert_abs_diff_eq!(center, 3.0, epsilon = 0.01);
945    }
946
947    #[test]
948    fn test_focal_stddev() {
949        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
950        for y in 0..5 {
951            for x in 0..5 {
952                src.set_pixel(x, y, (x + y) as f64)
953                    .expect("setting pixel should succeed in test");
954            }
955        }
956
957        let window = WindowShape::rectangular(3, 3)
958            .expect("3x3 rectangular window creation should succeed in test");
959        let result = focal_stddev(&src, &window, &BoundaryMode::Edge)
960            .expect("focal_stddev operation should succeed in test");
961
962        // Should have non-zero standard deviation
963        let center = result
964            .get_pixel(2, 2)
965            .expect("getting pixel (2,2) from result should succeed in test");
966        assert!(center > 0.0);
967    }
968
969    #[test]
970    fn test_focal_mean_separable() {
971        let mut src = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
972        for y in 0..10 {
973            for x in 0..10 {
974                src.set_pixel(x, y, (x + y) as f64)
975                    .expect("setting pixel should succeed in test");
976            }
977        }
978
979        let result = focal_mean_separable(&src, 3, 3)
980            .expect("focal_mean_separable operation should succeed in test");
981
982        // Compare with generic version
983        let window = WindowShape::rectangular(3, 3)
984            .expect("3x3 rectangular window creation should succeed in test");
985        let expected = focal_mean(&src, &window, &BoundaryMode::Edge)
986            .expect("focal_mean operation should succeed in test");
987
988        for y in 1..9 {
989            for x in 1..9 {
990                let val = result
991                    .get_pixel(x, y)
992                    .expect("getting pixel from result should succeed in test");
993                let exp = expected
994                    .get_pixel(x, y)
995                    .expect("getting pixel from expected should succeed in test");
996                assert_abs_diff_eq!(val, exp, epsilon = 0.1);
997            }
998        }
999    }
1000
1001    #[test]
1002    fn test_boundary_modes() {
1003        let src = RasterBuffer::zeros(3, 3, RasterDataType::Float32);
1004        let window = WindowShape::rectangular(3, 3)
1005            .expect("3x3 rectangular window creation should succeed in test");
1006
1007        // Test all boundary modes - they should not panic
1008        let _ = focal_mean(&src, &window, &BoundaryMode::Ignore);
1009        let _ = focal_mean(&src, &window, &BoundaryMode::Constant(0));
1010        let _ = focal_mean(&src, &window, &BoundaryMode::Reflect);
1011        let _ = focal_mean(&src, &window, &BoundaryMode::Wrap);
1012        let _ = focal_mean(&src, &window, &BoundaryMode::Edge);
1013    }
1014}