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}