use crate::rgb::Rgb;
#[derive(Debug, Clone, PartialEq)]
pub enum ColorTransform {
None,
Grayscale,
Invert,
Sepia,
Brightness(f32),
Contrast(f32),
Saturate(f32),
HueRotate(f32),
Tint { color: Rgb, amount: f32 },
Compose(Vec<ColorTransform>),
}
impl ColorTransform {
pub fn apply(&self, c: Rgb) -> Rgb {
match self {
ColorTransform::None => c,
ColorTransform::Grayscale => {
let v = (0.299 * f32::from(c.r())
+ 0.587 * f32::from(c.g())
+ 0.114 * f32::from(c.b()))
.round()
.clamp(0.0, 255.0) as u8;
Rgb::new(v, v, v)
}
ColorTransform::Invert => Rgb::new(255 - c.r(), 255 - c.g(), 255 - c.b()),
ColorTransform::Sepia => {
let (r, g, b) = (f32::from(c.r()), f32::from(c.g()), f32::from(c.b()));
let tr = (0.393 * r + 0.769 * g + 0.189 * b)
.round()
.clamp(0.0, 255.0) as u8;
let tg = (0.349 * r + 0.686 * g + 0.168 * b)
.round()
.clamp(0.0, 255.0) as u8;
let tb = (0.272 * r + 0.534 * g + 0.131 * b)
.round()
.clamp(0.0, 255.0) as u8;
Rgb::new(tr, tg, tb)
}
ColorTransform::Brightness(factor) => {
let factor = factor.clamp(0.0, 4.0);
let ch = |v: u8| (f32::from(v) * factor).round().clamp(0.0, 255.0) as u8;
Rgb::new(ch(c.r()), ch(c.g()), ch(c.b()))
}
ColorTransform::Contrast(factor) => {
let factor = factor.clamp(0.0, 4.0);
let ch = |v: u8| {
(((f32::from(v) - 128.0) * factor) + 128.0)
.round()
.clamp(0.0, 255.0) as u8
};
Rgb::new(ch(c.r()), ch(c.g()), ch(c.b()))
}
ColorTransform::Saturate(factor) => {
let factor = factor.clamp(0.0, 4.0);
let (h, s, l) = rgb_to_hsl(c);
hsl_to_rgb(h, (s * factor).clamp(0.0, 1.0), l)
}
ColorTransform::HueRotate(degrees) => {
let (h, s, l) = rgb_to_hsl(c);
hsl_to_rgb((h + degrees).rem_euclid(360.0), s, l)
}
ColorTransform::Tint { color, amount } => c.mix(*color, *amount),
ColorTransform::Compose(steps) => steps.iter().fold(c, |acc, step| step.apply(acc)),
}
}
pub fn apply_rgb8_buffer(&self, buf: &mut [u8]) {
for pixel in buf.chunks_exact_mut(3) {
let out = self.apply(Rgb::new(pixel[0], pixel[1], pixel[2]));
pixel[0] = out.r();
pixel[1] = out.g();
pixel[2] = out.b();
}
}
}
pub(crate) fn rgb_to_hsl(c: Rgb) -> (f32, f32, f32) {
let r = f32::from(c.r()) / 255.0;
let g = f32::from(c.g()) / 255.0;
let b = f32::from(c.b()) / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
if (max - min).abs() < f32::EPSILON {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == r {
((g - b) / d).rem_euclid(6.0)
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
} * 60.0;
(h.rem_euclid(360.0), s, l)
}
pub(crate) fn hsl_to_rgb(h: f32, s: f32, l: f32) -> Rgb {
if s <= f32::EPSILON {
let v = (l * 255.0).round().clamp(0.0, 255.0) as u8;
return Rgb::new(v, v, v);
}
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h.rem_euclid(360.0) / 60.0;
let x = c * (1.0 - (hp.rem_euclid(2.0) - 1.0).abs());
let (r1, g1, b1) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
Rgb::new(
((r1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
((g1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
((b1 + m) * 255.0).round().clamp(0.0, 255.0) as u8,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grayscale_produces_equal_channels() {
let out = ColorTransform::Grayscale.apply(Rgb::new(10, 200, 50));
assert_eq!(out.r(), out.g());
assert_eq!(out.g(), out.b());
}
#[test]
fn grayscale_is_a_fixed_point_for_gray_input() {
let gray = Rgb::new(100, 100, 100);
assert_eq!(ColorTransform::Grayscale.apply(gray), gray);
}
#[test]
fn invert_is_its_own_inverse() {
let c = Rgb::new(10, 200, 50);
let twice = ColorTransform::Invert.apply(ColorTransform::Invert.apply(c));
assert_eq!(twice, c);
}
#[test]
fn invert_black_is_white_and_vice_versa() {
assert_eq!(ColorTransform::Invert.apply(Rgb::BLACK), Rgb::WHITE);
assert_eq!(ColorTransform::Invert.apply(Rgb::WHITE), Rgb::BLACK);
}
#[test]
fn sepia_removes_blue_dominance() {
let out = ColorTransform::Sepia.apply(Rgb::new(20, 20, 200));
assert!(out.r() > out.b());
}
#[test]
fn brightness_one_is_identity() {
let c = Rgb::new(30, 128, 240);
assert_eq!(ColorTransform::Brightness(1.0).apply(c), c);
}
#[test]
fn brightness_zero_is_black() {
let c = Rgb::new(30, 128, 240);
assert_eq!(ColorTransform::Brightness(0.0).apply(c), Rgb::BLACK);
}
#[test]
fn brightness_clamps_channels_at_255() {
let out = ColorTransform::Brightness(10.0).apply(Rgb::new(200, 200, 200));
assert_eq!(out, Rgb::new(255, 255, 255));
}
#[test]
fn contrast_one_is_identity() {
let c = Rgb::new(10, 128, 240);
assert_eq!(ColorTransform::Contrast(1.0).apply(c), c);
}
#[test]
fn contrast_zero_collapses_to_mid_gray() {
let out = ColorTransform::Contrast(0.0).apply(Rgb::new(10, 250, 128));
assert_eq!(out, Rgb::new(128, 128, 128));
}
#[test]
fn saturate_zero_matches_grayscale_for_a_neutral_input() {
let gray = Rgb::new(150, 150, 150);
let out = ColorTransform::Saturate(0.0).apply(gray);
assert_eq!(out.r(), out.g());
assert_eq!(out.g(), out.b());
}
#[test]
fn saturate_one_is_close_to_identity() {
let c = Rgb::new(200, 40, 40);
let out = ColorTransform::Saturate(1.0).apply(c);
assert!((i16::from(out.r()) - i16::from(c.r())).abs() <= 2);
assert!((i16::from(out.g()) - i16::from(c.g())).abs() <= 2);
assert!((i16::from(out.b()) - i16::from(c.b())).abs() <= 2);
}
#[test]
fn hue_rotate_by_360_is_close_to_identity() {
let c = Rgb::new(200, 40, 40);
let out = ColorTransform::HueRotate(360.0).apply(c);
assert!((i16::from(out.r()) - i16::from(c.r())).abs() <= 2);
assert!((i16::from(out.g()) - i16::from(c.g())).abs() <= 2);
assert!((i16::from(out.b()) - i16::from(c.b())).abs() <= 2);
}
#[test]
fn hue_rotate_by_180_swaps_a_primary_red_toward_cyan() {
let out = ColorTransform::HueRotate(180.0).apply(Rgb::new(255, 0, 0));
assert!(out.r() < 20);
assert!(out.g() > 200);
assert!(out.b() > 200);
}
#[test]
fn tint_zero_is_identity_and_one_is_the_target_color() {
let c = Rgb::new(10, 20, 30);
assert_eq!(
ColorTransform::Tint {
color: Rgb::RED,
amount: 0.0
}
.apply(c),
c
);
assert_eq!(
ColorTransform::Tint {
color: Rgb::RED,
amount: 1.0
}
.apply(c),
Rgb::RED
);
}
#[test]
fn compose_applies_each_step_in_order() {
let c = Rgb::new(10, 20, 30);
let composed = ColorTransform::Compose(vec![
ColorTransform::Invert,
ColorTransform::Invert,
ColorTransform::Grayscale,
])
.apply(c);
assert_eq!(composed, ColorTransform::Grayscale.apply(c));
}
#[test]
fn none_is_identity() {
let c = Rgb::new(1, 2, 3);
assert_eq!(ColorTransform::None.apply(c), c);
}
#[test]
fn apply_rgb8_buffer_transforms_every_pixel() {
let mut buf = vec![10u8, 20, 30, 200, 100, 50];
ColorTransform::Invert.apply_rgb8_buffer(&mut buf);
assert_eq!(buf, vec![245, 235, 225, 55, 155, 205]);
}
#[test]
fn hsl_round_trip_preserves_grayscale() {
let (h, s, l) = rgb_to_hsl(Rgb::new(128, 128, 128));
assert_eq!(s, 0.0);
let back = hsl_to_rgb(h, s, l);
assert_eq!(back, Rgb::new(128, 128, 128));
}
#[test]
fn hsl_round_trip_preserves_primary_hues() {
for c in [Rgb::RED, Rgb::GREEN, Rgb::BLUE] {
let (h, s, l) = rgb_to_hsl(c);
let back = hsl_to_rgb(h, s, l);
assert_eq!(back, c, "round trip failed for {c:?}");
}
}
}