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}