1use image::{Rgb, RgbImage};
30use rayon::prelude::*;
31use std::path::{Path, PathBuf};
32use thiserror::Error;
33
34const SAMPLE_STEP: u32 = 4;
40
41const INK_PERCENTILE: f64 = 0.05;
43
44const PAPER_PERCENTILE: f64 = 0.95;
46
47const MIN_SCALE: f64 = 0.8;
49
50const MAX_SCALE: f64 = 4.0;
52
53const DEFAULT_SAT_THRESHOLD: u8 = 55;
55
56const DEFAULT_COLOR_DIST_THRESHOLD: u8 = 35;
58
59const DEFAULT_WHITE_CLIP_RANGE: u8 = 30;
61
62#[derive(Debug, Error)]
68pub enum ColorStatsError {
69 #[error("Image not found: {0}")]
70 ImageNotFound(PathBuf),
71
72 #[error("Invalid image: {0}")]
73 InvalidImage(String),
74
75 #[error("No valid pages for analysis")]
76 NoValidPages,
77
78 #[error("IO error: {0}")]
79 IoError(#[from] std::io::Error),
80}
81
82pub type Result<T> = std::result::Result<T, ColorStatsError>;
83
84#[derive(Debug, Clone, Default)]
90pub struct ColorStats {
91 pub page_number: usize,
93
94 pub paper_r: f64,
96 pub paper_g: f64,
98 pub paper_b: f64,
100
101 pub ink_r: f64,
103 pub ink_g: f64,
105 pub ink_b: f64,
107
108 pub mean_r: f64,
110 pub mean_g: f64,
112 pub mean_b: f64,
114}
115
116impl ColorStats {
117 pub fn paper_luminance(&self) -> f64 {
119 0.299 * self.paper_r + 0.587 * self.paper_g + 0.114 * self.paper_b
120 }
121
122 pub fn ink_luminance(&self) -> f64 {
124 0.299 * self.ink_r + 0.587 * self.ink_g + 0.114 * self.ink_b
125 }
126}
127
128#[derive(Debug, Clone)]
140pub struct BleedSuppression {
141 pub hue_min: f32,
144
145 pub hue_max: f32,
148
149 pub saturation_max: f32,
152
153 pub value_min: f32,
156
157 pub enabled: bool,
159
160 pub strength: f32,
163}
164
165impl Default for BleedSuppression {
166 fn default() -> Self {
167 Self {
168 hue_min: 20.0,
169 hue_max: 65.0,
170 saturation_max: 1.0, value_min: 0.35, enabled: true,
175 strength: 1.0,
176 }
177 }
178}
179
180impl BleedSuppression {
181 pub fn new(hue_min: f32, hue_max: f32, saturation_max: f32, value_min: f32) -> Self {
183 Self {
184 hue_min,
185 hue_max,
186 saturation_max,
187 value_min,
188 enabled: true,
189 strength: 1.0,
190 }
191 }
192
193 pub fn is_bleed_through(&self, h: f32, s: f32, v: f32) -> bool {
203 if !self.enabled {
204 return false;
205 }
206
207 let hue_match = h >= self.hue_min && h <= self.hue_max;
209
210 let sat_match = s <= self.saturation_max;
212
213 let val_match = v >= self.value_min;
215
216 hue_match && sat_match && val_match
217 }
218
219 pub fn aggressive() -> Self {
221 Self {
222 hue_min: 15.0,
223 hue_max: 75.0,
224 saturation_max: 0.40,
225 value_min: 0.60,
226 enabled: true,
227 strength: 1.0,
228 }
229 }
230
231 pub fn gentle() -> Self {
233 Self {
234 hue_min: 25.0,
235 hue_max: 55.0,
236 saturation_max: 0.20,
237 value_min: 0.80,
238 enabled: true,
239 strength: 0.7,
240 }
241 }
242
243 pub fn disabled() -> Self {
245 Self {
246 enabled: false,
247 ..Default::default()
248 }
249 }
250}
251
252#[derive(Debug, Clone)]
254pub struct GlobalColorParam {
255 pub scale_r: f64,
257 pub scale_g: f64,
259 pub scale_b: f64,
261
262 pub offset_r: f64,
264 pub offset_g: f64,
266 pub offset_b: f64,
268
269 pub ghost_suppress_threshold: u8,
271 pub white_clip_range: u8,
273
274 pub paper_r: u8,
276 pub paper_g: u8,
277 pub paper_b: u8,
278
279 pub sat_threshold: u8,
281 pub color_dist_threshold: u8,
283
284 pub bleed_hue_min: f32,
286 pub bleed_hue_max: f32,
288 pub bleed_value_min: f32,
290
291 pub bleed_suppression: BleedSuppression,
293}
294
295impl Default for GlobalColorParam {
296 fn default() -> Self {
297 Self {
298 scale_r: 1.0,
299 scale_g: 1.0,
300 scale_b: 1.0,
301 offset_r: 0.0,
302 offset_g: 0.0,
303 offset_b: 0.0,
304 ghost_suppress_threshold: 200,
305 white_clip_range: DEFAULT_WHITE_CLIP_RANGE,
306 paper_r: 255,
307 paper_g: 255,
308 paper_b: 255,
309 sat_threshold: DEFAULT_SAT_THRESHOLD,
310 color_dist_threshold: DEFAULT_COLOR_DIST_THRESHOLD,
311 bleed_hue_min: 20.0,
312 bleed_hue_max: 65.0,
313 bleed_value_min: 0.35,
314 bleed_suppression: BleedSuppression::default(),
315 }
316 }
317}
318
319pub struct ColorAnalyzer;
325
326impl ColorAnalyzer {
327 pub fn calculate_stats(image_path: &Path) -> Result<ColorStats> {
329 if !image_path.exists() {
330 return Err(ColorStatsError::ImageNotFound(image_path.to_path_buf()));
331 }
332
333 let img =
334 image::open(image_path).map_err(|e| ColorStatsError::InvalidImage(e.to_string()))?;
335
336 let rgb = img.to_rgb8();
337 Ok(Self::calculate_stats_from_image(&rgb, 0))
338 }
339
340 pub fn calculate_stats_from_image(image: &RgbImage, page_number: usize) -> ColorStats {
342 let (w, h) = image.dimensions();
343 let step = SAMPLE_STEP;
344
345 let mut histogram = [0u64; 256];
347 let mut total = 0u64;
348
349 for y in (0..h).step_by(step as usize) {
350 for x in (0..w).step_by(step as usize) {
351 let pixel = image.get_pixel(x, y);
352 let lum = Self::luminance(pixel.0[0], pixel.0[1], pixel.0[2]);
353 histogram[lum as usize] += 1;
354 total += 1;
355 }
356 }
357
358 let low_target = (total as f64 * INK_PERCENTILE) as u64;
360 let high_target = (total as f64 * PAPER_PERCENTILE) as u64;
361
362 let mut low_lum = 0u8;
363 let mut high_lum = 255u8;
364 let mut acc = 0u64;
365
366 for (i, &count) in histogram.iter().enumerate() {
367 acc += count;
368 if acc >= low_target && low_lum == 0 {
369 low_lum = i as u8;
370 }
371 if acc >= high_target {
372 high_lum = i as u8;
373 break;
374 }
375 }
376
377 let mut sum_paper_r = 0u64;
379 let mut sum_paper_g = 0u64;
380 let mut sum_paper_b = 0u64;
381 let mut cnt_paper = 0u64;
382
383 let mut sum_ink_r = 0u64;
384 let mut sum_ink_g = 0u64;
385 let mut sum_ink_b = 0u64;
386 let mut cnt_ink = 0u64;
387
388 for y in (0..h).step_by(step as usize) {
389 for x in (0..w).step_by(step as usize) {
390 let pixel = image.get_pixel(x, y);
391 let (r, g, b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
392 let lum = Self::luminance(r, g, b);
393
394 if lum >= high_lum {
395 sum_paper_r += r as u64;
397 sum_paper_g += g as u64;
398 sum_paper_b += b as u64;
399 cnt_paper += 1;
400 } else if lum <= low_lum {
401 sum_ink_r += r as u64;
403 sum_ink_g += g as u64;
404 sum_ink_b += b as u64;
405 cnt_ink += 1;
406 }
407 }
408 }
409
410 if cnt_paper == 0 {
412 cnt_paper = 1;
413 }
414 if cnt_ink == 0 {
415 cnt_ink = 1;
416 }
417
418 let paper_r = sum_paper_r as f64 / cnt_paper as f64;
419 let paper_g = sum_paper_g as f64 / cnt_paper as f64;
420 let paper_b = sum_paper_b as f64 / cnt_paper as f64;
421
422 let ink_r = sum_ink_r as f64 / cnt_ink as f64;
423 let ink_g = sum_ink_g as f64 / cnt_ink as f64;
424 let ink_b = sum_ink_b as f64 / cnt_ink as f64;
425
426 ColorStats {
427 page_number,
428 paper_r,
429 paper_g,
430 paper_b,
431 ink_r,
432 ink_g,
433 ink_b,
434 mean_r: paper_r,
435 mean_g: paper_g,
436 mean_b: paper_b,
437 }
438 }
439
440 pub fn exclude_outliers(stats_list: &[ColorStats]) -> Vec<ColorStats> {
442 if stats_list.len() < 3 {
443 return stats_list.to_vec();
444 }
445
446 let mut luminances: Vec<(usize, f64)> = stats_list
448 .iter()
449 .enumerate()
450 .map(|(i, s)| (i, s.paper_luminance()))
451 .collect();
452
453 luminances.sort_by(|a, b| a.1.total_cmp(&b.1));
454
455 let median = Self::percentile_f64(
457 &luminances.iter().map(|(_, l)| *l).collect::<Vec<_>>(),
458 50.0,
459 );
460
461 let mut deviations: Vec<f64> = luminances.iter().map(|(_, l)| (l - median).abs()).collect();
463 deviations.sort_by(|a, b| a.total_cmp(b));
464 let mad = Self::percentile_f64(&deviations, 50.0);
465
466 let threshold = mad * 1.5;
468 let valid_indices: Vec<usize> = luminances
469 .iter()
470 .filter(|(_, l)| (l - median).abs() <= threshold)
471 .map(|(i, _)| *i)
472 .collect();
473
474 if valid_indices.is_empty() {
475 return stats_list.to_vec();
476 }
477
478 valid_indices
479 .iter()
480 .map(|&i| stats_list[i].clone())
481 .collect()
482 }
483
484 pub fn decide_global_adjustment(stats_list: &[ColorStats]) -> GlobalColorParam {
486 if stats_list.is_empty() {
487 return GlobalColorParam::default();
488 }
489
490 let filtered = Self::exclude_outliers(stats_list);
492 if filtered.is_empty() {
493 return GlobalColorParam::default();
494 }
495
496 let bg_r = Self::percentile_f64(
498 &filtered.iter().map(|s| s.paper_r).collect::<Vec<_>>(),
499 50.0,
500 );
501 let bg_g = Self::percentile_f64(
502 &filtered.iter().map(|s| s.paper_g).collect::<Vec<_>>(),
503 50.0,
504 );
505 let bg_b = Self::percentile_f64(
506 &filtered.iter().map(|s| s.paper_b).collect::<Vec<_>>(),
507 50.0,
508 );
509
510 let ink_r =
511 Self::percentile_f64(&filtered.iter().map(|s| s.ink_r).collect::<Vec<_>>(), 50.0);
512 let ink_g =
513 Self::percentile_f64(&filtered.iter().map(|s| s.ink_g).collect::<Vec<_>>(), 50.0);
514 let ink_b =
515 Self::percentile_f64(&filtered.iter().map(|s| s.ink_b).collect::<Vec<_>>(), 50.0);
516
517 let ink_lum = 0.299 * ink_r + 0.587 * ink_g + 0.114 * ink_b;
520 let paper_lum = 0.299 * bg_r + 0.587 * bg_g + 0.114 * bg_b;
521 let contrast = paper_lum - ink_lum;
522
523 if contrast < 100.0 || ink_lum > 100.0 {
528 return GlobalColorParam::default();
529 }
530
531 let (scale_r, offset_r) = Self::linear_scale(bg_r, ink_r);
533 let (scale_g, offset_g) = Self::linear_scale(bg_g, ink_g);
534 let (scale_b, offset_b) = Self::linear_scale(bg_b, ink_b);
535
536 let clamp8 = |v: f64| v.clamp(0.0, 255.0) as u8;
538
539 let bg_lum_scaled = 0.299 * clamp8(bg_r * scale_r + offset_r) as f64
540 + 0.587 * clamp8(bg_g * scale_g + offset_g) as f64
541 + 0.114 * clamp8(bg_b * scale_b + offset_b) as f64;
542
543 let ink_lum_scaled = 0.299 * clamp8(ink_r * scale_r + offset_r) as f64
544 + 0.587 * clamp8(ink_g * scale_g + offset_g) as f64
545 + 0.114 * clamp8(ink_b * scale_b + offset_b) as f64;
546
547 let ghost_threshold = ((ink_lum_scaled + bg_lum_scaled) * 0.5).clamp(0.0, 255.0) as u8;
549
550 GlobalColorParam {
551 scale_r,
552 scale_g,
553 scale_b,
554 offset_r,
555 offset_g,
556 offset_b,
557 ghost_suppress_threshold: ghost_threshold,
558 white_clip_range: DEFAULT_WHITE_CLIP_RANGE,
559 paper_r: bg_r.round() as u8,
560 paper_g: bg_g.round() as u8,
561 paper_b: bg_b.round() as u8,
562 sat_threshold: DEFAULT_SAT_THRESHOLD,
563 color_dist_threshold: DEFAULT_COLOR_DIST_THRESHOLD,
564 bleed_hue_min: 20.0,
565 bleed_hue_max: 65.0,
566 bleed_value_min: 0.35,
567 bleed_suppression: BleedSuppression::default(),
568 }
569 }
570
571 pub fn apply_adjustment(image: &mut RgbImage, params: &GlobalColorParam) {
573 let (w, h) = image.dimensions();
574 let clip_start = params.ghost_suppress_threshold as i32;
575 let clip_end = (255 - params.white_clip_range as i32).clamp(0, 255);
576
577 for y in 0..h {
578 for x in 0..w {
579 let pixel = image.get_pixel(x, y);
580 let (src_r, src_g, src_b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
581
582 let mut r = Self::clamp8(src_r as f64 * params.scale_r + params.offset_r);
584 let mut g = Self::clamp8(src_g as f64 * params.scale_g + params.offset_g);
585 let mut b = Self::clamp8(src_b as f64 * params.scale_b + params.offset_b);
586
587 let lum = Self::luminance(r, g, b) as i32;
589 if lum >= clip_start {
590 let max = r.max(g).max(b);
591 let min = r.min(g).min(b);
592 let sat = if max == 0 {
593 0
594 } else {
595 (max - min) as i32 * 255 / max as i32
596 };
597
598 let dist = (r as i32 - params.paper_r as i32).abs()
599 + (g as i32 - params.paper_g as i32).abs()
600 + (b as i32 - params.paper_b as i32).abs();
601
602 if sat < params.sat_threshold as i32
603 && dist < params.color_dist_threshold as i32
604 {
605 let t = ((lum - clip_start) as f64 / (clip_end - clip_start + 1) as f64)
606 .clamp(0.0, 1.0);
607 let wgt = t * t * (3.0 - 2.0 * t); r = Self::clamp8(r as f64 + (255.0 - r as f64) * wgt);
610 g = Self::clamp8(g as f64 + (255.0 - g as f64) * wgt);
611 b = Self::clamp8(b as f64 + (255.0 - b as f64) * wgt);
612 }
613 }
614
615 let (hue, _, _) = Self::rgb_to_hsv(r, g, b);
618
619 let max2 = r.max(g).max(b);
620 let min2 = r.min(g).min(b);
621 let sat2 = if max2 == 0 {
622 0
623 } else {
624 (max2 - min2) as i32 * 255 / max2 as i32
625 };
626 let lum2 = Self::luminance(r, g, b);
627
628 let is_pastel_pink = lum2 > 230 && sat2 < 30 && (hue <= 40.0 || hue >= 330.0);
630
631 if is_pastel_pink {
632 r = 255;
633 g = 255;
634 b = 255;
635 }
636
637 image.put_pixel(x, y, Rgb([r, g, b]));
638 }
639 }
640 }
641
642 pub fn apply_bleed_suppression(image: &mut RgbImage, bleed_config: &BleedSuppression) {
646 if !bleed_config.enabled {
647 return;
648 }
649
650 let (w, h) = image.dimensions();
651
652 for y in 0..h {
653 for x in 0..w {
654 let pixel = image.get_pixel(x, y);
655 let (r, g, b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
656
657 let (hue, sat, val) = Self::rgb_to_hsv(r, g, b);
658
659 if bleed_config.is_bleed_through(hue, sat, val) {
660 let strength = bleed_config.strength;
661 let new_r = Self::clamp8(r as f64 + (255.0 - r as f64) * strength as f64);
662 let new_g = Self::clamp8(g as f64 + (255.0 - g as f64) * strength as f64);
663 let new_b = Self::clamp8(b as f64 + (255.0 - b as f64) * strength as f64);
664 image.put_pixel(x, y, Rgb([new_r, new_g, new_b]));
665 }
666 }
667 }
668 }
669
670 pub fn detect_bleed_percentage(image: &RgbImage, bleed_config: &BleedSuppression) -> f64 {
674 if !bleed_config.enabled {
675 return 0.0;
676 }
677
678 let (w, h) = image.dimensions();
679 let mut bleed_count = 0u64;
680
681 for y in (0..h).step_by(SAMPLE_STEP as usize) {
682 for x in (0..w).step_by(SAMPLE_STEP as usize) {
683 let pixel = image.get_pixel(x, y);
684 let (r, g, b) = (pixel.0[0], pixel.0[1], pixel.0[2]);
685 let (hue, sat, val) = Self::rgb_to_hsv(r, g, b);
686
687 if bleed_config.is_bleed_through(hue, sat, val) {
688 bleed_count += 1;
689 }
690 }
691 }
692
693 let sample_total = ((w / SAMPLE_STEP) * (h / SAMPLE_STEP)) as f64;
694 if sample_total > 0.0 {
695 (bleed_count as f64 / sample_total) * 100.0
696 } else {
697 0.0
698 }
699 }
700
701 pub fn analyze_book_pages(
703 image_paths: &[PathBuf],
704 ) -> Result<(Vec<ColorStats>, Vec<ColorStats>)> {
705 let stats_results: Vec<Result<ColorStats>> = image_paths
706 .par_iter()
707 .enumerate()
708 .map(|(i, path)| {
709 let mut stats = Self::calculate_stats(path)?;
710 stats.page_number = i + 1;
711 Ok(stats)
712 })
713 .collect();
714
715 let stats: Vec<ColorStats> = stats_results.into_iter().filter_map(|r| r.ok()).collect();
716
717 if stats.is_empty() {
718 return Err(ColorStatsError::NoValidPages);
719 }
720
721 let odd: Vec<ColorStats> = stats
723 .iter()
724 .filter(|s| s.page_number % 2 == 1)
725 .cloned()
726 .collect();
727 let even: Vec<ColorStats> = stats
728 .iter()
729 .filter(|s| s.page_number % 2 == 0)
730 .cloned()
731 .collect();
732
733 Ok((odd, even))
734 }
735
736 fn luminance(r: u8, g: u8, b: u8) -> u8 {
741 (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64).round() as u8
742 }
743
744 fn clamp8(v: f64) -> u8 {
745 v.clamp(0.0, 255.0).round() as u8
746 }
747
748 fn linear_scale(bg: f64, ink: f64) -> (f64, f64) {
749 let diff = bg - ink;
750 if diff < 1.0 {
751 return (1.0, 0.0);
752 }
753 let s = (255.0 / diff).clamp(MIN_SCALE, MAX_SCALE);
754 let o = -ink * s;
755 (s, o)
756 }
757
758 fn percentile_f64(values: &[f64], p: f64) -> f64 {
759 if values.is_empty() {
760 return 0.0;
761 }
762 let mut sorted = values.to_vec();
763 sorted.sort_by(|a, b| a.total_cmp(b));
764
765 let rank = (p / 100.0) * (sorted.len() - 1) as f64;
766 let lo = rank.floor() as usize;
767 let hi = rank.ceil() as usize;
768
769 if lo == hi {
770 sorted[lo]
771 } else {
772 sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo as f64)
773 }
774 }
775
776 fn rgb_to_hsv(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
777 let rf = r as f32 / 255.0;
778 let gf = g as f32 / 255.0;
779 let bf = b as f32 / 255.0;
780
781 let max = rf.max(gf).max(bf);
782 let min = rf.min(gf).min(bf);
783 let v = max;
784 let d = max - min;
785 let s = if max == 0.0 { 0.0 } else { d / max };
786
787 let h = if d == 0.0 {
788 0.0
789 } else if max == rf {
790 60.0 * (((gf - bf) / d) % 6.0)
791 } else if max == gf {
792 60.0 * (((bf - rf) / d) + 2.0)
793 } else {
794 60.0 * (((rf - gf) / d) + 4.0)
795 };
796
797 let h = if h < 0.0 { h + 360.0 } else { h };
798 (h, s, v)
799 }
800}
801
802#[cfg(test)]
807mod tests {
808 use super::*;
809
810 #[test]
811 fn test_default_global_color_param() {
812 let params = GlobalColorParam::default();
813 assert_eq!(params.scale_r, 1.0);
814 assert_eq!(params.scale_g, 1.0);
815 assert_eq!(params.scale_b, 1.0);
816 assert_eq!(params.offset_r, 0.0);
817 assert_eq!(params.ghost_suppress_threshold, 200);
818 }
819
820 #[test]
821 fn test_color_stats_luminance() {
822 let stats = ColorStats {
823 page_number: 1,
824 paper_r: 255.0,
825 paper_g: 255.0,
826 paper_b: 255.0,
827 ink_r: 0.0,
828 ink_g: 0.0,
829 ink_b: 0.0,
830 ..Default::default()
831 };
832
833 assert!((stats.paper_luminance() - 255.0).abs() < 0.1);
834 assert!((stats.ink_luminance() - 0.0).abs() < 0.1);
835 }
836
837 #[test]
838 fn test_luminance_calculation() {
839 assert_eq!(ColorAnalyzer::luminance(255, 255, 255), 255);
840 assert_eq!(ColorAnalyzer::luminance(0, 0, 0), 0);
841 }
842
843 #[test]
844 fn test_linear_scale() {
845 let (s, o) = ColorAnalyzer::linear_scale(255.0, 0.0);
846 assert!((s - 1.0).abs() < 0.01);
847 assert!((o - 0.0).abs() < 0.01);
848
849 let (s, _o) = ColorAnalyzer::linear_scale(200.0, 50.0);
851 assert!(s > 1.0);
855 }
856
857 #[test]
858 fn test_percentile() {
859 let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
860 assert!((ColorAnalyzer::percentile_f64(&values, 50.0) - 3.0).abs() < 0.01);
861 assert!((ColorAnalyzer::percentile_f64(&values, 0.0) - 1.0).abs() < 0.01);
862 assert!((ColorAnalyzer::percentile_f64(&values, 100.0) - 5.0).abs() < 0.01);
863 }
864
865 #[test]
866 fn test_exclude_outliers_small_list() {
867 let stats = vec![
868 ColorStats {
869 page_number: 1,
870 paper_r: 250.0,
871 paper_g: 250.0,
872 paper_b: 250.0,
873 ..Default::default()
874 },
875 ColorStats {
876 page_number: 2,
877 paper_r: 240.0,
878 paper_g: 240.0,
879 paper_b: 240.0,
880 ..Default::default()
881 },
882 ];
883
884 let filtered = ColorAnalyzer::exclude_outliers(&stats);
885 assert_eq!(filtered.len(), 2); }
887
888 #[test]
889 fn test_exclude_outliers() {
890 let stats = vec![
891 ColorStats {
892 page_number: 1,
893 paper_r: 250.0,
894 paper_g: 250.0,
895 paper_b: 250.0,
896 ..Default::default()
897 },
898 ColorStats {
899 page_number: 2,
900 paper_r: 245.0,
901 paper_g: 245.0,
902 paper_b: 245.0,
903 ..Default::default()
904 },
905 ColorStats {
906 page_number: 3,
907 paper_r: 248.0,
908 paper_g: 248.0,
909 paper_b: 248.0,
910 ..Default::default()
911 },
912 ColorStats {
913 page_number: 4,
914 paper_r: 100.0,
915 paper_g: 100.0,
916 paper_b: 100.0,
917 ..Default::default()
918 }, ColorStats {
920 page_number: 5,
921 paper_r: 252.0,
922 paper_g: 252.0,
923 paper_b: 252.0,
924 ..Default::default()
925 },
926 ];
927
928 let filtered = ColorAnalyzer::exclude_outliers(&stats);
929 assert!(filtered.len() < stats.len() || filtered.iter().all(|s| s.page_number != 4));
931 }
932
933 #[test]
934 fn test_decide_global_adjustment() {
935 let stats = vec![ColorStats {
936 page_number: 1,
937 paper_r: 250.0,
938 paper_g: 248.0,
939 paper_b: 245.0,
940 ink_r: 10.0,
941 ink_g: 10.0,
942 ink_b: 10.0,
943 ..Default::default()
944 }];
945
946 let params = ColorAnalyzer::decide_global_adjustment(&stats);
947 assert!(params.scale_r > 0.9);
948 assert!(params.paper_r > 240);
949 }
950
951 #[test]
952 fn test_rgb_to_hsv() {
953 let (h, s, v) = ColorAnalyzer::rgb_to_hsv(255, 0, 0);
955 assert!(h.abs() < 1.0 || (h - 360.0).abs() < 1.0);
956 assert!((s - 1.0).abs() < 0.01);
957 assert!((v - 1.0).abs() < 0.01);
958
959 let (_, s, v) = ColorAnalyzer::rgb_to_hsv(255, 255, 255);
961 assert!((s - 0.0).abs() < 0.01);
962 assert!((v - 1.0).abs() < 0.01);
963 }
964
965 #[test]
966 fn test_apply_adjustment_identity() {
967 let mut img = RgbImage::from_pixel(10, 10, Rgb([128, 128, 128]));
968 let params = GlobalColorParam::default();
969
970 ColorAnalyzer::apply_adjustment(&mut img, ¶ms);
971
972 let pixel = img.get_pixel(5, 5);
974 assert!(pixel.0[0] > 100 && pixel.0[0] < 200);
975 }
976
977 #[test]
978 fn test_image_not_found() {
979 let result = ColorAnalyzer::calculate_stats(Path::new("/nonexistent/image.png"));
980 assert!(matches!(result, Err(ColorStatsError::ImageNotFound(_))));
981 }
982
983 #[test]
984 fn test_calculate_stats_from_image() {
985 let img = RgbImage::from_pixel(100, 100, Rgb([240, 238, 235]));
986 let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
987
988 assert_eq!(stats.page_number, 1);
989 assert!(stats.paper_r > 230.0);
991 }
992
993 #[test]
994 fn test_send_sync() {
995 fn assert_send_sync<T: Send + Sync>() {}
996 assert_send_sync::<ColorStats>();
997 assert_send_sync::<GlobalColorParam>();
998 assert_send_sync::<ColorStatsError>();
999 }
1000
1001 #[test]
1002 fn test_error_types() {
1003 let _err1 = ColorStatsError::ImageNotFound(PathBuf::from("/test"));
1004 let _err2 = ColorStatsError::InvalidImage("bad".to_string());
1005 let _err3 = ColorStatsError::NoValidPages;
1006 }
1007
1008 #[test]
1014 fn test_tc_color_001_white_background_black_text() {
1015 let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255]));
1017 for y in 40..60 {
1019 for x in 20..80 {
1020 img.put_pixel(x, y, Rgb([0, 0, 0]));
1021 }
1022 }
1023
1024 let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
1025
1026 assert!(
1028 stats.paper_luminance() > 240.0,
1029 "Paper luminance {} should be > 240",
1030 stats.paper_luminance()
1031 );
1032
1033 assert!(
1035 stats.ink_luminance() < 30.0,
1036 "Ink luminance {} should be < 30",
1037 stats.ink_luminance()
1038 );
1039 }
1040
1041 #[test]
1043 fn test_tc_color_002_yellowed_paper_correction() {
1044 let mut img = RgbImage::from_pixel(100, 100, Rgb([245, 235, 210])); for y in 40..60 {
1048 for x in 20..80 {
1049 img.put_pixel(x, y, Rgb([30, 25, 20]));
1050 }
1051 }
1052
1053 let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
1054
1055 assert!(
1057 stats.paper_r < 255.0 || stats.paper_g < 255.0 || stats.paper_b < 255.0,
1058 "Yellowed paper should not be pure white"
1059 );
1060
1061 assert!(
1063 stats.paper_b < stats.paper_r,
1064 "Yellowed paper should have lower blue than red"
1065 );
1066
1067 let all_stats = vec![stats];
1069 let params = ColorAnalyzer::decide_global_adjustment(&all_stats);
1070
1071 assert!(
1073 params.scale_b >= params.scale_r,
1074 "Blue scale {} should be >= red scale {} to correct yellowing",
1075 params.scale_b,
1076 params.scale_r
1077 );
1078 }
1079
1080 #[test]
1082 fn test_tc_color_003_ghost_suppression_params() {
1083 let mut img = RgbImage::from_pixel(100, 100, Rgb([245, 243, 240])); for y in 30..40 {
1087 for x in 20..80 {
1088 img.put_pixel(x, y, Rgb([30, 28, 25])); }
1090 }
1091 for y in 60..70 {
1093 for x in 20..80 {
1094 img.put_pixel(x, y, Rgb([200, 198, 195])); }
1096 }
1097
1098 let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
1099 let params = ColorAnalyzer::decide_global_adjustment(&[stats]);
1100
1101 assert!(
1105 params.ghost_suppress_threshold > 0 && params.ghost_suppress_threshold < 255,
1106 "Ghost suppression threshold {} should be in valid range",
1107 params.ghost_suppress_threshold
1108 );
1109
1110 assert!(
1112 params.scale_r > 0.0 && params.scale_g > 0.0 && params.scale_b > 0.0,
1113 "Scale factors should be positive: R={}, G={}, B={}",
1114 params.scale_r,
1115 params.scale_g,
1116 params.scale_b
1117 );
1118
1119 assert!(
1121 params.paper_r > 200 && params.paper_g > 200 && params.paper_b > 200,
1122 "Paper color should be light: R={}, G={}, B={}",
1123 params.paper_r,
1124 params.paper_g,
1125 params.paper_b
1126 );
1127 }
1128
1129 #[test]
1131 fn test_tc_color_004_color_image_saturation_preserved() {
1132 let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255]));
1134 for y in 20..40 {
1136 for x in 20..40 {
1137 img.put_pixel(x, y, Rgb([255, 50, 50])); }
1139 }
1140 for y in 60..80 {
1142 for x in 60..80 {
1143 img.put_pixel(x, y, Rgb([50, 50, 255])); }
1145 }
1146
1147 let _original_red = *img.get_pixel(30, 30);
1148 let _original_blue = *img.get_pixel(70, 70);
1149
1150 let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
1151 let params = ColorAnalyzer::decide_global_adjustment(&[stats]);
1152 ColorAnalyzer::apply_adjustment(&mut img, ¶ms);
1153
1154 let adjusted_red = img.get_pixel(30, 30);
1156 let adjusted_blue = img.get_pixel(70, 70);
1157
1158 assert!(
1160 adjusted_red.0[0] > adjusted_red.0[1] && adjusted_red.0[0] > adjusted_red.0[2],
1161 "Red pixel should remain red after adjustment"
1162 );
1163
1164 assert!(
1166 adjusted_blue.0[2] > adjusted_blue.0[0] && adjusted_blue.0[2] > adjusted_blue.0[1],
1167 "Blue pixel should remain blue after adjustment"
1168 );
1169 }
1170
1171 #[test]
1173 fn test_tc_color_005_outlier_exclusion_mad() {
1174 let stats = vec![
1176 ColorStats {
1177 page_number: 1,
1178 paper_r: 250.0,
1179 paper_g: 250.0,
1180 paper_b: 250.0,
1181 ink_r: 10.0,
1182 ink_g: 10.0,
1183 ink_b: 10.0,
1184 ..Default::default()
1185 },
1186 ColorStats {
1187 page_number: 2,
1188 paper_r: 248.0,
1189 paper_g: 248.0,
1190 paper_b: 248.0,
1191 ink_r: 12.0,
1192 ink_g: 12.0,
1193 ink_b: 12.0,
1194 ..Default::default()
1195 },
1196 ColorStats {
1197 page_number: 3,
1198 paper_r: 252.0,
1199 paper_g: 252.0,
1200 paper_b: 252.0,
1201 ink_r: 8.0,
1202 ink_g: 8.0,
1203 ink_b: 8.0,
1204 ..Default::default()
1205 },
1206 ColorStats {
1207 page_number: 4,
1208 paper_r: 50.0, paper_g: 50.0,
1210 paper_b: 50.0,
1211 ink_r: 10.0,
1212 ink_g: 10.0,
1213 ink_b: 10.0,
1214 ..Default::default()
1215 },
1216 ColorStats {
1217 page_number: 5,
1218 paper_r: 249.0,
1219 paper_g: 249.0,
1220 paper_b: 249.0,
1221 ink_r: 11.0,
1222 ink_g: 11.0,
1223 ink_b: 11.0,
1224 ..Default::default()
1225 },
1226 ColorStats {
1227 page_number: 6,
1228 paper_r: 251.0,
1229 paper_g: 251.0,
1230 paper_b: 251.0,
1231 ink_r: 9.0,
1232 ink_g: 9.0,
1233 ink_b: 9.0,
1234 ..Default::default()
1235 },
1236 ];
1237
1238 let filtered = ColorAnalyzer::exclude_outliers(&stats);
1239
1240 let has_outlier = filtered.iter().any(|s| s.page_number == 4);
1242
1243 assert!(
1244 !has_outlier || filtered.len() < stats.len(),
1245 "Outlier page 4 should be excluded by MAD filter"
1246 );
1247
1248 if filtered.len() > 1 {
1250 let paper_values: Vec<f64> = filtered.iter().map(|s| s.paper_r).collect();
1251 let min = paper_values.iter().cloned().fold(f64::INFINITY, f64::min);
1252 let max = paper_values
1253 .iter()
1254 .cloned()
1255 .fold(f64::NEG_INFINITY, f64::max);
1256
1257 assert!(
1258 max - min < 50.0,
1259 "Filtered paper values should be consistent (range {} is too large)",
1260 max - min
1261 );
1262 }
1263 }
1264
1265 #[test]
1271 fn test_bleed_suppression_default() {
1272 let bleed = BleedSuppression::default();
1273 assert_eq!(bleed.hue_min, 20.0);
1274 assert_eq!(bleed.hue_max, 65.0);
1275 assert_eq!(bleed.saturation_max, 1.0); assert_eq!(bleed.value_min, 0.35); assert!(bleed.enabled);
1279 assert_eq!(bleed.strength, 1.0);
1280 }
1281
1282 #[test]
1284 fn test_bleed_detection_yellow_bleed() {
1285 let bleed = BleedSuppression::default();
1286
1287 assert!(bleed.is_bleed_through(40.0, 0.2, 0.8));
1289
1290 assert!(bleed.is_bleed_through(40.0, 0.5, 0.8));
1292
1293 assert!(!bleed.is_bleed_through(40.0, 0.2, 0.3));
1295 }
1296
1297 #[test]
1299 fn test_bleed_detection_out_of_range() {
1300 let bleed = BleedSuppression::default();
1301
1302 assert!(!bleed.is_bleed_through(240.0, 0.2, 0.8));
1304
1305 assert!(!bleed.is_bleed_through(0.0, 0.2, 0.8));
1307
1308 assert!(!bleed.is_bleed_through(120.0, 0.2, 0.8));
1310 }
1311
1312 #[test]
1314 fn test_bleed_detection_disabled() {
1315 let bleed = BleedSuppression::disabled();
1316
1317 assert!(!bleed.is_bleed_through(40.0, 0.2, 0.8));
1319 }
1320
1321 #[test]
1323 fn test_apply_bleed_suppression() {
1324 let mut img = RgbImage::from_pixel(10, 10, Rgb([255, 240, 200])); let bleed = BleedSuppression::default();
1328 ColorAnalyzer::apply_bleed_suppression(&mut img, &bleed);
1329
1330 let pixel = img.get_pixel(5, 5);
1332 assert!(
1333 pixel.0[0] > 250 && pixel.0[1] > 250 && pixel.0[2] > 250,
1334 "Bleed pixel should be whitened: {:?}",
1335 pixel
1336 );
1337 }
1338
1339 #[test]
1341 fn test_detect_bleed_percentage() {
1342 let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255])); for y in 0..50 {
1347 for x in 0..50 {
1348 img.put_pixel(x, y, Rgb([255, 240, 200])); }
1350 }
1351
1352 let bleed = BleedSuppression::default();
1353 let percentage = ColorAnalyzer::detect_bleed_percentage(&img, &bleed);
1354
1355 assert!(
1357 percentage > 10.0 && percentage < 40.0,
1358 "Bleed percentage {} should be around 25%",
1359 percentage
1360 );
1361 }
1362
1363 #[test]
1365 fn test_bleed_suppression_aggressive() {
1366 let bleed = BleedSuppression::aggressive();
1367
1368 assert!(bleed.hue_min < 20.0);
1370 assert!(bleed.hue_max > 65.0);
1371 assert!(bleed.saturation_max > 0.30);
1372 assert!(bleed.value_min < 0.70);
1373 }
1374
1375 #[test]
1377 fn test_bleed_suppression_gentle() {
1378 let bleed = BleedSuppression::gentle();
1379
1380 assert!(bleed.hue_min > 20.0);
1382 assert!(bleed.hue_max < 65.0);
1383 assert!(bleed.saturation_max < 0.30);
1384 assert!(bleed.value_min > 0.70);
1385 assert!(bleed.strength < 1.0);
1386 }
1387
1388 #[test]
1390 fn test_bleed_suppression_custom() {
1391 let bleed = BleedSuppression::new(30.0, 50.0, 0.25, 0.75);
1392
1393 assert_eq!(bleed.hue_min, 30.0);
1394 assert_eq!(bleed.hue_max, 50.0);
1395 assert_eq!(bleed.saturation_max, 0.25);
1396 assert_eq!(bleed.value_min, 0.75);
1397 }
1398
1399 #[test]
1401 fn test_global_color_param_includes_bleed() {
1402 let params = GlobalColorParam::default();
1403
1404 assert!(params.bleed_suppression.enabled);
1406 assert_eq!(params.bleed_suppression.hue_min, 20.0);
1407 }
1408}