use image::{Rgb, RgbImage};
use rayon::prelude::*;
use std::path::{Path, PathBuf};
use thiserror::Error;
const SAMPLE_STEP: u32 = 4;
const INK_PERCENTILE: f64 = 0.05;
const PAPER_PERCENTILE: f64 = 0.95;
const MIN_SCALE: f64 = 0.8;
const MAX_SCALE: f64 = 4.0;
const DEFAULT_SAT_THRESHOLD: u8 = 55;
const DEFAULT_COLOR_DIST_THRESHOLD: u8 = 35;
const DEFAULT_WHITE_CLIP_RANGE: u8 = 30;
#[derive(Debug, Error)]
pub enum ColorStatsError {
#[error("Image not found: {0}")]
ImageNotFound(PathBuf),
#[error("Invalid image: {0}")]
InvalidImage(String),
#[error("No valid pages for analysis")]
NoValidPages,
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, ColorStatsError>;
#[derive(Debug, Clone, Default)]
pub struct ColorStats {
pub page_number: usize,
pub paper_r: f64,
pub paper_g: f64,
pub paper_b: f64,
pub ink_r: f64,
pub ink_g: f64,
pub ink_b: f64,
pub mean_r: f64,
pub mean_g: f64,
pub mean_b: f64,
}
impl ColorStats {
pub fn paper_luminance(&self) -> f64 {
0.299 * self.paper_r + 0.587 * self.paper_g + 0.114 * self.paper_b
}
pub fn ink_luminance(&self) -> f64 {
0.299 * self.ink_r + 0.587 * self.ink_g + 0.114 * self.ink_b
}
}
#[derive(Debug, Clone)]
pub struct BleedSuppression {
pub hue_min: f32,
pub hue_max: f32,
pub saturation_max: f32,
pub value_min: f32,
pub enabled: bool,
pub strength: f32,
}
impl Default for BleedSuppression {
fn default() -> Self {
Self {
hue_min: 20.0,
hue_max: 65.0,
saturation_max: 1.0, value_min: 0.35, enabled: true,
strength: 1.0,
}
}
}
impl BleedSuppression {
pub fn new(hue_min: f32, hue_max: f32, saturation_max: f32, value_min: f32) -> Self {
Self {
hue_min,
hue_max,
saturation_max,
value_min,
enabled: true,
strength: 1.0,
}
}
pub fn is_bleed_through(&self, h: f32, s: f32, v: f32) -> bool {
if !self.enabled {
return false;
}
let hue_match = h >= self.hue_min && h <= self.hue_max;
let sat_match = s <= self.saturation_max;
let val_match = v >= self.value_min;
hue_match && sat_match && val_match
}
pub fn aggressive() -> Self {
Self {
hue_min: 15.0,
hue_max: 75.0,
saturation_max: 0.40,
value_min: 0.60,
enabled: true,
strength: 1.0,
}
}
pub fn gentle() -> Self {
Self {
hue_min: 25.0,
hue_max: 55.0,
saturation_max: 0.20,
value_min: 0.80,
enabled: true,
strength: 0.7,
}
}
pub fn disabled() -> Self {
Self {
enabled: false,
..Default::default()
}
}
}
#[derive(Debug, Clone)]
pub struct GlobalColorParam {
pub scale_r: f64,
pub scale_g: f64,
pub scale_b: f64,
pub offset_r: f64,
pub offset_g: f64,
pub offset_b: f64,
pub ghost_suppress_threshold: u8,
pub white_clip_range: u8,
pub paper_r: u8,
pub paper_g: u8,
pub paper_b: u8,
pub sat_threshold: u8,
pub color_dist_threshold: u8,
pub bleed_hue_min: f32,
pub bleed_hue_max: f32,
pub bleed_value_min: f32,
pub bleed_suppression: BleedSuppression,
}
impl Default for GlobalColorParam {
fn default() -> Self {
Self {
scale_r: 1.0,
scale_g: 1.0,
scale_b: 1.0,
offset_r: 0.0,
offset_g: 0.0,
offset_b: 0.0,
ghost_suppress_threshold: 200,
white_clip_range: DEFAULT_WHITE_CLIP_RANGE,
paper_r: 255,
paper_g: 255,
paper_b: 255,
sat_threshold: DEFAULT_SAT_THRESHOLD,
color_dist_threshold: DEFAULT_COLOR_DIST_THRESHOLD,
bleed_hue_min: 20.0,
bleed_hue_max: 65.0,
bleed_value_min: 0.35,
bleed_suppression: BleedSuppression::default(),
}
}
}
pub struct ColorAnalyzer;
impl ColorAnalyzer {
pub fn calculate_stats(image_path: &Path) -> Result<ColorStats> {
if !image_path.exists() {
return Err(ColorStatsError::ImageNotFound(image_path.to_path_buf()));
}
let img =
image::open(image_path).map_err(|e| ColorStatsError::InvalidImage(e.to_string()))?;
let rgb = img.to_rgb8();
Ok(Self::calculate_stats_from_image(&rgb, 0))
}
pub fn calculate_stats_from_image(image: &RgbImage, page_number: usize) -> ColorStats {
let (w, h) = image.dimensions();
let step = SAMPLE_STEP;
let mut histogram = [0u64; 256];
let mut total = 0u64;
for y in (0..h).step_by(step as usize) {
for x in (0..w).step_by(step as usize) {
let pixel = image.get_pixel(x, y);
let lum = Self::luminance(pixel.0[0], pixel.0[1], pixel.0[2]);
histogram[lum as usize] += 1;
total += 1;
}
}
let low_target = (total as f64 * INK_PERCENTILE) as u64;
let high_target = (total as f64 * PAPER_PERCENTILE) as u64;
let mut low_lum = 0u8;
let mut high_lum = 255u8;
let mut acc = 0u64;
for (i, &count) in histogram.iter().enumerate() {
acc += count;
if acc >= low_target && low_lum == 0 {
low_lum = i as u8;
}
if acc >= high_target {
high_lum = i as u8;
break;
}
}
let mut sum_paper_r = 0u64;
let mut sum_paper_g = 0u64;
let mut sum_paper_b = 0u64;
let mut cnt_paper = 0u64;
let mut sum_ink_r = 0u64;
let mut sum_ink_g = 0u64;
let mut sum_ink_b = 0u64;
let mut cnt_ink = 0u64;
for y in (0..h).step_by(step as usize) {
for x in (0..w).step_by(step as usize) {
let pixel = image.get_pixel(x, y);
let (r, g, b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
let lum = Self::luminance(r, g, b);
if lum >= high_lum {
sum_paper_r += r as u64;
sum_paper_g += g as u64;
sum_paper_b += b as u64;
cnt_paper += 1;
} else if lum <= low_lum {
sum_ink_r += r as u64;
sum_ink_g += g as u64;
sum_ink_b += b as u64;
cnt_ink += 1;
}
}
}
if cnt_paper == 0 {
cnt_paper = 1;
}
if cnt_ink == 0 {
cnt_ink = 1;
}
let paper_r = sum_paper_r as f64 / cnt_paper as f64;
let paper_g = sum_paper_g as f64 / cnt_paper as f64;
let paper_b = sum_paper_b as f64 / cnt_paper as f64;
let ink_r = sum_ink_r as f64 / cnt_ink as f64;
let ink_g = sum_ink_g as f64 / cnt_ink as f64;
let ink_b = sum_ink_b as f64 / cnt_ink as f64;
ColorStats {
page_number,
paper_r,
paper_g,
paper_b,
ink_r,
ink_g,
ink_b,
mean_r: paper_r,
mean_g: paper_g,
mean_b: paper_b,
}
}
pub fn exclude_outliers(stats_list: &[ColorStats]) -> Vec<ColorStats> {
if stats_list.len() < 3 {
return stats_list.to_vec();
}
let mut luminances: Vec<(usize, f64)> = stats_list
.iter()
.enumerate()
.map(|(i, s)| (i, s.paper_luminance()))
.collect();
luminances.sort_by(|a, b| a.1.total_cmp(&b.1));
let median = Self::percentile_f64(
&luminances.iter().map(|(_, l)| *l).collect::<Vec<_>>(),
50.0,
);
let mut deviations: Vec<f64> = luminances.iter().map(|(_, l)| (l - median).abs()).collect();
deviations.sort_by(|a, b| a.total_cmp(b));
let mad = Self::percentile_f64(&deviations, 50.0);
let threshold = mad * 1.5;
let valid_indices: Vec<usize> = luminances
.iter()
.filter(|(_, l)| (l - median).abs() <= threshold)
.map(|(i, _)| *i)
.collect();
if valid_indices.is_empty() {
return stats_list.to_vec();
}
valid_indices
.iter()
.map(|&i| stats_list[i].clone())
.collect()
}
pub fn decide_global_adjustment(stats_list: &[ColorStats]) -> GlobalColorParam {
if stats_list.is_empty() {
return GlobalColorParam::default();
}
let filtered = Self::exclude_outliers(stats_list);
if filtered.is_empty() {
return GlobalColorParam::default();
}
let bg_r = Self::percentile_f64(
&filtered.iter().map(|s| s.paper_r).collect::<Vec<_>>(),
50.0,
);
let bg_g = Self::percentile_f64(
&filtered.iter().map(|s| s.paper_g).collect::<Vec<_>>(),
50.0,
);
let bg_b = Self::percentile_f64(
&filtered.iter().map(|s| s.paper_b).collect::<Vec<_>>(),
50.0,
);
let ink_r =
Self::percentile_f64(&filtered.iter().map(|s| s.ink_r).collect::<Vec<_>>(), 50.0);
let ink_g =
Self::percentile_f64(&filtered.iter().map(|s| s.ink_g).collect::<Vec<_>>(), 50.0);
let ink_b =
Self::percentile_f64(&filtered.iter().map(|s| s.ink_b).collect::<Vec<_>>(), 50.0);
let ink_lum = 0.299 * ink_r + 0.587 * ink_g + 0.114 * ink_b;
let paper_lum = 0.299 * bg_r + 0.587 * bg_g + 0.114 * bg_b;
let contrast = paper_lum - ink_lum;
if contrast < 100.0 || ink_lum > 100.0 {
return GlobalColorParam::default();
}
let (scale_r, offset_r) = Self::linear_scale(bg_r, ink_r);
let (scale_g, offset_g) = Self::linear_scale(bg_g, ink_g);
let (scale_b, offset_b) = Self::linear_scale(bg_b, ink_b);
let clamp8 = |v: f64| v.clamp(0.0, 255.0) as u8;
let bg_lum_scaled = 0.299 * clamp8(bg_r * scale_r + offset_r) as f64
+ 0.587 * clamp8(bg_g * scale_g + offset_g) as f64
+ 0.114 * clamp8(bg_b * scale_b + offset_b) as f64;
let ink_lum_scaled = 0.299 * clamp8(ink_r * scale_r + offset_r) as f64
+ 0.587 * clamp8(ink_g * scale_g + offset_g) as f64
+ 0.114 * clamp8(ink_b * scale_b + offset_b) as f64;
let ghost_threshold = ((ink_lum_scaled + bg_lum_scaled) * 0.5).clamp(0.0, 255.0) as u8;
GlobalColorParam {
scale_r,
scale_g,
scale_b,
offset_r,
offset_g,
offset_b,
ghost_suppress_threshold: ghost_threshold,
white_clip_range: DEFAULT_WHITE_CLIP_RANGE,
paper_r: bg_r.round() as u8,
paper_g: bg_g.round() as u8,
paper_b: bg_b.round() as u8,
sat_threshold: DEFAULT_SAT_THRESHOLD,
color_dist_threshold: DEFAULT_COLOR_DIST_THRESHOLD,
bleed_hue_min: 20.0,
bleed_hue_max: 65.0,
bleed_value_min: 0.35,
bleed_suppression: BleedSuppression::default(),
}
}
pub fn apply_adjustment(image: &mut RgbImage, params: &GlobalColorParam) {
let (w, h) = image.dimensions();
let clip_start = params.ghost_suppress_threshold as i32;
let clip_end = (255 - params.white_clip_range as i32).clamp(0, 255);
for y in 0..h {
for x in 0..w {
let pixel = image.get_pixel(x, y);
let (src_r, src_g, src_b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
let mut r = Self::clamp8(src_r as f64 * params.scale_r + params.offset_r);
let mut g = Self::clamp8(src_g as f64 * params.scale_g + params.offset_g);
let mut b = Self::clamp8(src_b as f64 * params.scale_b + params.offset_b);
let lum = Self::luminance(r, g, b) as i32;
if lum >= clip_start {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let sat = if max == 0 {
0
} else {
(max - min) as i32 * 255 / max as i32
};
let dist = (r as i32 - params.paper_r as i32).abs()
+ (g as i32 - params.paper_g as i32).abs()
+ (b as i32 - params.paper_b as i32).abs();
if sat < params.sat_threshold as i32
&& dist < params.color_dist_threshold as i32
{
let t = ((lum - clip_start) as f64 / (clip_end - clip_start + 1) as f64)
.clamp(0.0, 1.0);
let wgt = t * t * (3.0 - 2.0 * t);
r = Self::clamp8(r as f64 + (255.0 - r as f64) * wgt);
g = Self::clamp8(g as f64 + (255.0 - g as f64) * wgt);
b = Self::clamp8(b as f64 + (255.0 - b as f64) * wgt);
}
}
let (hue, _, _) = Self::rgb_to_hsv(r, g, b);
let max2 = r.max(g).max(b);
let min2 = r.min(g).min(b);
let sat2 = if max2 == 0 {
0
} else {
(max2 - min2) as i32 * 255 / max2 as i32
};
let lum2 = Self::luminance(r, g, b);
let is_pastel_pink = lum2 > 230 && sat2 < 30 && (hue <= 40.0 || hue >= 330.0);
if is_pastel_pink {
r = 255;
g = 255;
b = 255;
}
image.put_pixel(x, y, Rgb([r, g, b]));
}
}
}
pub fn apply_bleed_suppression(image: &mut RgbImage, bleed_config: &BleedSuppression) {
if !bleed_config.enabled {
return;
}
let (w, h) = image.dimensions();
for y in 0..h {
for x in 0..w {
let pixel = image.get_pixel(x, y);
let (r, g, b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
let (hue, sat, val) = Self::rgb_to_hsv(r, g, b);
if bleed_config.is_bleed_through(hue, sat, val) {
let strength = bleed_config.strength;
let new_r = Self::clamp8(r as f64 + (255.0 - r as f64) * strength as f64);
let new_g = Self::clamp8(g as f64 + (255.0 - g as f64) * strength as f64);
let new_b = Self::clamp8(b as f64 + (255.0 - b as f64) * strength as f64);
image.put_pixel(x, y, Rgb([new_r, new_g, new_b]));
}
}
}
}
pub fn detect_bleed_percentage(image: &RgbImage, bleed_config: &BleedSuppression) -> f64 {
if !bleed_config.enabled {
return 0.0;
}
let (w, h) = image.dimensions();
let mut bleed_count = 0u64;
for y in (0..h).step_by(SAMPLE_STEP as usize) {
for x in (0..w).step_by(SAMPLE_STEP as usize) {
let pixel = image.get_pixel(x, y);
let (r, g, b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
let (hue, sat, val) = Self::rgb_to_hsv(r, g, b);
if bleed_config.is_bleed_through(hue, sat, val) {
bleed_count += 1;
}
}
}
let sample_total = ((w / SAMPLE_STEP) * (h / SAMPLE_STEP)) as f64;
if sample_total > 0.0 {
(bleed_count as f64 / sample_total) * 100.0
} else {
0.0
}
}
pub fn analyze_book_pages(
image_paths: &[PathBuf],
) -> Result<(Vec<ColorStats>, Vec<ColorStats>)> {
let stats_results: Vec<Result<ColorStats>> = image_paths
.par_iter()
.enumerate()
.map(|(i, path)| {
let mut stats = Self::calculate_stats(path)?;
stats.page_number = i + 1;
Ok(stats)
})
.collect();
let stats: Vec<ColorStats> = stats_results.into_iter().filter_map(|r| r.ok()).collect();
if stats.is_empty() {
return Err(ColorStatsError::NoValidPages);
}
let odd: Vec<ColorStats> = stats
.iter()
.filter(|s| s.page_number % 2 == 1)
.cloned()
.collect();
let even: Vec<ColorStats> = stats
.iter()
.filter(|s| s.page_number % 2 == 0)
.cloned()
.collect();
Ok((odd, even))
}
fn luminance(r: u8, g: u8, b: u8) -> u8 {
(0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64).round() as u8
}
fn clamp8(v: f64) -> u8 {
v.clamp(0.0, 255.0).round() as u8
}
fn linear_scale(bg: f64, ink: f64) -> (f64, f64) {
let diff = bg - ink;
if diff < 1.0 {
return (1.0, 0.0);
}
let s = (255.0 / diff).clamp(MIN_SCALE, MAX_SCALE);
let o = -ink * s;
(s, o)
}
fn percentile_f64(values: &[f64], p: f64) -> f64 {
if values.is_empty() {
return 0.0;
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.total_cmp(b));
let rank = (p / 100.0) * (sorted.len() - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo as f64)
}
}
fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
let max = rf.max(gf).max(bf);
let min = rf.min(gf).min(bf);
let v = max;
let d = max - min;
let s = if max == 0.0 { 0.0 } else { d / max };
let h = if d == 0.0 {
0.0
} else if max == rf {
60.0 * (((gf - bf) / d) % 6.0)
} else if max == gf {
60.0 * (((bf - rf) / d) + 2.0)
} else {
60.0 * (((rf - gf) / d) + 4.0)
};
let h = if h < 0.0 { h + 360.0 } else { h };
(h, s, v)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_global_color_param() {
let params = GlobalColorParam::default();
assert_eq!(params.scale_r, 1.0);
assert_eq!(params.scale_g, 1.0);
assert_eq!(params.scale_b, 1.0);
assert_eq!(params.offset_r, 0.0);
assert_eq!(params.ghost_suppress_threshold, 200);
}
#[test]
fn test_color_stats_luminance() {
let stats = ColorStats {
page_number: 1,
paper_r: 255.0,
paper_g: 255.0,
paper_b: 255.0,
ink_r: 0.0,
ink_g: 0.0,
ink_b: 0.0,
..Default::default()
};
assert!((stats.paper_luminance() - 255.0).abs() < 0.1);
assert!((stats.ink_luminance() - 0.0).abs() < 0.1);
}
#[test]
fn test_luminance_calculation() {
assert_eq!(ColorAnalyzer::luminance(255, 255, 255), 255);
assert_eq!(ColorAnalyzer::luminance(0, 0, 0), 0);
}
#[test]
fn test_linear_scale() {
let (s, o) = ColorAnalyzer::linear_scale(255.0, 0.0);
assert!((s - 1.0).abs() < 0.01);
assert!((o - 0.0).abs() < 0.01);
let (s, _o) = ColorAnalyzer::linear_scale(200.0, 50.0);
assert!(s > 1.0);
}
#[test]
fn test_percentile() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert!((ColorAnalyzer::percentile_f64(&values, 50.0) - 3.0).abs() < 0.01);
assert!((ColorAnalyzer::percentile_f64(&values, 0.0) - 1.0).abs() < 0.01);
assert!((ColorAnalyzer::percentile_f64(&values, 100.0) - 5.0).abs() < 0.01);
}
#[test]
fn test_exclude_outliers_small_list() {
let stats = vec![
ColorStats {
page_number: 1,
paper_r: 250.0,
paper_g: 250.0,
paper_b: 250.0,
..Default::default()
},
ColorStats {
page_number: 2,
paper_r: 240.0,
paper_g: 240.0,
paper_b: 240.0,
..Default::default()
},
];
let filtered = ColorAnalyzer::exclude_outliers(&stats);
assert_eq!(filtered.len(), 2); }
#[test]
fn test_exclude_outliers() {
let stats = vec![
ColorStats {
page_number: 1,
paper_r: 250.0,
paper_g: 250.0,
paper_b: 250.0,
..Default::default()
},
ColorStats {
page_number: 2,
paper_r: 245.0,
paper_g: 245.0,
paper_b: 245.0,
..Default::default()
},
ColorStats {
page_number: 3,
paper_r: 248.0,
paper_g: 248.0,
paper_b: 248.0,
..Default::default()
},
ColorStats {
page_number: 4,
paper_r: 100.0,
paper_g: 100.0,
paper_b: 100.0,
..Default::default()
}, ColorStats {
page_number: 5,
paper_r: 252.0,
paper_g: 252.0,
paper_b: 252.0,
..Default::default()
},
];
let filtered = ColorAnalyzer::exclude_outliers(&stats);
assert!(filtered.len() < stats.len() || filtered.iter().all(|s| s.page_number != 4));
}
#[test]
fn test_decide_global_adjustment() {
let stats = vec![ColorStats {
page_number: 1,
paper_r: 250.0,
paper_g: 248.0,
paper_b: 245.0,
ink_r: 10.0,
ink_g: 10.0,
ink_b: 10.0,
..Default::default()
}];
let params = ColorAnalyzer::decide_global_adjustment(&stats);
assert!(params.scale_r > 0.9);
assert!(params.paper_r > 240);
}
#[test]
fn test_rgb_to_hsv() {
let (h, s, v) = ColorAnalyzer::rgb_to_hsv(255, 0, 0);
assert!(h.abs() < 1.0 || (h - 360.0).abs() < 1.0);
assert!((s - 1.0).abs() < 0.01);
assert!((v - 1.0).abs() < 0.01);
let (_, s, v) = ColorAnalyzer::rgb_to_hsv(255, 255, 255);
assert!((s - 0.0).abs() < 0.01);
assert!((v - 1.0).abs() < 0.01);
}
#[test]
fn test_apply_adjustment_identity() {
let mut img = RgbImage::from_pixel(10, 10, Rgb([128, 128, 128]));
let params = GlobalColorParam::default();
ColorAnalyzer::apply_adjustment(&mut img, ¶ms);
let pixel = img.get_pixel(5, 5);
assert!(pixel.0[0] > 100 && pixel.0[0] < 200);
}
#[test]
fn test_image_not_found() {
let result = ColorAnalyzer::calculate_stats(Path::new("/nonexistent/image.png"));
assert!(matches!(result, Err(ColorStatsError::ImageNotFound(_))));
}
#[test]
fn test_calculate_stats_from_image() {
let img = RgbImage::from_pixel(100, 100, Rgb([240, 238, 235]));
let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
assert_eq!(stats.page_number, 1);
assert!(stats.paper_r > 230.0);
}
#[test]
fn test_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ColorStats>();
assert_send_sync::<GlobalColorParam>();
assert_send_sync::<ColorStatsError>();
}
#[test]
fn test_error_types() {
let _err1 = ColorStatsError::ImageNotFound(PathBuf::from("/test"));
let _err2 = ColorStatsError::InvalidImage("bad".to_string());
let _err3 = ColorStatsError::NoValidPages;
}
#[test]
fn test_tc_color_001_white_background_black_text() {
let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255]));
for y in 40..60 {
for x in 20..80 {
img.put_pixel(x, y, Rgb([0, 0, 0]));
}
}
let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
assert!(
stats.paper_luminance() > 240.0,
"Paper luminance {} should be > 240",
stats.paper_luminance()
);
assert!(
stats.ink_luminance() < 30.0,
"Ink luminance {} should be < 30",
stats.ink_luminance()
);
}
#[test]
fn test_tc_color_002_yellowed_paper_correction() {
let mut img = RgbImage::from_pixel(100, 100, Rgb([245, 235, 210])); for y in 40..60 {
for x in 20..80 {
img.put_pixel(x, y, Rgb([30, 25, 20]));
}
}
let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
assert!(
stats.paper_r < 255.0 || stats.paper_g < 255.0 || stats.paper_b < 255.0,
"Yellowed paper should not be pure white"
);
assert!(
stats.paper_b < stats.paper_r,
"Yellowed paper should have lower blue than red"
);
let all_stats = vec![stats];
let params = ColorAnalyzer::decide_global_adjustment(&all_stats);
assert!(
params.scale_b >= params.scale_r,
"Blue scale {} should be >= red scale {} to correct yellowing",
params.scale_b,
params.scale_r
);
}
#[test]
fn test_tc_color_003_ghost_suppression_params() {
let mut img = RgbImage::from_pixel(100, 100, Rgb([245, 243, 240])); for y in 30..40 {
for x in 20..80 {
img.put_pixel(x, y, Rgb([30, 28, 25])); }
}
for y in 60..70 {
for x in 20..80 {
img.put_pixel(x, y, Rgb([200, 198, 195])); }
}
let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
let params = ColorAnalyzer::decide_global_adjustment(&[stats]);
assert!(
params.ghost_suppress_threshold > 0 && params.ghost_suppress_threshold < 255,
"Ghost suppression threshold {} should be in valid range",
params.ghost_suppress_threshold
);
assert!(
params.scale_r > 0.0 && params.scale_g > 0.0 && params.scale_b > 0.0,
"Scale factors should be positive: R={}, G={}, B={}",
params.scale_r,
params.scale_g,
params.scale_b
);
assert!(
params.paper_r > 200 && params.paper_g > 200 && params.paper_b > 200,
"Paper color should be light: R={}, G={}, B={}",
params.paper_r,
params.paper_g,
params.paper_b
);
}
#[test]
fn test_tc_color_004_color_image_saturation_preserved() {
let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255]));
for y in 20..40 {
for x in 20..40 {
img.put_pixel(x, y, Rgb([255, 50, 50])); }
}
for y in 60..80 {
for x in 60..80 {
img.put_pixel(x, y, Rgb([50, 50, 255])); }
}
let _original_red = *img.get_pixel(30, 30);
let _original_blue = *img.get_pixel(70, 70);
let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
let params = ColorAnalyzer::decide_global_adjustment(&[stats]);
ColorAnalyzer::apply_adjustment(&mut img, ¶ms);
let adjusted_red = img.get_pixel(30, 30);
let adjusted_blue = img.get_pixel(70, 70);
assert!(
adjusted_red.0[0] > adjusted_red.0[1] && adjusted_red.0[0] > adjusted_red.0[2],
"Red pixel should remain red after adjustment"
);
assert!(
adjusted_blue.0[2] > adjusted_blue.0[0] && adjusted_blue.0[2] > adjusted_blue.0[1],
"Blue pixel should remain blue after adjustment"
);
}
#[test]
fn test_tc_color_005_outlier_exclusion_mad() {
let stats = vec![
ColorStats {
page_number: 1,
paper_r: 250.0,
paper_g: 250.0,
paper_b: 250.0,
ink_r: 10.0,
ink_g: 10.0,
ink_b: 10.0,
..Default::default()
},
ColorStats {
page_number: 2,
paper_r: 248.0,
paper_g: 248.0,
paper_b: 248.0,
ink_r: 12.0,
ink_g: 12.0,
ink_b: 12.0,
..Default::default()
},
ColorStats {
page_number: 3,
paper_r: 252.0,
paper_g: 252.0,
paper_b: 252.0,
ink_r: 8.0,
ink_g: 8.0,
ink_b: 8.0,
..Default::default()
},
ColorStats {
page_number: 4,
paper_r: 50.0, paper_g: 50.0,
paper_b: 50.0,
ink_r: 10.0,
ink_g: 10.0,
ink_b: 10.0,
..Default::default()
},
ColorStats {
page_number: 5,
paper_r: 249.0,
paper_g: 249.0,
paper_b: 249.0,
ink_r: 11.0,
ink_g: 11.0,
ink_b: 11.0,
..Default::default()
},
ColorStats {
page_number: 6,
paper_r: 251.0,
paper_g: 251.0,
paper_b: 251.0,
ink_r: 9.0,
ink_g: 9.0,
ink_b: 9.0,
..Default::default()
},
];
let filtered = ColorAnalyzer::exclude_outliers(&stats);
let has_outlier = filtered.iter().any(|s| s.page_number == 4);
assert!(
!has_outlier || filtered.len() < stats.len(),
"Outlier page 4 should be excluded by MAD filter"
);
if filtered.len() > 1 {
let paper_values: Vec<f64> = filtered.iter().map(|s| s.paper_r).collect();
let min = paper_values.iter().cloned().fold(f64::INFINITY, f64::min);
let max = paper_values
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
assert!(
max - min < 50.0,
"Filtered paper values should be consistent (range {} is too large)",
max - min
);
}
}
#[test]
fn test_bleed_suppression_default() {
let bleed = BleedSuppression::default();
assert_eq!(bleed.hue_min, 20.0);
assert_eq!(bleed.hue_max, 65.0);
assert_eq!(bleed.saturation_max, 1.0); assert_eq!(bleed.value_min, 0.35); assert!(bleed.enabled);
assert_eq!(bleed.strength, 1.0);
}
#[test]
fn test_bleed_detection_yellow_bleed() {
let bleed = BleedSuppression::default();
assert!(bleed.is_bleed_through(40.0, 0.2, 0.8));
assert!(bleed.is_bleed_through(40.0, 0.5, 0.8));
assert!(!bleed.is_bleed_through(40.0, 0.2, 0.3));
}
#[test]
fn test_bleed_detection_out_of_range() {
let bleed = BleedSuppression::default();
assert!(!bleed.is_bleed_through(240.0, 0.2, 0.8));
assert!(!bleed.is_bleed_through(0.0, 0.2, 0.8));
assert!(!bleed.is_bleed_through(120.0, 0.2, 0.8));
}
#[test]
fn test_bleed_detection_disabled() {
let bleed = BleedSuppression::disabled();
assert!(!bleed.is_bleed_through(40.0, 0.2, 0.8));
}
#[test]
fn test_apply_bleed_suppression() {
let mut img = RgbImage::from_pixel(10, 10, Rgb([255, 240, 200]));
let bleed = BleedSuppression::default();
ColorAnalyzer::apply_bleed_suppression(&mut img, &bleed);
let pixel = img.get_pixel(5, 5);
assert!(
pixel.0[0] > 250 && pixel.0[1] > 250 && pixel.0[2] > 250,
"Bleed pixel should be whitened: {:?}",
pixel
);
}
#[test]
fn test_detect_bleed_percentage() {
let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255]));
for y in 0..50 {
for x in 0..50 {
img.put_pixel(x, y, Rgb([255, 240, 200])); }
}
let bleed = BleedSuppression::default();
let percentage = ColorAnalyzer::detect_bleed_percentage(&img, &bleed);
assert!(
percentage > 10.0 && percentage < 40.0,
"Bleed percentage {} should be around 25%",
percentage
);
}
#[test]
fn test_bleed_suppression_aggressive() {
let bleed = BleedSuppression::aggressive();
assert!(bleed.hue_min < 20.0);
assert!(bleed.hue_max > 65.0);
assert!(bleed.saturation_max > 0.30);
assert!(bleed.value_min < 0.70);
}
#[test]
fn test_bleed_suppression_gentle() {
let bleed = BleedSuppression::gentle();
assert!(bleed.hue_min > 20.0);
assert!(bleed.hue_max < 65.0);
assert!(bleed.saturation_max < 0.30);
assert!(bleed.value_min > 0.70);
assert!(bleed.strength < 1.0);
}
#[test]
fn test_bleed_suppression_custom() {
let bleed = BleedSuppression::new(30.0, 50.0, 0.25, 0.75);
assert_eq!(bleed.hue_min, 30.0);
assert_eq!(bleed.hue_max, 50.0);
assert_eq!(bleed.saturation_max, 0.25);
assert_eq!(bleed.value_min, 0.75);
}
#[test]
fn test_global_color_param_includes_bleed() {
let params = GlobalColorParam::default();
assert!(params.bleed_suppression.enabled);
assert_eq!(params.bleed_suppression.hue_min, 20.0);
}
}