Skip to main content

oxigdal_gpu/
tiled.rs

1//! Tiled raster processing for datasets exceeding VRAM budget.
2//!
3//! This module provides automatic tile-based decomposition of large rasters
4//! that would otherwise overflow GPU VRAM. Key concepts:
5//!
6//! - A **tile** is a rectangular sub-region of the full raster.
7//! - An **overlap halo** (optional) extends each tile by `overlap_pixels` on
8//!   every active edge, using edge-replication for boundary pixels.
9//! - **Splitting** decomposes a flat f32 raster into `Vec<RasterTile>`.
10//! - **Stitching** assembles processed tiles back into a full raster,
11//!   discarding the halo and writing only the core interior.
12//! - **`auto_tile_size`** finds a tile size that fits inside an available
13//!   VRAM budget by halving the area until it fits.
14//! - **`execute_tiled`** orchestrates split → per-tile `tile_fn` → stitch.
15
16use crate::error::{GpuError, GpuResult};
17
18// ─────────────────────────────────────────────────────────────────────────────
19// TiledConfig
20// ─────────────────────────────────────────────────────────────────────────────
21
22/// Configuration for tiled raster processing.
23///
24/// The default tile size (512 × 512) fits comfortably within typical VRAM
25/// budgets for f32 rasters.  Increase `overlap_pixels` when a kernel requires
26/// neighbouring pixel context (e.g. convolution with radius `r` needs
27/// `overlap_pixels = r`).
28#[derive(Debug, Clone)]
29pub struct TiledConfig {
30    /// Tile width in pixels.  Default: 512.
31    pub tile_width: usize,
32    /// Tile height in pixels.  Default: 512.
33    pub tile_height: usize,
34    /// Overlap in pixels on every active edge (halo for kernels needing
35    /// neighbours).  Default: 0.
36    pub overlap_pixels: usize,
37    /// Safety margin: fraction of the VRAM budget to keep free.
38    /// Must be in `[0.0, 1.0)`.  Default: 0.1 (10 %).
39    pub vram_safety_margin: f64,
40}
41
42impl Default for TiledConfig {
43    fn default() -> Self {
44        Self {
45            tile_width: 512,
46            tile_height: 512,
47            overlap_pixels: 0,
48            vram_safety_margin: 0.1,
49        }
50    }
51}
52
53impl TiledConfig {
54    /// Set tile width and height.
55    pub fn with_tile_size(mut self, w: usize, h: usize) -> Self {
56        self.tile_width = w;
57        self.tile_height = h;
58        self
59    }
60
61    /// Set overlap (halo) width in pixels on each edge.
62    pub fn with_overlap(mut self, pixels: usize) -> Self {
63        self.overlap_pixels = pixels;
64        self
65    }
66
67    /// Set the VRAM safety margin fraction.  Clamped to `[0.0, 0.99]`.
68    pub fn with_vram_safety_margin(mut self, margin: f64) -> Self {
69        self.vram_safety_margin = margin.clamp(0.0, 0.99);
70        self
71    }
72}
73
74// ─────────────────────────────────────────────────────────────────────────────
75// RasterTile
76// ─────────────────────────────────────────────────────────────────────────────
77
78/// A single tile extracted from a raster, optionally padded with an overlap halo.
79///
80/// `data` is stored row-major (top-left origin) and includes halo pixels if
81/// `overlap_pixels > 0`.  The halo is filled using edge-replication (the value
82/// at the nearest in-bounds pixel is copied).
83#[derive(Debug, Clone)]
84pub struct RasterTile {
85    /// Pixel data (row-major, f32, single band).
86    /// Length == `padded_width() * padded_height()`.
87    pub data: Vec<f32>,
88
89    /// Core tile width in pixels (without overlap).
90    pub width: usize,
91    /// Core tile height in pixels (without overlap).
92    pub height: usize,
93
94    /// Overlap rows added above the core region.
95    pub overlap_top: usize,
96    /// Overlap columns added to the right of the core region.
97    pub overlap_right: usize,
98    /// Overlap rows added below the core region.
99    pub overlap_bottom: usize,
100    /// Overlap columns added to the left of the core region.
101    pub overlap_left: usize,
102
103    /// X pixel coordinate of the tile's top-left corner in the full raster
104    /// (excluding overlap extension, i.e. the origin of the core region).
105    pub origin_x: usize,
106    /// Y pixel coordinate of the tile's top-left corner in the full raster
107    /// (excluding overlap extension).
108    pub origin_y: usize,
109
110    /// Full raster width in pixels.
111    pub raster_width: usize,
112    /// Full raster height in pixels.
113    pub raster_height: usize,
114
115    /// Flat tile index: `tile_row * tiles_per_row + tile_col`.
116    pub tile_index: usize,
117}
118
119impl RasterTile {
120    /// Width of `data` in pixels (core + left halo + right halo).
121    #[inline]
122    pub fn padded_width(&self) -> usize {
123        self.width + self.overlap_left + self.overlap_right
124    }
125
126    /// Height of `data` in pixels (core + top halo + bottom halo).
127    #[inline]
128    pub fn padded_height(&self) -> usize {
129        self.height + self.overlap_top + self.overlap_bottom
130    }
131
132    /// Total number of f32 elements in `data`.
133    #[inline]
134    pub fn padded_len(&self) -> usize {
135        self.padded_width() * self.padded_height()
136    }
137}
138
139// ─────────────────────────────────────────────────────────────────────────────
140// split_into_tiles
141// ─────────────────────────────────────────────────────────────────────────────
142
143/// Split a flat f32 raster (row-major) into tiles with an optional overlap halo.
144///
145/// Edge handling: when a tile would extend beyond the raster boundary the halo
146/// is filled with the value at the nearest valid pixel (edge-replication /
147/// clamp-to-edge).
148///
149/// Tiles are returned in row-major tile order (left-to-right within each row,
150/// top-to-bottom across rows).
151///
152/// If `raster_width == 0 || raster_height == 0` an empty `Vec` is returned.
153/// If `config.tile_width == 0 || config.tile_height == 0` the entire raster is
154/// returned as a single tile.
155pub fn split_into_tiles(
156    raster: &[f32],
157    raster_width: usize,
158    raster_height: usize,
159    config: &TiledConfig,
160) -> Vec<RasterTile> {
161    if raster_width == 0 || raster_height == 0 {
162        return Vec::new();
163    }
164
165    // Treat a zero tile dimension as "whole raster".
166    let tile_w = if config.tile_width == 0 {
167        raster_width
168    } else {
169        config.tile_width
170    };
171    let tile_h = if config.tile_height == 0 {
172        raster_height
173    } else {
174        config.tile_height
175    };
176    let overlap = config.overlap_pixels;
177
178    // Number of tiles along each axis.
179    let tiles_x = raster_width.div_ceil(tile_w);
180    let tiles_y = raster_height.div_ceil(tile_h);
181
182    let mut result = Vec::with_capacity(tiles_x * tiles_y);
183
184    for ty in 0..tiles_y {
185        for tx in 0..tiles_x {
186            // Core region (in raster coordinates).
187            let core_x0 = tx * tile_w;
188            let core_y0 = ty * tile_h;
189            let core_w = tile_w.min(raster_width - core_x0);
190            let core_h = tile_h.min(raster_height - core_y0);
191
192            // Compute actual halo sizes — zero at raster boundary.
193            let halo_top = overlap.min(core_y0);
194            let halo_left = overlap.min(core_x0);
195            let halo_bottom = overlap.min(raster_height.saturating_sub(core_y0 + core_h));
196            let halo_right = overlap.min(raster_width.saturating_sub(core_x0 + core_w));
197
198            // For corner/edge tiles we still want the overlap to contain valid
199            // (replicated) data even if the raster has no pixels there.  The
200            // padded_width / padded_height always include the full `overlap`
201            // halo; pixels outside the raster are clamped to the edge row/col.
202            let pad_top = overlap;
203            let pad_left = overlap;
204            let pad_bottom = overlap;
205            let pad_right = overlap;
206
207            let padded_w = core_w + pad_left + pad_right;
208            let padded_h = core_h + pad_top + pad_bottom;
209
210            let mut data = vec![0.0_f32; padded_w * padded_h];
211
212            for row in 0..padded_h {
213                // Row in raster space corresponding to this padded row.
214                // Clamp to [0, raster_height-1] for edge replication.
215                let raster_row = (core_y0 as isize - overlap as isize + row as isize)
216                    .clamp(0, raster_height as isize - 1) as usize;
217
218                for col in 0..padded_w {
219                    // Column in raster space, clamped similarly.
220                    let raster_col = (core_x0 as isize - overlap as isize + col as isize)
221                        .clamp(0, raster_width as isize - 1)
222                        as usize;
223
224                    let src_idx = raster_row * raster_width + raster_col;
225                    let dst_idx = row * padded_w + col;
226
227                    data[dst_idx] = if src_idx < raster.len() {
228                        raster[src_idx]
229                    } else {
230                        0.0
231                    };
232                }
233            }
234
235            result.push(RasterTile {
236                data,
237                width: core_w,
238                height: core_h,
239                overlap_top: pad_top,
240                overlap_right: pad_right,
241                overlap_bottom: pad_bottom,
242                overlap_left: pad_left,
243                // Actual halo extent (may be smaller at boundary).
244                // We expose the configured overlap uniformly; stitching uses
245                // `overlap_left/top` to locate the core interior.
246                origin_x: core_x0,
247                origin_y: core_y0,
248                raster_width,
249                raster_height,
250                tile_index: ty * tiles_x + tx,
251            });
252
253            // Suppress unused variable warnings for the computed-but-not-stored
254            // actual halo sizes (they were intermediate calculations).
255            let _ = (halo_top, halo_left, halo_bottom, halo_right);
256        }
257    }
258
259    result
260}
261
262// ─────────────────────────────────────────────────────────────────────────────
263// stitch_tiles
264// ─────────────────────────────────────────────────────────────────────────────
265
266/// Stitch processed tiles back into a full raster.
267///
268/// Only the non-overlap core interior (`tile.width × tile.height`) of each tile
269/// is written into the output at `(tile.origin_x, tile.origin_y)`.  Halo pixels
270/// are discarded.  Pixels not covered by any tile are left as `0.0`.
271///
272/// Tiles may be supplied in any order; `tile_index` is not used for placement
273/// (origin coordinates are used instead).
274pub fn stitch_tiles(tiles: &[RasterTile], raster_width: usize, raster_height: usize) -> Vec<f32> {
275    let mut output = vec![0.0_f32; raster_width * raster_height];
276
277    for tile in tiles {
278        let padded_w = tile.padded_width();
279        let core_w = tile.width;
280        let core_h = tile.height;
281        let halo_left = tile.overlap_left;
282        let halo_top = tile.overlap_top;
283
284        for row in 0..core_h {
285            for col in 0..core_w {
286                let src_row = halo_top + row;
287                let src_col = halo_left + col;
288                let src_idx = src_row * padded_w + src_col;
289
290                let dst_row = tile.origin_y + row;
291                let dst_col = tile.origin_x + col;
292
293                if dst_row < raster_height && dst_col < raster_width {
294                    let dst_idx = dst_row * raster_width + dst_col;
295                    if src_idx < tile.data.len() {
296                        output[dst_idx] = tile.data[src_idx];
297                    }
298                }
299            }
300        }
301    }
302
303    output
304}
305
306// ─────────────────────────────────────────────────────────────────────────────
307// vram_per_tile
308// ─────────────────────────────────────────────────────────────────────────────
309
310/// Estimate the VRAM required to process one tile.
311///
312/// Budget formula:
313/// - input f32 buffer: `padded_len × 4` bytes
314/// - output f32 buffer: `padded_len × 4` bytes
315/// - uniform / metadata buffer: 256 bytes (rounded up to wgpu alignment)
316///
317/// Returns the total in bytes.
318pub fn vram_per_tile(tile: &RasterTile) -> usize {
319    tile.padded_len() * 4 * 2 + 256
320}
321
322// ─────────────────────────────────────────────────────────────────────────────
323// auto_tile_size
324// ─────────────────────────────────────────────────────────────────────────────
325
326/// Compute an auto-selected tile size that fits within the effective VRAM budget.
327///
328/// Algorithm:
329/// 1. Compute the effective budget: `vram_budget_bytes * (1 - safety_margin)`.
330/// 2. Start with `(preferred_w, preferred_h)`.
331/// 3. On each iteration check whether a synthetic tile of that size fits.
332/// 4. If not, halve along the wider axis (alternating width / height).
333/// 5. Stop when the tile fits or dimensions reach the floor `(16, 16)`.
334///
335/// Returns `(tile_width, tile_height)`.
336pub fn auto_tile_size(
337    preferred_w: usize,
338    preferred_h: usize,
339    overlap: usize,
340    vram_budget_bytes: usize,
341    safety_margin: f64,
342) -> (usize, usize) {
343    let effective_budget =
344        (vram_budget_bytes as f64 * (1.0 - safety_margin.clamp(0.0, 0.99))) as usize;
345
346    let mut w = preferred_w.max(16);
347    let mut h = preferred_h.max(16);
348
349    // Minimum floor — guarantee forward progress.
350    const MIN: usize = 16;
351
352    loop {
353        // Synthesise a representative tile using the current dimensions.
354        let synthetic = RasterTile {
355            data: Vec::new(),
356            width: w,
357            height: h,
358            overlap_top: overlap,
359            overlap_right: overlap,
360            overlap_bottom: overlap,
361            overlap_left: overlap,
362            origin_x: 0,
363            origin_y: 0,
364            raster_width: w,
365            raster_height: h,
366            tile_index: 0,
367        };
368
369        if vram_per_tile(&synthetic) <= effective_budget {
370            return (w, h);
371        }
372
373        // Tile does not fit — halve the larger dimension.
374        if w > MIN || h > MIN {
375            // Halve whichever dimension is currently larger (prefer width first
376            // when equal so splitting is deterministic).
377            if w >= h {
378                w = (w / 2).max(MIN);
379            } else {
380                h = (h / 2).max(MIN);
381            }
382        } else {
383            // Already at minimum floor — return it regardless.
384            return (MIN, MIN);
385        }
386    }
387}
388
389// ─────────────────────────────────────────────────────────────────────────────
390// execute_tiled
391// ─────────────────────────────────────────────────────────────────────────────
392
393/// High-level tiled executor: split → per-tile `tile_fn` → stitch.
394///
395/// `tile_fn(tile) -> GpuResult<Vec<f32>>` receives each tile and must return a
396/// `Vec<f32>` whose length equals `tile.padded_len()` (the same layout as
397/// `tile.data`).  GPU submission typically happens inside `tile_fn`.
398///
399/// The stitcher discards the halo and writes only the core interior into the
400/// full-resolution output.
401///
402/// # Errors
403///
404/// Returns the first error propagated by `tile_fn`.
405pub fn execute_tiled<F>(
406    raster: &[f32],
407    width: usize,
408    height: usize,
409    config: &TiledConfig,
410    tile_fn: F,
411) -> GpuResult<Vec<f32>>
412where
413    F: Fn(&RasterTile) -> GpuResult<Vec<f32>>,
414{
415    if width == 0 || height == 0 {
416        return Ok(Vec::new());
417    }
418
419    let tiles = split_into_tiles(raster, width, height, config);
420
421    let mut processed_tiles: Vec<RasterTile> = Vec::with_capacity(tiles.len());
422
423    for tile in &tiles {
424        let processed_data = tile_fn(tile).map_err(|e| {
425            GpuError::execution_failed(format!(
426                "tile {} (origin {},{}) failed: {}",
427                tile.tile_index, tile.origin_x, tile.origin_y, e
428            ))
429        })?;
430
431        // Validate returned length matches expectation.
432        if processed_data.len() != tile.padded_len() {
433            return Err(GpuError::execution_failed(format!(
434                "tile_fn for tile {} returned {} elements but expected {} (padded_len)",
435                tile.tile_index,
436                processed_data.len(),
437                tile.padded_len(),
438            )));
439        }
440
441        processed_tiles.push(RasterTile {
442            data: processed_data,
443            width: tile.width,
444            height: tile.height,
445            overlap_top: tile.overlap_top,
446            overlap_right: tile.overlap_right,
447            overlap_bottom: tile.overlap_bottom,
448            overlap_left: tile.overlap_left,
449            origin_x: tile.origin_x,
450            origin_y: tile.origin_y,
451            raster_width: tile.raster_width,
452            raster_height: tile.raster_height,
453            tile_index: tile.tile_index,
454        });
455    }
456
457    Ok(stitch_tiles(&processed_tiles, width, height))
458}
459
460// ─────────────────────────────────────────────────────────────────────────────
461// Unit tests (in-module — compile with lib)
462// ─────────────────────────────────────────────────────────────────────────────
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_tiled_config_default() {
470        let cfg = TiledConfig::default();
471        assert_eq!(cfg.tile_width, 512);
472        assert_eq!(cfg.tile_height, 512);
473        assert_eq!(cfg.overlap_pixels, 0);
474        assert!((cfg.vram_safety_margin - 0.1).abs() < f64::EPSILON);
475    }
476
477    #[test]
478    fn test_tiled_config_builder() {
479        let cfg = TiledConfig::default()
480            .with_tile_size(256, 128)
481            .with_overlap(4)
482            .with_vram_safety_margin(0.2);
483        assert_eq!(cfg.tile_width, 256);
484        assert_eq!(cfg.tile_height, 128);
485        assert_eq!(cfg.overlap_pixels, 4);
486        assert!((cfg.vram_safety_margin - 0.2).abs() < f64::EPSILON);
487    }
488
489    #[test]
490    fn test_raster_tile_padded_dimensions() {
491        let tile = RasterTile {
492            data: vec![0.0; (10 + 2 + 2) * (8 + 3 + 3)],
493            width: 10,
494            height: 8,
495            overlap_top: 3,
496            overlap_right: 2,
497            overlap_bottom: 3,
498            overlap_left: 2,
499            origin_x: 0,
500            origin_y: 0,
501            raster_width: 100,
502            raster_height: 100,
503            tile_index: 0,
504        };
505        assert_eq!(tile.padded_width(), 14);
506        assert_eq!(tile.padded_height(), 14);
507        assert_eq!(tile.padded_len(), 196);
508    }
509}