Skip to main content

superbook_pdf/
color_stats.rs

1//! Color Statistics and Global Color Adjustment module
2//!
3//! Provides functionality for analyzing color statistics across pages
4//! and applying global color correction for consistent book appearance.
5//!
6//! # Features
7//!
8//! - Paper/ink color extraction via histogram analysis
9//! - MAD-based outlier exclusion
10//! - Linear scale/offset color adjustment
11//! - Ghost suppression for see-through pages
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use superbook_pdf::color_stats::{ColorStats, ColorAnalyzer, GlobalColorParam};
17//! use std::path::Path;
18//!
19//! // Analyze a page
20//! let stats = ColorAnalyzer::calculate_stats(Path::new("page.png")).unwrap();
21//! println!("Paper color: RGB({:.0}, {:.0}, {:.0})",
22//!     stats.paper_r, stats.paper_g, stats.paper_b);
23//!
24//! // Get global adjustment parameters
25//! let params = ColorAnalyzer::decide_global_adjustment(&[stats]);
26//! println!("Scale R: {:.2}", params.scale_r);
27//! ```
28
29use image::{Rgb, RgbImage};
30use rayon::prelude::*;
31use std::path::{Path, PathBuf};
32use thiserror::Error;
33
34// ============================================================
35// Constants
36// ============================================================
37
38/// Sample step for histogram building (skip pixels for performance)
39const SAMPLE_STEP: u32 = 4;
40
41/// Percentile for ink color detection (dark pixels)
42const INK_PERCENTILE: f64 = 0.05;
43
44/// Percentile for paper color detection (light pixels)
45const PAPER_PERCENTILE: f64 = 0.95;
46
47/// Minimum scale factor for color adjustment
48const MIN_SCALE: f64 = 0.8;
49
50/// Maximum scale factor for color adjustment
51const MAX_SCALE: f64 = 4.0;
52
53/// Default saturation threshold for paper detection
54const DEFAULT_SAT_THRESHOLD: u8 = 55;
55
56/// Default color distance threshold
57const DEFAULT_COLOR_DIST_THRESHOLD: u8 = 35;
58
59/// Default white clip range
60const DEFAULT_WHITE_CLIP_RANGE: u8 = 30;
61
62// ============================================================
63// Error Types
64// ============================================================
65
66/// Color analysis error types
67#[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// ============================================================
85// Data Structures
86// ============================================================
87
88/// Color statistics for a single page
89#[derive(Debug, Clone, Default)]
90pub struct ColorStats {
91    /// Page number (1-based)
92    pub page_number: usize,
93
94    /// Paper (background) color - Red channel average
95    pub paper_r: f64,
96    /// Paper (background) color - Green channel average
97    pub paper_g: f64,
98    /// Paper (background) color - Blue channel average
99    pub paper_b: f64,
100
101    /// Ink (foreground) color - Red channel average
102    pub ink_r: f64,
103    /// Ink (foreground) color - Green channel average
104    pub ink_g: f64,
105    /// Ink (foreground) color - Blue channel average
106    pub ink_b: f64,
107
108    /// Mean R (for backward compatibility, same as paper_r)
109    pub mean_r: f64,
110    /// Mean G (for backward compatibility, same as paper_g)
111    pub mean_g: f64,
112    /// Mean B (for backward compatibility, same as paper_b)
113    pub mean_b: f64,
114}
115
116impl ColorStats {
117    /// Calculate paper luminance (ITU-R BT.601)
118    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    /// Calculate ink luminance
123    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/// Bleed-through (裏写り) suppression parameters using HSV color space
129///
130/// This structure defines the HSV color ranges that identify bleed-through
131/// artifacts from the reverse side of pages in scanned books.
132///
133/// # Phase 1.3 Enhancement
134///
135/// Bleed-through typically appears as:
136/// - Yellowish/orange tint (hue 20-65 degrees)
137/// - Low saturation (< 30% for pastel colors)
138/// - High value/brightness (> 70% for light colors)
139#[derive(Debug, Clone)]
140pub struct BleedSuppression {
141    /// Minimum hue for bleed detection (degrees, 0-360)
142    /// Yellow starts around 20 degrees
143    pub hue_min: f32,
144
145    /// Maximum hue for bleed detection (degrees, 0-360)
146    /// Orange ends around 65 degrees
147    pub hue_max: f32,
148
149    /// Maximum saturation for bleed detection (0.0-1.0)
150    /// Only detect pastel/faded colors (typically < 0.3)
151    pub saturation_max: f32,
152
153    /// Minimum value (brightness) for bleed detection (0.0-1.0)
154    /// Only detect light colors (typically > 0.7)
155    pub value_min: f32,
156
157    /// Enable bleed suppression
158    pub enabled: bool,
159
160    /// Strength of bleed suppression (0.0-1.0)
161    /// 1.0 = full white, 0.0 = no change
162    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            // C# version uses BleedValueMin = 0.35, no saturation check
171            // But we keep saturation_max for additional filtering
172            saturation_max: 1.0,  // No saturation filter (match C# behavior)
173            value_min: 0.35,      // Match C# BleedValueMin = 0.35
174            enabled: true,
175            strength: 1.0,
176        }
177    }
178}
179
180impl BleedSuppression {
181    /// Create a new BleedSuppression configuration
182    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    /// Check if a pixel is a bleed-through artifact
194    ///
195    /// # Arguments
196    /// * `h` - Hue (0-360 degrees)
197    /// * `s` - Saturation (0.0-1.0)
198    /// * `v` - Value/Brightness (0.0-1.0)
199    ///
200    /// # Returns
201    /// `true` if the pixel matches bleed-through characteristics
202    pub fn is_bleed_through(&self, h: f32, s: f32, v: f32) -> bool {
203        if !self.enabled {
204            return false;
205        }
206
207        // Check hue range (yellow to orange)
208        let hue_match = h >= self.hue_min && h <= self.hue_max;
209
210        // Check saturation (low saturation = pastel/faded)
211        let sat_match = s <= self.saturation_max;
212
213        // Check value (high brightness)
214        let val_match = v >= self.value_min;
215
216        hue_match && sat_match && val_match
217    }
218
219    /// Create configuration for aggressive bleed removal
220    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    /// Create configuration for gentle bleed removal
232    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    /// Disable bleed suppression
244    pub fn disabled() -> Self {
245        Self {
246            enabled: false,
247            ..Default::default()
248        }
249    }
250}
251
252/// Global color adjustment parameters
253#[derive(Debug, Clone)]
254pub struct GlobalColorParam {
255    /// Scale factor for Red channel
256    pub scale_r: f64,
257    /// Scale factor for Green channel
258    pub scale_g: f64,
259    /// Scale factor for Blue channel
260    pub scale_b: f64,
261
262    /// Offset for Red channel
263    pub offset_r: f64,
264    /// Offset for Green channel
265    pub offset_g: f64,
266    /// Offset for Blue channel
267    pub offset_b: f64,
268
269    /// Ghost suppression luminance threshold
270    pub ghost_suppress_threshold: u8,
271    /// White clip range (how close to white to clip)
272    pub white_clip_range: u8,
273
274    /// Representative paper color RGB
275    pub paper_r: u8,
276    pub paper_g: u8,
277    pub paper_b: u8,
278
279    /// Saturation threshold for paper detection (0-255)
280    pub sat_threshold: u8,
281    /// Color distance threshold (L1 norm)
282    pub color_dist_threshold: u8,
283
284    /// Bleed-through hue range minimum (degrees) - legacy
285    pub bleed_hue_min: f32,
286    /// Bleed-through hue range maximum (degrees) - legacy
287    pub bleed_hue_max: f32,
288    /// Bleed-through minimum value (HSV) - legacy
289    pub bleed_value_min: f32,
290
291    /// Enhanced bleed suppression configuration (Phase 1.3)
292    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
319// ============================================================
320// Color Analyzer
321// ============================================================
322
323/// Color statistics analyzer
324pub struct ColorAnalyzer;
325
326impl ColorAnalyzer {
327    /// Calculate color statistics from an image file
328    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    /// Calculate color statistics from an RGB image
341    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        // Build luminance histogram
346        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        // Find 5% (ink) and 95% (paper) percentile luminance thresholds
359        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        // Calculate RGB averages for paper (bright) and ink (dark) pixels
378        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                    // Paper pixel
396                    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                    // Ink pixel
402                    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        // Prevent division by zero
411        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    /// Exclude outlier pages using MAD (Median Absolute Deviation)
441    pub fn exclude_outliers(stats_list: &[ColorStats]) -> Vec<ColorStats> {
442        if stats_list.len() < 3 {
443            return stats_list.to_vec();
444        }
445
446        // Calculate paper luminance for each page
447        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        // Calculate median
456        let median = Self::percentile_f64(
457            &luminances.iter().map(|(_, l)| *l).collect::<Vec<_>>(),
458            50.0,
459        );
460
461        // Calculate MAD
462        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        // Filter out outliers (> 1.5 * MAD from median)
467        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    /// Decide global color adjustment parameters from filtered stats
485    pub fn decide_global_adjustment(stats_list: &[ColorStats]) -> GlobalColorParam {
486        if stats_list.is_empty() {
487            return GlobalColorParam::default();
488        }
489
490        // Exclude outliers using MAD
491        let filtered = Self::exclude_outliers(stats_list);
492        if filtered.is_empty() {
493            return GlobalColorParam::default();
494        }
495
496        // Calculate median of paper/ink colors
497        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        // C#互換: ink色が明るすぎる場合は補正をスキップ
518        // (画像の大部分が白い場合、5%パーセンタイルでは実際のインクを捉えられない)
519        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        // コントラストが小さい場合は補正をスキップ
524        // - ink輝度が100以上: 本来のインク色ではなく、薄灰色を誤検出している
525        // - コントラストが100未満: 既に十分なコントラストがある
526        // C#版では元々良好な画像には補正が効かない設計
527        if contrast < 100.0 || ink_lum > 100.0 {
528            return GlobalColorParam::default();
529        }
530
531        // Calculate linear scale: ink -> 0, paper -> 255
532        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        // Calculate ghost suppression threshold
537        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        // Ghost threshold = midpoint between paper and ink
548        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    /// Apply global color adjustment to an image
572    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                // Linear color correction
583                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                // Paper-like pixel whitening (smooth-step)
588                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); // Smooth-step
608
609                        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                // C#互換: パステルピンク(赤桃色)のみ完全白化
616                // Note: Phase 1.3のBleedSuppressionは削除(C#版に存在しない機能で灰色化の原因)
617                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                // C#版と同じ条件: 高輝度 + 低彩度 + 赤桃色Hue範囲のみ
629                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    /// Apply bleed-through suppression only (without other adjustments)
643    ///
644    /// Phase 1.3: Dedicated function for bleed-through removal
645    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    /// Detect bleed-through percentage in an image
671    ///
672    /// Returns the percentage of pixels that match bleed-through characteristics.
673    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    /// Analyze multiple pages and return statistics with outliers filtered per group
702    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        // Split into odd and even pages
722        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    // ============================================================
737    // Private Helper Functions
738    // ============================================================
739
740    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// ============================================================
803// Tests
804// ============================================================
805
806#[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        // When ink is 50, paper is 200, we need to scale to 0-255
850        let (s, _o) = ColorAnalyzer::linear_scale(200.0, 50.0);
851        // 50 -> 0, 200 -> 255
852        // s * 50 + o = 0  =>  o = -50s
853        // s * 200 + o = 255  =>  200s - 50s = 255  =>  150s = 255  =>  s = 1.7
854        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); // Too few to filter
886    }
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            }, // Outlier
919            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        // Page 4 should be excluded as an outlier
930        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        // Red
954        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        // White
960        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, &params);
971
972        // With identity transform, pixels should be close to original
973        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        // Uniform image should have paper close to pixel values
990        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    // ============================================================
1009    // Spec TC ID Tests
1010    // ============================================================
1011
1012    // TC-COLOR-001: 白背景・黒文字 - paper≈255, ink≈0
1013    #[test]
1014    fn test_tc_color_001_white_background_black_text() {
1015        // Create image with mostly white background and some black pixels
1016        let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255]));
1017        // Add some black "text" pixels
1018        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        // Paper should be close to white (255)
1027        assert!(
1028            stats.paper_luminance() > 240.0,
1029            "Paper luminance {} should be > 240",
1030            stats.paper_luminance()
1031        );
1032
1033        // Ink should be close to black (0)
1034        assert!(
1035            stats.ink_luminance() < 30.0,
1036            "Ink luminance {} should be < 30",
1037            stats.ink_luminance()
1038        );
1039    }
1040
1041    // TC-COLOR-002: 黄ばんだ紙 - paper<255, 補正で白化
1042    #[test]
1043    fn test_tc_color_002_yellowed_paper_correction() {
1044        // Create image with yellowed paper (cream/beige tint)
1045        let mut img = RgbImage::from_pixel(100, 100, Rgb([245, 235, 210])); // Yellowed paper
1046        // Add some dark text
1047        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        // Paper color should be less than pure white
1056        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        // Blue channel should be lower than red (yellowing)
1062        assert!(
1063            stats.paper_b < stats.paper_r,
1064            "Yellowed paper should have lower blue than red"
1065        );
1066
1067        // After correction, should become more neutral
1068        let all_stats = vec![stats];
1069        let params = ColorAnalyzer::decide_global_adjustment(&all_stats);
1070
1071        // Scale should correct the yellowing (blue needs more boost)
1072        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    // TC-COLOR-003: ゴースト抑制パラメータ計算
1081    #[test]
1082    fn test_tc_color_003_ghost_suppression_params() {
1083        // Create realistic book page: white paper with dark text
1084        let mut img = RgbImage::from_pixel(100, 100, Rgb([245, 243, 240])); // Slightly off-white paper
1085        // Add dark text
1086        for y in 30..40 {
1087            for x in 20..80 {
1088                img.put_pixel(x, y, Rgb([30, 28, 25])); // Dark ink
1089            }
1090        }
1091        // Add faint ghost/bleed-through (between ink and paper)
1092        for y in 60..70 {
1093            for x in 20..80 {
1094                img.put_pixel(x, y, Rgb([200, 198, 195])); // Ghost - lighter than ink, darker than paper
1095            }
1096        }
1097
1098        let stats = ColorAnalyzer::calculate_stats_from_image(&img, 1);
1099        let params = ColorAnalyzer::decide_global_adjustment(&[stats]);
1100
1101        // Ghost suppression threshold should be between ink and paper luminance
1102        // Paper luminance ≈ 243, Ink luminance ≈ 28
1103        // After scaling, threshold should be around midpoint
1104        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        // Scale should be positive (mapping ink to 0, paper to 255)
1111        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        // Paper color should be detected
1120        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    // TC-COLOR-004: カラー画像 - 彩度保持
1130    #[test]
1131    fn test_tc_color_004_color_image_saturation_preserved() {
1132        // Create image with colored content
1133        let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255]));
1134        // Add red block
1135        for y in 20..40 {
1136            for x in 20..40 {
1137                img.put_pixel(x, y, Rgb([255, 50, 50])); // Red
1138            }
1139        }
1140        // Add blue block
1141        for y in 60..80 {
1142            for x in 60..80 {
1143                img.put_pixel(x, y, Rgb([50, 50, 255])); // Blue
1144            }
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, &params);
1153
1154        // Check that colored pixels still have color (not desaturated to gray)
1155        let adjusted_red = img.get_pixel(30, 30);
1156        let adjusted_blue = img.get_pixel(70, 70);
1157
1158        // Red pixel should still be predominantly red
1159        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        // Blue pixel should still be predominantly blue
1165        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    // TC-COLOR-005: 外れ値ページ - MADで除外
1172    #[test]
1173    fn test_tc_color_005_outlier_exclusion_mad() {
1174        // Create stats with one obvious outlier
1175        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, // Extreme outlier - dark page
1209                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        // The outlier (page 4 with paper_r=50) should be excluded
1241        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        // Remaining pages should have consistent paper color
1249        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    // ============================================================
1266    // Phase 1.3: BleedSuppression Tests
1267    // ============================================================
1268
1269    // TC-BLEED-001: BleedSuppression デフォルト値 (C#互換)
1270    #[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        // C# version: no saturation filter, BleedValueMin = 0.35
1276        assert_eq!(bleed.saturation_max, 1.0);  // No saturation filter
1277        assert_eq!(bleed.value_min, 0.35);      // Match C# BleedValueMin
1278        assert!(bleed.enabled);
1279        assert_eq!(bleed.strength, 1.0);
1280    }
1281
1282    // TC-BLEED-002: 裏写り検出 - 黄色系 (C#互換)
1283    #[test]
1284    fn test_bleed_detection_yellow_bleed() {
1285        let bleed = BleedSuppression::default();
1286
1287        // Yellow bleed-through (hue=40, any sat, high val)
1288        assert!(bleed.is_bleed_through(40.0, 0.2, 0.8));
1289
1290        // C# version doesn't filter by saturation, so high sat yellow is also bleed
1291        assert!(bleed.is_bleed_through(40.0, 0.5, 0.8));
1292
1293        // Not bleed: dark yellow (value < 0.35)
1294        assert!(!bleed.is_bleed_through(40.0, 0.2, 0.3));
1295    }
1296
1297    // TC-BLEED-003: 裏写り検出 - 範囲外
1298    #[test]
1299    fn test_bleed_detection_out_of_range() {
1300        let bleed = BleedSuppression::default();
1301
1302        // Blue (hue=240) - not bleed
1303        assert!(!bleed.is_bleed_through(240.0, 0.2, 0.8));
1304
1305        // Red (hue=0) - not bleed
1306        assert!(!bleed.is_bleed_through(0.0, 0.2, 0.8));
1307
1308        // Green (hue=120) - not bleed
1309        assert!(!bleed.is_bleed_through(120.0, 0.2, 0.8));
1310    }
1311
1312    // TC-BLEED-004: 裏写り検出 - 無効時
1313    #[test]
1314    fn test_bleed_detection_disabled() {
1315        let bleed = BleedSuppression::disabled();
1316
1317        // Should not detect anything when disabled
1318        assert!(!bleed.is_bleed_through(40.0, 0.2, 0.8));
1319    }
1320
1321    // TC-BLEED-005: 裏写り抑制適用
1322    #[test]
1323    fn test_apply_bleed_suppression() {
1324        // Create image with yellow bleed-through
1325        let mut img = RgbImage::from_pixel(10, 10, Rgb([255, 240, 200])); // Light yellow
1326
1327        let bleed = BleedSuppression::default();
1328        ColorAnalyzer::apply_bleed_suppression(&mut img, &bleed);
1329
1330        // Pixel should be whitened
1331        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    // TC-BLEED-006: 裏写り検出率
1340    #[test]
1341    fn test_detect_bleed_percentage() {
1342        // Create image with some bleed-through
1343        let mut img = RgbImage::from_pixel(100, 100, Rgb([255, 255, 255])); // White
1344
1345        // Add bleed-through area (25% of image)
1346        for y in 0..50 {
1347            for x in 0..50 {
1348                img.put_pixel(x, y, Rgb([255, 240, 200])); // Light yellow
1349            }
1350        }
1351
1352        let bleed = BleedSuppression::default();
1353        let percentage = ColorAnalyzer::detect_bleed_percentage(&img, &bleed);
1354
1355        // Should detect roughly 25% bleed
1356        assert!(
1357            percentage > 10.0 && percentage < 40.0,
1358            "Bleed percentage {} should be around 25%",
1359            percentage
1360        );
1361    }
1362
1363    // TC-BLEED-007: アグレッシブモード
1364    #[test]
1365    fn test_bleed_suppression_aggressive() {
1366        let bleed = BleedSuppression::aggressive();
1367
1368        // Aggressive should have wider ranges
1369        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    // TC-BLEED-008: ジェントルモード
1376    #[test]
1377    fn test_bleed_suppression_gentle() {
1378        let bleed = BleedSuppression::gentle();
1379
1380        // Gentle should have narrower ranges
1381        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    // TC-BLEED-009: カスタム設定
1389    #[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    // TC-BLEED-010: GlobalColorParamにBleedSuppression含む
1400    #[test]
1401    fn test_global_color_param_includes_bleed() {
1402        let params = GlobalColorParam::default();
1403
1404        // Should include default bleed suppression
1405        assert!(params.bleed_suppression.enabled);
1406        assert_eq!(params.bleed_suppression.hue_min, 20.0);
1407    }
1408}