Skip to main content

oxigdal_algorithms/parallel/
raster.rs

1//! Parallel raster operations
2//!
3//! This module provides parallel implementations of common raster operations
4//! for multi-core performance improvements.
5
6use rayon::prelude::*;
7
8use crate::error::{AlgorithmError, Result};
9use oxigdal_core::buffer::RasterBuffer;
10use oxigdal_core::types::RasterDataType;
11
12/// Configuration for chunked parallel operations
13#[derive(Debug, Clone)]
14pub struct ChunkConfig {
15    /// Number of threads to use
16    pub num_threads: Option<usize>,
17    /// Chunk size in pixels
18    pub chunk_size: Option<usize>,
19    /// Minimum chunk size to avoid overhead
20    pub min_chunk_size: usize,
21}
22
23impl Default for ChunkConfig {
24    fn default() -> Self {
25        Self {
26            num_threads: None,
27            chunk_size: None,
28            min_chunk_size: 8192, // 8K pixels minimum
29        }
30    }
31}
32
33impl ChunkConfig {
34    /// Creates a new chunk configuration
35    #[must_use]
36    pub const fn new() -> Self {
37        Self {
38            num_threads: None,
39            chunk_size: None,
40            min_chunk_size: 8192,
41        }
42    }
43
44    /// Sets the number of threads
45    #[must_use]
46    pub const fn with_threads(mut self, num_threads: usize) -> Self {
47        self.num_threads = Some(num_threads);
48        self
49    }
50
51    /// Sets the chunk size
52    #[must_use]
53    pub const fn with_chunk_size(mut self, chunk_size: usize) -> Self {
54        self.chunk_size = Some(chunk_size);
55        self
56    }
57
58    /// Calculates the optimal chunk size for the given buffer
59    #[must_use]
60    pub fn calculate_chunk_size(&self, buffer: &RasterBuffer) -> usize {
61        if let Some(size) = self.chunk_size {
62            return size.max(self.min_chunk_size);
63        }
64
65        let total_pixels = buffer.pixel_count() as usize;
66        let threads = self.num_threads.unwrap_or_else(rayon::current_num_threads);
67
68        // Aim for 6-8 chunks per thread for good load balancing
69        let target_chunks = threads * 7;
70        let chunk_size = total_pixels / target_chunks;
71
72        chunk_size.max(self.min_chunk_size)
73    }
74}
75
76/// Reduction operation for parallel reduce
77#[derive(Debug, Clone, Copy)]
78pub enum ReduceOp {
79    /// Sum all values
80    Sum,
81    /// Find minimum value
82    Min,
83    /// Find maximum value
84    Max,
85    /// Calculate mean
86    Mean,
87    /// Count valid (non-nodata) values
88    Count,
89}
90
91/// Result of a parallel reduction operation
92#[derive(Debug, Clone, Copy)]
93pub struct ReduceResult {
94    /// The computed value
95    pub value: f64,
96    /// Number of pixels processed
97    pub count: u64,
98}
99
100/// Apply a function to each pixel in parallel
101///
102/// This function maps a transformation function over all pixels in the raster
103/// using parallel processing. The operation is performed in chunks for better
104/// cache locality.
105///
106/// # Arguments
107///
108/// * `input` - Input raster buffer
109/// * `func` - Function to apply to each pixel
110///
111/// # Returns
112///
113/// A new raster buffer with the transformed values
114///
115/// # Errors
116///
117/// Returns an error if pixel access fails or buffer creation fails
118///
119/// # Example
120///
121/// ```no_run
122/// # #[cfg(feature = "parallel")]
123/// # {
124/// use oxigdal_algorithms::parallel::parallel_map_raster;
125/// use oxigdal_core::buffer::RasterBuffer;
126/// use oxigdal_core::types::RasterDataType;
127/// # use oxigdal_algorithms::error::Result;
128///
129/// # fn main() -> Result<()> {
130/// let input = RasterBuffer::zeros(1000, 1000, RasterDataType::Float32);
131/// let output = parallel_map_raster(&input, |pixel| pixel * 2.0)?;
132/// # Ok(())
133/// # }
134/// # }
135/// ```
136pub fn parallel_map_raster<F>(input: &RasterBuffer, func: F) -> Result<RasterBuffer>
137where
138    F: Fn(f64) -> f64 + Sync + Send,
139{
140    let config = ChunkConfig::default();
141    parallel_map_raster_with_config(input, &config, func)
142}
143
144/// Apply a function to each pixel in parallel with custom configuration
145///
146/// # Arguments
147///
148/// * `input` - Input raster buffer
149/// * `config` - Chunk configuration
150/// * `func` - Function to apply to each pixel
151///
152/// # Returns
153///
154/// A new raster buffer with the transformed values
155///
156/// # Errors
157///
158/// Returns an error if pixel access fails or buffer creation fails
159pub fn parallel_map_raster_with_config<F>(
160    input: &RasterBuffer,
161    config: &ChunkConfig,
162    func: F,
163) -> Result<RasterBuffer>
164where
165    F: Fn(f64) -> f64 + Sync + Send,
166{
167    let width = input.width();
168    let height = input.height();
169    let data_type = input.data_type();
170
171    // Create output buffer
172    let mut output = RasterBuffer::zeros(width, height, data_type);
173
174    // Calculate chunk size
175    let chunk_size = config.calculate_chunk_size(input);
176    let total_pixels = (width * height) as usize;
177
178    // Process in parallel chunks
179    let pixel_indices: Vec<usize> = (0..total_pixels).collect();
180
181    // Process chunks in parallel and collect results
182    let results: Result<Vec<(usize, f64)>> = pixel_indices
183        .par_chunks(chunk_size)
184        .flat_map(|chunk| {
185            chunk
186                .iter()
187                .map(|&idx| {
188                    let x = (idx as u64) % width;
189                    let y = (idx as u64) / width;
190                    let value = input.get_pixel(x, y)?;
191                    let result = func(value);
192                    Ok((idx, result))
193                })
194                .collect::<Vec<_>>()
195        })
196        .collect();
197
198    // Write results to output buffer
199    for (idx, value) in results? {
200        let x = (idx as u64) % width;
201        let y = (idx as u64) / width;
202        output.set_pixel(x, y, value)?;
203    }
204
205    Ok(output)
206}
207
208/// Reduce raster values using a parallel reduction operation
209///
210/// This function applies a reduction operation (sum, min, max, mean, count)
211/// to all pixels in the raster using parallel processing.
212///
213/// # Arguments
214///
215/// * `input` - Input raster buffer
216/// * `op` - Reduction operation to apply
217///
218/// # Returns
219///
220/// The result of the reduction operation
221///
222/// # Errors
223///
224/// Returns an error if pixel access fails
225///
226/// # Example
227///
228/// ```no_run
229/// # #[cfg(feature = "parallel")]
230/// # {
231/// use oxigdal_algorithms::parallel::{parallel_reduce_raster, ReduceOp};
232/// use oxigdal_core::buffer::RasterBuffer;
233/// use oxigdal_core::types::RasterDataType;
234/// # use oxigdal_algorithms::error::Result;
235///
236/// # fn main() -> Result<()> {
237/// let input = RasterBuffer::zeros(1000, 1000, RasterDataType::Float32);
238/// let result = parallel_reduce_raster(&input, ReduceOp::Sum)?;
239/// println!("Sum: {}", result.value);
240/// # Ok(())
241/// # }
242/// # }
243/// ```
244pub fn parallel_reduce_raster(input: &RasterBuffer, op: ReduceOp) -> Result<ReduceResult> {
245    let config = ChunkConfig::default();
246    parallel_reduce_raster_with_config(input, &config, op)
247}
248
249/// Reduce raster values with custom configuration
250///
251/// # Arguments
252///
253/// * `input` - Input raster buffer
254/// * `config` - Chunk configuration
255/// * `op` - Reduction operation to apply
256///
257/// # Returns
258///
259/// The result of the reduction operation
260///
261/// # Errors
262///
263/// Returns an error if pixel access fails
264pub fn parallel_reduce_raster_with_config(
265    input: &RasterBuffer,
266    config: &ChunkConfig,
267    op: ReduceOp,
268) -> Result<ReduceResult> {
269    let width = input.width();
270    let height = input.height();
271    let chunk_size = config.calculate_chunk_size(input);
272    let total_pixels = (width * height) as usize;
273
274    let pixel_indices: Vec<usize> = (0..total_pixels).collect();
275
276    // Parallel reduction
277    let result = pixel_indices
278        .par_chunks(chunk_size)
279        .map(|chunk| {
280            let mut local_sum = 0.0;
281            let mut local_min = f64::MAX;
282            let mut local_max = f64::MIN;
283            let mut local_count = 0u64;
284
285            for &idx in chunk {
286                let x = (idx as u64) % width;
287                let y = (idx as u64) / width;
288
289                match input.get_pixel(x, y) {
290                    Ok(value) if !input.is_nodata(value) && value.is_finite() => {
291                        match op {
292                            ReduceOp::Sum | ReduceOp::Mean => local_sum += value,
293                            ReduceOp::Min => local_min = local_min.min(value),
294                            ReduceOp::Max => local_max = local_max.max(value),
295                            ReduceOp::Count => {}
296                        }
297                        local_count += 1;
298                    }
299                    Ok(_) => {} // Skip nodata or invalid values
300                    Err(e) => return Err(e),
301                }
302            }
303
304            Ok((local_sum, local_min, local_max, local_count))
305        })
306        .reduce(
307            || Ok((0.0, f64::MAX, f64::MIN, 0u64)),
308            |acc, item| {
309                let (sum1, min1, max1, count1) = acc?;
310                let (sum2, min2, max2, count2) = item?;
311                Ok((sum1 + sum2, min1.min(min2), max1.max(max2), count1 + count2))
312            },
313        )?;
314
315    let (sum, min, max, count) = result;
316
317    let value = match op {
318        ReduceOp::Sum => sum,
319        ReduceOp::Min => min,
320        ReduceOp::Max => max,
321        ReduceOp::Mean => {
322            if count > 0 {
323                sum / count as f64
324            } else {
325                f64::NAN
326            }
327        }
328        ReduceOp::Count => count as f64,
329    };
330
331    Ok(ReduceResult { value, count })
332}
333
334/// Transform a raster using a parallel transformation
335///
336/// This is similar to `parallel_map_raster` but operates on the raw byte data
337/// for maximum performance when the transformation can work directly on bytes.
338///
339/// # Arguments
340///
341/// * `input` - Input raster buffer
342/// * `output_type` - Output data type
343/// * `func` - Transformation function
344///
345/// # Returns
346///
347/// A new raster buffer with the transformed values
348///
349/// # Errors
350///
351/// Returns an error if transformation fails
352pub fn parallel_transform_raster<F>(
353    input: &RasterBuffer,
354    output_type: RasterDataType,
355    func: F,
356) -> Result<RasterBuffer>
357where
358    F: Fn(u64, u64, f64) -> f64 + Sync + Send,
359{
360    let config = ChunkConfig::default();
361    parallel_transform_raster_with_config(input, output_type, &config, func)
362}
363
364/// Transform a raster with custom configuration
365///
366/// # Arguments
367///
368/// * `input` - Input raster buffer
369/// * `output_type` - Output data type
370/// * `config` - Chunk configuration
371/// * `func` - Transformation function (x, y, value) -> result
372///
373/// # Returns
374///
375/// A new raster buffer with the transformed values
376///
377/// # Errors
378///
379/// Returns an error if transformation fails
380pub fn parallel_transform_raster_with_config<F>(
381    input: &RasterBuffer,
382    output_type: RasterDataType,
383    config: &ChunkConfig,
384    func: F,
385) -> Result<RasterBuffer>
386where
387    F: Fn(u64, u64, f64) -> f64 + Sync + Send,
388{
389    let width = input.width();
390    let height = input.height();
391
392    // Create output buffer
393    let mut output = RasterBuffer::zeros(width, height, output_type);
394
395    // Calculate chunk size
396    let chunk_size = config.calculate_chunk_size(input);
397    let total_pixels = (width * height) as usize;
398
399    let pixel_indices: Vec<usize> = (0..total_pixels).collect();
400
401    // Process chunks in parallel
402    let results: Result<Vec<(usize, f64)>> = pixel_indices
403        .par_chunks(chunk_size)
404        .flat_map(|chunk| {
405            chunk
406                .iter()
407                .map(|&idx| {
408                    let x = (idx as u64) % width;
409                    let y = (idx as u64) / width;
410                    let value = input.get_pixel(x, y)?;
411                    let result = func(x, y, value);
412                    Ok((idx, result))
413                })
414                .collect::<Vec<_>>()
415        })
416        .collect();
417
418    // Write results to output buffer
419    for (idx, value) in results? {
420        let x = (idx as u64) % width;
421        let y = (idx as u64) / width;
422        output.set_pixel(x, y, value)?;
423    }
424
425    Ok(output)
426}
427
428/// Apply a windowed operation in parallel
429///
430/// This function applies a windowed operation (e.g., convolution, focal statistics)
431/// to each pixel using its neighborhood. The operation is performed in parallel
432/// with proper edge handling.
433///
434/// # Arguments
435///
436/// * `input` - Input raster buffer
437/// * `window_size` - Size of the window (must be odd)
438/// * `func` - Function to apply to each window
439///
440/// # Returns
441///
442/// A new raster buffer with the results
443///
444/// # Errors
445///
446/// Returns an error if window size is invalid or processing fails
447pub fn parallel_windowed_operation<F>(
448    input: &RasterBuffer,
449    window_size: usize,
450    func: F,
451) -> Result<RasterBuffer>
452where
453    F: Fn(&[f64]) -> f64 + Sync + Send,
454{
455    if window_size.is_multiple_of(2) {
456        return Err(AlgorithmError::InvalidParameter {
457            parameter: "window_size",
458            message: "Window size must be odd".to_string(),
459        });
460    }
461
462    let width = input.width();
463    let height = input.height();
464    let data_type = input.data_type();
465
466    let mut output = RasterBuffer::zeros(width, height, data_type);
467
468    let radius = (window_size / 2) as i64;
469
470    // Process rows in parallel
471    let row_results: Result<Vec<Vec<f64>>> = (0..height)
472        .into_par_iter()
473        .map(|y| {
474            let mut row = Vec::with_capacity(width as usize);
475
476            for x in 0..width {
477                let mut window = Vec::with_capacity(window_size * window_size);
478
479                // Extract window
480                for wy in (y as i64 - radius)..=(y as i64 + radius) {
481                    for wx in (x as i64 - radius)..=(x as i64 + radius) {
482                        if wx >= 0 && wx < width as i64 && wy >= 0 && wy < height as i64 {
483                            match input.get_pixel(wx as u64, wy as u64) {
484                                Ok(value) if !input.is_nodata(value) => window.push(value),
485                                _ => {} // Skip nodata or out of bounds
486                            }
487                        }
488                    }
489                }
490
491                let result = if window.is_empty() {
492                    input.nodata().as_f64().unwrap_or(f64::NAN)
493                } else {
494                    func(&window)
495                };
496
497                row.push(result);
498            }
499
500            Ok(row)
501        })
502        .collect();
503
504    // Write results to output
505    for (y, row) in row_results?.into_iter().enumerate() {
506        for (x, value) in row.into_iter().enumerate() {
507            output.set_pixel(x as u64, y as u64, value)?;
508        }
509    }
510
511    Ok(output)
512}
513
514/// Parallel focal mean filter
515///
516/// Computes the mean of values in a window around each pixel.
517///
518/// # Arguments
519///
520/// * `input` - Input raster buffer
521/// * `window_size` - Size of the window (must be odd)
522///
523/// # Returns
524///
525/// A new raster buffer with the filtered values
526///
527/// # Errors
528///
529/// Returns an error if window size is invalid or processing fails
530pub fn parallel_focal_mean(input: &RasterBuffer, window_size: usize) -> Result<RasterBuffer> {
531    parallel_windowed_operation(input, window_size, |window| {
532        if window.is_empty() {
533            f64::NAN
534        } else {
535            window.iter().sum::<f64>() / window.len() as f64
536        }
537    })
538}
539
540/// Parallel focal median filter
541///
542/// Computes the median of values in a window around each pixel.
543///
544/// # Arguments
545///
546/// * `input` - Input raster buffer
547/// * `window_size` - Size of the window (must be odd)
548///
549/// # Returns
550///
551/// A new raster buffer with the filtered values
552///
553/// # Errors
554///
555/// Returns an error if window size is invalid or processing fails
556pub fn parallel_focal_median(input: &RasterBuffer, window_size: usize) -> Result<RasterBuffer> {
557    parallel_windowed_operation(input, window_size, |window| {
558        if window.is_empty() {
559            return f64::NAN;
560        }
561
562        let mut sorted = window.to_vec();
563        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
564
565        let mid = sorted.len() / 2;
566        if sorted.len() % 2 == 0 {
567            (sorted[mid - 1] + sorted[mid]) / 2.0
568        } else {
569            sorted[mid]
570        }
571    })
572}
573
574// ---------------------------------------------------------------------------
575// Strip-based parallel terrain and focal functions
576// ---------------------------------------------------------------------------
577
578/// Focal operation selector for the strip-based parallel focal function.
579///
580/// Each variant maps to the corresponding scalar `focal_*` function in
581/// `crate::raster::focal`.
582#[cfg(feature = "parallel")]
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584pub enum FocalOp {
585    /// Arithmetic mean over the window
586    Mean,
587    /// Median value over the window
588    Median,
589    /// Minimum value over the window
590    Min,
591    /// Maximum value over the window
592    Max,
593    /// Sum of values over the window
594    Sum,
595    /// Max minus min over the window
596    Range,
597    /// Population standard deviation over the window
598    StdDev,
599}
600
601/// Default strip height used when decomposing a raster into horizontal bands.
602///
603/// Chosen to keep per-strip working sets comfortably inside L2 cache for
604/// typical raster widths (≤16 384 pixels × 8 bytes/pixel ≈ 1 MiB) while
605/// giving rayon enough strips to balance work across threads.
606#[cfg(feature = "parallel")]
607const DEFAULT_STRIP_HEIGHT: u64 = 128;
608
609/// Extracts a contiguous sub-buffer of rows `[row_start, row_end)` from `src`.
610#[cfg(feature = "parallel")]
611fn extract_row_range(src: &RasterBuffer, row_start: u64, row_end: u64) -> Result<RasterBuffer> {
612    let w = src.width();
613    let h = row_end - row_start;
614    let mut sub = RasterBuffer::zeros(w, h, src.data_type());
615    for sy in 0..h {
616        let gy = row_start + sy;
617        for x in 0..w {
618            let v = src.get_pixel(x, gy).map_err(AlgorithmError::Core)?;
619            sub.set_pixel(x, sy, v).map_err(AlgorithmError::Core)?;
620        }
621    }
622    Ok(sub)
623}
624
625/// Copies rows from `partial` (starting at `partial_row_start`) into `dst`
626/// at the global row range `[dst_row_start, dst_row_end)`.
627#[cfg(feature = "parallel")]
628fn write_strip_to_output(
629    partial: &RasterBuffer,
630    partial_row_start: u64,
631    dst: &mut RasterBuffer,
632    dst_row_start: u64,
633    dst_row_end: u64,
634) -> Result<()> {
635    let w = dst.width();
636    for dy in 0..(dst_row_end - dst_row_start) {
637        let py = partial_row_start + dy;
638        let gy = dst_row_start + dy;
639        for x in 0..w {
640            let v = partial.get_pixel(x, py).map_err(AlgorithmError::Core)?;
641            dst.set_pixel(x, gy, v).map_err(AlgorithmError::Core)?;
642        }
643    }
644    Ok(())
645}
646
647/// Computes hillshade in parallel using horizontal strip decomposition.
648///
649/// Each strip extends `[strip_row_start, strip_row_end)` in the output, and the
650/// sub-buffer passed to the scalar kernel includes a 1-row halo on both sides so
651/// that the 3×3 finite-difference stencil never reads out-of-bounds.  The border
652/// rows of the full raster (row 0 and row H-1) stay zero, matching the scalar
653/// `hillshade()` behaviour.
654///
655/// # Errors
656///
657/// Returns an error if any strip fails to process or pixel access fails.
658#[cfg(feature = "parallel")]
659pub fn hillshade_parallel(
660    dem: &RasterBuffer,
661    params: crate::raster::HillshadeParams,
662) -> Result<RasterBuffer> {
663    let w = dem.width();
664    let h = dem.height();
665
666    // Scalar kernel requires ≥ 3×3; let it produce the error for small rasters.
667    if w < 3 || h < 3 {
668        return crate::raster::hillshade(dem, params);
669    }
670
671    // Interior rows only (1..h-1).  Rows 0 and h-1 stay zero — same as scalar.
672    let interior_start: u64 = 1;
673    let interior_end: u64 = h - 1;
674    let interior_h = interior_end - interior_start;
675
676    let strip_h = DEFAULT_STRIP_HEIGHT.min(interior_h).max(1);
677    let n_strips = interior_h.div_ceil(strip_h) as usize;
678
679    let strips: Vec<(u64, u64)> = (0..n_strips)
680        .map(|i| {
681            let s = interior_start + i as u64 * strip_h;
682            let e = (s + strip_h).min(interior_end);
683            (s, e)
684        })
685        .collect();
686
687    // Each strip item: (global_row_start, global_row_end, partial_result).
688    let strip_results: Result<Vec<(u64, u64, RasterBuffer)>> = strips
689        .into_par_iter()
690        .map(|(s, e)| {
691            // 1-row halo: s-1 is always ≥ 0 because s ≥ interior_start = 1.
692            let halo_top = s - 1;
693            let halo_bot = (e + 1).min(h);
694
695            let sub = extract_row_range(dem, halo_top, halo_bot)?;
696            let partial = crate::raster::hillshade(&sub, params)?;
697            Ok((s, e, partial))
698        })
699        .collect();
700
701    let mut out = RasterBuffer::zeros(w, h, dem.data_type());
702    for (s, e, partial) in strip_results? {
703        // Inside `partial`, local row 1 maps to global row s.
704        // Local row 0 (top halo) is discarded.
705        write_strip_to_output(&partial, 1, &mut out, s, e)?;
706    }
707    Ok(out)
708}
709
710/// Computes slope in parallel using horizontal strip decomposition.
711///
712/// Applies the same 1-row halo strategy as [`hillshade_parallel`] so that the
713/// 3×3 Horn gradient is well-defined inside every strip.  Border rows stay zero.
714///
715/// # Errors
716///
717/// Returns an error if any strip fails or pixel access fails.
718#[cfg(feature = "parallel")]
719pub fn slope_parallel(dem: &RasterBuffer, pixel_size: f64, z_factor: f64) -> Result<RasterBuffer> {
720    let w = dem.width();
721    let h = dem.height();
722
723    if w < 3 || h < 3 {
724        return crate::raster::slope(dem, pixel_size, z_factor);
725    }
726
727    let interior_start: u64 = 1;
728    let interior_end: u64 = h - 1;
729    let interior_h = interior_end - interior_start;
730
731    let strip_h = DEFAULT_STRIP_HEIGHT.min(interior_h).max(1);
732    let n_strips = interior_h.div_ceil(strip_h) as usize;
733
734    let strips: Vec<(u64, u64)> = (0..n_strips)
735        .map(|i| {
736            let s = interior_start + i as u64 * strip_h;
737            let e = (s + strip_h).min(interior_end);
738            (s, e)
739        })
740        .collect();
741
742    let strip_results: Result<Vec<(u64, u64, RasterBuffer)>> = strips
743        .into_par_iter()
744        .map(|(s, e)| {
745            let halo_top = s - 1;
746            let halo_bot = (e + 1).min(h);
747
748            let sub = extract_row_range(dem, halo_top, halo_bot)?;
749            let partial = crate::raster::slope(&sub, pixel_size, z_factor)?;
750            Ok((s, e, partial))
751        })
752        .collect();
753
754    let mut out = RasterBuffer::zeros(w, h, dem.data_type());
755    for (s, e, partial) in strip_results? {
756        write_strip_to_output(&partial, 1, &mut out, s, e)?;
757    }
758    Ok(out)
759}
760
761/// Runs the appropriate scalar focal function for the given [`FocalOp`].
762#[cfg(feature = "parallel")]
763fn dispatch_focal(
764    sub: &RasterBuffer,
765    window: &crate::raster::WindowShape,
766    op: FocalOp,
767    boundary: &crate::raster::FocalBoundaryMode,
768) -> Result<RasterBuffer> {
769    use crate::raster::{
770        focal_max, focal_mean, focal_median, focal_min, focal_range, focal_stddev, focal_sum,
771    };
772    match op {
773        FocalOp::Mean => focal_mean(sub, window, boundary),
774        FocalOp::Median => focal_median(sub, window, boundary),
775        FocalOp::Min => focal_min(sub, window, boundary),
776        FocalOp::Max => focal_max(sub, window, boundary),
777        FocalOp::Sum => focal_sum(sub, window, boundary),
778        FocalOp::Range => focal_range(sub, window, boundary),
779        FocalOp::StdDev => focal_stddev(sub, window, boundary),
780    }
781}
782
783/// Computes a focal (neighbourhood) statistic in parallel using horizontal strip
784/// decomposition.
785///
786/// The halo height equals the window's vertical radius `(window_height - 1) / 2`
787/// so that every output pixel inside a strip has a complete neighbourhood, identical
788/// to what the scalar function would see.
789///
790/// When the halo rows extend to the edge of the source raster, the sub-buffer
791/// edge coincides with the true raster edge, so the supplied `boundary` mode
792/// applies correctly without special-casing.
793///
794/// # Arguments
795///
796/// * `src` - Source raster buffer.
797/// * `window` - Window shape (rectangular, circular, or custom).
798/// * `op` - Which focal statistic to compute.
799/// * `boundary` - How to handle pixels that fall outside the raster edge.
800///
801/// # Errors
802///
803/// Returns an error if any strip fails or pixel access fails.
804#[cfg(feature = "parallel")]
805pub fn focal_parallel(
806    src: &RasterBuffer,
807    window: &crate::raster::WindowShape,
808    op: FocalOp,
809    boundary: &crate::raster::FocalBoundaryMode,
810) -> Result<RasterBuffer> {
811    let w = src.width();
812    let h = src.height();
813
814    // Vertical halo = half the window height.
815    let (_, win_h) = window.dimensions();
816    let halo = (win_h / 2) as u64;
817
818    let strip_h = DEFAULT_STRIP_HEIGHT.min(h).max(1);
819    let n_strips = h.div_ceil(strip_h) as usize;
820
821    let strips: Vec<(u64, u64)> = (0..n_strips)
822        .map(|i| {
823            let s = i as u64 * strip_h;
824            let e = (s + strip_h).min(h);
825            (s, e)
826        })
827        .collect();
828
829    // Each item: (global_row_start, global_row_end, partial_result, local_row_start_in_partial).
830    let strip_results: Result<Vec<(u64, u64, RasterBuffer, u64)>> = strips
831        .into_par_iter()
832        .map(|(s, e)| {
833            let halo_top = s.saturating_sub(halo);
834            let halo_bot = (e + halo).min(h);
835
836            let sub = extract_row_range(src, halo_top, halo_bot)?;
837            let partial = dispatch_focal(&sub, window, op, boundary)?;
838
839            // Local offset within partial that corresponds to global row s.
840            let local_start = s - halo_top;
841            Ok((s, e, partial, local_start))
842        })
843        .collect();
844
845    let mut out = RasterBuffer::zeros(w, h, src.data_type());
846    for (s, e, partial, local_start) in strip_results? {
847        write_strip_to_output(&partial, local_start, &mut out, s, e)?;
848    }
849    Ok(out)
850}
851
852#[cfg(test)]
853mod tests {
854    #![allow(clippy::expect_used)]
855
856    use super::*;
857    use approx::assert_relative_eq;
858
859    #[test]
860    fn test_chunk_config() {
861        let config = ChunkConfig::default();
862        assert!(config.num_threads.is_none());
863        assert!(config.chunk_size.is_none());
864        assert_eq!(config.min_chunk_size, 8192);
865    }
866
867    #[test]
868    fn test_chunk_config_builder() {
869        let config = ChunkConfig::new().with_threads(4).with_chunk_size(1024);
870        assert_eq!(config.num_threads, Some(4));
871        assert_eq!(config.chunk_size, Some(1024));
872    }
873
874    #[test]
875    fn test_parallel_map_raster() {
876        let input = RasterBuffer::zeros(100, 100, RasterDataType::Float32);
877        let output = parallel_map_raster(&input, |pixel| pixel + 1.0).expect("should work");
878
879        assert_eq!(output.width(), 100);
880        assert_eq!(output.height(), 100);
881
882        let value = output.get_pixel(50, 50).expect("should work");
883        assert_relative_eq!(value, 1.0, epsilon = 1e-6);
884    }
885
886    #[test]
887    fn test_parallel_reduce_sum() {
888        let mut input = RasterBuffer::zeros(100, 100, RasterDataType::Float32);
889
890        // Fill with ones
891        for y in 0..100 {
892            for x in 0..100 {
893                input.set_pixel(x, y, 1.0).expect("should work");
894            }
895        }
896
897        let result = parallel_reduce_raster(&input, ReduceOp::Sum).expect("should work");
898        assert_relative_eq!(result.value, 10000.0, epsilon = 1e-6);
899        assert_eq!(result.count, 10000);
900    }
901
902    #[test]
903    fn test_parallel_reduce_min_max() {
904        let mut input = RasterBuffer::zeros(100, 100, RasterDataType::Float32);
905
906        // Fill with values 0-9999
907        for y in 0..100 {
908            for x in 0..100 {
909                let value = (y * 100 + x) as f64;
910                input.set_pixel(x, y, value).expect("should work");
911            }
912        }
913
914        let min_result = parallel_reduce_raster(&input, ReduceOp::Min).expect("should work");
915        assert_relative_eq!(min_result.value, 0.0, epsilon = 1e-6);
916
917        let max_result = parallel_reduce_raster(&input, ReduceOp::Max).expect("should work");
918        assert_relative_eq!(max_result.value, 9999.0, epsilon = 1e-6);
919    }
920
921    #[test]
922    fn test_parallel_reduce_mean() {
923        let mut input = RasterBuffer::zeros(100, 100, RasterDataType::Float32);
924
925        // Fill with values 0-9999
926        for y in 0..100 {
927            for x in 0..100 {
928                let value = (y * 100 + x) as f64;
929                input.set_pixel(x, y, value).expect("should work");
930            }
931        }
932
933        let result = parallel_reduce_raster(&input, ReduceOp::Mean).expect("should work");
934        assert_relative_eq!(result.value, 4999.5, epsilon = 0.1);
935    }
936
937    #[test]
938    fn test_parallel_transform() {
939        let input = RasterBuffer::zeros(100, 100, RasterDataType::Float32);
940
941        // Transform: multiply by x coordinate
942        let output = parallel_transform_raster(&input, RasterDataType::Float32, |x, _y, value| {
943            value + x as f64
944        })
945        .expect("should work");
946
947        let value = output.get_pixel(50, 25).expect("should work");
948        assert_relative_eq!(value, 50.0, epsilon = 1e-6);
949    }
950
951    #[test]
952    fn test_parallel_focal_mean() {
953        let mut input = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
954
955        // Set center pixel to 100, rest to 0
956        input.set_pixel(5, 5, 100.0).expect("should work");
957
958        let output = parallel_focal_mean(&input, 3).expect("should work");
959
960        // Center should be average of 9 pixels (100 + 8*0) / 9
961        let value = output.get_pixel(5, 5).expect("should work");
962        assert_relative_eq!(value, 100.0 / 9.0, epsilon = 1e-6);
963    }
964
965    #[test]
966    fn test_parallel_focal_median() {
967        let mut input = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
968
969        // Set center pixel to 100, rest to 0
970        input.set_pixel(5, 5, 100.0).expect("should work");
971
972        let output = parallel_focal_median(&input, 3).expect("should work");
973
974        // Median of [0,0,0,0,0,0,0,0,100] is 0
975        let value = output.get_pixel(5, 5).expect("should work");
976        assert_relative_eq!(value, 0.0, epsilon = 1e-6);
977    }
978
979    #[test]
980    fn test_invalid_window_size() {
981        let input = RasterBuffer::zeros(10, 10, RasterDataType::Float32);
982
983        // Even window size should fail
984        let result = parallel_focal_mean(&input, 4);
985        assert!(result.is_err());
986    }
987}