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}