Skip to main content

terra_texture_rs/
lib.rs

1//! Fused elementwise kernels for TerraTexture, accelerating the
2//! pure-numpy implementations in `terra_texture.blend`,
3//! `terra_texture.derivatives` and `terra_texture.stretch` without
4//! changing their public behaviour.
5//!
6//! The Python package tries to import this compiled extension and falls
7//! back to its numpy implementation if the import fails (unbuilt,
8//! unsupported platform, or a plain `pip install` without the compiled
9//! wheel). That fallback is load-bearing, not incidental: the package's
10//! stated design goal is that `terra_texture.derivatives` and
11//! `terra_texture.blend` have zero hard dependencies beyond numpy/scipy.
12//!
13//! # Kernels
14//!
15//! | Module | Core function(s) | Input | Output |
16//! |---|---|---|---|
17//! | [`soft_light`] | [`soft_light_core`], [`soft_light_rgb_core`] | two `f32` arrays, same shape, (H, W) or (H, W, C) | same shape |
18//! | [`luminosity_blend`] | [`luminosity_blend_core`] | `f32` (H, W, 3) + `f32` (H, W) | `f32` (H, W, 3) |
19//! | [`curvature`] | [`curvatures_core`] | `f32` DEM (H, W), NaN-free | two `f32` (H, W) |
20//! | [`hillshade`] | [`hillshade_core`] | `f32` DEM (H, W), NaN-free | `f32` (H, W) in \[0, 1\] |
21//! | [`stretch`] | [`stretch_std_core`] | `f32` (H, W), NaN allowed | `f32` (H, W) in \[0, 1\] or NaN |
22//!
23//! All arrays are `f32` throughout, matching the `float32` arrays the
24//! Python side passes in.
25//!
26//! # Structure
27//!
28//! Each kernel is split into two layers:
29//! - a `*_core` function in its kernel module: pure [`ndarray`] in, pure
30//!   `ndarray` out, no PyO3 types anywhere. This is what
31//!   `benches/blend_bench.rs` and any `#[test]`s call directly, with no
32//!   Python interpreter needed.
33//! - a `#[pyfunction]` wrapper in the private `python` module: unwraps
34//!   the numpy arrays into `ndarray` views, calls the core function, and
35//!   wraps the result back up. `python` is the only module that imports
36//!   `pyo3` or `numpy`.
37//!
38//! This split is why `[lib] crate-type` includes `"rlib"` alongside the
39//! `"cdylib"` Python needs: an rlib is what `cargo bench`/`cargo test`
40//! link against to call the core functions in-process.
41//!
42//! The public kernel functions are re-exported at the crate root, so
43//! `use terra_texture_rs::soft_light_serial;`-style imports (e.g. in
44//! `benches/blend_bench.rs`) work alongside the full module paths.
45//!
46//! # Output buffers
47//!
48//! Every core function writes into a caller-allocated `out` array rather
49//! than returning a new one, so the Python wrappers can allocate once
50//! and hand the buffer straight back to numpy. `out` must have the shape
51//! stated in each function's docs; mismatched shapes panic (see each
52//! function's **Panics** section).
53//!
54//! # The serial/parallel threshold
55//!
56//! Rayon's parallel dispatch has fixed per-call overhead (splitting work,
57//! synchronizing threads) that only pays for itself once there's enough
58//! work per thread to amortize it. Below [`PARALLEL_THRESHOLD`] elements,
59//! kernels run a plain serial loop instead. The threshold is a
60//! **provisional placeholder**, not a measured value; see its docs.
61
62// Sidebar logo: the same logo.jpg the pdoc docs use, loaded from the
63// public repo so it works at every page depth. Sized by
64// docs/rustdoc-header.html to fit the sidebar like the pdoc one does.
65#![doc(html_logo_url = "https://raw.githubusercontent.com/H4rdy12/TerraTexture/main/docs/docs_template/logo.jpg")]
66
67mod common;
68pub mod curvature;
69pub mod hillshade;
70pub mod luminosity_blend;
71mod python;
72pub mod soft_light;
73pub mod stretch;
74
75pub use common::PARALLEL_THRESHOLD;
76pub use curvature::curvatures_core;
77pub use hillshade::hillshade_core;
78pub use luminosity_blend::{luminosity_blend_core, luminosity_blend_parallel, luminosity_blend_serial};
79pub use soft_light::{
80    soft_light_core, soft_light_parallel, soft_light_rgb_core, soft_light_rgb_parallel, soft_light_rgb_serial,
81    soft_light_serial,
82};
83pub use stretch::stretch_std_core;
84
85// //! Fused elementwise kernels for `terra_texture.blend`, accelerating the
86// //! pure-numpy implementations in `blend.py` without changing their
87// //! public behaviour.
88// //!
89// //! `blend.py` tries to import this compiled extension and falls back to
90// //! its numpy implementation if the import fails (unbuilt, unsupported
91// //! platform, or a plain `pip install` without the compiled wheel) --
92// //! that fallback is load-bearing, not incidental: the package's stated
93// //! design goal is that `terra_texture.derivatives`/`terra_texture.blend`
94// //! have zero hard dependencies beyond numpy/scipy.
95// //!
96// //! ## Structure
97// //!
98// //! Each kernel is split into two layers:
99// //! - a `*_core` function: pure `ndarray` in, pure `ndarray` out, no PyO3
100// //!   types anywhere. This is what `benches/blend_bench.rs` and any future
101// //!   `#[test]`s call directly -- no Python interpreter needed to run them.
102// //! - a `#[pyfunction]` wrapper: unwraps the numpy arrays into `ndarray`
103// //!   views, calls the core function, wraps the result back up. This is
104// //!   the only layer that knows about Python at all.
105// //!
106// //! This split is why `[lib] crate-type` includes `"rlib"` alongside the
107// //! `"cdylib"` Python needs -- an rlib is what `cargo bench`/`cargo test`
108// //! link against to call the core functions in-process.
109// //!
110// //! ## The serial/parallel threshold
111// //!
112// //! Rayon's parallel dispatch has fixed per-call overhead (splitting work,
113// //! synchronizing threads) that only pays for itself once there's enough
114// //! work per thread to amortize it. Below `PARALLEL_THRESHOLD` elements,
115// //! kernels run a plain serial loop instead of `par_for_each`. The
116// //! threshold below is a **provisional placeholder**, not a measured
117// //! value -- run `cargo bench` (see `benches/blend_bench.rs`) to find the
118// //! actual crossover point on real hardware and update it here.
119
120// use ndarray::{Array2, Array3, ArrayView2, ArrayView3, Zip};
121// use numpy::{IntoPyArray, PyArray2, PyArray3, PyReadonlyArray2, PyReadonlyArray3};
122// use pyo3::prelude::*;
123// use rayon::prelude::*;
124
125// // TODO(benchmark): this is a guess, not a measurement. Run `cargo bench`
126// // and replace it with whatever `blend_bench.rs` actually finds as the
127// // point where `par_for_each` starts winning over a serial loop on the
128// // target hardware. 256x256 = 65_536 is picked only because it "feels"
129// // like a plausible order of magnitude for rayon's dispatch overhead to
130// // have been amortized -- treat it as unverified until benchmarked.
131// pub const PARALLEL_THRESHOLD: usize = 65_536;
132
133// #[inline]
134// fn soft_light_pixel(a: f32, b: f32) -> f32 {
135//     let v = if b <= 0.5 {
136//         2.0 * a * b + a * a * (1.0 - 2.0 * b)
137//     } else {
138//         // matches Python's np.sqrt(np.clip(a, 0, 1)) -- clamped to BOTH
139//         // 0 and 1 before the sqrt, not just floored at 0.
140//         2.0 * a * (1.0 - b) + a.clamp(0.0, 1.0).sqrt() * (2.0 * b - 1.0)
141//     };
142//     v.clamp(0.0, 1.0)
143// }
144
145// /// Serial (single-threaded) soft light -- exposed publicly alongside
146// /// `soft_light_parallel` purely so `benches/blend_bench.rs` can compare
147// /// them directly at every array size, independent of whatever
148// /// `PARALLEL_THRESHOLD` currently guesses. `soft_light_core()` below is
149// /// the actual dispatch entry point everything else should call.
150// pub fn soft_light_serial(a: ArrayView2<f32>, b: ArrayView2<f32>, out: &mut Array2<f32>) {
151//     Zip::from(out)
152//         .and(&a)
153//         .and(&b)
154//         .for_each(|o, &a, &b| *o = soft_light_pixel(a, b));
155// }
156
157// /// Parallel (rayon) soft light -- see `soft_light_serial`'s doc comment.
158// pub fn soft_light_parallel(a: ArrayView2<f32>, b: ArrayView2<f32>, out: &mut Array2<f32>) {
159//     Zip::from(out)
160//         .and(&a)
161//         .and(&b)
162//         .par_for_each(|o, &a, &b| *o = soft_light_pixel(a, b));
163// }
164
165// /// Pure computation: Photoshop-style soft light blend. `base`/`blend`
166// /// arrays in [0, 1], same shape. Matches `blend.py`'s `soft_light()`
167// /// exactly (verified against it in `tests/test_blend.py`'s Rust-parity
168// /// tests, when the extension is built). Dispatches to the serial or
169// /// parallel path based on `PARALLEL_THRESHOLD` -- this is the function
170// /// the `#[pyfunction]` wrapper and any other caller should use.
171// pub fn soft_light_core(a: ArrayView2<f32>, b: ArrayView2<f32>, out: &mut Array2<f32>) {
172//     if a.len() >= PARALLEL_THRESHOLD {
173//         soft_light_parallel(a, b, out);
174//     } else {
175//         soft_light_serial(a, b, out);
176//     }
177// }
178
179// /// Same as `soft_light_core` but over 3-D (H, W, C) arrays -- e.g. RGB
180// /// or RGBA imagery -- in ONE pass instead of the Python-side dispatch
181// /// calling the 2-D kernel once per channel. `soft_light_pixel` is
182// /// already channel-agnostic (a plain f32 -> f32 elementwise formula
183// /// with no cross-channel interaction), so this is the exact same
184// /// per-element math as the 2-D path; the only reason this exists as a
185// /// separate kernel rather than just reshaping and reusing the 2-D one
186// /// is to avoid the per-channel `np.ascontiguousarray()` copy the
187// /// Python-side loop needed (each `a[..., c]` channel slice of a
188// /// contiguous (H, W, C) array is itself non-contiguous). Operating on
189// /// the whole (H, W, C) buffer directly, already contiguous, skips that
190// /// entirely -- one input read, one output write, per element, with no
191// /// intermediate per-channel arrays at all.
192// pub fn soft_light_rgb_serial(a: ArrayView3<f32>, b: ArrayView3<f32>, out: &mut Array3<f32>) {
193//     Zip::from(out)
194//         .and(&a)
195//         .and(&b)
196//         .for_each(|o, &a, &b| *o = soft_light_pixel(a, b));
197// }
198
199// pub fn soft_light_rgb_parallel(a: ArrayView3<f32>, b: ArrayView3<f32>, out: &mut Array3<f32>) {
200//     Zip::from(out)
201//         .and(&a)
202//         .and(&b)
203//         .par_for_each(|o, &a, &b| *o = soft_light_pixel(a, b));
204// }
205
206// pub fn soft_light_rgb_core(a: ArrayView3<f32>, b: ArrayView3<f32>, out: &mut Array3<f32>) {
207//     if a.len() >= PARALLEL_THRESHOLD {
208//         soft_light_rgb_parallel(a, b, out);
209//     } else {
210//         soft_light_rgb_serial(a, b, out);
211//     }
212// }
213
214// #[inline]
215// fn luminosity_blend_pixel(r0: f32, g0: f32, b0: f32, target_lum: f32) -> (f32, f32, f32) {
216//     const EPS: f32 = 1e-12;
217
218//     let lum_backdrop = 0.3 * r0 + 0.59 * g0 + 0.11 * b0;
219//     let d = target_lum - lum_backdrop;
220//     let (r1, g1, b1) = (r0 + d, g0 + d, b0 + d);
221
222//     // lum(r1, g1, b1) == target_lum exactly, since 0.3 + 0.59 + 0.11 ==
223//     // 1.0 -- reuse target_lum as `l` instead of recomputing the weighted
224//     // sum a second time. This is the fusion-enabled simplification that
225//     // isn't available to the two-pass numpy version (soft_light() and
226//     // _clip_color() run as separate, unrelated calls there).
227//     let l = target_lum;
228
229//     let n = r1.min(g1).min(b1);
230//     let x = r1.max(g1).max(b1);
231
232//     // low clip: uses the ORIGINAL n, applied to (r1, g1, b1)
233//     let (r2, g2, b2) = if n < 0.0 {
234//         let scale = l / (l - n + EPS);
235//         (l + (r1 - l) * scale, l + (g1 - l) * scale, l + (b1 - l) * scale)
236//     } else {
237//         (r1, g1, b1)
238//     };
239
240//     // high clip: uses the ORIGINAL x, but applied to the (possibly
241//     // already low-clipped) (r2, g2, b2) -- matches blend.py's sequential
242//     // `rgb = np.where(...)` reassignment order exactly.
243//     let (r3, g3, b3) = if x > 1.0 {
244//         let scale = (1.0 - l) / (x - l + EPS);
245//         (l + (r2 - l) * scale, l + (g2 - l) * scale, l + (b2 - l) * scale)
246//     } else {
247//         (r2, g2, b2)
248//     };
249
250//     (r3.clamp(0.0, 1.0), g3.clamp(0.0, 1.0), b3.clamp(0.0, 1.0))
251// }
252
253// /// Serial variant -- see `soft_light_serial`'s doc comment for why this
254// /// is exposed publicly alongside `luminosity_blend_parallel`.
255// pub fn luminosity_blend_serial(backdrop_rgb: ArrayView3<f32>, luminosity: ArrayView2<f32>, out: &mut Array3<f32>) {
256//     Zip::from(out.outer_iter_mut())
257//         .and(backdrop_rgb.outer_iter())
258//         .and(luminosity.outer_iter())
259//         .for_each(luminosity_blend_row);
260// }
261
262// /// Parallel (rayon) variant -- see `soft_light_serial`'s doc comment.
263// pub fn luminosity_blend_parallel(backdrop_rgb: ArrayView3<f32>, luminosity: ArrayView2<f32>, out: &mut Array3<f32>) {
264//     Zip::from(out.outer_iter_mut())
265//         .and(backdrop_rgb.outer_iter())
266//         .and(luminosity.outer_iter())
267//         .par_for_each(luminosity_blend_row);
268// }
269
270// #[inline]
271// fn luminosity_blend_row(
272//     mut out_row: ndarray::ArrayViewMut2<f32>,
273//     backdrop_row: ArrayView2<f32>,
274//     lum_row: ndarray::ArrayView1<f32>,
275// ) {
276//     let w = out_row.shape()[0];
277//     for x in 0..w {
278//         let r0 = backdrop_row[[x, 0]];
279//         let g0 = backdrop_row[[x, 1]];
280//         let b0 = backdrop_row[[x, 2]];
281//         let l = lum_row[x];
282//         let (r, g, b) = luminosity_blend_pixel(r0, g0, b0, l);
283//         out_row[[x, 0]] = r;
284//         out_row[[x, 1]] = g;
285//         out_row[[x, 2]] = b;
286//     }
287// }
288
289// /// Pure computation: SVG/Photoshop 'Luminosity' blend mode. `backdrop_rgb`
290// /// is (H, W, 3) in [0, 1]; `luminosity` is (H, W) in [0, 1]. Matches
291// /// `blend.py`'s `luminosity_blend()` (== `_clip_color(backdrop_rgb + d)`)
292// /// exactly, fused into a single per-pixel pass with no intermediate
293// /// arrays -- see the module docs above for the algebraic shortcut this
294// /// enables over the two-function numpy version. Dispatches to the
295// /// serial or parallel path based on `PARALLEL_THRESHOLD`.
296// pub fn luminosity_blend_core(backdrop_rgb: ArrayView3<f32>, luminosity: ArrayView2<f32>, out: &mut Array3<f32>) {
297//     let (h, w, _) = backdrop_rgb.dim();
298//     if h * w >= PARALLEL_THRESHOLD {
299//         luminosity_blend_parallel(backdrop_rgb, luminosity, out);
300//     } else {
301//         luminosity_blend_serial(backdrop_rgb, luminosity, out);
302//     }
303// }
304
305// // ============================================================================
306// // Curvature (profile/planform) + hillshade -- fused DEM-derivative kernels.
307// //
308// // Mirrors TerraTexture.derivatives exactly: same "gradient of gradient"
309// // approximation (np.gradient applied twice), same edge_order=1 boundary
310// // handling (one-sided forward/backward differences at the array edges,
311// // second-order central differences everywhere else -- numpy's default),
312// // same Zevenbergen & Thorne curvature formulas. Verified bit-parity
313// // against derivatives.py in tests/test_derivatives_rust.py.
314// //
315// // Callers are responsible for nan-filling the DEM first (derivatives.py's
316// // _fill_nan_nearest) and re-masking the NaN/void cells afterwards -- same
317// // division of labour as blend.py's Rust dispatch: Rust owns the plain
318// // numeric math on a clean float32 array, Python owns the NaN bookkeeping.
319// // ============================================================================
320
321// /// numpy-compatible `np.gradient(arr, spacing)` along axis 0 (rows) and
322// /// axis 1 (columns), default `edge_order=1`: one-sided forward/backward
323// /// difference at the first/last index of each axis, second-order central
324// /// difference everywhere in between. Returns (d/d_axis0, d/d_axis1) --
325// /// same order as numpy's `zy, zx = np.gradient(dem, cellsize)`.
326// fn gradient2d(arr: ArrayView2<f32>, spacing: f32) -> (Array2<f32>, Array2<f32>) {
327//     let (h, w) = arr.dim();
328//     let mut d_axis0 = Array2::<f32>::zeros((h, w));
329//     let mut d_axis1 = Array2::<f32>::zeros((h, w));
330
331//     // axis 0 (down rows), column by column
332//     if h == 1 {
333//         // np.gradient on a length-1 axis returns zeros
334//         d_axis0.fill(0.0);
335//     } else {
336//         for j in 0..w {
337//             d_axis0[[0, j]] = (arr[[1, j]] - arr[[0, j]]) / spacing;
338//             d_axis0[[h - 1, j]] = (arr[[h - 1, j]] - arr[[h - 2, j]]) / spacing;
339//         }
340//         for i in 1..h - 1 {
341//             for j in 0..w {
342//                 d_axis0[[i, j]] = (arr[[i + 1, j]] - arr[[i - 1, j]]) / (2.0 * spacing);
343//             }
344//         }
345//     }
346
347//     // axis 1 (across columns), row by row
348//     if w == 1 {
349//         d_axis1.fill(0.0);
350//     } else {
351//         for i in 0..h {
352//             d_axis1[[i, 0]] = (arr[[i, 1]] - arr[[i, 0]]) / spacing;
353//             d_axis1[[i, w - 1]] = (arr[[i, w - 1]] - arr[[i, w - 2]]) / spacing;
354//             for j in 1..w - 1 {
355//                 d_axis1[[i, j]] = (arr[[i, j + 1]] - arr[[i, j - 1]]) / (2.0 * spacing);
356//             }
357//         }
358//     }
359
360//     (d_axis0, d_axis1)
361// }
362
363// #[inline]
364// fn curvature_pixel(p: f32, q: f32, r: f32, t: f32, s: f32) -> (f32, f32) {
365//     let p2q2 = p * p + q * q;
366//     if p2q2 < 1e-9 {
367//         return (0.0, 0.0); // flat cell -- matches derivatives.py's `flat` mask
368//     }
369//     let profile_raw = -(r * p * p + 2.0 * s * p * q + t * q * q) / (p2q2 * (1.0 + p2q2).powf(1.5));
370//     let planform_raw = -(r * q * q - 2.0 * s * p * q + t * p * p) / p2q2.powf(1.5);
371
372//     // matches np.nan_to_num(..., nan=0.0, posinf=0.0, neginf=0.0)
373//     let clean = |v: f32| if v.is_finite() { v } else { 0.0 };
374//     (clean(profile_raw), clean(planform_raw))
375// }
376
377// /// Pure computation: profile + planform curvature. `dem` must already be
378// /// NaN-free (caller nan-fills; see module note above). Matches
379// /// `derivatives.py`'s `curvatures()` (minus its NaN re-masking, which
380// /// stays the caller's job) exactly.
381// pub fn curvatures_core(
382//     dem: ArrayView2<f32>,
383//     cellsize: f32,
384//     profile_out: &mut Array2<f32>,
385//     planform_out: &mut Array2<f32>,
386// ) {
387//     let (zy, zx) = gradient2d(dem, cellsize);
388//     let (zxy, zxx) = gradient2d(zx.view(), cellsize);
389//     let (zyy, _zyx) = gradient2d(zy.view(), cellsize); // zyx discarded, matches derivatives.py
390
391//     let n = dem.len();
392//     // 2 outputs + 5 inputs = 7 producers, one over ndarray::Zip's max
393//     // arity of 6 -- fall back to plain contiguous slices + rayon here
394//     // instead (all arrays are freshly `zeros()`-allocated, hence
395//     // standard/C-contiguous, so `.as_slice()` is always `Some`).
396//     let zx_s = zx.as_slice().expect("gradient2d output not contiguous");
397//     let zy_s = zy.as_slice().expect("gradient2d output not contiguous");
398//     let zxx_s = zxx.as_slice().expect("gradient2d output not contiguous");
399//     let zyy_s = zyy.as_slice().expect("gradient2d output not contiguous");
400//     let zxy_s = zxy.as_slice().expect("gradient2d output not contiguous");
401//     let profile_s = profile_out.as_slice_mut().expect("profile_out not contiguous");
402//     let planform_s = planform_out.as_slice_mut().expect("planform_out not contiguous");
403
404//     let compute = |i: usize, po: &mut f32, plo: &mut f32| {
405//         let (profile, planform) = curvature_pixel(zx_s[i], zy_s[i], zxx_s[i], zyy_s[i], zxy_s[i]);
406//         *po = profile;
407//         *plo = planform;
408//     };
409
410//     if n >= PARALLEL_THRESHOLD {
411//         profile_s
412//             .par_iter_mut()
413//             .zip(planform_s.par_iter_mut())
414//             .enumerate()
415//             .for_each(|(i, (po, plo))| compute(i, po, plo));
416//     } else {
417//         profile_s
418//             .iter_mut()
419//             .zip(planform_s.iter_mut())
420//             .enumerate()
421//             .for_each(|(i, (po, plo))| compute(i, po, plo));
422//     }
423// }
424
425// #[inline]
426// fn hillshade_pixel(zx: f32, zy: f32, sin_az: f32, cos_az: f32, sin_alt: f32, cos_alt: f32) -> f32 {
427//     // Algebraic expansion of the original
428//     //   slope = pi/2 - atan(hypot(zx, zy))
429//     //   aspect = atan2(-zx, zy)
430//     //   shaded = sin(alt)*sin(slope) + cos(alt)*cos(slope)*cos(az - aspect)
431//     // using sin(atan(g)) = g/sqrt(1+g^2), cos(atan(g)) = 1/sqrt(1+g^2),
432//     // and cos(az-aspect) = cos(az)cos(aspect) + sin(az)sin(aspect) with
433//     // sin(aspect) = -zx/g, cos(aspect) = zy/g (g = hypot(zx, zy)). The
434//     // factor of `g` cancels completely, leaving one sqrt and no
435//     // atan/atan2/sin(slope)/cos(slope) per pixel at all -- verified
436//     // bit-for-bit (float32 tolerance) against derivatives.py's original
437//     // formula in tests/test_derivatives_rust.py, including the flat
438//     // (zx=zy=0) case, which needs no special-casing here since
439//     // sqrt(1+0+0)=1 rather than a 0/0 from hypot(0,0).
440//     let denom = (1.0 + zx * zx + zy * zy).sqrt();
441//     let shaded = (sin_alt + cos_alt * (cos_az * zy - sin_az * zx)) / denom;
442//     shaded.clamp(0.0, 1.0)
443// }
444
445// /// Pure computation: hillshade. `dem` must already be NaN-free (see
446// /// module note above). `azimuth`/`altitude` in degrees, matching
447// /// `derivatives.py`'s `hillshade()` signature exactly (including its
448// /// az = 360 - azimuth + 90 convention).
449// pub fn hillshade_core(dem: ArrayView2<f32>, cellsize: f32, azimuth: f32, altitude: f32, out: &mut Array2<f32>) {
450//     let (zy, zx) = gradient2d(dem, cellsize);
451//     let az = (360.0 - azimuth + 90.0).to_radians();
452//     let alt = altitude.to_radians();
453//     // sin_cos() computes both in one call and, more importantly, these
454//     // are computed ONCE for the whole DEM -- not per pixel like the
455//     // original az.sin()/alt.cos()/etc. calls inside the old
456//     // hillshade_pixel were (a much bigger win than the sin_cos()
457//     // fusion itself: 4 trig calls total instead of up to 2*H*W).
458//     let (sin_az, cos_az) = az.sin_cos();
459//     let (sin_alt, cos_alt) = alt.sin_cos();
460
461//     let (h, w) = dem.dim();
462//     let n = h * w;
463//     let combine = |o: &mut f32, &zx: &f32, &zy: &f32| {
464//         *o = hillshade_pixel(zx, zy, sin_az, cos_az, sin_alt, cos_alt);
465//     };
466//     let z = Zip::from(out).and(&zx).and(&zy);
467//     if n >= PARALLEL_THRESHOLD {
468//         z.par_for_each(combine);
469//     } else {
470//         z.for_each(combine);
471//     }
472// }
473
474// // ============================================================================
475// // stretch_std -- fused single-pass mean/std reduction + clip-stretch.
476// //
477// // Mirrors TerraTexture.stretch's stretch_std() exactly: NaN in -> NaN
478// // out; mean +/- n_std*std clipped to [0,1]. The numpy version calls
479// // np.nanmean() then np.nanstd() separately -- nanstd recomputes its own
480// // mean internally, so the array's mean ends up computed twice, plus a
481// // separate variance pass, plus the elementwise subtract/divide/clip
482// // (verified: deduplicating just the mean call gives only ~1.1x, numpy's
483// // C reductions are already efficient -- the real win here is doing ONE
484// // reduction pass (sum, sum-of-squares, count together) instead of
485// // several, not language speed per se).
486// //
487// // Accumulates in f64 for numerical robustness on large arrays -- this
488// // is NOT intended to bit-match numpy's own (float32, pairwise
489// // summation) internal accumulation, and isn't expected to; verified
490// // against it within a statistical tolerance instead (see
491// // tests/test_stretch_rust.py), which is the correct bar for a mean/std
492// // computation, not exact equality.
493// // ============================================================================
494
495// #[inline]
496// fn stretch_pixel(v: f32, lo: f32, denom: f32) -> f32 {
497//     if v.is_nan() {
498//         f32::NAN // matches np.clip((arr - lo) / denom, 0, 1): NaN propagates, never clamped away
499//     } else {
500//         ((v - lo) / denom).clamp(0.0, 1.0)
501//     }
502// }
503
504// fn sum_stats_serial(arr: ArrayView2<f32>) -> (f64, f64, u64) {
505//     let mut sum = 0.0f64;
506//     let mut sumsq = 0.0f64;
507//     let mut count = 0u64;
508//     for &v in arr.iter() {
509//         if !v.is_nan() {
510//             let vd = v as f64;
511//             sum += vd;
512//             sumsq += vd * vd;
513//             count += 1;
514//         }
515//     }
516//     (sum, sumsq, count)
517// }
518
519// fn sum_stats_parallel(arr: ArrayView2<f32>) -> (f64, f64, u64) {
520//     // rayon's fold+reduce needs a flat parallel iterator; ndarray's own
521//     // arrays are standard/C-contiguous here (always freshly allocated
522//     // or a `.ascontiguousarray()`'d caller-provided one -- see
523//     // stretch.py's dispatch), so `.as_slice()` is reliably `Some`. Falls
524//     // back to the serial path in the (should-be-unreachable in
525//     // practice) case it isn't, rather than panicking.
526//     match arr.as_slice() {
527//         Some(slice) => slice
528//             .par_iter()
529//             .fold(
530//                 || (0.0f64, 0.0f64, 0u64),
531//                 |(s, sq, c), &v| {
532//                     if v.is_nan() {
533//                         (s, sq, c)
534//                     } else {
535//                         let vd = v as f64;
536//                         (s + vd, sq + vd * vd, c + 1)
537//                     }
538//                 },
539//             )
540//             .reduce(
541//                 || (0.0f64, 0.0f64, 0u64),
542//                 |(s1, sq1, c1), (s2, sq2, c2)| (s1 + s2, sq1 + sq2, c1 + c2),
543//             ),
544//         None => sum_stats_serial(arr),
545//     }
546// }
547
548// /// Pure computation: mean +/- n_std*std stretch to [0,1], NaN-safe.
549// /// Matches `stretch.py`'s `stretch_std()` exactly (within float
550// /// tolerance -- see module note above).
551// pub fn stretch_std_core(arr: ArrayView2<f32>, n_std: f32, out: &mut Array2<f32>) {
552//     let n = arr.len();
553//     let (sum, sumsq, count) = if n >= PARALLEL_THRESHOLD {
554//         sum_stats_parallel(arr)
555//     } else {
556//         sum_stats_serial(arr)
557//     };
558
559//     let mean_f64 = if count > 0 { sum / count as f64 } else { 0.0 };
560//     let variance_f64 = if count > 0 {
561//         (sumsq / count as f64 - mean_f64 * mean_f64).max(0.0) // guard tiny negative from float error
562//     } else {
563//         0.0
564//     };
565//     let mean = mean_f64 as f32;
566//     let std = variance_f64.sqrt() as f32;
567//     let lo = mean - n_std * std;
568//     let hi = mean + n_std * std;
569//     let denom = hi - lo + 1e-12;
570
571//     let combine = |o: &mut f32, &v: &f32| *o = stretch_pixel(v, lo, denom);
572//     let z = Zip::from(out).and(&arr);
573//     if n >= PARALLEL_THRESHOLD {
574//         z.par_for_each(combine);
575//     } else {
576//         z.for_each(combine);
577//     }
578// }
579
580// // to REMOVE GIL since we're in Rust only... we can use PyO3 idiom Python::allow_threads
581// // Release the GIL for the actual compute: soft_light_core may fan
582// // out across rayon's thread pool, and none of that work touches
583// // any Python object (a/b/out are plain ndarray views/buffers, not
584// // PyAny) -- so there's no reason another Python thread (e.g. a
585// // contextily tile-fetch thread) should be blocked from running
586// // while this executes. GIL is re-acquired automatically before
587// // this closure returns and `out` gets wrapped back into a PyArray.
588
589// #[pyfunction]
590// fn soft_light<'py>(
591//     py: Python<'py>,
592//     base: PyReadonlyArray2<'py, f32>,
593//     blend: PyReadonlyArray2<'py, f32>,
594// ) -> Bound<'py, PyArray2<f32>> {
595//     let a = base.as_array();
596//     let b = blend.as_array();
597//     let mut out = Array2::<f32>::zeros(a.raw_dim());
598//     py.detach(|| {
599//         soft_light_core(a, b, &mut out);
600//     });
601//     out.into_pyarray(py)
602// }
603
604// #[pyfunction]
605// fn soft_light_rgb<'py>(
606//     py: Python<'py>,
607//     base: PyReadonlyArray3<'py, f32>,
608//     blend: PyReadonlyArray3<'py, f32>,
609// ) -> Bound<'py, PyArray3<f32>> {
610//     let a = base.as_array();
611//     let b = blend.as_array();
612//     let mut out = Array3::<f32>::zeros(a.raw_dim());
613//     py.detach(|| {
614//         soft_light_rgb_core(a, b, &mut out);
615//     });
616//     out.into_pyarray(py)
617// }
618
619// #[pyfunction]
620// fn luminosity_blend<'py>(
621//     py: Python<'py>,
622//     backdrop_rgb: PyReadonlyArray3<'py, f32>,
623//     luminosity: PyReadonlyArray2<'py, f32>,
624// ) -> Bound<'py, PyArray3<f32>> {
625//     let backdrop = backdrop_rgb.as_array();
626//     let lum = luminosity.as_array();
627//     let mut out = Array3::<f32>::zeros(backdrop.raw_dim());
628//     py.detach(|| {
629//         luminosity_blend_core(backdrop, lum, &mut out);
630//     });
631//     out.into_pyarray(py)
632// }
633
634// #[pyfunction]
635// fn curvatures<'py>(
636//     py: Python<'py>,
637//     dem: PyReadonlyArray2<'py, f32>,
638//     cellsize: f32,
639// ) -> (Bound<'py, PyArray2<f32>>, Bound<'py, PyArray2<f32>>) {
640//     let d = dem.as_array();
641//     let mut profile = Array2::<f32>::zeros(d.raw_dim());
642//     let mut planform = Array2::<f32>::zeros(d.raw_dim());
643//     py.detach(|| {
644//         curvatures_core(d, cellsize, &mut profile, &mut planform);
645//     });
646//     (profile.into_pyarray(py), planform.into_pyarray(py))
647// }
648
649// #[pyfunction]
650// fn hillshade<'py>(
651//     py: Python<'py>,
652//     dem: PyReadonlyArray2<'py, f32>,
653//     cellsize: f32,
654//     azimuth: f32,
655//     altitude: f32,
656// ) -> Bound<'py, PyArray2<f32>> {
657//     let d = dem.as_array();
658//     let mut out = Array2::<f32>::zeros(d.raw_dim());
659//     py.detach(|| {
660//         hillshade_core(d, cellsize, azimuth, altitude, &mut out);
661//     });
662//     out.into_pyarray(py)
663// }
664
665// #[pyfunction]
666// fn stretch_std<'py>(py: Python<'py>, arr: PyReadonlyArray2<'py, f32>, n_std: f32) -> Bound<'py, PyArray2<f32>> {
667//     let a = arr.as_array();
668//     let mut out = Array2::<f32>::zeros(a.raw_dim());
669//     py.detach(|| {
670//         stretch_std_core(a, n_std, &mut out);
671//     });
672//     out.into_pyarray(py)
673// }
674
675// #[pymodule]
676// fn terra_texture_rs(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
677//     m.add_function(wrap_pyfunction!(soft_light, m)?)?;
678//     m.add_function(wrap_pyfunction!(soft_light_rgb, m)?)?;
679//     m.add_function(wrap_pyfunction!(luminosity_blend, m)?)?;
680//     m.add_function(wrap_pyfunction!(curvatures, m)?)?;
681//     m.add_function(wrap_pyfunction!(hillshade, m)?)?;
682//     m.add_function(wrap_pyfunction!(stretch_std, m)?)?;
683//     Ok(())
684// }