Skip to content

Commit cbbbf78

Browse files
lilithclaude
andcommitted
docs: Document XYB roundtrip is lossless
Exhaustive testing of all 16.7 million possible RGB colors shows that butteraugli-oxide's XYB roundtrip is perfectly lossless. Updated documentation to reflect this finding. The XYB roundtrip option is still useful for comparing against codecs that may use a different (lossy) XYB implementation internally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent d23e9c4 commit cbbbf78

File tree

1 file changed

+38
-71
lines changed

1 file changed

+38
-71
lines changed

src/metrics/xyb.rs

Lines changed: 38 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
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
1213
use 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

Comments
 (0)