const PCT_LOW: f32 = 10.0;
const PCT_HIGH: f32 = 90.0;
const PERCENT_SCALE: f32 = 100.0;
const SUM_FLOOR: f32 = 10.0;
const RANGE_FLOOR: f32 = 10.0;
const CONTRAST_NUM: f32 = 200.0;
const SHIFT: f32 = 25.0;
const U8_MIN: f32 = 0.0;
const U8_MAX: f32 = 255.0;
fn percentile(sorted: &[f32], percent: f32) -> f32 {
let n = sorted.len();
if n == 0 {
return 0.0;
}
if n == 1 {
return sorted[0];
}
let rank = percent / PERCENT_SCALE * (n - 1) as f32;
let lower = rank.floor();
let lower_index = lower as usize;
let upper_index = rank.ceil() as usize;
let lower_value = sorted[lower_index];
let upper_value = sorted[upper_index];
lower_value + (upper_value - lower_value) * (rank - lower)
}
pub(crate) fn contrast_grey(gray: &[u8]) -> (f32, f32, f32) {
if gray.is_empty() {
return (0.0, 0.0, 0.0);
}
let mut sorted: Vec<f32> = gray.iter().map(|&value| value as f32).collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let high = percentile(&sorted, PCT_HIGH);
let low = percentile(&sorted, PCT_LOW);
let contrast = (high - low) / (high + low).max(SUM_FLOOR);
(contrast, high, low)
}
pub(crate) fn adjust_contrast_grey(gray: &[u8], target: f32) -> Vec<u8> {
let (contrast, high, low) = contrast_grey(gray);
if contrast >= target {
return gray.to_vec();
}
let ratio = CONTRAST_NUM / (high - low).max(RANGE_FLOOR);
gray.iter()
.map(|&value| {
let adjusted = ((value as f32) - low + SHIFT) * ratio;
adjusted.clamp(U8_MIN, U8_MAX).trunc() as u8
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const FLOAT_TOLERANCE: f32 = 1e-4;
fn assert_close(actual: f32, expected: f32, label: &str) {
assert!(
(actual - expected).abs() < FLOAT_TOLERANCE,
"{label}: expected {expected}, got {actual}"
);
}
#[test]
fn should_compute_linear_interpolated_percentiles_and_contrast() {
let gray = [0u8, 50, 100, 150, 200];
let (contrast, high, low) = contrast_grey(&gray);
assert_close(high, 180.0, "high");
assert_close(low, 20.0, "low");
assert_close(contrast, 0.8, "contrast");
}
#[test]
fn should_stretch_low_contrast_input() {
let gray = [70u8, 80, 90, 100, 110, 120, 130, 140, 150, 180, 200];
let (contrast, _, _) = contrast_grey(&gray);
assert!(contrast < 0.4, "input must be low-contrast, got {contrast}");
let adjusted = adjust_contrast_grey(&gray, 0.4);
let expected = vec![30u8, 50, 70, 90, 110, 130, 150, 170, 190, 250, 255];
assert_eq!(adjusted, expected);
let input_spread = *gray.iter().max().unwrap() - *gray.iter().min().unwrap();
let output_spread = *adjusted.iter().max().unwrap() - *adjusted.iter().min().unwrap();
assert!(
output_spread > input_spread,
"output spread {output_spread} should exceed input spread {input_spread}"
);
}
#[test]
fn should_return_high_contrast_input_unchanged() {
let gray = [0u8, 50, 100, 150, 200];
let adjusted = adjust_contrast_grey(&gray, 0.4);
assert_eq!(adjusted, gray.to_vec());
}
#[test]
fn should_preserve_output_length_when_stretching() {
let gray = [100u8, 101, 102, 103, 104, 105, 106, 107, 108, 109];
let adjusted = adjust_contrast_grey(&gray, 0.4);
assert_eq!(adjusted.len(), gray.len());
}
#[test]
fn should_return_zeros_for_empty_input() {
assert_eq!(contrast_grey(&[]), (0.0, 0.0, 0.0));
}
#[test]
fn should_return_empty_vec_for_empty_input() {
assert_eq!(adjust_contrast_grey(&[], 0.4), Vec::<u8>::new());
}
}