Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions hsl.mbt
Original file line number Diff line number Diff line change
@@ -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)
})
}
87 changes: 87 additions & 0 deletions hsl_test.mbt
Original file line number Diff line number Diff line change
@@ -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..<samples.length() {
let (r, g, b) = samples[i]
let (h, s, l) = rgb_to_hsl(r, g, b)
let (r2, g2, b2) = hsl_to_rgb(h, s, l)
assert_eq(r2, r)
assert_eq(g2, g)
assert_eq(b2, b)
}
}

///|
test "grays keep zero saturation and exact lightness" {
for v in [0, 64, 128, 200, 255] {
let (_, s, l) = rgb_to_hsl(v, v, v)
assert_eq((s * 1000.0).to_int(), 0)
// L = v/255 exactly.
let (r2, g2, b2) = hsl_to_rgb(0.0, s, l)
assert_eq(r2, v)
assert_eq(g2, v)
assert_eq(b2, v)
}
}

///|
test "adjust_lightness lightens toward white and darkens toward black" {
let img = single(200, 50, 50, 255)
// +1 lightness saturates to pure white regardless of hue.
let white = img.adjust_lightness(1.0).get_pixel(0, 0)
assert_eq(white.0.to_int(), 255)
assert_eq(white.1.to_int(), 255)
assert_eq(white.2.to_int(), 255)
// -1 lightness collapses to black.
let black = img.adjust_lightness(-1.0).get_pixel(0, 0)
assert_eq(black.0.to_int(), 0)
assert_eq(black.1.to_int(), 0)
assert_eq(black.2.to_int(), 0)
// A small positive delta brightens every channel without reordering.
let (r, g, b, _) = img.adjust_lightness(0.2).get_pixel(0, 0)
assert_true(r.to_int() > 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)
}
5 changes: 5 additions & 0 deletions pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading