Skip to main content

oxigdal_algorithms/raster/streaming/
mod.rs

1/// Streaming chunked raster processing.
2///
3/// This module provides an out-of-core raster processing abstraction that
4/// decomposes a large raster into overlapping tiles (chunks with halo cells)
5/// and applies a kernel function to each tile, stitching results into a full
6/// output array.
7///
8/// # Design
9///
10/// The [`ChunkedRaster`] holds a [`RasterSource`] reference and exposes a
11/// [`ChunkIterator`] that emits [`Chunk`] values in raster-scan order.  Each
12/// chunk carries the core pixel region **plus** a surrounding halo of
13/// user-specified thickness; halo cells that would fall outside the source
14/// raster are zero-padded.  This halo allows neighbourhood-based algorithms
15/// (hillshade, slope, focal mean, …) to produce seamless results at chunk
16/// boundaries.
17///
18/// [`process_streaming`] is the primary entry point: it iterates the chunks,
19/// calls a user-provided kernel closure, and assembles the per-chunk outputs
20/// (without halo) into a single contiguous `Vec<f32>` in the same pixel order
21/// as the source raster.
22///
23/// Convenience functions [`streaming_hillshade`], [`streaming_slope`], and
24/// [`streaming_focal_mean`] wrap the underlying buffer-based algorithms using
25/// the conversion helpers defined here.
26pub mod chunk;
27pub mod iter;
28
29pub use chunk::{Chunk, InMemoryRasterSource, RasterError, RasterSource};
30pub use iter::ChunkIterator;
31
32use crate::raster::focal::{BoundaryMode, WindowShape, focal_mean};
33use crate::raster::hillshade::{HillshadeParams, hillshade};
34use crate::raster::slope_aspect::slope;
35
36use oxigdal_core::buffer::RasterBuffer;
37use oxigdal_core::types::RasterDataType;
38
39// ---------------------------------------------------------------------------
40// Internal conversion helpers
41// ---------------------------------------------------------------------------
42
43/// Creates a `RasterBuffer` from a flat `Vec<f32>` slice with given dimensions.
44fn buffer_from_f32_slice(
45    data: &[f32],
46    width: usize,
47    height: usize,
48) -> Result<RasterBuffer, RasterError> {
49    RasterBuffer::from_typed_vec(width, height, data.to_vec(), RasterDataType::Float32)
50        .map_err(|e| RasterError::AlgorithmError(e.to_string()))
51}
52
53/// Extracts all pixel values from a `RasterBuffer` as `Vec<f32>`.
54fn f32_vec_from_buffer(buf: &RasterBuffer) -> Result<Vec<f32>, RasterError> {
55    buf.as_slice::<f32>()
56        .map(|s| s.to_vec())
57        .map_err(|e| RasterError::AlgorithmError(e.to_string()))
58}
59
60// ---------------------------------------------------------------------------
61// ChunkedRaster
62// ---------------------------------------------------------------------------
63
64/// A tiled view of a [`RasterSource`] that yields overlapping chunks with halo
65/// (ghost / border) cells for seamless neighbourhood processing.
66///
67/// Create with [`ChunkedRaster::new`] and iterate with [`ChunkedRaster::iter`].
68pub struct ChunkedRaster<R: RasterSource> {
69    /// The underlying raster data source.
70    pub source: R,
71    /// Total width of the source raster in pixels.
72    pub total_width: usize,
73    /// Total height of the source raster in pixels.
74    pub total_height: usize,
75    /// Target core width and height of each chunk (actual edge chunks may be smaller).
76    pub chunk_size: usize,
77    /// Number of halo pixels added on each side of every chunk.
78    pub halo: usize,
79}
80
81impl<R: RasterSource> ChunkedRaster<R> {
82    /// Creates a new `ChunkedRaster`.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`RasterError::InvalidChunkSize`] if `chunk_size == 0`.
87    pub fn new(source: R, chunk_size: usize, halo: usize) -> Result<Self, RasterError> {
88        if chunk_size == 0 {
89            return Err(RasterError::InvalidChunkSize);
90        }
91        let total_width = source.width();
92        let total_height = source.height();
93        Ok(Self {
94            source,
95            total_width,
96            total_height,
97            chunk_size,
98            halo,
99        })
100    }
101
102    /// Returns a [`ChunkIterator`] that yields all chunks in raster-scan order.
103    pub fn iter(&self) -> ChunkIterator<'_, R> {
104        ChunkIterator::new(self)
105    }
106}
107
108// ---------------------------------------------------------------------------
109// process_streaming
110// ---------------------------------------------------------------------------
111
112/// Applies a kernel function chunk-by-chunk and stitches the results into a
113/// full `Vec<f32>` output raster.
114///
115/// The kernel closure receives a [`Chunk`] (which includes surrounding halo
116/// cells in its `data` field) and **must** return exactly
117/// `chunk.width * chunk.height` values (the core region, without halo) in
118/// row-major order.  Results are assembled at the correct position in the
119/// output raster.
120///
121/// # Errors
122///
123/// Propagates any [`RasterError`] emitted by the iterator.  Also panics
124/// (via `assert_eq!`) if the kernel returns a vector of the wrong length —
125/// this is an API contract violation in the caller's closure.
126pub fn process_streaming<R, F>(
127    raster: &ChunkedRaster<R>,
128    mut kernel: F,
129) -> Result<Vec<f32>, RasterError>
130where
131    R: RasterSource,
132    F: FnMut(&Chunk) -> Result<Vec<f32>, RasterError>,
133{
134    let mut output = vec![0.0_f32; raster.total_width * raster.total_height];
135
136    for chunk_result in raster.iter() {
137        let chunk = chunk_result?;
138        let chunk_output = kernel(&chunk)?;
139
140        assert_eq!(
141            chunk_output.len(),
142            chunk.width * chunk.height,
143            "streaming kernel must return width×height values (no halo)"
144        );
145
146        // Scatter the per-chunk output into the correct position in `output`.
147        for row in 0..chunk.height {
148            for col in 0..chunk.width {
149                let src_idx = row * chunk.width + col;
150                let dst_x = chunk.x + col;
151                let dst_y = chunk.y + row;
152                if dst_x < raster.total_width && dst_y < raster.total_height {
153                    output[dst_y * raster.total_width + dst_x] = chunk_output[src_idx];
154                }
155            }
156        }
157    }
158
159    Ok(output)
160}
161
162// ---------------------------------------------------------------------------
163// Inner-region extraction helper
164// ---------------------------------------------------------------------------
165
166/// Extracts the inner sub-region from a 2-D output array, stripping off `halo`
167/// rows/columns on each side.
168///
169/// `data` must have dimensions `full_w × full_h`.  The returned vector has
170/// `inner_w × inner_h` elements.  Indices that fall outside `[0, full_w) ×
171/// [0, full_h)` are clamped and filled with `0.0`.
172pub fn extract_inner(
173    data: &[f32],
174    full_w: usize,
175    full_h: usize,
176    halo: usize,
177    inner_w: usize,
178    inner_h: usize,
179) -> Vec<f32> {
180    let mut inner = Vec::with_capacity(inner_w * inner_h);
181    for row in halo..(halo + inner_h) {
182        for col in halo..(halo + inner_w) {
183            if row < full_h && col < full_w {
184                inner.push(data[row * full_w + col]);
185            } else {
186                inner.push(0.0_f32);
187            }
188        }
189    }
190    inner
191}
192
193// ---------------------------------------------------------------------------
194// streaming_hillshade
195// ---------------------------------------------------------------------------
196
197/// Processes hillshade in a streaming (chunk-by-chunk) fashion.
198///
199/// Each chunk is inflated by `raster.halo` cells on all sides before the
200/// hillshade algorithm runs; only the inner `width × height` result pixels are
201/// kept and assembled into the full output.  Using `halo ≥ 1` ensures correct
202/// Horn-gradient computation at chunk boundaries.
203///
204/// # Errors
205///
206/// Returns any [`RasterError`] produced by chunk iteration or the hillshade
207/// algorithm.
208pub fn streaming_hillshade<R: RasterSource>(
209    raster: &ChunkedRaster<R>,
210    azimuth: f64,
211    altitude: f64,
212) -> Result<Vec<f32>, RasterError> {
213    process_streaming(raster, |chunk| {
214        let full_w = chunk.full_width();
215        let full_h = chunk.full_height();
216
217        let dem = buffer_from_f32_slice(&chunk.data, full_w, full_h)?;
218
219        // Use validated HillshadeParams; clamp azimuth/altitude to valid ranges
220        // so callers do not have to worry about boundary values.
221        let params = HillshadeParams::new(azimuth.clamp(0.0, 360.0), altitude.clamp(0.0, 90.0));
222
223        let result_buf =
224            hillshade(&dem, params).map_err(|e| RasterError::AlgorithmError(e.to_string()))?;
225
226        let result_data = f32_vec_from_buffer(&result_buf)?;
227
228        // Strip the halo rows/columns to obtain the core region.
229        Ok(extract_inner(
230            &result_data,
231            full_w,
232            full_h,
233            chunk.halo,
234            chunk.width,
235            chunk.height,
236        ))
237    })
238}
239
240// ---------------------------------------------------------------------------
241// streaming_slope
242// ---------------------------------------------------------------------------
243
244/// Processes slope in a streaming (chunk-by-chunk) fashion.
245///
246/// Uses `pixel_size = 1.0` and `z_factor = 1.0`.  Halo cells prevent
247/// discontinuities at chunk borders.
248///
249/// # Errors
250///
251/// Returns any [`RasterError`] produced by chunk iteration or the slope
252/// algorithm.
253pub fn streaming_slope<R: RasterSource>(
254    raster: &ChunkedRaster<R>,
255) -> Result<Vec<f32>, RasterError> {
256    process_streaming(raster, |chunk| {
257        let full_w = chunk.full_width();
258        let full_h = chunk.full_height();
259
260        let dem = buffer_from_f32_slice(&chunk.data, full_w, full_h)?;
261
262        let result_buf =
263            slope(&dem, 1.0, 1.0).map_err(|e| RasterError::AlgorithmError(e.to_string()))?;
264
265        let result_data = f32_vec_from_buffer(&result_buf)?;
266
267        Ok(extract_inner(
268            &result_data,
269            full_w,
270            full_h,
271            chunk.halo,
272            chunk.width,
273            chunk.height,
274        ))
275    })
276}
277
278// ---------------------------------------------------------------------------
279// streaming_focal_mean
280// ---------------------------------------------------------------------------
281
282/// Processes focal mean in a streaming (chunk-by-chunk) fashion using a
283/// circular window of the given `radius`.
284///
285/// The chunk halo must be at least `ceil(radius)` pixels to avoid boundary
286/// artefacts.  The caller is responsible for selecting a suitable halo when
287/// constructing the [`ChunkedRaster`].
288///
289/// # Errors
290///
291/// Returns any [`RasterError`] produced by chunk iteration or the focal-mean
292/// algorithm.
293pub fn streaming_focal_mean<R: RasterSource>(
294    raster: &ChunkedRaster<R>,
295    radius: usize,
296) -> Result<Vec<f32>, RasterError> {
297    process_streaming(raster, |chunk| {
298        let full_w = chunk.full_width();
299        let full_h = chunk.full_height();
300
301        let src_buf = buffer_from_f32_slice(&chunk.data, full_w, full_h)?;
302
303        // Build a circular window matching the requested radius.
304        let window = WindowShape::circular(radius as f64)
305            .map_err(|e| RasterError::AlgorithmError(e.to_string()))?;
306
307        let result_buf = focal_mean(&src_buf, &window, &BoundaryMode::Edge)
308            .map_err(|e| RasterError::AlgorithmError(e.to_string()))?;
309
310        let result_data = f32_vec_from_buffer(&result_buf)?;
311
312        Ok(extract_inner(
313            &result_data,
314            full_w,
315            full_h,
316            chunk.halo,
317            chunk.width,
318            chunk.height,
319        ))
320    })
321}