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// }