11//! XYB color space roundtrip for fair metric comparison.
22//!
3- //! When comparing compressed images to originals, the original should first be
3+ //! When comparing compressed images to originals, the original can first be
44//! roundtripped through XYB color space (RGB → XYB → RGB) to isolate true
55//! compression error from color space conversion error.
66//!
7- //! This is especially important when evaluating codecs that operate in XYB
8- //! color space internally (like jpegli). The XYB color space uses an opsin
9- //! absorbance matrix that isn't perfectly invertible, leading to some loss
10- //! even before any compression happens.
7+ //! **Note:** With butteraugli-oxide's XYB implementation, the roundtrip is
8+ //! **lossless** for all 16.7 million possible RGB colors. This means the
9+ //! XYB roundtrip option is effectively a no-op with the current implementation.
10+ //! However, it may be useful for comparing against codecs that use a different
11+ //! (lossy) XYB implementation internally.
1112
1213use butteraugli_oxide:: xyb;
1314
@@ -24,9 +25,11 @@ use butteraugli_oxide::xyb;
2425/// 3. XYB → Linear RGB (f32)
2526/// 4. Linear RGB → sRGB (u8)
2627///
27- /// The conversion loss comes from:
28- /// - sRGB to linear conversion and back (u8 quantization at each end)
29- /// - XYB opsin matrix and its approximate inverse
28+ /// # Lossless Property
29+ ///
30+ /// With butteraugli-oxide's implementation, this roundtrip is **lossless**
31+ /// for all 16.7 million possible RGB colors. The f32 precision is sufficient
32+ /// to perfectly reconstruct the original u8 values after the round trip.
3033///
3134/// # Arguments
3235///
@@ -81,10 +84,9 @@ mod tests {
8184 }
8285
8386 #[ test]
84- fn test_xyb_roundtrip_extreme_colors ( ) {
85- // Test that roundtrip works for all extreme colors
86- // The XYB opsin matrix is designed for perceptual quality, not perfect inversion
87- // butteraugli's own tests allow up to 15 levels of difference for saturated colors
87+ fn test_xyb_roundtrip_lossless ( ) {
88+ // Verify that XYB roundtrip is lossless for a representative sample
89+ // (Full 16.7M color test is in bench_tests below)
8890 let test_colors = [
8991 [ 255u8 , 0 , 0 ] , // Red
9092 [ 0u8 , 255 , 0 ] , // Green
@@ -95,74 +97,39 @@ mod tests {
9597 [ 0u8 , 0 , 0 ] , // Black
9698 [ 255u8 , 255 , 255 ] , // White
9799 [ 128u8 , 128 , 128 ] , // Gray
100+ [ 200u8 , 150 , 130 ] , // Skin tone
101+ [ 135u8 , 180 , 230 ] , // Sky blue
98102 ] ;
99103
100104 for color in & test_colors {
101105 let rgb = vec ! [ color[ 0 ] , color[ 1 ] , color[ 2 ] ] ;
102106 let result = xyb_roundtrip ( & rgb, 1 , 1 ) ;
103-
104- // Just verify the result is valid u8 values (no panic)
105- // The opsin matrix approximation can cause significant drift for saturated colors
106- // This is expected and documented in butteraugli's xyb.rs
107- assert ! ( result. len( ) == 3 , "Result should have 3 components" ) ;
108-
109- // For debugging: print the actual differences
110- let _dr = ( result[ 0 ] as i16 - color[ 0 ] as i16 ) . abs ( ) ;
111- let _dg = ( result[ 1 ] as i16 - color[ 1 ] as i16 ) . abs ( ) ;
112- let _db = ( result[ 2 ] as i16 - color[ 2 ] as i16 ) . abs ( ) ;
107+ assert_eq ! (
108+ & result[ ..] ,
109+ & rgb[ ..] ,
110+ "XYB roundtrip should be lossless for {:?}" ,
111+ color
112+ ) ;
113113 }
114114 }
115115
116+ /// Exhaustive test: verify lossless roundtrip for ALL 16.7M colors
117+ /// Run with: cargo test --release test_xyb_roundtrip_exhaustive
116118 #[ test]
117- fn test_xyb_roundtrip_black_and_white ( ) {
118- // Black and white should roundtrip well since they're on the achromatic axis
119- let black = [ 0u8 , 0 , 0 ] ;
120- let white = [ 255u8 , 255 , 255 ] ;
121-
122- let result_black = xyb_roundtrip ( & black, 1 , 1 ) ;
123- let result_white = xyb_roundtrip ( & white, 1 , 1 ) ;
124-
125- // Black should stay black (all values close to 0)
126- assert ! ( result_black[ 0 ] < 5 , "Black R: {}" , result_black[ 0 ] ) ;
127- assert ! ( result_black[ 1 ] < 5 , "Black G: {}" , result_black[ 1 ] ) ;
128- assert ! ( result_black[ 2 ] < 5 , "Black B: {}" , result_black[ 2 ] ) ;
129-
130- // White should stay white (all values close to 255)
131- assert ! ( result_white[ 0 ] > 250 , "White R: {}" , result_white[ 0 ] ) ;
132- assert ! ( result_white[ 1 ] > 250 , "White G: {}" , result_white[ 1 ] ) ;
133- assert ! ( result_white[ 2 ] > 250 , "White B: {}" , result_white[ 2 ] ) ;
134- }
135-
136- #[ test]
137- fn test_xyb_roundtrip_typical_photo_colors ( ) {
138- // Test colors typical in photographs (skin tones, sky, grass)
139- // These should have smaller errors than saturated primaries
140- let photo_colors = [
141- [ 200u8 , 150 , 130 ] , // Skin tone
142- [ 135u8 , 180 , 230 ] , // Sky blue
143- [ 80u8 , 140 , 60 ] , // Grass green
144- [ 180u8 , 120 , 80 ] , // Wood brown
145- ] ;
146-
147- for color in & photo_colors {
148- let rgb = vec ! [ color[ 0 ] , color[ 1 ] , color[ 2 ] ] ;
149- let result = xyb_roundtrip ( & rgb, 1 , 1 ) ;
150-
151- // Photo-realistic colors should roundtrip with smaller error
152- let max_diff = 20 ; // Allow up to 20 levels for any color
153- let dr = ( result[ 0 ] as i16 - color[ 0 ] as i16 ) . abs ( ) ;
154- let dg = ( result[ 1 ] as i16 - color[ 1 ] as i16 ) . abs ( ) ;
155- let db = ( result[ 2 ] as i16 - color[ 2 ] as i16 ) . abs ( ) ;
156-
157- assert ! (
158- dr <= max_diff && dg <= max_diff && db <= max_diff,
159- "Photo color {:?} → {:?}, diffs: ({}, {}, {})" ,
160- color,
161- & result[ ..] ,
162- dr,
163- dg,
164- db
165- ) ;
119+ #[ ignore] // Takes ~1.5 seconds in release mode
120+ fn test_xyb_roundtrip_exhaustive ( ) {
121+ let mut failures = 0u64 ;
122+ for r in 0 ..=255u8 {
123+ for g in 0 ..=255u8 {
124+ for b in 0 ..=255u8 {
125+ let rgb = vec ! [ r, g, b] ;
126+ let result = xyb_roundtrip ( & rgb, 1 , 1 ) ;
127+ if result[ 0 ] != r || result[ 1 ] != g || result[ 2 ] != b {
128+ failures += 1 ;
129+ }
130+ }
131+ }
166132 }
133+ assert_eq ! ( failures, 0 , "XYB roundtrip should be lossless for all colors" ) ;
167134 }
168135}
0 commit comments