11//! XYB color space roundtrip for fair metric comparison.
22//!
33//! When comparing compressed images to originals, the original can first be
4- //! roundtripped through XYB color space (RGB → XYB → RGB) to isolate true
5- //! compression error from color space conversion error .
4+ //! roundtripped through XYB color space with u8 quantization to simulate what
5+ //! happens when a codec stores XYB values at 8-bit precision .
66//!
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.
7+ //! This isolates true compression error from color space conversion error,
8+ //! which is important when evaluating codecs that operate in XYB color space
9+ //! internally (like jpegli).
10+ //!
11+ //! ## Quantization Loss
12+ //!
13+ //! With u8 quantization of XYB values, the roundtrip introduces some loss:
14+ //!
15+ //! | Max Diff | % of Colors |
16+ //! |----------|-------------|
17+ //! | Exact (0) | 15.7% |
18+ //! | ≤1 | 71.3% |
19+ //! | ≤2 | 84.7% |
20+ //! | ≤5 | 95.8% |
21+ //! | ≤10 | 99.3% |
22+ //!
23+ //! Maximum observed difference: 26 levels (for bright saturated yellows).
24+ //! Mean absolute error: ~0.69 per channel.
1225
1326use butteraugli_oxide:: xyb;
1427
15- /// Roundtrip RGB through XYB color space.
28+ // XYB value ranges for all possible sRGB u8 inputs (empirically determined)
29+ const X_MIN : f32 = -0.016 ; // Slightly padded from -0.015386
30+ const X_MAX : f32 = 0.029 ; // Slightly padded from 0.028100
31+ const Y_MIN : f32 = 0.0 ;
32+ const Y_MAX : f32 = 0.846 ; // Slightly padded from 0.845309
33+ const B_MIN : f32 = 0.0 ;
34+ const B_MAX : f32 = 0.846 ; // Slightly padded from 0.845309
35+
36+ /// Quantize a value to u8 precision within a given range.
37+ #[ inline]
38+ fn quantize_to_u8 ( value : f32 , min : f32 , max : f32 ) -> f32 {
39+ let range = max - min;
40+ let normalized = ( value - min) / range;
41+ let quantized = ( normalized * 255.0 ) . round ( ) . clamp ( 0.0 , 255.0 ) / 255.0 ;
42+ quantized * range + min
43+ }
44+
45+ /// Roundtrip RGB through XYB color space with u8 quantization.
1646///
17- /// This simulates the color space conversion that happens during encoding,
18- /// allowing metrics to measure only the compression loss and not the
19- /// unavoidable color space conversion loss.
47+ /// This simulates the color space conversion and quantization that happens
48+ /// during encoding when a codec stores XYB values at 8-bit precision.
2049///
2150/// # Algorithm
2251///
2352/// 1. sRGB (u8) → Linear RGB (f32)
2453/// 2. Linear RGB → XYB (f32)
25- /// 3. XYB → Linear RGB (f32)
26- /// 4. Linear RGB → sRGB (u8)
27- ///
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.
54+ /// 3. **Quantize each XYB channel to u8 precision**
55+ /// 4. XYB (quantized) → Linear RGB (f32)
56+ /// 5. Linear RGB → sRGB (u8)
3357///
3458/// # Arguments
3559///
@@ -52,9 +76,16 @@ pub fn xyb_roundtrip(rgb: &[u8], width: usize, height: usize) -> Vec<u8> {
5276 let g = rgb[ i * 3 + 1 ] ;
5377 let b = rgb[ i * 3 + 2 ] ;
5478
55- // Convert to XYB and back
79+ // Convert to XYB
5680 let ( x, y, b_xyb) = xyb:: srgb_to_xyb ( r, g, b) ;
57- let ( r_out, g_out, b_out) = xyb:: xyb_to_srgb ( x, y, b_xyb) ;
81+
82+ // Quantize XYB to u8 precision
83+ let x_q = quantize_to_u8 ( x, X_MIN , X_MAX ) ;
84+ let y_q = quantize_to_u8 ( y, Y_MIN , Y_MAX ) ;
85+ let b_q = quantize_to_u8 ( b_xyb, B_MIN , B_MAX ) ;
86+
87+ // Convert back to RGB
88+ let ( r_out, g_out, b_out) = xyb:: xyb_to_srgb ( x_q, y_q, b_q) ;
5889
5990 result[ i * 3 ] = r_out;
6091 result[ i * 3 + 1 ] = g_out;
@@ -84,52 +115,31 @@ mod tests {
84115 }
85116
86117 #[ test]
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)
90- let test_colors = [
91- [ 255u8 , 0 , 0 ] , // Red
92- [ 0u8 , 255 , 0 ] , // Green
93- [ 0u8 , 0 , 255 ] , // Blue
94- [ 255u8 , 255 , 0 ] , // Yellow
95- [ 0u8 , 255 , 255 ] , // Cyan
96- [ 255u8 , 0 , 255 ] , // Magenta
97- [ 0u8 , 0 , 0 ] , // Black
98- [ 255u8 , 255 , 255 ] , // White
99- [ 128u8 , 128 , 128 ] , // Gray
100- [ 200u8 , 150 , 130 ] , // Skin tone
101- [ 135u8 , 180 , 230 ] , // Sky blue
102- ] ;
103-
104- for color in & test_colors {
105- let rgb = vec ! [ color[ 0 ] , color[ 1 ] , color[ 2 ] ] ;
106- let result = xyb_roundtrip ( & rgb, 1 , 1 ) ;
107- assert_eq ! (
108- & result[ ..] ,
109- & rgb[ ..] ,
110- "XYB roundtrip should be lossless for {:?}" ,
111- color
112- ) ;
113- }
114- }
118+ fn test_xyb_roundtrip_has_quantization_loss ( ) {
119+ // With u8 quantization, we expect some loss
120+ // Max observed is 26 for bright yellows, but typical is much smaller
121+ let mut max_diff = 0i32 ;
115122
116- /// Exhaustive test: verify lossless roundtrip for ALL 16.7M colors
117- /// Run with: cargo test --release test_xyb_roundtrip_exhaustive
118- #[ test]
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 {
123+ // Sample systematically
124+ for r in ( 0 ..=255u8 ) . step_by ( 16 ) {
125+ for g in ( 0 ..=255u8 ) . step_by ( 16 ) {
126+ for b in ( 0 ..=255u8 ) . step_by ( 16 ) {
125127 let rgb = vec ! [ r, g, b] ;
126128 let result = xyb_roundtrip ( & rgb, 1 , 1 ) ;
127- if result[ 0 ] != r || result[ 1 ] != g || result[ 2 ] != b {
128- failures += 1 ;
129- }
129+
130+ let dr = ( result[ 0 ] as i32 - r as i32 ) . abs ( ) ;
131+ let dg = ( result[ 1 ] as i32 - g as i32 ) . abs ( ) ;
132+ let db = ( result[ 2 ] as i32 - b as i32 ) . abs ( ) ;
133+ max_diff = max_diff. max ( dr) . max ( dg) . max ( db) ;
130134 }
131135 }
132136 }
133- assert_eq ! ( failures, 0 , "XYB roundtrip should be lossless for all colors" ) ;
137+
138+ // Should have some non-zero loss but bounded
139+ assert ! (
140+ max_diff <= 30 ,
141+ "Max diff {} exceeds expected bound" ,
142+ max_diff
143+ ) ;
134144 }
135145}
0 commit comments