#![doc = include_str!("hsl.md")]
use palette::{Hsl, IntoColor, Srgb};
pub type WeightFn = fn(f32, f32) -> f32;
pub fn hue_distance(a: f32, b: f32) -> f32 {
let d = (a - b).rem_euclid(360.0);
if d > 180.0 {
360.0 - d
} else {
d
}
}
pub fn cosine_weight(hue_dist: f32, half_width: f32) -> f32 {
if hue_dist >= half_width {
0.0
} else {
((hue_dist / half_width) * std::f32::consts::PI).cos() * 0.5 + 0.5
}
}
const CHANNEL_CENTERS: [f32; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 330.0];
const CHANNEL_HALF_WIDTHS: [f32; 8] = [30.0, 30.0, 30.0, 60.0, 60.0, 30.0, 30.0, 30.0];
pub fn apply_hsl(
r: f32,
g: f32,
b: f32,
hue_shifts: &[f32; 8],
saturation_shifts: &[f32; 8],
luminance_shifts: &[f32; 8],
weight_fn: WeightFn,
) -> (f32, f32, f32) {
let srgb = Srgb::new(r, g, b);
let hsl: Hsl = srgb.into_color();
let pixel_hue = hsl.hue.into_positive_degrees();
let pixel_sat = hsl.saturation;
if pixel_sat < 1e-4 {
return (r, g, b);
}
let mut total_hue_shift = 0.0f32;
let mut total_sat_shift = 0.0f32;
let mut total_lum_shift = 0.0f32;
for i in 0..8 {
let dist = hue_distance(pixel_hue, CHANNEL_CENTERS[i]);
let weight = weight_fn(dist, CHANNEL_HALF_WIDTHS[i]) * pixel_sat;
if weight > 0.0 {
total_hue_shift += weight * hue_shifts[i];
total_sat_shift += weight * (saturation_shifts[i] / 100.0);
total_lum_shift += weight * (luminance_shifts[i] / 100.0);
}
}
let new_hue = (pixel_hue + total_hue_shift).rem_euclid(360.0);
let new_sat = (hsl.saturation + total_sat_shift).clamp(0.0, 1.0);
let new_lum = (hsl.lightness + total_lum_shift).clamp(0.0, 1.0);
let new_hsl = Hsl::new(new_hue, new_sat, new_lum);
let rgb: Srgb<f32> = new_hsl.into_color();
(rgb.red, rgb.green, rgb.blue)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hue_distance_same_is_zero() {
assert_eq!(hue_distance(120.0, 120.0), 0.0);
}
#[test]
fn hue_distance_opposite_is_180() {
assert!((hue_distance(0.0, 180.0) - 180.0).abs() < 1e-6);
}
#[test]
fn hue_distance_wraps_around() {
assert!((hue_distance(350.0, 10.0) - 20.0).abs() < 1e-6);
assert!((hue_distance(10.0, 350.0) - 20.0).abs() < 1e-6);
}
#[test]
fn hue_distance_is_symmetric() {
assert!((hue_distance(30.0, 90.0) - hue_distance(90.0, 30.0)).abs() < 1e-6);
}
#[test]
fn cosine_weight_at_center_is_one() {
assert!((cosine_weight(0.0, 30.0) - 1.0).abs() < 1e-6);
}
#[test]
fn cosine_weight_at_half_width_is_zero() {
assert!(cosine_weight(30.0, 30.0).abs() < 1e-6);
}
#[test]
fn cosine_weight_beyond_half_width_is_zero() {
assert_eq!(cosine_weight(45.0, 30.0), 0.0);
}
#[test]
fn cosine_weight_at_half_distance_is_between_zero_and_one() {
let w = cosine_weight(15.0, 30.0);
assert!(w > 0.0 && w < 1.0, "Expected 0 < {} < 1", w);
}
#[test]
fn apply_hsl_all_zeros_is_identity() {
let zeros = [0.0f32; 8];
let (r, g, b) = apply_hsl(1.0, 0.0, 0.0, &zeros, &zeros, &zeros, cosine_weight);
assert!((r - 1.0).abs() < 1e-4, "r: expected ~1.0, got {r}");
assert!(g.abs() < 1e-4, "g: expected ~0.0, got {g}");
assert!(b.abs() < 1e-4, "b: expected ~0.0, got {b}");
}
#[test]
fn apply_hsl_red_hue_shift_rotates_red() {
let mut hue = [0.0f32; 8];
hue[0] = 120.0; let zeros = [0.0f32; 8];
let (r, g, _b) = apply_hsl(1.0, 0.0, 0.0, &hue, &zeros, &zeros, cosine_weight);
assert!(
g > r,
"Expected green > red after +120° hue shift, got r={r} g={g}"
);
}
#[test]
fn apply_hsl_red_saturation_decrease_desaturates() {
let zeros = [0.0f32; 8];
let mut sat = [0.0f32; 8];
sat[0] = -100.0; let (r, g, b) = apply_hsl(1.0, 0.0, 0.0, &zeros, &sat, &zeros, cosine_weight);
let input_spread = 1.0f32; assert!(
(r - g).abs() < input_spread,
"Expected channels closer after desaturation, got r={r} g={g}"
);
assert!(
(r - b).abs() < input_spread,
"Expected channels closer after desaturation, got r={r} b={b}"
);
}
#[test]
fn apply_hsl_green_shift_does_not_affect_red() {
let zeros = [0.0f32; 8];
let mut sat = [0.0f32; 8];
sat[3] = -100.0; let (r, g, b) = apply_hsl(1.0, 0.0, 0.0, &zeros, &sat, &zeros, cosine_weight);
assert!(
(r - 1.0).abs() < 1e-3,
"Red pixel should be unaffected by green channel"
);
assert!(g.abs() < 1e-3);
assert!(b.abs() < 1e-3);
}
#[test]
fn apply_hsl_gray_pixel_unaffected() {
let mut hue = [0.0f32; 8];
let mut sat = [0.0f32; 8];
let mut lum = [0.0f32; 8];
hue[0] = 90.0;
sat[0] = 50.0;
lum[0] = 50.0;
let (r, g, b) = apply_hsl(0.5, 0.5, 0.5, &hue, &sat, &lum, cosine_weight);
assert!(
(r - 0.5).abs() < 1e-3,
"Gray should be unaffected, got r={r}"
);
assert!(
(g - 0.5).abs() < 1e-3,
"Gray should be unaffected, got g={g}"
);
assert!(
(b - 0.5).abs() < 1e-3,
"Gray should be unaffected, got b={b}"
);
}
#[test]
fn apply_hsl_luminance_brightens() {
let zeros = [0.0f32; 8];
let mut lum = [0.0f32; 8];
lum[0] = 50.0; let (r, g, b) = apply_hsl(1.0, 0.0, 0.0, &zeros, &zeros, &lum, cosine_weight);
let orig_sum: f32 = 1.0 + 0.0 + 0.0;
let new_sum = r + g + b;
assert!(
new_sum > orig_sum,
"Expected brighter, got sum={new_sum} vs {orig_sum}"
);
}
}