use rayon::prelude::*;
use crate::color_space::{linear_fraction_to_srgb, srgb_channel_to_linear, srgb_fraction_to_linear};
use crate::color_space_lab::{oklab_to_rgb, rgb_to_oklab, OkLab};
use crate::palettes::Palette;
const LUM_R: f64 = 0.2126729;
const LUM_G: f64 = 0.7151522;
const LUM_B: f64 = 0.0721750;
fn luminance([r, g, b]: [f64; 3]) -> f64 {
LUM_R * r + LUM_G * g + LUM_B * b
}
fn palette_to_linear(palette: &Palette) -> Vec<[f64; 3]> {
palette
.colors
.iter()
.map(|&[r, g, b]| {
[
srgb_channel_to_linear(r),
srgb_channel_to_linear(g),
srgb_channel_to_linear(b),
]
})
.collect()
}
fn scale_dark_pixel(pixel: &mut [f64; 3], target_y: f64) {
let max_ch = pixel[0].max(pixel[1]).max(pixel[2]);
if max_ch > 1e-12 {
let scale = target_y / max_ch;
pixel[0] = (pixel[0] * scale).clamp(0.0, 1.0);
pixel[1] = (pixel[1] * scale).clamp(0.0, 1.0);
pixel[2] = (pixel[2] * scale).clamp(0.0, 1.0);
} else {
pixel[0] = target_y;
pixel[1] = target_y;
pixel[2] = target_y;
}
}
fn percentile_pair(values: &[f64], p_low: f64, p_high: f64) -> (f64, f64) {
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len().saturating_sub(1);
let idx_lo = ((p_low / 100.0) * n as f64).round() as usize;
let idx_hi = ((p_high / 100.0) * n as f64).round() as usize;
(sorted[idx_lo.min(sorted.len() - 1)], sorted[idx_hi.min(sorted.len() - 1)])
}
pub fn apply_exposure(pixels: &mut [[f64; 3]], factor: f64) {
if (factor - 1.0).abs() < 1e-9 {
return;
}
pixels.par_iter_mut().for_each(|pixel| {
pixel[0] = (pixel[0] * factor).clamp(0.0, 1.0);
pixel[1] = (pixel[1] * factor).clamp(0.0, 1.0);
pixel[2] = (pixel[2] * factor).clamp(0.0, 1.0);
});
}
pub fn adjust_saturation(pixels: &mut [[f64; 3]], factor: f64) {
if (factor - 1.0).abs() < 1e-9 {
return;
}
pixels.par_iter_mut().for_each(|pixel| {
let lab = rgb_to_oklab(pixel[0], pixel[1], pixel[2]);
let scaled = OkLab {
l: lab.l,
a: (lab.a * factor).clamp(-1.0, 1.0),
b: (lab.b * factor).clamp(-1.0, 1.0),
};
*pixel = oklab_to_rgb(scaled);
});
}
pub fn apply_shadows_highlights(pixels: &mut [[f64; 3]], shadows: f64, highlights: f64) {
if shadows <= 0.0 && highlights <= 0.0 {
return;
}
let shadows = shadows.clamp(0.0, 1.0);
let highlights = highlights.clamp(0.0, 1.0);
pixels.par_iter_mut().for_each(|pixel| {
let y = luminance(*pixel);
if y <= 1e-6 {
return;
}
let g = linear_fraction_to_srgb(y);
let g_prime = if g <= 0.5 {
let t = g / 0.5;
0.5 * t.powf(1.0 - shadows)
} else {
let t = (g - 0.5) / 0.5;
0.5 + 0.5 * t.powf(1.0 + highlights)
};
let y_prime = srgb_fraction_to_linear(g_prime);
let scale = (y_prime / y).clamp(0.0, 1e6);
pixel[0] = (pixel[0] * scale).clamp(0.0, 1.0);
pixel[1] = (pixel[1] * scale).clamp(0.0, 1.0);
pixel[2] = (pixel[2] * scale).clamp(0.0, 1.0);
});
}
pub fn compress_dynamic_range(pixels: &mut [[f64; 3]], palette: &Palette, strength: f64) {
if strength <= 0.0 {
return;
}
let pal = palette_to_linear(palette);
let black_y = luminance(pal[0]);
let white_y = luminance(pal[1]);
let display_range = white_y - black_y;
if display_range <= 0.0 {
return;
}
pixels.par_iter_mut().for_each(|pixel| {
let y = luminance(*pixel);
let compressed_y = black_y + y * display_range;
let target_y = y + strength * (compressed_y - y);
if y > 1e-6 {
let scale = (target_y / y).clamp(0.0, 1e6);
pixel[0] = (pixel[0] * scale).clamp(0.0, 1.0);
pixel[1] = (pixel[1] * scale).clamp(0.0, 1.0);
pixel[2] = (pixel[2] * scale).clamp(0.0, 1.0);
} else {
scale_dark_pixel(pixel, black_y * strength);
}
});
}
pub fn auto_compress_dynamic_range(pixels: &mut [[f64; 3]], palette: &Palette) {
let pal = palette_to_linear(palette);
let black_y = luminance(pal[0]);
let white_y = luminance(pal[1]);
let display_range = white_y - black_y;
if display_range <= 0.0 {
return;
}
let lum_values: Vec<f64> = pixels.iter().map(|&p| luminance(p)).collect();
let (p_low, p_high) = percentile_pair(&lum_values, 2.0, 98.0);
let image_range = p_high - p_low;
if image_range < 1e-6 {
compress_dynamic_range(pixels, palette, 1.0);
return;
}
const TOLERANCE: f64 = 0.10;
let fits_shadows = p_low >= black_y - TOLERANCE * display_range;
let fits_highlights = p_high <= white_y + TOLERANCE * display_range;
if fits_shadows && fits_highlights {
return;
}
let nonzero: Vec<f64> = lum_values.iter().copied().filter(|&y| y > 1e-6).collect();
let strength = if !nonzero.is_empty() {
let l_lav = (nonzero.iter().map(|&y| (y + 1e-5).ln()).sum::<f64>() / nonzero.len() as f64).exp();
let log_min = p_low.max(1e-5).ln();
let log_max = p_high.max(1e-5).ln();
let log_range = log_max - log_min;
if log_range > 1e-6 {
let skew = (log_max - (l_lav + 1e-5).ln()) / log_range;
skew.clamp(0.0, 1.0).powf(1.4)
} else {
1.0
}
} else {
1.0
};
pixels.par_iter_mut().for_each(|pixel| {
let y = luminance(*pixel);
let normalized = ((y - p_low) / image_range).clamp(0.0, 1.0);
let target_y_full = black_y + normalized * display_range;
let target_y = y + strength * (target_y_full - y);
if y > 1e-6 {
let scale = (target_y / y).clamp(0.0, 1e6);
pixel[0] = (pixel[0] * scale).clamp(0.0, 1.0);
pixel[1] = (pixel[1] * scale).clamp(0.0, 1.0);
pixel[2] = (pixel[2] * scale).clamp(0.0, 1.0);
} else {
scale_dark_pixel(pixel, target_y);
}
});
}
const GAMUT_THRESHOLD: f64 = 0.15;
const GAMUT_THRESHOLD_MAX: f64 = 0.45;
fn nearest_on_segment(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
let edge = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let edge_len_sq = edge[0] * edge[0] + edge[1] * edge[1] + edge[2] * edge[2];
if edge_len_sq < 1e-10 {
return 0.0;
}
let diff = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
let dot = diff[0] * edge[0] + diff[1] * edge[1] + diff[2] * edge[2];
(dot / edge_len_sq).clamp(0.0, 1.0)
}
fn dist_sq(a: [f64; 3], b: [f64; 3]) -> f64 {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
d[0] * d[0] + d[1] * d[1] + d[2] * d[2]
}
fn lerp3(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
[
a[0] + t * (b[0] - a[0]),
a[1] + t * (b[1] - a[1]),
a[2] + t * (b[2] - a[2]),
]
}
fn smoothstep(edge0: f64, edge1: f64, x: f64) -> f64 {
let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
pub fn gamut_compress(pixels: &mut [[f64; 3]], palette: &Palette, strength: f64) {
if strength <= 0.0 {
return;
}
let pal_linear = palette_to_linear(palette);
let pal_lab: Vec<_> = pal_linear
.iter()
.map(|&[r, g, b]| rgb_to_oklab(r, g, b))
.collect();
let n = pal_linear.len();
struct Edge {
a_lab: [f64; 3],
b_lab: [f64; 3],
a_lin: [f64; 3],
b_lin: [f64; 3],
}
let edges: Vec<Edge> = (0..n)
.flat_map(|i| (i + 1..n).map(move |j| (i, j)))
.map(|(i, j)| Edge {
a_lab: [pal_lab[i].l, pal_lab[i].a, pal_lab[i].b],
b_lab: [pal_lab[j].l, pal_lab[j].a, pal_lab[j].b],
a_lin: pal_linear[i],
b_lin: pal_linear[j],
})
.collect();
pixels.par_iter_mut().for_each(|pixel| {
let px_lab = rgb_to_oklab(pixel[0], pixel[1], pixel[2]);
let px_lab_arr = [px_lab.l, px_lab.a, px_lab.b];
let mut best_dist_sq = f64::INFINITY;
let mut best_target = *pixel;
for e in &edges {
let t = nearest_on_segment(px_lab_arr, e.a_lab, e.b_lab);
let nearest_lab = lerp3(e.a_lab, e.b_lab, t);
let d = dist_sq(px_lab_arr, nearest_lab);
if d < best_dist_sq {
best_dist_sq = d;
best_target = lerp3(e.a_lin, e.b_lin, t);
}
}
let nearest_dist = best_dist_sq.sqrt();
let blend = smoothstep(GAMUT_THRESHOLD, GAMUT_THRESHOLD_MAX, nearest_dist) * strength;
pixel[0] = (pixel[0] + blend * (best_target[0] - pixel[0])).clamp(0.0, 1.0);
pixel[1] = (pixel[1] + blend * (best_target[1] - pixel[1])).clamp(0.0, 1.0);
pixel[2] = (pixel[2] + blend * (best_target[2] - pixel[2])).clamp(0.0, 1.0);
});
}
#[cfg(test)]
mod tests {
use super::*;
fn spectra_palette() -> &'static Palette {
use crate::measured_palettes::SPECTRA_7_3_6COLOR;
&SPECTRA_7_3_6COLOR
}
#[test]
fn compress_strength_zero_is_identity() {
let mut pixels = vec![[0.8, 0.4, 0.2_f64]];
let original = pixels[0];
compress_dynamic_range(&mut pixels, spectra_palette(), 0.0);
assert_eq!(pixels[0], original);
}
#[test]
fn compress_reduces_highlights() {
let mut pixels = vec![[1.0, 1.0, 1.0_f64]];
compress_dynamic_range(&mut pixels, spectra_palette(), 1.0);
assert!(luminance(pixels[0]) < 1.0, "highlight should be compressed");
}
#[test]
fn gamut_compress_identity_on_palette_colors() {
let pal = spectra_palette();
let [r, g, b] = pal.colors[0];
let mut pixels = vec![[
srgb_channel_to_linear(r),
srgb_channel_to_linear(g),
srgb_channel_to_linear(b),
]];
let original = pixels[0];
gamut_compress(&mut pixels, pal, 1.0);
let d = dist_sq(pixels[0], original);
assert!(d < 1e-10, "palette color should not be moved: d={d}");
}
#[test]
fn compress_maps_white_to_display_white() {
let mut pixels = vec![[1.0, 1.0, 1.0_f64]];
let pal = spectra_palette();
compress_dynamic_range(&mut pixels, pal, 1.0);
let pal_lin = palette_to_linear(pal);
let white_y = luminance(pal_lin[1]);
let out_y = luminance(pixels[0]);
assert!(
(out_y - white_y).abs() < 1e-6,
"white pixel should compress to display white point (expected {white_y}, got {out_y})"
);
}
#[test]
fn compress_maps_black_to_display_black() {
let mut pixels = vec![[0.0, 0.0, 0.0_f64]];
let pal = spectra_palette();
compress_dynamic_range(&mut pixels, pal, 1.0);
let pal_lin = palette_to_linear(pal);
let black_y = luminance(pal_lin[0]);
let out_y = luminance(pixels[0]);
assert!(
(out_y - black_y).abs() < 1e-4,
"black pixel should compress to display black point (expected {black_y}, got {out_y})"
);
}
#[test]
fn auto_compress_skips_in_range_images() {
let pal = spectra_palette();
let pal_lin = palette_to_linear(pal);
let black_y = luminance(pal_lin[0]);
let white_y = luminance(pal_lin[1]);
let mid = (black_y + white_y) / 2.0;
let lo = black_y + 0.05 * (white_y - black_y);
let hi = white_y - 0.05 * (white_y - black_y);
let mut pixels = vec![[lo, lo, lo], [mid, mid, mid], [hi, hi, hi]];
let original = pixels.clone();
auto_compress_dynamic_range(&mut pixels, pal);
for (i, (got, expected)) in pixels.iter().zip(original.iter()).enumerate() {
let d: f64 = got.iter().zip(expected.iter()).map(|(a, b)| (a - b).abs()).sum();
assert!(d < 1e-6, "pixel {i} should not be modified by auto-compress: moved by {d}");
}
}
#[test]
fn auto_compress_does_not_crush_dark_outliers() {
let pal = spectra_palette();
let mut pixels: Vec<[f64; 3]> = (0..98)
.map(|i| {
let v = 0.7 + 0.3 * (i as f64 / 97.0);
[v, v, v]
})
.collect();
let outlier = [0.0004_f64, 0.0004, 0.0004];
pixels.push(outlier);
pixels.push(outlier);
let in_y = luminance(outlier);
auto_compress_dynamic_range(&mut pixels, pal);
let out_y = luminance(pixels[98]);
assert!(
out_y >= in_y - 1e-9,
"sub-percentile dark outlier must not be crushed darker (in={in_y:.6}, out={out_y:.6})"
);
}
#[test]
fn auto_compress_preserves_dark_pixel_chroma() {
let pal = spectra_palette();
let mut pixels = vec![[1.0_f64, 1.0, 1.0]; 96];
pixels.extend(std::iter::repeat_n([0.0_f64, 0.0, 1e-5], 4));
auto_compress_dynamic_range(&mut pixels, pal);
let [r, g, b] = pixels[96];
assert!(
b >= r && b >= g && b > 0.0,
"blue should remain dominant after auto-compress of a dark blue pixel: [{r}, {g}, {b}]"
);
}
#[test]
fn gamut_compress_moves_out_of_gamut_pixel() {
let pal = spectra_palette();
let mut pixels = vec![[1.0_f64, 0.0, 0.0]]; let original = pixels[0];
gamut_compress(&mut pixels, pal, 1.0);
let moved = pixels[0].iter().zip(original.iter()).any(|(a, b)| (a - b).abs() > 1e-6);
assert!(moved, "vivid red should be moved toward the palette by gamut_compress");
}
#[test]
fn exposure_factor_one_is_identity() {
let mut pixels = vec![[0.5_f64, 0.2, 0.1]];
let original = pixels.clone();
apply_exposure(&mut pixels, 1.0);
assert_eq!(pixels, original);
}
#[test]
fn exposure_factor_greater_than_one_brightens() {
let mut pixels = vec![[0.2_f64, 0.2, 0.2]];
apply_exposure(&mut pixels, 2.0);
assert!(pixels[0][0] > 0.39 && pixels[0][0] < 0.41);
}
#[test]
fn saturation_factor_one_is_identity() {
let mut pixels = vec![[0.5_f64, 0.2, 0.1]];
let original = pixels.clone();
adjust_saturation(&mut pixels, 1.0);
for (got, expected) in pixels.iter().zip(original.iter()) {
for (a, b) in got.iter().zip(expected.iter()) {
assert!((a - b).abs() < 1e-9);
}
}
}
#[test]
fn saturation_factor_zero_produces_gray() {
let mut pixels = vec![[0.8_f64, 0.2, 0.1]];
adjust_saturation(&mut pixels, 0.0);
let [r, g, b] = pixels[0];
assert!(
(r - g).abs() < 1e-3 && (r - b).abs() < 1e-3,
"factor=0 must yield neutral gray: [{r}, {g}, {b}]"
);
}
#[test]
fn shadows_highlights_zero_is_identity() {
let mut pixels = vec![[0.2_f64, 0.2, 0.2], [0.7, 0.7, 0.7]];
let original = pixels.clone();
apply_shadows_highlights(&mut pixels, 0.0, 0.0);
assert_eq!(pixels, original);
}
#[test]
fn shadows_lifts_only_lower_half() {
let mut pixels = vec![[0.1_f64, 0.1, 0.1], [0.8, 0.8, 0.8]];
apply_shadows_highlights(&mut pixels, 0.5, 0.0);
assert!(pixels[0][0] > 0.1, "shadow should be lifted: {}", pixels[0][0]);
assert!((pixels[1][0] - 0.8).abs() < 1e-6, "highlight should be unchanged: {}", pixels[1][0]);
}
#[test]
fn dark_pixel_chroma_preserved_after_compress() {
let pal = spectra_palette();
let mut pixels = vec![[0.0_f64, 0.0, 1e-5]];
compress_dynamic_range(&mut pixels, pal, 1.0);
let [r, g, b] = pixels[0];
assert!(
b >= r && b >= g,
"blue channel should remain dominant after dark pixel compression: [{r}, {g}, {b}]"
);
}
}