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