Skip to main content

terra_texture_rs/
luminosity_blend.rs

1//! SVG/Photoshop 'Luminosity' blend mode, fused into a single per-pixel
2//! pass.
3//!
4//! Matches `blend.py`'s `luminosity_blend()`, i.e.
5//! `_clip_color(backdrop_rgb + d)`, exactly. The numpy version runs the
6//! luminosity shift and `_clip_color()` as two separate, unrelated
7//! passes with intermediate arrays; fusing them here enables one
8//! algebraic shortcut on top of skipping the intermediates.
9//!
10//! # Algorithm
11//!
12//! Per pixel, with luminance `lum(r, g, b) = 0.3r + 0.59g + 0.11b`:
13//!
14//! 1. Shift every channel by `d = target_lum - lum(backdrop)`.
15//! 2. Clip the shifted colour back into \[0, 1\] while preserving its
16//!    luminance: first pull the minimum channel up to 0 if it went
17//!    negative, then pull the maximum channel down to 1 if it went over.
18//! 3. Clamp each channel to \[0, 1\] to absorb float error.
19//!
20//! The fusion shortcut: after step 1, `lum(shifted) == target_lum`
21//! exactly, because the weights sum to 1.0. So step 2 reuses
22//! `target_lum` instead of recomputing the weighted sum.
23//!
24//! # Which function to call
25//!
26//! Call [`luminosity_blend_core`]. The `*_serial`/`*_parallel` variants
27//! are public only so `benches/blend_bench.rs` can time both paths to
28//! tune [`PARALLEL_THRESHOLD`].
29
30use ndarray::{Array3, ArrayView1, ArrayView2, ArrayView3, ArrayViewMut2, Zip};
31
32use crate::common::PARALLEL_THRESHOLD;
33
34/// Luminosity blend for one pixel.
35///
36/// Takes the backdrop colour `(r0, g0, b0)` and the `target_lum` to
37/// impose, all `f32` nominally in \[0, 1\]. Returns the blended
38/// `(r, g, b)` as `f32`, each clamped to \[0, 1\]. See the module docs for
39/// the algorithm.
40#[inline]
41fn luminosity_blend_pixel(r0: f32, g0: f32, b0: f32, target_lum: f32) -> (f32, f32, f32) {
42    const EPS: f32 = 1e-12;
43
44    let lum_backdrop = 0.3 * r0 + 0.59 * g0 + 0.11 * b0;
45    let d = target_lum - lum_backdrop;
46    let (r1, g1, b1) = (r0 + d, g0 + d, b0 + d);
47
48    // Fusion shortcut (see module docs): lum(r1, g1, b1) == target_lum.
49    let l = target_lum;
50
51    let n = r1.min(g1).min(b1);
52    let x = r1.max(g1).max(b1);
53
54    // low clip: uses the ORIGINAL n, applied to (r1, g1, b1)
55    let (r2, g2, b2) = if n < 0.0 {
56        let scale = l / (l - n + EPS);
57        (l + (r1 - l) * scale, l + (g1 - l) * scale, l + (b1 - l) * scale)
58    } else {
59        (r1, g1, b1)
60    };
61
62    // high clip: uses the ORIGINAL x, but applied to the (possibly
63    // already low-clipped) (r2, g2, b2) -- matches blend.py's sequential
64    // `rgb = np.where(...)` reassignment order exactly.
65    let (r3, g3, b3) = if x > 1.0 {
66        let scale = (1.0 - l) / (x - l + EPS);
67        (l + (r2 - l) * scale, l + (g2 - l) * scale, l + (b2 - l) * scale)
68    } else {
69        (r2, g2, b2)
70    };
71
72    (r3.clamp(0.0, 1.0), g3.clamp(0.0, 1.0), b3.clamp(0.0, 1.0))
73}
74
75/// Luminosity blend for one image row.
76///
77/// * `out_row` - `ArrayViewMut2<f32>`, shape (W, 3): written.
78/// * `backdrop_row` - `ArrayView2<f32>`, shape (W, 3): read.
79/// * `lum_row` - `ArrayView1<f32>`, shape (W,): read.
80#[inline]
81fn luminosity_blend_row(mut out_row: ArrayViewMut2<f32>, backdrop_row: ArrayView2<f32>, lum_row: ArrayView1<f32>) {
82    let w = out_row.shape()[0];
83    for x in 0..w {
84        let r0 = backdrop_row[[x, 0]];
85        let g0 = backdrop_row[[x, 1]];
86        let b0 = backdrop_row[[x, 2]];
87        let l = lum_row[x];
88        let (r, g, b) = luminosity_blend_pixel(r0, g0, b0, l);
89        out_row[[x, 0]] = r;
90        out_row[[x, 1]] = g;
91        out_row[[x, 2]] = b;
92    }
93}
94
95/// Luminosity blend, single-threaded.
96///
97/// Same arguments, output and panics as [`luminosity_blend_core`], but
98/// always runs serially. Public for benchmarking only (see module docs).
99pub fn luminosity_blend_serial(backdrop_rgb: ArrayView3<f32>, luminosity: ArrayView2<f32>, out: &mut Array3<f32>) {
100    Zip::from(out.outer_iter_mut())
101        .and(backdrop_rgb.outer_iter())
102        .and(luminosity.outer_iter())
103        .for_each(luminosity_blend_row);
104}
105
106/// Luminosity blend, parallelised across rows with rayon.
107///
108/// Same arguments, output and panics as [`luminosity_blend_core`], but
109/// always runs in parallel. Public for benchmarking only (see module
110/// docs).
111pub fn luminosity_blend_parallel(backdrop_rgb: ArrayView3<f32>, luminosity: ArrayView2<f32>, out: &mut Array3<f32>) {
112    Zip::from(out.outer_iter_mut())
113        .and(backdrop_rgb.outer_iter())
114        .and(luminosity.outer_iter())
115        .par_for_each(luminosity_blend_row);
116}
117
118/// Replace the luminance of an RGB image with a target luminance,
119/// keeping its hue and saturation. The entry point for this blend.
120///
121/// # Arguments
122///
123/// * `backdrop_rgb` - `ArrayView3<f32>`, shape (H, W, 3): the colour
124///   image, channels in R, G, B order, values nominally in \[0, 1\].
125/// * `luminosity` - `ArrayView2<f32>`, shape (H, W): the target
126///   luminance per pixel (e.g. a hillshade), values nominally in \[0, 1\].
127/// * `out` - `&mut Array3<f32>`, shape (H, W, 3): overwritten with the
128///   blended RGB image, every element in \[0, 1\].
129///
130/// Runs serially below [`PARALLEL_THRESHOLD`]
131/// **pixels** (H × W, not H × W × 3) and in parallel at or above it.
132///
133/// # Panics
134///
135/// * If `backdrop_rgb`, `luminosity` and `out` disagree on H.
136/// * If the channel axis of `backdrop_rgb` or `out` has fewer than 3
137///   entries (out-of-bounds index).
138///
139/// Shapes are only checked by indexing, so some mismatches pass
140/// silently instead of panicking: a W mismatch where `backdrop_rgb` or
141/// `luminosity` is wider than `out` ignores the extra columns, and a
142/// channel count above 3 reads and writes only channels 0 to 2. Always
143/// pass matching (H, W) and exactly 3 channels.
144///
145/// # Example
146///
147/// ```
148/// use ndarray::{Array2, Array3};
149/// use terra_texture_rs::luminosity_blend_core;
150///
151/// // a flat mid-grey image, relit to luminance 0.8
152/// let backdrop = Array3::<f32>::from_elem((2, 2, 3), 0.5);
153/// let lum = Array2::<f32>::from_elem((2, 2), 0.8);
154/// let mut out = Array3::<f32>::zeros(backdrop.raw_dim());
155/// luminosity_blend_core(backdrop.view(), lum.view(), &mut out);
156///
157/// // grey stays grey, now at the target luminance
158/// assert!(out.iter().all(|&v| (v - 0.8).abs() < 1e-5));
159/// ```
160pub fn luminosity_blend_core(backdrop_rgb: ArrayView3<f32>, luminosity: ArrayView2<f32>, out: &mut Array3<f32>) {
161    let (h, w, _) = backdrop_rgb.dim();
162    if h * w >= PARALLEL_THRESHOLD {
163        luminosity_blend_parallel(backdrop_rgb, luminosity, out);
164    } else {
165        luminosity_blend_serial(backdrop_rgb, luminosity, out);
166    }
167}