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}