Skip to main content

codec_eval/metrics/
icc.rs

1//! ICC color profile handling for accurate metric calculation.
2//!
3//! This module provides ICC profile transformation to ensure images are in
4//! sRGB color space before computing perceptual quality metrics.
5//!
6//! # Why ICC Profiles Matter
7//!
8//! When comparing image quality, both images must be in the same color space.
9//! Many encoded images (especially XYB JPEGs from jpegli) embed non-sRGB ICC
10//! profiles. Without proper color management:
11//!
12//! - Metrics will report incorrect scores
13//! - SSIMULACRA2 can be off by 1-2 points (1-2% error at high quality)
14//! - Colors may appear shifted even when compression is lossless
15//!
16//! # Implementation Notes
17//!
18//! This module uses moxcms, a pure-Rust CMS that most closely matches libjxl's
19//! skcms library. According to experiments (see ssimulacra2-fork/EXPERIMENTS.md):
20//!
21//! | CMS Backend | SSIMULACRA2 Score | Difference from skcms |
22//! |-------------|-------------------|----------------------|
23//! | libjxl (skcms) | 88.48 | — (reference) |
24//! | moxcms Linear | 86.96 | -1.52 (1.7%) |
25//! | lcms2 Perceptual | 85.97 | -2.51 (2.8%) |
26//!
27//! The remaining gap is likely due to JPEG decoder differences, not CMS.
28
29use crate::error::{Error, Result};
30
31/// Color profile information for an image.
32#[derive(Debug, Clone, Default)]
33pub enum ColorProfile {
34    /// Standard sRGB color space (no transformation needed).
35    #[default]
36    Srgb,
37    /// Embedded ICC profile data.
38    Icc(Vec<u8>),
39}
40
41impl ColorProfile {
42    /// Check if this is the sRGB profile.
43    #[must_use]
44    pub fn is_srgb(&self) -> bool {
45        matches!(self, Self::Srgb)
46    }
47
48    /// Create from ICC profile bytes, or None if no profile (assumes sRGB).
49    #[must_use]
50    pub fn from_icc_bytes(icc: Option<&[u8]>) -> Self {
51        match icc {
52            Some(data) if !data.is_empty() => Self::Icc(data.to_vec()),
53            _ => Self::Srgb,
54        }
55    }
56}
57
58/// Transform RGB pixels from source ICC profile to sRGB.
59///
60/// # Arguments
61///
62/// * `rgb` - RGB8 pixel data (3 bytes per pixel)
63/// * `profile` - Source color profile
64///
65/// # Returns
66///
67/// Transformed RGB8 pixels in sRGB color space, or the original if already sRGB.
68#[cfg(feature = "icc")]
69pub fn transform_to_srgb(rgb: &[u8], profile: &ColorProfile) -> Result<Vec<u8>> {
70    use moxcms::{ColorProfile as MoxProfile, Layout, TransformOptions};
71
72    match profile {
73        ColorProfile::Srgb => Ok(rgb.to_vec()),
74        ColorProfile::Icc(icc_data) => {
75            let input_profile =
76                MoxProfile::new_from_slice(icc_data).map_err(|e| Error::MetricCalculation {
77                    metric: "ICC".to_string(),
78                    reason: format!("Failed to parse ICC profile: {e}"),
79                })?;
80
81            let srgb = MoxProfile::new_srgb();
82
83            // Use Linear interpolation (default) - closest match to skcms
84            // See EXPERIMENTS.md for comparison results
85            let transform = input_profile
86                .create_transform_8bit(Layout::Rgb, &srgb, Layout::Rgb, TransformOptions::default())
87                .map_err(|e| Error::MetricCalculation {
88                    metric: "ICC".to_string(),
89                    reason: format!("Failed to create ICC transform: {e}"),
90                })?;
91
92            let mut output = vec![0u8; rgb.len()];
93            transform
94                .transform(rgb, &mut output)
95                .map_err(|e| Error::MetricCalculation {
96                    metric: "ICC".to_string(),
97                    reason: format!("Failed to apply ICC transform: {e}"),
98                })?;
99
100            Ok(output)
101        }
102    }
103}
104
105/// Transform RGB pixels from source ICC profile to sRGB (no-op without icc feature).
106#[cfg(not(feature = "icc"))]
107pub fn transform_to_srgb(rgb: &[u8], profile: &ColorProfile) -> Result<Vec<u8>> {
108    match profile {
109        ColorProfile::Srgb => Ok(rgb.to_vec()),
110        ColorProfile::Icc(_) => Err(Error::MetricCalculation {
111            metric: "ICC".to_string(),
112            reason: "ICC profile support requires the 'icc' feature".to_string(),
113        }),
114    }
115}
116
117/// Transform two images to sRGB and return them.
118///
119/// This is the main entry point for ICC-aware metric calculation.
120/// Both images are transformed to sRGB before comparison.
121pub fn prepare_for_comparison(
122    reference: &[u8],
123    reference_profile: &ColorProfile,
124    test: &[u8],
125    test_profile: &ColorProfile,
126) -> Result<(Vec<u8>, Vec<u8>)> {
127    let ref_srgb = transform_to_srgb(reference, reference_profile)?;
128    let test_srgb = transform_to_srgb(test, test_profile)?;
129    Ok((ref_srgb, test_srgb))
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_srgb_passthrough() {
138        let rgb = vec![100u8, 150, 200, 50, 100, 150];
139        let result = transform_to_srgb(&rgb, &ColorProfile::Srgb).unwrap();
140        assert_eq!(result, rgb);
141    }
142
143    #[test]
144    fn test_color_profile_default() {
145        assert!(ColorProfile::default().is_srgb());
146    }
147
148    #[test]
149    fn test_from_icc_bytes_none() {
150        let profile = ColorProfile::from_icc_bytes(None);
151        assert!(profile.is_srgb());
152    }
153
154    #[test]
155    fn test_from_icc_bytes_empty() {
156        let profile = ColorProfile::from_icc_bytes(Some(&[]));
157        assert!(profile.is_srgb());
158    }
159
160    #[test]
161    fn test_from_icc_bytes_data() {
162        let profile = ColorProfile::from_icc_bytes(Some(&[1, 2, 3, 4]));
163        assert!(!profile.is_srgb());
164    }
165}