///|
/// Apply a 256-entry lookup table to the RGB channels; alpha is preserved.
fn apply_rgb_lut(img : Image, lut : Array[Byte]) -> Image {
map_rgba(img, fn(r, g, b, a) {
(lut[r.to_int()], lut[g.to_int()], lut[b.to_int()], a)
})
}
///|
/// Brightness adjustment using a multiplicative factor.
/// `factor = 1.0` returns the original image, `factor = 0.0` returns a black image.
pub fn adjust_brightness(img : Image, factor : Double) -> Image {
apply_rgb_lut(
img,
Array::makei(256, fn(i) { round_byte(i.to_double() * factor) }),
)
}
///|
/// Contrast adjustment based on image mean.
/// `factor = 1.0` returns the original image, `factor = 0.0` returns a solid gray image.
pub fn adjust_contrast(img : Image, factor : Double) -> Image {
let mut sum = 0
let n = img.h * img.w
for i = 0; i < n; i = i + 1 {
let o = i * 4
sum = sum + luma(img.data[o], img.data[o + 1], img.data[o + 2])
}
let mean = sum.to_double() / n.to_double()
apply_rgb_lut(
img,
Array::makei(256, fn(i) {
round_byte(mean * (1.0 - factor) + i.to_double() * factor)
}),
)
}
///|
/// Gamma correction. `gamma > 1` darkens, `gamma < 1` brightens.
pub fn adjust_gamma(img : Image, gamma : Double) -> Image {
apply_rgb_lut(
img,
Array::makei(256, fn(i) {
round_byte(@math.pow(i.to_double() / 255.0, gamma) * 255.0)
}),
)
}
///|
/// Linearly rescale the RGB value range into `[min, max]`.
///
/// Finds the current minimum and maximum across all RGB channels, then maps
/// that range linearly onto `[min, max]`. If the input range is empty the
/// image is returned unchanged. The alpha channel is preserved.
pub fn normalize(img : Image, min : Byte, max : Byte) -> Image {
let mut lo = 255
let mut hi = 0
let n = img.h * img.w
for i = 0; i < n; i = i + 1 {
let o = i * 4
for c = 0; c < 3; c = c + 1 {
let v = img.data[o + c].to_int()
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
}
if hi <= lo {
return img.clone()
}
let mn = min.to_int()
let mx = max.to_int()
let span = (mx - mn).to_double() / (hi - lo).to_double()
apply_rgb_lut(
img,
Array::makei(256, fn(i) {
clamp_byte(mn + round_byte((i - lo).to_double() * span).to_int())
}),
)
}
///|
/// Automatic contrast stretch with histogram cutoff.
///
/// Builds the image histogram, discards `cutoff` percent of the pixel count
/// from each end, then linearly stretches the remaining intensity range to
/// the full 0–255 range. The alpha channel is preserved.
pub fn auto_contrast(img : Image, cutoff : Int) -> Image {
let hist = histogram(img)
let total = img.h * img.w
if total == 0 {
return img.clone()
}
let cut = total * clampi(cutoff, 0, 49) / 100
let mut lo = 0
let mut acc = 0
for i = 0; i < 256; i = i + 1 {
acc = acc + hist[i]
if acc > cut {
lo = i
break
}
}
let mut hi = 255
acc = 0
for i = 255; i >= 0; i = i - 1 {
acc = acc + hist[i]
if acc > cut {
hi = i
break
}
}
if hi <= lo {
return img.clone()
}
let span = 255.0 / (hi - lo).to_double()
apply_rgb_lut(
img,
Array::makei(256, fn(i) { round_byte((i - lo).to_double() * span) }),
)
}
///|
/// Standardize the image to zero mean and unit standard deviation.
///
/// Computes the mean and standard deviation of the per-pixel luma, then
/// returns `(luma - mean) / std` for each pixel. A zero-variance image uses
/// `std = 1.0` to avoid division by zero.
///
/// Returns an `h × w` array of `Double` values.
pub fn standardize(img : Image) -> Array[Array[Double]] {
let n = img.h * img.w
let mut sum = 0.0
let mut sum2 = 0.0
for i = 0; i < n; i = i + 1 {
let o = i * 4
let v = luma(img.data[o], img.data[o + 1], img.data[o + 2]).to_double()
sum = sum + v
sum2 = sum2 + v * v
}
let mean = sum / n.to_double()
let variance = sum2 / n.to_double() - mean * mean
let std = if variance > 0.0 { variance.sqrt() } else { 1.0 }
let out = Array::makei(img.h, fn(_i) { Array::make(img.w, 0.0) })
for y = 0; y < img.h; y = y + 1 {
for x = 0; x < img.w; x = x + 1 {
let o = img.offset(y, x)
let v = luma(img.data[o], img.data[o + 1], img.data[o + 2]).to_double()
out[y][x] = (v - mean) / std
}
}
out
}