Skip to main content

oxigeo_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 oxigeo_algorithms::raster::focal::{focal_mean, WindowShape, BoundaryMode};
26//! use oxigeo_core::buffer::RasterBuffer;
27//! use oxigeo_core::types::RasterDataType;
28//! # use oxigeo_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 oxigeo_core::buffer::RasterBuffer;
42use oxigeo_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 oxigeo_core::OxiGeoError;
77
78        if width == 0 || height == 0 {
79            return Err(OxiGeoError::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.is_multiple_of(2) || height.is_multiple_of(2) {
95            let suggested_width = if width.is_multiple_of(2) {
96                width + 1
97            } else {
98                width
99            };
100            let suggested_height = if height.is_multiple_of(2) {
101                height + 1
102            } else {
103                height
104            };
105            return Err(OxiGeoError::invalid_parameter_builder(
106                "window_size",
107                format!("window dimensions must be odd, got {}x{}", width, height),
108            )
109            .with_parameter("width", width.to_string())
110            .with_parameter("height", height.to_string())
111            .with_operation("focal_operation")
112            .with_suggestion(format!(
113                "Use odd window dimensions for symmetric neighborhood. Try {}x{} instead",
114                suggested_width, suggested_height
115            ))
116            .build()
117            .into());
118        }
119        Ok(Self::Rectangular { width, height })
120    }
121
122    /// Creates a circular window
123    ///
124    /// # Arguments
125    ///
126    /// * `radius` - Window radius (must be positive)
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if radius is not positive
131    pub fn circular(radius: f64) -> Result<Self> {
132        use oxigeo_core::OxiGeoError;
133
134        if radius <= 0.0 {
135            return Err(OxiGeoError::invalid_parameter_builder(
136                "radius",
137                format!("window radius must be positive, got {}", radius),
138            )
139            .with_parameter("value", radius.to_string())
140            .with_parameter("min", "0.0")
141            .with_operation("focal_operation")
142            .with_suggestion(
143                "Use positive radius value. Common values: 1.0, 1.5, 2.0, 3.0 (in pixels)",
144            )
145            .build()
146            .into());
147        }
148        Ok(Self::Circular { radius })
149    }
150
151    /// Creates a custom window from a binary mask
152    ///
153    /// # Arguments
154    ///
155    /// * `mask` - Binary mask (true = include pixel)
156    /// * `width` - Mask width
157    /// * `height` - Mask height
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if dimensions don't match mask size or are even
162    pub fn custom(mask: Vec<bool>, width: usize, height: usize) -> Result<Self> {
163        use oxigeo_core::OxiGeoError;
164
165        if mask.len() != width * height {
166            let expected = width * height;
167            return Err(OxiGeoError::invalid_parameter_builder(
168                "mask",
169                format!(
170                    "mask size must match width * height, got {} but expected {}",
171                    mask.len(),
172                    expected
173                ),
174            )
175            .with_parameter("mask_length", mask.len().to_string())
176            .with_parameter("width", width.to_string())
177            .with_parameter("height", height.to_string())
178            .with_parameter("expected_length", expected.to_string())
179            .with_operation("focal_operation")
180            .with_suggestion(format!(
181                "Provide a mask with exactly {} elements ({}x{})",
182                expected, width, height
183            ))
184            .build()
185            .into());
186        }
187        if width.is_multiple_of(2) || height.is_multiple_of(2) {
188            let suggested_width = if width.is_multiple_of(2) {
189                width + 1
190            } else {
191                width
192            };
193            let suggested_height = if height.is_multiple_of(2) {
194                height + 1
195            } else {
196                height
197            };
198            return Err(OxiGeoError::invalid_parameter_builder(
199                "window_size",
200                format!("window dimensions must be odd, got {}x{}", width, height),
201            )
202            .with_parameter("width", width.to_string())
203            .with_parameter("height", height.to_string())
204            .with_operation("focal_operation")
205            .with_suggestion(format!(
206                "Use odd window dimensions for symmetric neighborhood. Try {}x{} instead",
207                suggested_width, suggested_height
208            ))
209            .build()
210            .into());
211        }
212        Ok(Self::Custom {
213            mask,
214            width,
215            height,
216        })
217    }
218
219    /// Gets the window dimensions
220    #[must_use]
221    pub fn dimensions(&self) -> (usize, usize) {
222        match self {
223            Self::Rectangular { width, height } => (*width, *height),
224            Self::Circular { radius } => {
225                let size = (radius.ceil() * 2.0 + 1.0) as usize;
226                (size, size)
227            }
228            Self::Custom { width, height, .. } => (*width, *height),
229        }
230    }
231
232    /// Checks if a pixel at given offset is included in the window
233    #[must_use]
234    pub fn includes(&self, dx: i64, dy: i64) -> bool {
235        match self {
236            Self::Rectangular { width, height } => {
237                let hw = (*width / 2) as i64;
238                let hh = (*height / 2) as i64;
239                dx.abs() <= hw && dy.abs() <= hh
240            }
241            Self::Circular { radius } => {
242                let dist = ((dx * dx + dy * dy) as f64).sqrt();
243                dist <= *radius
244            }
245            Self::Custom {
246                mask,
247                width,
248                height,
249            } => {
250                let hw = (*width / 2) as i64;
251                let hh = (*height / 2) as i64;
252                if dx.abs() > hw || dy.abs() > hh {
253                    return false;
254                }
255                let x = (dx + hw) as usize;
256                let y = (dy + hh) as usize;
257                mask[y * width + x]
258            }
259        }
260    }
261
262    /// Returns all offsets included in the window
263    #[must_use]
264    pub fn offsets(&self) -> Vec<(i64, i64)> {
265        let (width, height) = self.dimensions();
266        let hw = (width / 2) as i64;
267        let hh = (height / 2) as i64;
268
269        let mut result = Vec::new();
270        for dy in -hh..=hh {
271            for dx in -hw..=hw {
272                if self.includes(dx, dy) {
273                    result.push((dx, dy));
274                }
275            }
276        }
277        result
278    }
279
280    /// Returns the number of cells in the window
281    #[must_use]
282    pub fn cell_count(&self) -> usize {
283        self.offsets().len()
284    }
285}
286
287/// Boundary handling mode for focal operations
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum BoundaryMode {
290    /// Ignore pixels outside boundary (smaller neighborhood at edges)
291    Ignore,
292
293    /// Use a constant value for pixels outside boundary
294    Constant(i64),
295
296    /// Reflect values at the boundary
297    Reflect,
298
299    /// Wrap around to opposite edge
300    Wrap,
301
302    /// Extend edge values
303    Edge,
304}
305
306/// Focal operation type
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum FocalOperation {
309    /// Mean (average) value
310    Mean,
311
312    /// Median value
313    Median,
314
315    /// Range (max - min)
316    Range,
317
318    /// Variety (count of unique values)
319    Variety,
320
321    /// Minimum value
322    Min,
323
324    /// Maximum value
325    Max,
326
327    /// Sum of values
328    Sum,
329
330    /// Standard deviation
331    StdDev,
332
333    /// Majority (most common value)
334    Majority,
335}
336
337/// Computes focal mean over a raster
338///
339/// # Arguments
340///
341/// * `src` - Source raster buffer
342/// * `window` - Window shape
343/// * `boundary` - Boundary handling mode
344///
345/// # Errors
346///
347/// Returns an error if the operation fails
348pub fn focal_mean(
349    src: &RasterBuffer,
350    window: &WindowShape,
351    boundary: &BoundaryMode,
352) -> Result<RasterBuffer> {
353    focal_operation(src, window, boundary, FocalOperation::Mean)
354}
355
356/// Computes focal median over a raster
357///
358/// # Arguments
359///
360/// * `src` - Source raster buffer
361/// * `window` - Window shape
362/// * `boundary` - Boundary handling mode
363///
364/// # Errors
365///
366/// Returns an error if the operation fails
367pub fn focal_median(
368    src: &RasterBuffer,
369    window: &WindowShape,
370    boundary: &BoundaryMode,
371) -> Result<RasterBuffer> {
372    focal_operation(src, window, boundary, FocalOperation::Median)
373}
374
375/// Computes focal range (max - min) over a raster
376///
377/// # Arguments
378///
379/// * `src` - Source raster buffer
380/// * `window` - Window shape
381/// * `boundary` - Boundary handling mode
382///
383/// # Errors
384///
385/// Returns an error if the operation fails
386pub fn focal_range(
387    src: &RasterBuffer,
388    window: &WindowShape,
389    boundary: &BoundaryMode,
390) -> Result<RasterBuffer> {
391    focal_operation(src, window, boundary, FocalOperation::Range)
392}
393
394/// Computes focal variety (unique value count) over a raster
395///
396/// # Arguments
397///
398/// * `src` - Source raster buffer
399/// * `window` - Window shape
400/// * `boundary` - Boundary handling mode
401///
402/// # Errors
403///
404/// Returns an error if the operation fails
405pub fn focal_variety(
406    src: &RasterBuffer,
407    window: &WindowShape,
408    boundary: &BoundaryMode,
409) -> Result<RasterBuffer> {
410    focal_operation(src, window, boundary, FocalOperation::Variety)
411}
412
413/// Computes focal minimum over a raster
414///
415/// # Arguments
416///
417/// * `src` - Source raster buffer
418/// * `window` - Window shape
419/// * `boundary` - Boundary handling mode
420///
421/// # Errors
422///
423/// Returns an error if the operation fails
424pub fn focal_min(
425    src: &RasterBuffer,
426    window: &WindowShape,
427    boundary: &BoundaryMode,
428) -> Result<RasterBuffer> {
429    focal_operation(src, window, boundary, FocalOperation::Min)
430}
431
432/// Computes focal maximum over a raster
433///
434/// # Arguments
435///
436/// * `src` - Source raster buffer
437/// * `window` - Window shape
438/// * `boundary` - Boundary handling mode
439///
440/// # Errors
441///
442/// Returns an error if the operation fails
443pub fn focal_max(
444    src: &RasterBuffer,
445    window: &WindowShape,
446    boundary: &BoundaryMode,
447) -> Result<RasterBuffer> {
448    focal_operation(src, window, boundary, FocalOperation::Max)
449}
450
451/// Computes focal sum over a raster
452///
453/// # Arguments
454///
455/// * `src` - Source raster buffer
456/// * `window` - Window shape
457/// * `boundary` - Boundary handling mode
458///
459/// # Errors
460///
461/// Returns an error if the operation fails
462pub fn focal_sum(
463    src: &RasterBuffer,
464    window: &WindowShape,
465    boundary: &BoundaryMode,
466) -> Result<RasterBuffer> {
467    focal_operation(src, window, boundary, FocalOperation::Sum)
468}
469
470/// Computes focal standard deviation over a raster
471///
472/// # Arguments
473///
474/// * `src` - Source raster buffer
475/// * `window` - Window shape
476/// * `boundary` - Boundary handling mode
477///
478/// # Errors
479///
480/// Returns an error if the operation fails
481pub fn focal_stddev(
482    src: &RasterBuffer,
483    window: &WindowShape,
484    boundary: &BoundaryMode,
485) -> Result<RasterBuffer> {
486    focal_operation(src, window, boundary, FocalOperation::StdDev)
487}
488
489/// Computes focal majority (most common value) over a raster
490///
491/// # Arguments
492///
493/// * `src` - Source raster buffer
494/// * `window` - Window shape
495/// * `boundary` - Boundary handling mode
496///
497/// # Errors
498///
499/// Returns an error if the operation fails
500pub fn focal_majority(
501    src: &RasterBuffer,
502    window: &WindowShape,
503    boundary: &BoundaryMode,
504) -> Result<RasterBuffer> {
505    focal_operation(src, window, boundary, FocalOperation::Majority)
506}
507
508/// Generic focal operation
509fn focal_operation(
510    src: &RasterBuffer,
511    window: &WindowShape,
512    boundary: &BoundaryMode,
513    operation: FocalOperation,
514) -> Result<RasterBuffer> {
515    let width = src.width();
516    let height = src.height();
517    // Preserve the source NoData designation on the output so that pixels whose
518    // entire window is NoData are marked as NoData (rather than a spurious 0.0),
519    // and so downstream callers can query `dst.is_nodata(..)` on the result.
520    let mut dst = RasterBuffer::nodata_filled(width, height, src.data_type(), src.nodata());
521
522    let offsets = window.offsets();
523
524    #[cfg(feature = "parallel")]
525    {
526        let results: Result<Vec<_>> = (0..height)
527            .into_par_iter()
528            .map(|y| {
529                let mut row_data = Vec::with_capacity(width as usize);
530                for x in 0..width {
531                    let value = compute_focal_value(src, x, y, &offsets, boundary, operation)?;
532                    row_data.push(value);
533                }
534                Ok((y, row_data))
535            })
536            .collect();
537
538        for (y, row_data) in results? {
539            for (x, value) in row_data.into_iter().enumerate() {
540                dst.set_pixel(x as u64, y, value)
541                    .map_err(AlgorithmError::Core)?;
542            }
543        }
544    }
545
546    #[cfg(not(feature = "parallel"))]
547    {
548        for y in 0..height {
549            for x in 0..width {
550                let value = compute_focal_value(src, x, y, &offsets, boundary, operation)?;
551                dst.set_pixel(x, y, value).map_err(AlgorithmError::Core)?;
552            }
553        }
554    }
555
556    Ok(dst)
557}
558
559/// Computes the focal value for a single pixel
560fn compute_focal_value(
561    src: &RasterBuffer,
562    x: u64,
563    y: u64,
564    offsets: &[(i64, i64)],
565    boundary: &BoundaryMode,
566    operation: FocalOperation,
567) -> Result<f64> {
568    let width = src.width() as i64;
569    let height = src.height() as i64;
570    let x_i64 = x as i64;
571    let y_i64 = y as i64;
572
573    let mut values = Vec::with_capacity(offsets.len());
574
575    for &(dx, dy) in offsets {
576        let nx = x_i64 + dx;
577        let ny = y_i64 + dy;
578
579        let value = if nx >= 0 && nx < width && ny >= 0 && ny < height {
580            src.get_pixel(nx as u64, ny as u64)
581                .map_err(AlgorithmError::Core)?
582        } else {
583            match boundary {
584                BoundaryMode::Ignore => continue,
585                BoundaryMode::Constant(c) => *c as f64,
586                BoundaryMode::Reflect => {
587                    let rx = if nx < 0 {
588                        -nx - 1
589                    } else if nx >= width {
590                        2 * width - nx - 1
591                    } else {
592                        nx
593                    };
594                    let ry = if ny < 0 {
595                        -ny - 1
596                    } else if ny >= height {
597                        2 * height - ny - 1
598                    } else {
599                        ny
600                    };
601                    src.get_pixel(rx as u64, ry as u64)
602                        .map_err(AlgorithmError::Core)?
603                }
604                BoundaryMode::Wrap => {
605                    let wx = ((nx % width) + width) % width;
606                    let wy = ((ny % height) + height) % height;
607                    src.get_pixel(wx as u64, wy as u64)
608                        .map_err(AlgorithmError::Core)?
609                }
610                BoundaryMode::Edge => {
611                    let ex = nx.clamp(0, width - 1);
612                    let ey = ny.clamp(0, height - 1);
613                    src.get_pixel(ex as u64, ey as u64)
614                        .map_err(AlgorithmError::Core)?
615                }
616            }
617        };
618
619        // Exclude NoData sentinels (e.g. -9999 or NaN) from the neighborhood so
620        // they are not silently averaged/summed/min'd into the focal result.
621        if src.is_nodata(value) || value.is_nan() {
622            continue;
623        }
624
625        values.push(value);
626    }
627
628    if values.is_empty() {
629        // Every contributing pixel in the window was NoData: propagate NoData to
630        // the output. If the source declares a NoData value, emit that sentinel;
631        // otherwise (no NoData declared) an empty window can only arise from
632        // BoundaryMode::Ignore at an edge, for which 0.0 preserves prior behavior.
633        return Ok(src.nodata().as_f64().unwrap_or(0.0));
634    }
635
636    let result = match operation {
637        FocalOperation::Mean => {
638            let sum: f64 = values.iter().sum();
639            sum / values.len() as f64
640        }
641        FocalOperation::Median => {
642            values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
643            let mid = values.len() / 2;
644            if values.len() % 2 == 0 {
645                (values[mid - 1] + values[mid]) / 2.0
646            } else {
647                values[mid]
648            }
649        }
650        FocalOperation::Range => {
651            // Compute min and max in a single pass using fold (panic-free)
652            // This is safe because values is guaranteed non-empty by check at line 590
653            let (min, max) = values.iter().copied().fold(
654                (f64::INFINITY, f64::NEG_INFINITY),
655                |(min_val, max_val), val| {
656                    let new_min = if val < min_val { val } else { min_val };
657                    let new_max = if val > max_val { val } else { max_val };
658                    (new_min, new_max)
659                },
660            );
661            max - min
662        }
663        FocalOperation::Variety => {
664            let mut unique = values.clone();
665            unique.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
666            unique.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
667            unique.len() as f64
668        }
669        FocalOperation::Min => {
670            // Use fold to find minimum (panic-free)
671            // This is safe because values is guaranteed non-empty by check at line 590
672            values
673                .iter()
674                .copied()
675                .fold(f64::INFINITY, |acc, val| if val < acc { val } else { acc })
676        }
677        FocalOperation::Max => {
678            // Use fold to find maximum (panic-free)
679            // This is safe because values is guaranteed non-empty by check at line 590
680            values.iter().copied().fold(
681                f64::NEG_INFINITY,
682                |acc, val| if val > acc { val } else { acc },
683            )
684        }
685        FocalOperation::Sum => values.iter().sum(),
686        FocalOperation::StdDev => {
687            let mean = values.iter().sum::<f64>() / values.len() as f64;
688            let variance = values
689                .iter()
690                .map(|v| {
691                    let diff = v - mean;
692                    diff * diff
693                })
694                .sum::<f64>()
695                / values.len() as f64;
696            variance.sqrt()
697        }
698        FocalOperation::Majority => {
699            // Count occurrences (with tolerance for floating point)
700            let mut counts: Vec<(f64, usize)> = Vec::new();
701            for &val in &values {
702                if let Some(entry) = counts.iter_mut().find(|(v, _)| (v - val).abs() < 1e-10) {
703                    entry.1 += 1;
704                } else {
705                    counts.push((val, 1));
706                }
707            }
708            // Use fold to find value with maximum count (panic-free)
709            // This is safe because counts is guaranteed non-empty since values is non-empty (checked at line 590)
710            let (majority_val, _) =
711                counts
712                    .into_iter()
713                    .fold((0.0, 0), |(acc_val, acc_count), (val, count)| {
714                        if count > acc_count {
715                            (val, count)
716                        } else {
717                            (acc_val, acc_count)
718                        }
719                    });
720            majority_val
721        }
722    };
723
724    Ok(result)
725}
726
727/// Optimized focal mean for rectangular windows using separable filtering
728///
729/// This is much faster than the generic implementation for large rectangular windows.
730///
731/// # Arguments
732///
733/// * `src` - Source raster buffer
734/// * `width` - Window width (must be odd)
735/// * `height` - Window height (must be odd)
736///
737/// # Errors
738///
739/// Returns an error if the operation fails
740pub fn focal_mean_separable(
741    src: &RasterBuffer,
742    width: usize,
743    height: usize,
744) -> Result<RasterBuffer> {
745    if width.is_multiple_of(2) || height.is_multiple_of(2) {
746        return Err(AlgorithmError::InvalidParameter {
747            parameter: "window_size",
748            message: "Window dimensions must be odd".to_string(),
749        });
750    }
751
752    let img_width = src.width();
753    let img_height = src.height();
754
755    // Horizontal pass
756    let mut temp = RasterBuffer::zeros(img_width, img_height, RasterDataType::Float64);
757    let hw = (width / 2) as i64;
758
759    for y in 0..img_height {
760        for x in 0..img_width {
761            let mut sum = 0.0;
762            let mut count = 0;
763
764            for dx in -hw..=hw {
765                let nx = (x as i64 + dx).clamp(0, img_width as i64 - 1);
766                sum += src.get_pixel(nx as u64, y).map_err(AlgorithmError::Core)?;
767                count += 1;
768            }
769
770            temp.set_pixel(x, y, sum / count as f64)
771                .map_err(AlgorithmError::Core)?;
772        }
773    }
774
775    // Vertical pass
776    let mut dst = RasterBuffer::zeros(img_width, img_height, src.data_type());
777    let hh = (height / 2) as i64;
778
779    for y in 0..img_height {
780        for x in 0..img_width {
781            let mut sum = 0.0;
782            let mut count = 0;
783
784            for dy in -hh..=hh {
785                let ny = (y as i64 + dy).clamp(0, img_height as i64 - 1);
786                sum += temp.get_pixel(x, ny as u64).map_err(AlgorithmError::Core)?;
787                count += 1;
788            }
789
790            dst.set_pixel(x, y, sum / count as f64)
791                .map_err(AlgorithmError::Core)?;
792        }
793    }
794
795    Ok(dst)
796}
797
798/// Applies a custom kernel convolution
799///
800/// # Arguments
801///
802/// * `src` - Source raster buffer
803/// * `kernel` - Convolution kernel weights
804/// * `width` - Kernel width
805/// * `height` - Kernel height
806/// * `normalize` - Whether to normalize by kernel sum
807///
808/// # Errors
809///
810/// Returns an error if the operation fails
811pub fn focal_convolve(
812    src: &RasterBuffer,
813    kernel: &[f64],
814    width: usize,
815    height: usize,
816    normalize: bool,
817) -> Result<RasterBuffer> {
818    if kernel.len() != width * height {
819        return Err(AlgorithmError::InvalidParameter {
820            parameter: "kernel",
821            message: "Kernel size must match width * height".to_string(),
822        });
823    }
824
825    let img_width = src.width();
826    let img_height = src.height();
827    let mut dst = RasterBuffer::zeros(img_width, img_height, src.data_type());
828
829    let hw = (width / 2) as i64;
830    let hh = (height / 2) as i64;
831
832    let kernel_sum: f64 = if normalize {
833        let sum: f64 = kernel.iter().sum();
834        if sum.abs() < 1e-10 { 1.0 } else { sum }
835    } else {
836        1.0
837    };
838
839    for y in 0..img_height {
840        for x in 0..img_width {
841            let mut sum = 0.0;
842
843            for ky in 0..height {
844                for kx in 0..width {
845                    let dx = kx as i64 - hw;
846                    let dy = ky as i64 - hh;
847                    let nx = (x as i64 + dx).clamp(0, img_width as i64 - 1);
848                    let ny = (y as i64 + dy).clamp(0, img_height as i64 - 1);
849
850                    let pixel_val = src
851                        .get_pixel(nx as u64, ny as u64)
852                        .map_err(AlgorithmError::Core)?;
853                    sum += pixel_val * kernel[ky * width + kx];
854                }
855            }
856
857            dst.set_pixel(x, y, sum / kernel_sum)
858                .map_err(AlgorithmError::Core)?;
859        }
860    }
861
862    Ok(dst)
863}
864
865#[cfg(test)]
866mod tests {
867    use super::*;
868    use approx::assert_abs_diff_eq;
869
870    #[test]
871    fn test_window_shape_rectangular() {
872        let window = WindowShape::rectangular(3, 3)
873            .expect("3x3 rectangular window creation should succeed in test");
874        assert_eq!(window.dimensions(), (3, 3));
875        assert_eq!(window.cell_count(), 9);
876        assert!(window.includes(0, 0));
877        assert!(window.includes(1, 1));
878        assert!(!window.includes(2, 2));
879    }
880
881    #[test]
882    fn test_window_shape_circular() {
883        let window = WindowShape::circular(1.5)
884            .expect("circular window with radius 1.5 should succeed in test");
885        assert!(window.includes(0, 0));
886        assert!(window.includes(1, 0));
887        assert!(window.includes(0, 1));
888        assert!(!window.includes(2, 2));
889    }
890
891    #[test]
892    fn test_window_shape_custom() {
893        let mask = vec![false, true, false, true, true, true, false, true, false];
894        let window = WindowShape::custom(mask, 3, 3)
895            .expect("custom 3x3 window creation should succeed in test");
896        assert_eq!(window.cell_count(), 5);
897        assert!(window.includes(0, 0));
898        assert!(!window.includes(1, 1));
899    }
900
901    #[test]
902    fn test_focal_mean() {
903        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
904        // Set center to 5, rest to 0
905        src.set_pixel(2, 2, 5.0)
906            .expect("setting pixel (2,2) should succeed in test");
907
908        let window = WindowShape::rectangular(3, 3)
909            .expect("3x3 rectangular window creation should succeed in test");
910        let result = focal_mean(&src, &window, &BoundaryMode::Edge)
911            .expect("focal_mean operation should succeed in test");
912
913        // Center should be ~0.55 (5/9)
914        let center = result
915            .get_pixel(2, 2)
916            .expect("getting pixel (2,2) from result should succeed in test");
917        assert_abs_diff_eq!(center, 5.0 / 9.0, epsilon = 0.01);
918    }
919
920    #[test]
921    fn test_focal_median() {
922        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
923        src.set_pixel(2, 2, 9.0)
924            .expect("setting pixel (2,2) should succeed in test");
925
926        let window = WindowShape::rectangular(3, 3)
927            .expect("3x3 rectangular window creation should succeed in test");
928        let result = focal_median(&src, &window, &BoundaryMode::Edge)
929            .expect("focal_median operation should succeed in test");
930
931        // Median should be 0 (8 zeros, 1 nine)
932        let center = result
933            .get_pixel(2, 2)
934            .expect("getting pixel (2,2) from result should succeed in test");
935        assert_abs_diff_eq!(center, 0.0, epsilon = 0.01);
936    }
937
938    #[test]
939    fn test_focal_range() {
940        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
941        src.set_pixel(2, 2, 10.0)
942            .expect("setting pixel (2,2) should succeed in test");
943
944        let window = WindowShape::rectangular(3, 3)
945            .expect("3x3 rectangular window creation should succeed in test");
946        let result = focal_range(&src, &window, &BoundaryMode::Edge)
947            .expect("focal_range operation should succeed in test");
948
949        // Range should be 10
950        let center = result
951            .get_pixel(2, 2)
952            .expect("getting pixel (2,2) from result should succeed in test");
953        assert_abs_diff_eq!(center, 10.0, epsilon = 0.01);
954    }
955
956    #[test]
957    fn test_focal_variety() {
958        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
959        src.set_pixel(2, 2, 1.0)
960            .expect("setting pixel (2,2) should succeed in test");
961        src.set_pixel(2, 3, 2.0)
962            .expect("setting pixel (2,3) should succeed in test");
963
964        let window = WindowShape::rectangular(3, 3)
965            .expect("3x3 rectangular window creation should succeed in test");
966        let result = focal_variety(&src, &window, &BoundaryMode::Edge)
967            .expect("focal_variety operation should succeed in test");
968
969        // Should have 3 unique values (0, 1, 2)
970        let center = result
971            .get_pixel(2, 2)
972            .expect("getting pixel (2,2) from result should succeed in test");
973        assert_abs_diff_eq!(center, 3.0, epsilon = 0.01);
974    }
975
976    #[test]
977    fn test_focal_stddev() {
978        let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float32);
979        for y in 0..5 {
980            for x in 0..5 {
981                src.set_pixel(x, y, (x + y) as f64)
982                    .expect("setting pixel should succeed in test");
983            }
984        }
985
986        let window = WindowShape::rectangular(3, 3)
987            .expect("3x3 rectangular window creation should succeed in test");
988        let result = focal_stddev(&src, &window, &BoundaryMode::Edge)
989            .expect("focal_stddev operation should succeed in test");
990
991        // Should have non-zero standard deviation
992        let center = result
993            .get_pixel(2, 2)
994            .expect("getting pixel (2,2) from result should succeed in test");
995        assert!(center > 0.0);
996    }
997
998    use proptest::prelude::*;
999
1000    proptest! {
1001        /// Property: at every pixel, focal_min <= focal_mean <= focal_max, and
1002        /// focal_range == focal_max - focal_min, for arbitrary finite rasters
1003        /// (no NoData declared here).
1004        #[test]
1005        fn prop_focal_min_mean_max_ordering(
1006            values in prop::collection::vec(-1000.0f64..1000.0, 25),
1007        ) {
1008            let mut src = RasterBuffer::zeros(5, 5, RasterDataType::Float64);
1009            for (i, &v) in values.iter().enumerate() {
1010                let (x, y) = (i as u64 % 5, i as u64 / 5);
1011                src.set_pixel(x, y, v).expect("set pixel");
1012            }
1013            let window = WindowShape::rectangular(3, 3).expect("window");
1014            let fmin = focal_min(&src, &window, &BoundaryMode::Edge).expect("min");
1015            let fmax = focal_max(&src, &window, &BoundaryMode::Edge).expect("max");
1016            let fmean = focal_mean(&src, &window, &BoundaryMode::Edge).expect("mean");
1017            let frange = focal_range(&src, &window, &BoundaryMode::Edge).expect("range");
1018
1019            for y in 0..5 {
1020                for x in 0..5 {
1021                    let mn = fmin.get_pixel(x, y).expect("mn");
1022                    let mx = fmax.get_pixel(x, y).expect("mx");
1023                    let me = fmean.get_pixel(x, y).expect("me");
1024                    let rg = frange.get_pixel(x, y).expect("rg");
1025                    prop_assert!(mn <= me + 1e-9);
1026                    prop_assert!(me <= mx + 1e-9);
1027                    prop_assert!((rg - (mx - mn)).abs() < 1e-6);
1028                }
1029            }
1030        }
1031    }
1032
1033    #[test]
1034    fn test_focal_mean_excludes_nodata() {
1035        use oxigeo_core::types::NoDataValue;
1036        // 5x5 buffer where NoData is the -9999 sentinel.
1037        let mut src =
1038            RasterBuffer::nodata_filled(5, 5, RasterDataType::Float32, NoDataValue::Float(-9999.0));
1039        // Fill everything with 4.0 first, then drop a NoData sentinel next to the
1040        // center so the naive mean would be dragged toward -9999.
1041        for y in 0..5 {
1042            for x in 0..5 {
1043                src.set_pixel(x, y, 4.0)
1044                    .expect("setting pixel should succeed in test");
1045            }
1046        }
1047        src.set_pixel(1, 2, -9999.0)
1048            .expect("setting NoData pixel should succeed in test");
1049
1050        let window = WindowShape::rectangular(3, 3)
1051            .expect("3x3 rectangular window creation should succeed in test");
1052        let result = focal_mean(&src, &window, &BoundaryMode::Edge)
1053            .expect("focal_mean operation should succeed in test");
1054
1055        // The NoData pixel must be excluded, so the mean over the 3x3 window at
1056        // the center is exactly 4.0 (all remaining valid neighbors are 4.0),
1057        // NOT dragged toward the -9999 sentinel.
1058        let center = result
1059            .get_pixel(2, 2)
1060            .expect("getting pixel (2,2) from result should succeed in test");
1061        assert_abs_diff_eq!(center, 4.0, epsilon = 1e-6);
1062    }
1063
1064    #[test]
1065    fn test_focal_all_nodata_propagates_nodata() {
1066        use oxigeo_core::types::NoDataValue;
1067        // Every pixel is the NoData sentinel.
1068        let src =
1069            RasterBuffer::nodata_filled(5, 5, RasterDataType::Float32, NoDataValue::Float(-9999.0));
1070        let window = WindowShape::rectangular(3, 3)
1071            .expect("3x3 rectangular window creation should succeed in test");
1072        let result = focal_mean(&src, &window, &BoundaryMode::Edge)
1073            .expect("focal_mean operation should succeed in test");
1074
1075        // Output must be flagged NoData, not a spurious blend of the sentinels.
1076        let center = result
1077            .get_pixel(2, 2)
1078            .expect("getting pixel (2,2) from result should succeed in test");
1079        assert!(
1080            result.is_nodata(center),
1081            "all-NoData window must propagate NoData, got {center}"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_focal_sum_excludes_nan_nodata() {
1087        use oxigeo_core::types::NoDataValue;
1088        let mut src = RasterBuffer::nodata_filled(
1089            5,
1090            5,
1091            RasterDataType::Float64,
1092            NoDataValue::Float(f64::NAN),
1093        );
1094        for y in 0..5 {
1095            for x in 0..5 {
1096                src.set_pixel(x, y, 2.0)
1097                    .expect("setting pixel should succeed in test");
1098            }
1099        }
1100        src.set_pixel(2, 1, f64::NAN)
1101            .expect("setting NaN NoData pixel should succeed in test");
1102
1103        let window = WindowShape::rectangular(3, 3)
1104            .expect("3x3 rectangular window creation should succeed in test");
1105        let result = focal_sum(&src, &window, &BoundaryMode::Edge)
1106            .expect("focal_sum operation should succeed in test");
1107
1108        // 9-cell window minus 1 NaN cell = 8 valid cells * 2.0 = 16.0 (not NaN).
1109        let center = result
1110            .get_pixel(2, 2)
1111            .expect("getting pixel (2,2) from result should succeed in test");
1112        assert!(center.is_finite(), "sum must not be NaN-poisoned");
1113        assert_abs_diff_eq!(center, 16.0, epsilon = 1e-6);
1114    }
1115
1116    #[test]
1117    fn test_focal_mean_separable() {
1118        let mut src = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
1119        for y in 0..10 {
1120            for x in 0..10 {
1121                src.set_pixel(x, y, (x + y) as f64)
1122                    .expect("setting pixel should succeed in test");
1123            }
1124        }
1125
1126        let result = focal_mean_separable(&src, 3, 3)
1127            .expect("focal_mean_separable operation should succeed in test");
1128
1129        // Compare with generic version
1130        let window = WindowShape::rectangular(3, 3)
1131            .expect("3x3 rectangular window creation should succeed in test");
1132        let expected = focal_mean(&src, &window, &BoundaryMode::Edge)
1133            .expect("focal_mean operation should succeed in test");
1134
1135        for y in 1..9 {
1136            for x in 1..9 {
1137                let val = result
1138                    .get_pixel(x, y)
1139                    .expect("getting pixel from result should succeed in test");
1140                let exp = expected
1141                    .get_pixel(x, y)
1142                    .expect("getting pixel from expected should succeed in test");
1143                assert_abs_diff_eq!(val, exp, epsilon = 0.1);
1144            }
1145        }
1146    }
1147
1148    #[test]
1149    fn test_boundary_modes() {
1150        let src = RasterBuffer::zeros(3, 3, RasterDataType::Float32);
1151        let window = WindowShape::rectangular(3, 3)
1152            .expect("3x3 rectangular window creation should succeed in test");
1153
1154        // Test all boundary modes - they should not panic
1155        let _ = focal_mean(&src, &window, &BoundaryMode::Ignore);
1156        let _ = focal_mean(&src, &window, &BoundaryMode::Constant(0));
1157        let _ = focal_mean(&src, &window, &BoundaryMode::Reflect);
1158        let _ = focal_mean(&src, &window, &BoundaryMode::Wrap);
1159        let _ = focal_mean(&src, &window, &BoundaryMode::Edge);
1160    }
1161}