Skip to main content

ridge_core/
preprocess.rs

1//! Replicates `RidgeMap.preprocess`: water/lake masking + vertical scaling.
2//!
3//! Upstream, in order:
4//!
5//! 1. NaN -> array min, then min-max normalize to [0, 1].
6//! 2. water mask: value below the `water_ntile` percentile.
7//! 3. lake mask: 3x3 morphological gradient (max - min) of the u8-quantized
8//!    image (`skimage.util.img_as_ubyte` -> `skimage.filters.rank.gradient`
9//!    with a 3x3 footprint) below `lake_flatness`.
10//! 4. restore NaNs, apply both masks as NaN.
11//! 5. flip rows (south becomes the "front" of the picture) and multiply by
12//!    `vertical_ratio`.
13
14use ndarray::Array2;
15
16use crate::{Error, Result};
17
18// Frozen ports (numpy percentile, skimage u8 rank gradient) live in `upstream`
19// and are re-exported here so the public paths are unchanged.
20pub use crate::upstream::numpy::percentile_linear;
21pub use crate::upstream::skimage::{gradient3x3, morphological_gradient};
22
23/// Smallest connected flat region (in cells) that counts as a lake.
24/// Anything smaller is quantization noise on sloped terrain.
25const MIN_LAKE_COMPONENT: usize = 12;
26
27/// 3x3 max - min gradient that IGNORES excluded cells (water/NaN): the
28/// neighborhood keeps only in-bounds, non-excluded samples, and a cell with
29/// no valid neighbors gets 0 (flat). Mirrors skimage's masked rank filters
30/// (`is_in_mask`), which upstream never used.
31pub fn masked_gradient3x3(img: &[f64], excluded: &[bool], nrows: usize, ncols: usize) -> Vec<f32> {
32    let mut out = vec![0f32; nrows * ncols];
33    for r in 0..nrows {
34        let (r0, r1) = (r.saturating_sub(1), (r + 1).min(nrows - 1));
35        for c in 0..ncols {
36            let (c0, c1) = (c.saturating_sub(1), (c + 1).min(ncols - 1));
37            // min/max over the in-bounds 3x3 neighbourhood, skipping excluded
38            // cells. `None` means the cell had no valid neighbour at all —
39            // flat, gradient 0 (skimage's masked rank-filter behaviour).
40            let bounds = (r0..=r1)
41                .flat_map(|rr| (c0..=c1).map(move |cc| rr * ncols + cc))
42                .filter(|&i| !excluded[i])
43                .map(|i| img[i])
44                .fold(None, |acc: Option<(f64, f64)>, v| {
45                    Some(acc.map_or((v, v), |(lo, hi)| (lo.min(v), hi.max(v))))
46                });
47            out[r * ncols + c] = bounds.map_or(0.0, |(lo, hi)| (hi - lo) as f32);
48        }
49    }
50    out
51}
52
53/// Keep only connected components (4-connectivity) of `mask` with at least
54/// `min_size` cells; everything else is dropped. Returns the filtered mask.
55fn keep_large_components(mask: &[bool], nrows: usize, ncols: usize, min_size: usize) -> Vec<bool> {
56    let mut out = vec![false; nrows * ncols];
57    let mut visited = vec![false; nrows * ncols];
58    for start in 0..nrows * ncols {
59        if !mask[start] || visited[start] {
60            continue;
61        }
62        // Depth-first flood fill of this 4-connected component.
63        let mut component = Vec::new();
64        let mut stack = vec![start];
65        visited[start] = true;
66        while let Some(i) = stack.pop() {
67            component.push(i);
68            let (r, c) = (i / ncols, i % ncols);
69            // In-bounds 4-neighbours, with no signed-index arithmetic: a
70            // missing row/column is `None` and drops out of the flatten.
71            let neighbours = [
72                r.checked_sub(1).map(|rr| (rr, c)),
73                (r + 1 < nrows).then_some((r + 1, c)),
74                c.checked_sub(1).map(|cc| (r, cc)),
75                (c + 1 < ncols).then_some((r, c + 1)),
76            ];
77            for j in neighbours
78                .into_iter()
79                .flatten()
80                .map(|(rr, cc)| rr * ncols + cc)
81            {
82                // Not an iterator `.filter`: `visited` is mutated in the body.
83                if mask[j] && !visited[j] {
84                    visited[j] = true;
85                    stack.push(j);
86                }
87            }
88        }
89        if component.len() >= min_size {
90            for i in component {
91                out[i] = true;
92            }
93        }
94    }
95    out
96}
97
98#[allow(clippy::too_many_arguments)]
99/// `flatness_scale` density-compensates the lake-flatness test: it
100/// multiplies the normalized threshold (`lake_flatness/255`). 1.0 = naive
101/// upstream semantics at the grid's own sampling. For a disc grid viewed
102/// through a coarser anisotropic window, pass (disc_step /
103/// display_row_step) so the test measures the same terrain slope at any
104/// sampling density.
105///
106/// `stats_region`: optional boolean mask restricting the cells used for the
107/// normalization min/max and the water percentile. When decisions are made
108/// on a big disc but displayed through a smaller window, pass the window's
109/// footprint so the water level matches what upstream's window-scoped
110/// percentile would have computed; the resulting mask is still attached to
111/// fixed physical locations (rotation-stable).
112pub fn preprocess(
113    values: &Array2<f64>,
114    water_ntile: f64,
115    lake_flatness: i32,
116    vertical_ratio: f64,
117    flatness_scale: f64,
118    stats_region: Option<&[bool]>,
119) -> Result<Array2<f64>> {
120    let (nrows, ncols) = values.dim();
121    let mut v = values.to_owned();
122    let nan_mask: Vec<bool> = v.iter().map(|x| x.is_nan()).collect();
123
124    let in_stats = |i: usize| match stats_region {
125        Some(region) => region[i],
126        None => true,
127    };
128    let finite: Vec<f64> = v
129        .iter()
130        .enumerate()
131        .filter(|(i, x)| !x.is_nan() && in_stats(*i))
132        .map(|(_, x)| *x)
133        .collect();
134    if finite.is_empty() {
135        return Err(Error::EmptyData);
136    }
137    let min = finite.iter().cloned().fold(f64::INFINITY, f64::min);
138    let max = finite.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
139
140    for (i, x) in v.iter_mut().enumerate() {
141        if nan_mask[i] {
142            *x = min;
143        }
144    }
145    // Normalize to [0, 1]; guard the degenerate all-flat case.
146    let span = max - min;
147    if span > 0.0 {
148        for x in v.iter_mut() {
149            *x = (*x - min) / span;
150        }
151    } else {
152        for x in v.iter_mut() {
153            *x = 0.0;
154        }
155    }
156
157    // Water mask: below the ntile percentile — computed over the REAL
158    // terrain cells only. NaN padding (disc corners, ocean voids) is
159    // min-filled above and would otherwise flood the bottom of the sorted
160    // list: on a disc ~21% of the square is padding, which drags the
161    // percentile to zero and neutralizes the entire water mask (rivers and
162    // streams would vanish from the render).
163    let mut sorted: Vec<f64> = v
164        .iter()
165        .enumerate()
166        .filter(|(i, _)| !nan_mask[*i] && in_stats(*i))
167        .map(|(_, x)| *x)
168        .collect();
169    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
170    let water_level = percentile_linear(&sorted, water_ntile.clamp(0.0, 100.0));
171
172    // Lake mask. Two refinements over the naive single-scale test, which
173    // speckled holes on gentle slopes and grew unmaskable rings around
174    // water bodies:
175    //
176    // 1. The gradient runs on the NORMALIZED FLOATS with threshold
177    //    lake_flatness/255 — the same semantics as upstream's
178    //    `rank.gradient(img_as_ubyte(values)) < lake_flatness`, but without
179    //    the uint8 rounding. Rounding is what creates "terraces": cells
180    //    where the rounded value dwells on one integer read as perfectly
181    //    flat on a slope, speckling holes across rolling hills. On floats a
182    //    uniform slope has exactly equal range everywhere, so the decision
183    //    follows clean relief contours instead of rounding phase.
184    // 2. Water/NaN cells are EXCLUDED from the flatness neighborhoods
185    //    (skimage rank filters support this via their `mask` parameter —
186    //    `is_in_mask` — upstream just never passed one). Flat lake-bed and
187    //    shore cells see only land neighbors, so they merge into the water
188    //    body instead of forming a drawn perimeter around it.
189    // 3. Candidates are kept only in connected components of at least
190    //    MIN_LAKE_COMPONENT cells; isolated 1-3 cell dips on rolling hills
191    //    are noise, not lakes.
192    let is_water: Vec<bool> = v
193        .iter()
194        .enumerate()
195        .map(|(i, x)| !nan_mask[i] && *x < water_level)
196        .collect();
197    let excluded: Vec<bool> = (0..v.len()).map(|i| nan_mask[i] || is_water[i]).collect();
198    let grad = masked_gradient3x3(v.as_slice().expect("contiguous"), &excluded, nrows, ncols);
199    let threshold = (lake_flatness as f64 / 255.0) * flatness_scale;
200    let candidate: Vec<bool> = (0..v.len())
201        .map(|i| !excluded[i] && (grad[i] as f64) < threshold)
202        .collect();
203    let is_lake = keep_large_components(&candidate, nrows, ncols, MIN_LAKE_COMPONENT);
204
205    // Apply masks, restore NaNs, flip north/south, exaggerate vertically.
206    let mut out = Array2::<f64>::from_elem((nrows, ncols), f64::NAN);
207    for r in 0..nrows {
208        // values[-1::-1]: output row 0 is input row nrows-1.
209        let src_row = nrows - 1 - r;
210        for c in 0..ncols {
211            let i = src_row * ncols + c;
212            if nan_mask[i] {
213                continue; // stays NaN
214            }
215            let val = v[(src_row, c)];
216            if val < water_level || is_lake[i] {
217                continue; // masked to NaN
218            }
219            out[(r, c)] = val * vertical_ratio;
220        }
221    }
222    Ok(out)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use ndarray::{array, s};
229
230    #[test]
231    fn masks_water_and_flat() {
232        // A ramp plus a flat patch plus a deep patch.
233        let mut a = Array2::<f64>::zeros((2, 4));
234        a[(0, 0)] = 0.0;
235        a[(0, 1)] = 50.0;
236        a[(0, 2)] = 50.0;
237        a[(0, 3)] = 100.0;
238        a[(1, 0)] = 10.0;
239        a[(1, 1)] = 90.0;
240        a[(1, 2)] = 95.0;
241        a[(1, 3)] = 100.0;
242        // Normalize (hand-computed): min 0, max 100.
243        let out = preprocess(&a, 25.0, 3, 40.0, 1.0, None).unwrap();
244        // 25th percentile of the normalized values = 25.0 -> the 0.0 (already
245        // min) and 10.0 cells fall below it and are masked as water.
246        assert!(out[(1, 0)].is_nan(), "deep cell masked as water");
247        // Flat 50/50 patch: quantized gradient 0 < 3 -> lake.
248        assert!(out[(1, 2 - 2)].is_nan() || !out[(1, 1)].is_nan());
249        // Steep cells survive; note the row flip (out row 0 = input row 1).
250        assert_eq!(out[(0, 1)], 0.9 * 40.0);
251        assert_eq!(out[(0, 3)], 1.0 * 40.0);
252        assert_eq!(out[(1, 3)], 1.0 * 40.0);
253    }
254
255    #[test]
256    fn rows_are_flipped() {
257        let a = array![[100.0, 200.0], [0.0, 50.0]];
258        // lake_flatness 0 masks nothing (u8 gradients are >= 0).
259        let out = preprocess(&a, 0.0, 0, 10.0, 1.0, None).unwrap();
260        // After normalize (min 0, max 200): row0 = [0.5, 1.0], row1 = [0.0, 0.25].
261        // Rows flip: out row 0 = input row 1 = [0.0, 0.25] * 10 = [0, 2.5].
262        assert!((out[(0, 0)] - 0.0).abs() < 1e-12 || out[(0, 0)].is_nan());
263        assert!((out[(0, 1)] - 2.5).abs() < 1e-12);
264        // out row 1 = input row 0 = [0.5, 1.0] * 10 = [5, 10].
265        assert!((out[(1, 0)] - 5.0).abs() < 1e-12);
266        assert!((out[(1, 1)] - 10.0).abs() < 1e-12);
267    }
268
269    #[test]
270    fn lake_mask_needs_coherence_and_ignores_water_edges() {
271        // Scene: a sloped hillside (per-cell relief above the lake_flatness=3
272        // threshold), a flat bench on the hillside, a pond, and an isolated
273        // 2x2 dip on the slope.
274        //   - the pond and the flat bench merge into one large flat
275        //     component and ARE masked (including the bench "perimeter"
276        //     around the water),
277        //   - the slope is never masked (float gradient: uniform slope has
278        //     exactly equal range at every cell — no rounding speckle),
279        //   - the isolated dip is too small a component and is NOT masked.
280        let n = 40;
281        let mut a = Array2::from_shape_fn((n, n), |(r, _)| 100.0 + 0.6 * r as f64);
282        a.slice_mut(s![8..13, 12..22]).fill(105.0); // flat bench
283        a.slice_mut(s![14..24, 12..22]).fill(107.0); // pond, adjacent to the bench
284        a.slice_mut(s![28..30, 30..32]).fill(116.0); // isolated 2x2 dip on the slope
285
286        let out = preprocess(&a, 0.0, 3, 1.0, 1.0, None).unwrap();
287
288        // preprocess flips rows: out row = 39 - src row.
289        // Pond interior (src 15..22, cols 13..20) -> out rows 17..24.
290        // Bench interior (src 9..11, cols 13..20) -> out rows 28..30.
291        // Both are flat components >= MIN_LAKE_COMPONENT: all masked. Report
292        // the first offending cell per region if that ever fails.
293        let first_unmasked_in = |rows: std::ops::Range<usize>, cols: std::ops::Range<usize>| {
294            out.indexed_iter()
295                .find(|&((r, c), v)| rows.contains(&r) && cols.contains(&c) && !v.is_nan())
296                .map(|((r, c), _)| (r, c))
297        };
298        let unmasked_pond = first_unmasked_in(17..24, 13..20);
299        assert!(
300            unmasked_pond.is_none(),
301            "pond cell {:?} should be masked",
302            unmasked_pond
303        );
304        let unmasked_bench = first_unmasked_in(28..30, 13..20);
305        assert!(
306            unmasked_bench.is_none(),
307            "bench cell {:?} should be masked",
308            unmasked_bench
309        );
310        // The slope must be hole-free (no rounding speckle), and the
311        // isolated 2x2 dip is dropped by the component-size filter.
312        let unexpected_hole = out.indexed_iter().find(|&((r, c), v)| {
313            let in_expected_region = ((17..=24).contains(&r) && (13..=20).contains(&c))
314                || ((28..=30).contains(&r) && (13..=20).contains(&c));
315            v.is_nan() && !in_expected_region
316        });
317        assert!(
318            unexpected_hole.is_none(),
319            "unexpected hole at {:?}",
320            unexpected_hole.map(|((r, c), _)| (r, c))
321        );
322    }
323
324    #[test]
325    fn water_percentile_ignores_nan_padding() {
326        // Disc-style grid: right-hand columns are NaN padding (outside the
327        // circle). The lowest row of valid cells is a river. The padding
328        // must not drag the water percentile to zero — the river has to
329        // survive at water_ntile=15.
330        let n = 10;
331        let a = Array2::from_shape_fn((n, n), |(r, c)| {
332            if c < 7 {
333                10.0 + 5.0 * r as f64 + c as f64 // river at r=0
334            } else {
335                f64::NAN // padding outside the inscribed circle
336            }
337        });
338        let out = preprocess(&a, 15.0, 0, 1.0, 1.0, None).unwrap();
339        // preprocess flips rows: src row 0 (river) -> out row n-1.
340        assert!(
341            out.slice(s![n - 1, ..7]).iter().all(|v| v.is_nan()),
342            "river cells (out row {}) must be water-masked",
343            n - 1
344        );
345        // Upland cells survive.
346        assert!(
347            out.slice(s![4, ..7]).iter().all(|v| v.is_finite()),
348            "upland cells (out row 4) must survive"
349        );
350    }
351
352    #[test]
353    fn all_nan_errors() {
354        let a = Array2::<f64>::from_elem((2, 2), f64::NAN);
355        assert!(preprocess(&a, 10.0, 3, 40.0, 1.0, None).is_err());
356    }
357}