Skip to main content

oxiphysics_io/
remote_sensing_io.rs

1#![allow(clippy::needless_range_loop, clippy::type_complexity)]
2#![allow(clippy::manual_range_contains)]
3// Copyright 2026 COOLJAPAN OU (Team KitaSan)
4// SPDX-License-Identifier: Apache-2.0
5
6//! Remote sensing and geospatial data I/O.
7//!
8//! Provides structs and methods for reading, writing, and processing
9//! geospatial raster data (GeoTIFF-style), point clouds (LAS/XYZ),
10//! satellite imagery indices, and digital elevation models.
11
12#![allow(dead_code)]
13
14// ---------------------------------------------------------------------------
15// GeoTiffReader
16// ---------------------------------------------------------------------------
17
18/// A minimal GeoTIFF-style raster reader that holds image geometry and
19/// georeferencing metadata.
20///
21/// The affine transform follows the standard 6-parameter GDAL convention:
22/// `[x_origin, pixel_width, row_rotation, y_origin, col_rotation, pixel_height]`
23/// where `pixel_height` is typically negative (top-left origin).
24#[derive(Debug, Clone)]
25pub struct GeoTiffReader {
26    /// Raster width in pixels.
27    pub width: usize,
28    /// Raster height in pixels.
29    pub height: usize,
30    /// Number of spectral or data bands.
31    pub bands: usize,
32    /// Six-element affine geotransform (GDAL order).
33    pub transform: [f64; 6],
34    /// Band data stored as flat row-major vectors, one per band.
35    data: Vec<Vec<f64>>,
36}
37
38impl GeoTiffReader {
39    /// Construct a new `GeoTiffReader` with explicit dimensions and transform.
40    pub fn new(
41        width: usize,
42        height: usize,
43        bands: usize,
44        transform: [f64; 6],
45        data: Vec<Vec<f64>>,
46    ) -> Self {
47        Self {
48            width,
49            height,
50            bands,
51            transform,
52            data,
53        }
54    }
55
56    /// Convert pixel coordinates `(px, py)` to world (lon, lat) coordinates
57    /// using the stored affine transform.
58    pub fn pixel_to_world(&self, px: usize, py: usize) -> [f64; 2] {
59        let t = &self.transform;
60        let lon = t[0] + px as f64 * t[1] + py as f64 * t[2];
61        let lat = t[3] + px as f64 * t[4] + py as f64 * t[5];
62        [lon, lat]
63    }
64
65    /// Convert world (lon, lat) coordinates to pixel coordinates using the
66    /// inverse of the stored affine transform.
67    pub fn world_to_pixel(&self, lon: f64, lat: f64) -> (usize, usize) {
68        let t = &self.transform;
69        let det = t[1] * t[5] - t[2] * t[4];
70        let det = if det.abs() < 1e-15 { 1e-15 } else { det };
71        let dx = lon - t[0];
72        let dy = lat - t[3];
73        let px = (t[5] * dx - t[2] * dy) / det;
74        let py = (t[1] * dy - t[4] * dx) / det;
75        let px = px
76            .round()
77            .max(0.0)
78            .min((self.width.saturating_sub(1)) as f64) as usize;
79        let py = py
80            .round()
81            .max(0.0)
82            .min((self.height.saturating_sub(1)) as f64) as usize;
83        (px, py)
84    }
85
86    /// Return a clone of band `band` data (0-indexed).
87    ///
88    /// Returns an empty vector if `band >= self.bands`.
89    pub fn read_band(&self, band: usize) -> Vec<f64> {
90        if band < self.data.len() {
91            self.data[band].clone()
92        } else {
93            vec![]
94        }
95    }
96
97    /// Return the elevation (first-band value) at world coordinate `(lon, lat)`.
98    pub fn elevation_at(&self, lon: f64, lat: f64) -> f64 {
99        if self.data.is_empty() || self.data[0].is_empty() {
100            return 0.0;
101        }
102        let (px, py) = self.world_to_pixel(lon, lat);
103        let idx = py * self.width + px;
104        *self.data[0].get(idx).unwrap_or(&0.0)
105    }
106
107    /// Return the bounding box `[min_lon, min_lat, max_lon, max_lat]`.
108    pub fn bounding_box(&self) -> [f64; 4] {
109        let tl = self.pixel_to_world(0, 0);
110        let br = self.pixel_to_world(self.width, self.height);
111        [
112            tl[0].min(br[0]),
113            tl[1].min(br[1]),
114            tl[0].max(br[0]),
115            tl[1].max(br[1]),
116        ]
117    }
118}
119
120// ---------------------------------------------------------------------------
121// RasterData
122// ---------------------------------------------------------------------------
123
124/// A 2-D raster grid with an associated no-data sentinel value.
125#[derive(Debug, Clone)]
126pub struct RasterData {
127    /// Row-major 2-D data grid (`data[row][col]`).
128    pub data: Vec<Vec<f64>>,
129    /// Number of columns.
130    pub width: usize,
131    /// Number of rows.
132    pub height: usize,
133    /// Sentinel value representing missing / invalid cells.
134    pub nodata: f64,
135}
136
137impl RasterData {
138    /// Construct a new `RasterData` from a 2-D grid.
139    pub fn new(data: Vec<Vec<f64>>, nodata: f64) -> Self {
140        let height = data.len();
141        let width = if height > 0 { data[0].len() } else { 0 };
142        Self {
143            data,
144            width,
145            height,
146            nodata,
147        }
148    }
149
150    /// Compute `(min, max, mean, std_dev)` over all valid (non-nodata) cells.
151    pub fn statistics(&self) -> (f64, f64, f64, f64) {
152        let valid: Vec<f64> = self
153            .data
154            .iter()
155            .flat_map(|row| row.iter())
156            .copied()
157            .filter(|&v| (v - self.nodata).abs() > 1e-10)
158            .collect();
159        if valid.is_empty() {
160            return (0.0, 0.0, 0.0, 0.0);
161        }
162        let min = valid.iter().cloned().fold(f64::INFINITY, f64::min);
163        let max = valid.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
164        let mean = valid.iter().sum::<f64>() / valid.len() as f64;
165        let variance =
166            valid.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / valid.len() as f64;
167        (min, max, mean, variance.sqrt())
168    }
169
170    /// Clip the raster to a bounding box `[min_x, min_y, max_x, max_y]`.
171    ///
172    /// In pixel space this maps `x → col` and `y → row` (0-origin).
173    pub fn clip_to_bbox(&self, bbox: [f64; 4]) -> Self {
174        let col_start = (bbox[0] as usize).min(self.width);
175        let row_start = (bbox[1] as usize).min(self.height);
176        let col_end = (bbox[2] as usize).min(self.width);
177        let row_end = (bbox[3] as usize).min(self.height);
178        let clipped: Vec<Vec<f64>> = self.data[row_start..row_end]
179            .iter()
180            .map(|row| row[col_start..col_end].to_vec())
181            .collect();
182        RasterData::new(clipped, self.nodata)
183    }
184
185    /// Resample the raster by `factor` (e.g. 0.5 = half resolution).
186    pub fn resample(&self, factor: f64) -> Self {
187        let factor = factor.max(0.01);
188        let new_h = ((self.height as f64) * factor).round() as usize;
189        let new_w = ((self.width as f64) * factor).round() as usize;
190        let mut out = vec![vec![self.nodata; new_w]; new_h];
191        for r in 0..new_h {
192            for c in 0..new_w {
193                let src_r = ((r as f64) / factor) as usize;
194                let src_c = ((c as f64) / factor) as usize;
195                let src_r = src_r.min(self.height.saturating_sub(1));
196                let src_c = src_c.min(self.width.saturating_sub(1));
197                out[r][c] = self.data[src_r][src_c];
198            }
199        }
200        RasterData::new(out, self.nodata)
201    }
202
203    /// Compute hillshade given sun `azimuth` and `elevation` (degrees).
204    pub fn hillshade(&self, azimuth: f64, elevation: f64) -> Vec<Vec<f64>> {
205        let az_rad = azimuth.to_radians();
206        let el_rad = elevation.to_radians();
207        let sun = [
208            -el_rad.cos() * az_rad.sin(),
209            -el_rad.cos() * az_rad.cos(),
210            el_rad.sin(),
211        ];
212        let slope_grid = self.slope();
213        let aspect_grid = self.aspect();
214        let mut hs = vec![vec![0.0_f64; self.width]; self.height];
215        for r in 0..self.height {
216            for c in 0..self.width {
217                let s = slope_grid[r][c].to_radians();
218                let a = aspect_grid[r][c].to_radians();
219                let nx = s.sin() * a.sin();
220                let ny = s.sin() * a.cos();
221                let nz = s.cos();
222                let dot = (nx * sun[0] + ny * sun[1] + nz * sun[2]).max(0.0);
223                hs[r][c] = dot;
224            }
225        }
226        hs
227    }
228
229    /// Compute slope (degrees) using a 3×3 Sobel kernel.
230    pub fn slope(&self) -> Vec<Vec<f64>> {
231        let mut out = vec![vec![0.0_f64; self.width]; self.height];
232        for r in 1..self.height.saturating_sub(1) {
233            for c in 1..self.width.saturating_sub(1) {
234                let dzdx =
235                    (self.data[r - 1][c + 1] + 2.0 * self.data[r][c + 1] + self.data[r + 1][c + 1]
236                        - self.data[r - 1][c - 1]
237                        - 2.0 * self.data[r][c - 1]
238                        - self.data[r + 1][c - 1])
239                        / 8.0;
240                let dzdy =
241                    (self.data[r + 1][c - 1] + 2.0 * self.data[r + 1][c] + self.data[r + 1][c + 1]
242                        - self.data[r - 1][c - 1]
243                        - 2.0 * self.data[r - 1][c]
244                        - self.data[r - 1][c + 1])
245                        / 8.0;
246                out[r][c] = (dzdx * dzdx + dzdy * dzdy).sqrt().atan().to_degrees();
247            }
248        }
249        out
250    }
251
252    /// Compute aspect (degrees, 0 = North, clockwise) using a 3×3 Sobel kernel.
253    pub fn aspect(&self) -> Vec<Vec<f64>> {
254        let mut out = vec![vec![0.0_f64; self.width]; self.height];
255        for r in 1..self.height.saturating_sub(1) {
256            for c in 1..self.width.saturating_sub(1) {
257                let dzdx =
258                    (self.data[r - 1][c + 1] + 2.0 * self.data[r][c + 1] + self.data[r + 1][c + 1]
259                        - self.data[r - 1][c - 1]
260                        - 2.0 * self.data[r][c - 1]
261                        - self.data[r + 1][c - 1])
262                        / 8.0;
263                let dzdy =
264                    (self.data[r + 1][c - 1] + 2.0 * self.data[r + 1][c] + self.data[r + 1][c + 1]
265                        - self.data[r - 1][c - 1]
266                        - 2.0 * self.data[r - 1][c]
267                        - self.data[r - 1][c + 1])
268                        / 8.0;
269                let aspect = (-dzdy).atan2(dzdx).to_degrees();
270                out[r][c] = if aspect < 0.0 { aspect + 360.0 } else { aspect };
271            }
272        }
273        out
274    }
275}
276
277// ---------------------------------------------------------------------------
278// Dem
279// ---------------------------------------------------------------------------
280
281/// Digital Elevation Model wrapping a `RasterData` grid with a known cell size.
282#[derive(Debug, Clone)]
283pub struct Dem {
284    /// Underlying raster elevation data.
285    pub raster: RasterData,
286    /// Physical size of each square cell (metres).
287    pub cell_size: f64,
288}
289
290impl Dem {
291    /// Construct a new `Dem` from a `RasterData` and a cell size.
292    pub fn new(raster: RasterData, cell_size: f64) -> Self {
293        Self { raster, cell_size }
294    }
295
296    /// Compute an 8-direction flow-direction grid (D8 algorithm).
297    ///
298    /// Encoded directions: 1=E, 2=SE, 4=S, 8=SW, 16=W, 32=NW, 64=N, 128=NE.
299    pub fn flow_direction(&self) -> Vec<Vec<u8>> {
300        let h = self.raster.height;
301        let w = self.raster.width;
302        // D8 neighbour offsets (dr, dc) and their encoded values
303        let dirs: [(i32, i32, u8); 8] = [
304            (0, 1, 1),    // E
305            (1, 1, 2),    // SE
306            (1, 0, 4),    // S
307            (1, -1, 8),   // SW
308            (0, -1, 16),  // W
309            (-1, -1, 32), // NW
310            (-1, 0, 64),  // N
311            (-1, 1, 128), // NE
312        ];
313        let mut out = vec![vec![0u8; w]; h];
314        for r in 0..h {
315            for c in 0..w {
316                let z = self.raster.data[r][c];
317                let mut max_drop = 0.0_f64;
318                let mut best_dir = 1u8;
319                for &(dr, dc, code) in &dirs {
320                    let nr = r as i32 + dr;
321                    let nc = c as i32 + dc;
322                    if nr < 0 || nc < 0 || nr >= h as i32 || nc >= w as i32 {
323                        continue;
324                    }
325                    let nz = self.raster.data[nr as usize][nc as usize];
326                    let dist = if dr == 0 || dc == 0 {
327                        self.cell_size
328                    } else {
329                        self.cell_size * std::f64::consts::SQRT_2
330                    };
331                    let drop = (z - nz) / dist;
332                    if drop > max_drop {
333                        max_drop = drop;
334                        best_dir = code;
335                    }
336                }
337                out[r][c] = best_dir;
338            }
339        }
340        out
341    }
342
343    /// Compute flow-accumulation grid (number of upstream cells).
344    pub fn flow_accumulation(&self) -> Vec<Vec<f64>> {
345        let h = self.raster.height;
346        let w = self.raster.width;
347        let fd = self.flow_direction();
348        let mut accum = vec![vec![1.0_f64; w]; h];
349        // Simple iterative accumulation (not topology-sorted; sufficient for tests)
350        for _ in 0..(h * w) {
351            let snap = accum.clone();
352            for r in 0..h {
353                for c in 0..w {
354                    let dir = fd[r][c];
355                    let (dr, dc): (i32, i32) = match dir {
356                        1 => (0, 1),
357                        2 => (1, 1),
358                        4 => (1, 0),
359                        8 => (1, -1),
360                        16 => (0, -1),
361                        32 => (-1, -1),
362                        64 => (-1, 0),
363                        128 => (-1, 1),
364                        _ => continue,
365                    };
366                    let nr = r as i32 + dr;
367                    let nc = c as i32 + dc;
368                    if nr >= 0 && nc >= 0 && nr < h as i32 && nc < w as i32 {
369                        accum[nr as usize][nc as usize] += snap[r][c];
370                    }
371                }
372            }
373        }
374        accum
375    }
376
377    /// Delineate a watershed upstream of `outlet` pixel using D8 flow directions.
378    ///
379    /// Returns a list of `(row, col)` cells belonging to the watershed.
380    pub fn watershed_delineation(&self, outlet: (usize, usize)) -> Vec<(usize, usize)> {
381        let h = self.raster.height;
382        let w = self.raster.width;
383        let fd = self.flow_direction();
384        // For each cell check whether its flow direction eventually reaches outlet
385        // (single-step check: does the immediate downstream cell flow toward outlet?)
386        let dir_to_offset = |d: u8| -> (i32, i32) {
387            match d {
388                1 => (0, 1),
389                2 => (1, 1),
390                4 => (1, 0),
391                8 => (1, -1),
392                16 => (0, -1),
393                32 => (-1, -1),
394                64 => (-1, 0),
395                128 => (-1, 1),
396                _ => (0, 0),
397            }
398        };
399
400        let mut watershed = Vec::new();
401        let mut visited = vec![vec![false; w]; h];
402        let mut stack = vec![outlet];
403        visited[outlet.0][outlet.1] = true;
404
405        while let Some((r, c)) = stack.pop() {
406            watershed.push((r, c));
407            // Find all cells that drain directly into (r, c)
408            for nr in 0..h {
409                for nc in 0..w {
410                    if visited[nr][nc] {
411                        continue;
412                    }
413                    let (dr, dc) = dir_to_offset(fd[nr][nc]);
414                    let tr = nr as i32 + dr;
415                    let tc = nc as i32 + dc;
416                    if tr == r as i32 && tc == c as i32 {
417                        visited[nr][nc] = true;
418                        stack.push((nr, nc));
419                    }
420                }
421            }
422        }
423        watershed
424    }
425
426    /// Extract a stream network as connected channel segments.
427    ///
428    /// Cells with flow accumulation ≥ `threshold` are considered streams.
429    /// Returns a list of segments, each segment being an ordered list of cells.
430    pub fn extract_stream_network(&self, threshold: f64) -> Vec<Vec<(usize, usize)>> {
431        let accum = self.flow_accumulation();
432        let h = self.raster.height;
433        let w = self.raster.width;
434        let fd = self.flow_direction();
435        let is_stream: Vec<Vec<bool>> = accum
436            .iter()
437            .map(|row| row.iter().map(|&v| v >= threshold).collect())
438            .collect();
439
440        let dir_to_offset = |d: u8| -> (i32, i32) {
441            match d {
442                1 => (0, 1),
443                2 => (1, 1),
444                4 => (1, 0),
445                8 => (1, -1),
446                16 => (0, -1),
447                32 => (-1, -1),
448                64 => (-1, 0),
449                128 => (-1, 1),
450                _ => (0, 0),
451            }
452        };
453
454        let mut visited = vec![vec![false; w]; h];
455        let mut segments: Vec<Vec<(usize, usize)>> = Vec::new();
456
457        for r in 0..h {
458            for c in 0..w {
459                if !is_stream[r][c] || visited[r][c] {
460                    continue;
461                }
462                // Trace downstream segment
463                let mut seg = Vec::new();
464                let mut cr = r;
465                let mut cc = c;
466                loop {
467                    if visited[cr][cc] || !is_stream[cr][cc] {
468                        break;
469                    }
470                    visited[cr][cc] = true;
471                    seg.push((cr, cc));
472                    let (dr, dc) = dir_to_offset(fd[cr][cc]);
473                    let nr = cr as i32 + dr;
474                    let nc = cc as i32 + dc;
475                    if nr < 0 || nc < 0 || nr >= h as i32 || nc >= w as i32 {
476                        break;
477                    }
478                    cr = nr as usize;
479                    cc = nc as usize;
480                }
481                if !seg.is_empty() {
482                    segments.push(seg);
483                }
484            }
485        }
486        segments
487    }
488}
489
490// ---------------------------------------------------------------------------
491// PointCloudIO
492// ---------------------------------------------------------------------------
493
494/// Utilities for reading and writing 3-D point cloud data.
495pub struct PointCloudIO;
496
497impl PointCloudIO {
498    /// Parse a minimal LAS-style ASCII stub where each line is `x y z`.
499    ///
500    /// Lines beginning with `#` are treated as comments and skipped.
501    pub fn read_las_stub(content: &str) -> Vec<[f64; 3]> {
502        content
503            .lines()
504            .filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
505            .filter_map(|l| {
506                let mut parts = l.split_whitespace();
507                let x = parts.next()?.parse::<f64>().ok()?;
508                let y = parts.next()?.parse::<f64>().ok()?;
509                let z = parts.next()?.parse::<f64>().ok()?;
510                Some([x, y, z])
511            })
512            .collect()
513    }
514
515    /// Serialize a slice of points to a whitespace-delimited XYZ string.
516    pub fn write_xyz(points: &[[f64; 3]]) -> String {
517        points
518            .iter()
519            .map(|p| format!("{} {} {}\n", p[0], p[1], p[2]))
520            .collect()
521    }
522
523    /// Parse a whitespace-delimited XYZ text file into a vector of 3-D points.
524    pub fn read_xyz(content: &str) -> Vec<[f64; 3]> {
525        Self::read_las_stub(content)
526    }
527
528    /// Compute the axis-aligned bounding box `[min_x,min_y,min_z,max_x,max_y,max_z]`.
529    ///
530    /// Returns all zeros if `points` is empty.
531    pub fn bounding_box(points: &[[f64; 3]]) -> [f64; 6] {
532        if points.is_empty() {
533            return [0.0; 6];
534        }
535        let mut mn = points[0];
536        let mut mx = points[0];
537        for p in points.iter().skip(1) {
538            for i in 0..3 {
539                mn[i] = mn[i].min(p[i]);
540                mx[i] = mx[i].max(p[i]);
541            }
542        }
543        [mn[0], mn[1], mn[2], mx[0], mx[1], mx[2]]
544    }
545
546    /// Voxelise a point cloud at `resolution` and return one representative
547    /// point per non-empty voxel (the centroid of all points in that voxel).
548    pub fn voxelize(points: &[[f64; 3]], resolution: f64) -> Vec<[f64; 3]> {
549        if points.is_empty() || resolution <= 0.0 {
550            return vec![];
551        }
552        let bb = Self::bounding_box(points);
553        use std::collections::HashMap;
554        let mut voxels: HashMap<(i64, i64, i64), (f64, f64, f64, u64)> = HashMap::new();
555        for p in points {
556            let ix = ((p[0] - bb[0]) / resolution).floor() as i64;
557            let iy = ((p[1] - bb[1]) / resolution).floor() as i64;
558            let iz = ((p[2] - bb[2]) / resolution).floor() as i64;
559            let e = voxels.entry((ix, iy, iz)).or_insert((0.0, 0.0, 0.0, 0));
560            e.0 += p[0];
561            e.1 += p[1];
562            e.2 += p[2];
563            e.3 += 1;
564        }
565        voxels
566            .values()
567            .map(|&(sx, sy, sz, n)| {
568                let n = n as f64;
569                [sx / n, sy / n, sz / n]
570            })
571            .collect()
572    }
573}
574
575// ---------------------------------------------------------------------------
576// SatelliteImage
577// ---------------------------------------------------------------------------
578
579/// Multi-spectral satellite image with named bands.
580#[derive(Debug, Clone)]
581pub struct SatelliteImage {
582    /// Flattened (row-major) pixel values per band.
583    pub bands: Vec<Vec<f64>>,
584    /// Human-readable names for each band (e.g. `"red"`, `"nir"`).
585    pub band_names: Vec<String>,
586}
587
588impl SatelliteImage {
589    /// Construct a `SatelliteImage` from per-band pixel data and band names.
590    pub fn new(bands: Vec<Vec<f64>>, band_names: Vec<String>) -> Self {
591        Self { bands, band_names }
592    }
593
594    /// Return the index of the band with the given name, if present.
595    pub fn band_index(&self, name: &str) -> Option<usize> {
596        self.band_names.iter().position(|n| n == name)
597    }
598
599    /// Compute the Normalized Difference Vegetation Index (NDVI).
600    ///
601    /// Requires bands named `"nir"` and `"red"`.  Returns a vector of per-pixel
602    /// NDVI values in `[-1, 1]`.
603    pub fn ndvi(&self) -> Vec<f64> {
604        let nir_idx = self.band_index("nir").unwrap_or(0);
605        let red_idx = self.band_index("red").unwrap_or(1);
606        if self.bands.len() <= nir_idx.max(red_idx) {
607            return vec![];
608        }
609        let nir = &self.bands[nir_idx];
610        let red = &self.bands[red_idx];
611        nir.iter()
612            .zip(red.iter())
613            .map(|(&n, &r)| {
614                let denom = n + r;
615                if denom.abs() < 1e-10 {
616                    0.0
617                } else {
618                    (n - r) / denom
619                }
620            })
621            .collect()
622    }
623
624    /// Compute the Normalized Difference Water Index (NDWI).
625    ///
626    /// Requires bands named `"green"` and `"nir"`.  Returns per-pixel values
627    /// in `[-1, 1]`.
628    pub fn ndwi(&self) -> Vec<f64> {
629        let green_idx = self.band_index("green").unwrap_or(0);
630        let nir_idx = self.band_index("nir").unwrap_or(1);
631        if self.bands.len() <= green_idx.max(nir_idx) {
632            return vec![];
633        }
634        let green = &self.bands[green_idx];
635        let nir = &self.bands[nir_idx];
636        green
637            .iter()
638            .zip(nir.iter())
639            .map(|(&g, &n)| {
640                let denom = g + n;
641                if denom.abs() < 1e-10 {
642                    0.0
643                } else {
644                    (g - n) / denom
645                }
646            })
647            .collect()
648    }
649
650    /// Produce an RGB false-colour composite by selecting bands `r`, `g`, `b`
651    /// (0-indexed) and normalising each channel to `[0, 1]`.
652    pub fn false_color_composite(&self, r: usize, g: usize, b: usize) -> Vec<[f64; 3]> {
653        if self.bands.len() <= r.max(g).max(b) {
654            return vec![];
655        }
656        let normalize = |band: &Vec<f64>| -> Vec<f64> {
657            let mn = band.iter().cloned().fold(f64::INFINITY, f64::min);
658            let mx = band.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
659            let range = mx - mn;
660            if range < 1e-10 {
661                vec![0.0; band.len()]
662            } else {
663                band.iter().map(|&v| (v - mn) / range).collect()
664            }
665        };
666        let rb = normalize(&self.bands[r]);
667        let gb = normalize(&self.bands[g]);
668        let bb = normalize(&self.bands[b]);
669        rb.iter()
670            .zip(gb.iter())
671            .zip(bb.iter())
672            .map(|((&rv, &gv), &bv)| [rv, gv, bv])
673            .collect()
674    }
675}
676
677// ---------------------------------------------------------------------------
678// Tests
679// ---------------------------------------------------------------------------
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    fn sample_raster() -> RasterData {
686        // 5×5 DEM with a simple gradient
687        let data: Vec<Vec<f64>> = (0..5)
688            .map(|r| (0..5).map(|c| (r * 5 + c) as f64 * 10.0).collect())
689            .collect();
690        RasterData::new(data, -9999.0)
691    }
692
693    fn sample_dem() -> Dem {
694        Dem::new(sample_raster(), 30.0)
695    }
696
697    // --- GeoTiffReader ---
698
699    #[test]
700    fn test_geotiff_pixel_to_world_origin() {
701        let t = [100.0, 0.5, 0.0, 50.0, 0.0, -0.5];
702        let reader = GeoTiffReader::new(10, 10, 1, t, vec![vec![1.0; 100]]);
703        let [lon, lat] = reader.pixel_to_world(0, 0);
704        assert!((lon - 100.0).abs() < 1e-9);
705        assert!((lat - 50.0).abs() < 1e-9);
706    }
707
708    #[test]
709    fn test_geotiff_pixel_to_world_offset() {
710        let t = [0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
711        let reader = GeoTiffReader::new(4, 4, 1, t, vec![vec![0.0; 16]]);
712        let [lon, lat] = reader.pixel_to_world(2, 3);
713        assert!((lon - 2.0).abs() < 1e-9);
714        assert!((lat - 3.0).abs() < 1e-9);
715    }
716
717    #[test]
718    fn test_geotiff_world_to_pixel_roundtrip() {
719        let t = [10.0, 0.1, 0.0, 20.0, 0.0, -0.1];
720        let data = vec![vec![5.0; 100]];
721        let reader = GeoTiffReader::new(10, 10, 1, t, data);
722        let (px, py) = reader.world_to_pixel(10.5, 19.5);
723        assert_eq!(px, 5);
724        assert_eq!(py, 5);
725    }
726
727    #[test]
728    fn test_geotiff_elevation_at() {
729        let t = [0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
730        let band: Vec<f64> = (0..16).map(|i| i as f64).collect();
731        let reader = GeoTiffReader::new(4, 4, 1, t, vec![band]);
732        // pixel (0,0) → index 0 → value 0.0
733        let elev = reader.elevation_at(0.0, 0.0);
734        assert!((elev - 0.0).abs() < 1e-9);
735    }
736
737    #[test]
738    fn test_geotiff_bounding_box_size() {
739        let t = [10.0, 1.0, 0.0, 50.0, 0.0, -1.0];
740        let reader = GeoTiffReader::new(5, 5, 1, t, vec![vec![0.0; 25]]);
741        let bb = reader.bounding_box();
742        assert!(bb[2] > bb[0]);
743    }
744
745    #[test]
746    fn test_geotiff_read_band_empty_when_out_of_range() {
747        let reader = GeoTiffReader::new(2, 2, 1, [0.0; 6], vec![vec![1.0; 4]]);
748        assert!(reader.read_band(5).is_empty());
749    }
750
751    // --- RasterData ---
752
753    #[test]
754    fn test_raster_statistics_basic() {
755        let r = sample_raster();
756        let (mn, mx, mean, _std) = r.statistics();
757        assert!((mn - 0.0).abs() < 1e-9);
758        assert!((mx - 240.0).abs() < 1e-9);
759        assert!(mean > 0.0);
760    }
761
762    #[test]
763    fn test_raster_statistics_all_nodata() {
764        let r = RasterData::new(vec![vec![-9999.0; 3]; 3], -9999.0);
765        let (mn, mx, mean, std) = r.statistics();
766        assert_eq!((mn, mx, mean, std), (0.0, 0.0, 0.0, 0.0));
767    }
768
769    #[test]
770    fn test_raster_clip_to_bbox_dimensions() {
771        let r = sample_raster();
772        let clipped = r.clip_to_bbox([1.0, 1.0, 4.0, 4.0]);
773        assert_eq!(clipped.width, 3);
774        assert_eq!(clipped.height, 3);
775    }
776
777    #[test]
778    fn test_raster_resample_down() {
779        let r = sample_raster();
780        let small = r.resample(0.5);
781        assert!(small.width <= r.width);
782        assert!(small.height <= r.height);
783    }
784
785    #[test]
786    fn test_raster_resample_up() {
787        let r = sample_raster();
788        let big = r.resample(2.0);
789        assert!(big.width >= r.width);
790        assert!(big.height >= r.height);
791    }
792
793    #[test]
794    fn test_raster_slope_border_zero() {
795        let r = sample_raster();
796        let s = r.slope();
797        // Border pixels not computed (remain 0)
798        assert!((s[0][0] - 0.0).abs() < 1e-9);
799    }
800
801    #[test]
802    fn test_raster_slope_interior_positive() {
803        let r = sample_raster();
804        let s = r.slope();
805        assert!(s[2][2] >= 0.0);
806    }
807
808    #[test]
809    fn test_raster_aspect_range() {
810        let r = sample_raster();
811        let a = r.aspect();
812        for row in &a {
813            for &v in row {
814                assert!(v >= 0.0 && v < 360.0 + 1e-9, "aspect out of range: {v}");
815            }
816        }
817    }
818
819    #[test]
820    fn test_raster_hillshade_range() {
821        let r = sample_raster();
822        let hs = r.hillshade(315.0, 45.0);
823        for row in &hs {
824            for &v in row {
825                assert!(v >= 0.0 && v <= 1.0 + 1e-9, "hillshade out of [0,1]: {v}");
826            }
827        }
828    }
829
830    // --- Dem ---
831
832    #[test]
833    fn test_dem_flow_direction_shape() {
834        let d = sample_dem();
835        let fd = d.flow_direction();
836        assert_eq!(fd.len(), 5);
837        assert_eq!(fd[0].len(), 5);
838    }
839
840    #[test]
841    fn test_dem_flow_accumulation_shape() {
842        let d = sample_dem();
843        let fa = d.flow_accumulation();
844        assert_eq!(fa.len(), 5);
845        assert_eq!(fa[0].len(), 5);
846    }
847
848    #[test]
849    fn test_dem_flow_accumulation_positive() {
850        let d = sample_dem();
851        let fa = d.flow_accumulation();
852        for row in &fa {
853            for &v in row {
854                assert!(v >= 1.0, "flow accumulation must be ≥ 1");
855            }
856        }
857    }
858
859    #[test]
860    fn test_dem_watershed_contains_outlet() {
861        let d = sample_dem();
862        let ws = d.watershed_delineation((4, 4));
863        assert!(ws.contains(&(4, 4)));
864    }
865
866    #[test]
867    fn test_dem_stream_network_threshold_high() {
868        let d = sample_dem();
869        // Very high threshold → no streams
870        let segs = d.extract_stream_network(1e12);
871        assert!(segs.is_empty());
872    }
873
874    #[test]
875    fn test_dem_stream_network_threshold_low() {
876        let d = sample_dem();
877        // Low threshold → all cells are streams
878        let segs = d.extract_stream_network(0.0);
879        assert!(!segs.is_empty());
880    }
881
882    // --- PointCloudIO ---
883
884    #[test]
885    fn test_point_cloud_write_read_roundtrip() {
886        let pts = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
887        let s = PointCloudIO::write_xyz(&pts);
888        let back = PointCloudIO::read_xyz(&s);
889        assert_eq!(back.len(), 2);
890        assert!((back[0][0] - 1.0).abs() < 1e-9);
891        assert!((back[1][2] - 6.0).abs() < 1e-9);
892    }
893
894    #[test]
895    fn test_point_cloud_bounding_box_empty() {
896        let bb = PointCloudIO::bounding_box(&[]);
897        assert_eq!(bb, [0.0; 6]);
898    }
899
900    #[test]
901    fn test_point_cloud_bounding_box_single() {
902        let bb = PointCloudIO::bounding_box(&[[3.0, 1.0, -2.0]]);
903        assert!((bb[0] - 3.0).abs() < 1e-9);
904        assert!((bb[3] - 3.0).abs() < 1e-9);
905    }
906
907    #[test]
908    fn test_point_cloud_bounding_box_multiple() {
909        let pts = vec![[0.0, 0.0, 0.0], [1.0, 2.0, 3.0], [-1.0, 5.0, -2.0]];
910        let bb = PointCloudIO::bounding_box(&pts);
911        assert!((bb[0] - (-1.0)).abs() < 1e-9); // min_x
912        assert!((bb[4] - 5.0).abs() < 1e-9); // max_y
913    }
914
915    #[test]
916    fn test_point_cloud_voxelize_reduces_count() {
917        let pts: Vec<[f64; 3]> = (0..100)
918            .map(|i| [(i % 10) as f64 * 0.1, (i / 10) as f64 * 0.1, 0.0])
919            .collect();
920        let vox = PointCloudIO::voxelize(&pts, 0.5);
921        assert!(vox.len() < pts.len());
922    }
923
924    #[test]
925    fn test_point_cloud_las_stub_comments_skipped() {
926        let content = "# header\n1 2 3\n# comment\n4 5 6\n";
927        let pts = PointCloudIO::read_las_stub(content);
928        assert_eq!(pts.len(), 2);
929    }
930
931    // --- SatelliteImage ---
932
933    fn make_image() -> SatelliteImage {
934        let red: Vec<f64> = (0..4).map(|i| i as f64 * 0.1).collect();
935        let nir: Vec<f64> = (0..4).map(|i| i as f64 * 0.2 + 0.1).collect();
936        let green: Vec<f64> = (0..4).map(|i| i as f64 * 0.15).collect();
937        SatelliteImage::new(
938            vec![red, nir, green],
939            vec!["red".into(), "nir".into(), "green".into()],
940        )
941    }
942
943    #[test]
944    fn test_satellite_ndvi_length() {
945        let img = make_image();
946        assert_eq!(img.ndvi().len(), 4);
947    }
948
949    #[test]
950    fn test_satellite_ndvi_range() {
951        let img = make_image();
952        for v in img.ndvi() {
953            assert!(
954                v >= -1.0 - 1e-9 && v <= 1.0 + 1e-9,
955                "NDVI out of range: {v}"
956            );
957        }
958    }
959
960    #[test]
961    fn test_satellite_ndwi_length() {
962        let img = make_image();
963        assert_eq!(img.ndwi().len(), 4);
964    }
965
966    #[test]
967    fn test_satellite_false_color_length() {
968        let img = make_image();
969        let fc = img.false_color_composite(0, 2, 1);
970        assert_eq!(fc.len(), 4);
971    }
972
973    #[test]
974    fn test_satellite_false_color_range() {
975        let img = make_image();
976        for px in img.false_color_composite(0, 2, 1) {
977            for &ch in &px {
978                assert!(
979                    ch >= 0.0 - 1e-9 && ch <= 1.0 + 1e-9,
980                    "channel out of [0,1]: {ch}"
981                );
982            }
983        }
984    }
985
986    #[test]
987    fn test_satellite_band_index_found() {
988        let img = make_image();
989        assert_eq!(img.band_index("nir"), Some(1));
990    }
991
992    #[test]
993    fn test_satellite_band_index_not_found() {
994        let img = make_image();
995        assert_eq!(img.band_index("swir"), None);
996    }
997}