diff --git a/hsl.mbt b/hsl.mbt new file mode 100644 index 0000000..49061c2 --- /dev/null +++ b/hsl.mbt @@ -0,0 +1,98 @@ +///| +/// HSL color space: the "lightness" cousin of HSV. Hue is shared with HSV; +/// lightness places white at L=1 and black at L=0 with pure colors at +/// L=0.5, which makes symmetric lighten/darken operations natural. The +/// conversions mirror the existing HSV implementations so 8-bit round +/// trips are exact. + +///| +/// Converts 8-bit RGB to HSL: hue in degrees `[0, 360)`, saturation and +/// lightness in `[0, 1]`. +pub fn rgb_to_hsl(r : Int, g : Int, b : Int) -> (Double, Double, Double) { + let rf = r.to_double() / 255.0 + let gf = g.to_double() / 255.0 + let bf = b.to_double() / 255.0 + let mut mx = rf + if gf > mx { + mx = gf + } + if bf > mx { + mx = bf + } + let mut mn = rf + if gf < mn { + mn = gf + } + if bf < mn { + mn = bf + } + let d = mx - mn + let l = (mx + mn) / 2.0 + let h = if d == 0.0 { + 0.0 + } else if mx == rf { + wrap_hue(60.0 * (gf - bf) / d) + } else if mx == gf { + 60.0 * ((bf - rf) / d + 2.0) + } else { + 60.0 * ((rf - gf) / d + 4.0) + } + let s = if d == 0.0 { + 0.0 + } else if l < 0.5 { + d / (mx + mn) + } else { + d / (2.0 - mx - mn) + } + (h, s, l) +} + +///| +/// Converts HSL (hue in degrees, saturation/lightness in `[0, 1]`) back to +/// 8-bit RGB using the standard chroma/sector decomposition. +pub fn hsl_to_rgb(h : Double, s : Double, l : Double) -> (Int, Int, Int) { + let ld = if l - 0.5 < 0.0 { 0.5 - l } else { l - 0.5 } + let c = (1.0 - 2.0 * ld) * s + let hh = wrap_hue(h) / 60.0 + let mod2 = hh - 2.0 * ifloor(hh / 2.0).to_double() + let diff = if mod2 - 1.0 < 0.0 { 1.0 - mod2 } else { mod2 - 1.0 } + let x = c * (1.0 - diff) + let sector = ifloor(hh) + let (r1, g1, b1) = match sector { + 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 + ( + ((r1 + m) * 255.0 + 0.5).to_int(), + ((g1 + m) * 255.0 + 0.5).to_int(), + ((b1 + m) * 255.0 + 0.5).to_int(), + ) +} + +///| +/// Shifts every pixel's HSL lightness by `delta` (in `[-1, 1]`), clamping +/// the result into `[0, 1]`. Positive values lighten toward white while +/// preserving hue; negative values darken toward black. `delta == 0` +/// returns a copy. +pub fn Image::adjust_lightness(self : Image, delta : Double) -> Image { + if delta == 0.0 { + return self.copy() + } + self.map_rgb(fn(r, g, b) { + let (h, s, l) = rgb_to_hsl(r, g, b) + let nl_raw = l + delta + let nl = if nl_raw < 0.0 { + 0.0 + } else if nl_raw > 1.0 { + 1.0 + } else { + nl_raw + } + hsl_to_rgb(h, s, nl) + }) +} diff --git a/hsl_test.mbt b/hsl_test.mbt new file mode 100644 index 0000000..233c542 --- /dev/null +++ b/hsl_test.mbt @@ -0,0 +1,87 @@ +///| +/// HSL tests: primary-color reference values, 8-bit round trips, gray +/// invariance and lightness adjustment behavior. + +///| +test "rgb_to_hsl on primaries and white matches reference values" { + // Pure red: H=0, S=1, L=0.5. + let (h, s, l) = rgb_to_hsl(255, 0, 0) + assert_eq(h.to_int(), 0) + assert_eq((s * 100.0).to_int(), 100) + assert_eq((l * 100.0).to_int(), 50) + // Pure green / blue hues. + assert_eq(rgb_to_hsl(0, 255, 0).0.to_int(), 120) + assert_eq(rgb_to_hsl(0, 0, 255).0.to_int(), 240) + // White: L=1, S=0. + let (_, sw, lw) = rgb_to_hsl(255, 255, 255) + assert_eq((sw * 100.0).to_int(), 0) + assert_eq((lw * 100.0).to_int(), 100) +} + +///| +test "hsl round-trips 8-bit colors exactly" { + let samples = [ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (255, 255, 0), + (12, 200, 99), + (128, 128, 128), + (0, 0, 0), + (255, 255, 255), + (37, 142, 209), + (250, 128, 114), + ] + for i in 0.. 200) + assert_true(g.to_int() > 50) + assert_true(r.to_int() > g.to_int()) + assert_eq(g.to_int(), b.to_int()) // symmetric channels stay symmetric +} + +///| +test "adjust_lightness(0) is an exact copy and alpha is preserved" { + let img = single(120, 88, 33, 77) + let same = img.adjust_lightness(0.0) + for i in 0..<4 { + assert_eq(same.data[i].to_int(), img.data[i].to_int()) + } + assert_eq(img.adjust_lightness(0.3).get_pixel(0, 0).3.to_int(), 77) +} diff --git a/pkg.generated.mbti b/pkg.generated.mbti index 2f63eec..59c44b7 100644 --- a/pkg.generated.mbti +++ b/pkg.generated.mbti @@ -12,6 +12,8 @@ pub fn gif_decode(Array[Byte]) -> Image? pub fn hamming_distance(UInt64, UInt64) -> Int +pub fn hsl_to_rgb(Double, Double, Double) -> (Int, Int, Int) + pub fn hsv_to_rgb(Double, Double, Double) -> (Int, Int, Int) pub fn png_decode(Array[Byte]) -> Image? @@ -22,6 +24,8 @@ pub fn qoi_decode(Array[Byte]) -> Image? pub fn qoi_encode(Image) -> Array[Byte] +pub fn rgb_to_hsl(Int, Int, Int) -> (Double, Double, Double) + pub fn rgb_to_hsv(Int, Int, Int) -> (Double, Double, Double) pub fn rgb_to_ycbcr(Int, Int, Int) -> (Int, Int, Int) @@ -66,6 +70,7 @@ pub(all) struct Image { } pub fn Image::add_gaussian_noise(Self, UInt64, Double) -> Self pub fn Image::add_salt_pepper(Self, UInt64, Double) -> Self +pub fn Image::adjust_lightness(Self, Double) -> Self pub fn Image::affine(Self, Affine) -> Self pub fn Image::apply_filter_id(Self, Int, Double) -> Self pub fn Image::auto_contrast(Self) -> Self