Skip to main content

codec_eval/metrics/
xyb.rs

1//! XYB color space roundtrip for fair metric comparison.
2//!
3//! When comparing compressed images to originals, the original can first be
4//! roundtripped through XYB color space with u8 quantization to simulate what
5//! happens when a codec stores XYB values at 8-bit precision.
6//!
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.
25//!
26//! ## XYB Color Space
27//!
28//! XYB is a hybrid opponent/trichromatic color space used by butteraugli
29//! and JPEG XL. These functions are ported from butteraugli 0.4.0 to avoid
30//! depending on private API.
31
32// XYB conversion constants (from butteraugli 0.4.0)
33const XYB_OPSIN_ABSORBANCE_MATRIX: [f32; 9] = [
34    0.30, 0.622, 0.078, // Row 0
35    0.23, 0.692, 0.078, // Row 1
36    0.243_422_69, 0.204_767_44, 0.551_809_87, // Row 2
37];
38
39const XYB_OPSIN_ABSORBANCE_BIAS: [f32; 3] = [0.003_793_073_3, 0.003_793_073_3, 0.003_793_073_3];
40
41const XYB_NEG_OPSIN_ABSORBANCE_BIAS_CBRT: [f32; 3] = [
42    -0.155_954_12, // -cbrt(0.003_793_073_3)
43    -0.155_954_12,
44    -0.155_954_12,
45];
46
47const INV_OPSIN_MATRIX: [f32; 9] = [
48    11.031_567, -9.866_944, -0.164_623,
49    -3.254_147, 4.418_77, -0.164_623,
50    -3.658_851, 2.712_923, 1.945_928,
51];
52
53/// sRGB gamma decoding (sRGB to linear RGB).
54#[inline]
55fn srgb_to_linear_f32(v: f32) -> f32 {
56    if v <= 0.04045 {
57        v / 12.92
58    } else {
59        ((v + 0.055) / 1.055).powf(2.4)
60    }
61}
62
63/// sRGB gamma encoding (linear RGB to sRGB).
64#[inline]
65fn linear_to_srgb_f32(v: f32) -> f32 {
66    if v <= 0.003_130_8 {
67        v * 12.92
68    } else {
69        1.055 * v.powf(1.0 / 2.4) - 0.055
70    }
71}
72
73/// Convert sRGB u8 to linear float.
74#[inline]
75fn srgb_u8_to_linear(v: u8) -> f32 {
76    srgb_to_linear_f32(f32::from(v) / 255.0)
77}
78
79/// Convert linear float to sRGB u8.
80#[inline]
81fn linear_to_srgb_u8(v: f32) -> u8 {
82    (linear_to_srgb_f32(v.clamp(0.0, 1.0)) * 255.0).round() as u8
83}
84
85/// Mixed cube root transfer function.
86#[inline]
87fn mixed_cbrt(v: f32) -> f32 {
88    if v < 0.0 {
89        -((-v).cbrt())
90    } else {
91        v.cbrt()
92    }
93}
94
95/// Inverse of mixed cube root.
96#[inline]
97fn mixed_cube(v: f32) -> f32 {
98    if v < 0.0 {
99        -((-v).powi(3))
100    } else {
101        v.powi(3)
102    }
103}
104
105/// Convert linear RGB to XYB color space.
106#[allow(clippy::many_single_char_names)] // x, y, b, r, g are standard color channel names
107fn linear_rgb_to_xyb(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
108    // Apply opsin absorbance matrix
109    let m = &XYB_OPSIN_ABSORBANCE_MATRIX;
110    let bias = &XYB_OPSIN_ABSORBANCE_BIAS;
111
112    let opsin_r = m[0] * r + m[1] * g + m[2] * b + bias[0];
113    let opsin_g = m[3] * r + m[4] * g + m[5] * b + bias[1];
114    let opsin_b = m[6] * r + m[7] * g + m[8] * b + bias[2];
115
116    // Apply cube root
117    let cbrt_r = mixed_cbrt(opsin_r);
118    let cbrt_g = mixed_cbrt(opsin_g);
119    let cbrt_b = mixed_cbrt(opsin_b);
120
121    // Subtract bias
122    let neg_bias = &XYB_NEG_OPSIN_ABSORBANCE_BIAS_CBRT;
123    let cbrt_r = cbrt_r + neg_bias[0];
124    let cbrt_g = cbrt_g + neg_bias[1];
125    let cbrt_b = cbrt_b + neg_bias[2];
126
127    // Final XYB transform
128    let x = 0.5 * (cbrt_r - cbrt_g);
129    let y = 0.5 * (cbrt_r + cbrt_g);
130
131    (x, y, cbrt_b)
132}
133
134/// Convert XYB to linear RGB.
135#[allow(clippy::many_single_char_names)] // x, y, b, r, g are standard color channel names
136fn xyb_to_linear_rgb(x: f32, y: f32, b: f32) -> (f32, f32, f32) {
137    let neg_bias = &XYB_NEG_OPSIN_ABSORBANCE_BIAS_CBRT;
138
139    // Inverse XYB transform
140    let cbrt_r = y + x;
141    let cbrt_g = y - x;
142    let cbrt_b = b;
143
144    // Add back bias
145    let cbrt_r = cbrt_r - neg_bias[0];
146    let cbrt_g = cbrt_g - neg_bias[1];
147    let cbrt_b = cbrt_b - neg_bias[2];
148
149    // Inverse cube root
150    let opsin_r = mixed_cube(cbrt_r);
151    let opsin_g = mixed_cube(cbrt_g);
152    let opsin_b = mixed_cube(cbrt_b);
153
154    // Remove bias
155    let bias = &XYB_OPSIN_ABSORBANCE_BIAS;
156    let opsin_r = opsin_r - bias[0];
157    let opsin_g = opsin_g - bias[1];
158    let opsin_b = opsin_b - bias[2];
159
160    // Inverse opsin matrix
161    let inv = &INV_OPSIN_MATRIX;
162    let r = inv[0] * opsin_r + inv[1] * opsin_g + inv[2] * opsin_b;
163    let g = inv[3] * opsin_r + inv[4] * opsin_g + inv[5] * opsin_b;
164    let b_out = inv[6] * opsin_r + inv[7] * opsin_g + inv[8] * opsin_b;
165
166    (r, g, b_out)
167}
168
169/// Convert sRGB u8 to XYB.
170fn srgb_to_xyb(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
171    let lr = srgb_u8_to_linear(r);
172    let lg = srgb_u8_to_linear(g);
173    let lb = srgb_u8_to_linear(b);
174    linear_rgb_to_xyb(lr, lg, lb)
175}
176
177/// Convert XYB to sRGB u8.
178fn xyb_to_srgb(x: f32, y: f32, b: f32) -> (u8, u8, u8) {
179    let (lr, lg, lb) = xyb_to_linear_rgb(x, y, b);
180    (
181        linear_to_srgb_u8(lr),
182        linear_to_srgb_u8(lg),
183        linear_to_srgb_u8(lb),
184    )
185}
186
187// XYB value ranges for all possible sRGB u8 inputs (empirically determined)
188const X_MIN: f32 = -0.016; // Slightly padded from -0.015386
189const X_MAX: f32 = 0.029; // Slightly padded from 0.028100
190const Y_MIN: f32 = 0.0;
191const Y_MAX: f32 = 0.846; // Slightly padded from 0.845309
192const B_MIN: f32 = 0.0;
193const B_MAX: f32 = 0.846; // Slightly padded from 0.845309
194
195/// Quantize a value to u8 precision within a given range.
196#[inline]
197fn quantize_to_u8(value: f32, min: f32, max: f32) -> f32 {
198    let range = max - min;
199    let normalized = (value - min) / range;
200    let quantized = (normalized * 255.0).round().clamp(0.0, 255.0) / 255.0;
201    quantized * range + min
202}
203
204/// Roundtrip RGB through XYB color space with u8 quantization.
205///
206/// This simulates the color space conversion and quantization that happens
207/// during encoding when a codec stores XYB values at 8-bit precision.
208///
209/// # Algorithm
210///
211/// 1. sRGB (u8) → Linear RGB (f32)
212/// 2. Linear RGB → XYB (f32)
213/// 3. **Quantize each XYB channel to u8 precision**
214/// 4. XYB (quantized) → Linear RGB (f32)
215/// 5. Linear RGB → sRGB (u8)
216///
217/// # Arguments
218///
219/// * `rgb` - Input RGB8 buffer (3 bytes per pixel, row-major)
220/// * `width` - Image width in pixels
221/// * `height` - Image height in pixels
222///
223/// # Returns
224///
225/// Roundtripped RGB8 buffer with the same dimensions.
226#[must_use]
227#[allow(clippy::many_single_char_names)] // r, g, b, x, y are standard color channel names
228pub fn xyb_roundtrip(rgb: &[u8], width: usize, height: usize) -> Vec<u8> {
229    let num_pixels = width * height;
230    assert_eq!(rgb.len(), num_pixels * 3, "Buffer size mismatch");
231
232    let mut result = vec![0u8; num_pixels * 3];
233
234    for i in 0..num_pixels {
235        let r = rgb[i * 3];
236        let g = rgb[i * 3 + 1];
237        let b = rgb[i * 3 + 2];
238
239        // Convert to XYB
240        let (x, y, b_xyb) = srgb_to_xyb(r, g, b);
241
242        // Quantize XYB to u8 precision
243        let x_q = quantize_to_u8(x, X_MIN, X_MAX);
244        let y_q = quantize_to_u8(y, Y_MIN, Y_MAX);
245        let b_q = quantize_to_u8(b_xyb, B_MIN, B_MAX);
246
247        // Convert back to RGB
248        let (r_out, g_out, b_out) = xyb_to_srgb(x_q, y_q, b_q);
249
250        result[i * 3] = r_out;
251        result[i * 3 + 1] = g_out;
252        result[i * 3 + 2] = b_out;
253    }
254
255    result
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_xyb_roundtrip_preserves_size() {
264        let rgb: Vec<u8> = (0..64 * 64 * 3).map(|i| (i % 256) as u8).collect();
265        let result = xyb_roundtrip(&rgb, 64, 64);
266        assert_eq!(result.len(), rgb.len());
267    }
268
269    #[test]
270    fn test_xyb_roundtrip_deterministic() {
271        let rgb: Vec<u8> = (0..32 * 32 * 3).map(|i| ((i * 7) % 256) as u8).collect();
272        let result1 = xyb_roundtrip(&rgb, 32, 32);
273        let result2 = xyb_roundtrip(&rgb, 32, 32);
274        assert_eq!(result1, result2);
275    }
276
277    #[test]
278    fn test_xyb_roundtrip_has_quantization_loss() {
279        // With u8 quantization, we expect some loss
280        // Max observed is 26 for bright yellows, but typical is much smaller
281        let mut max_diff = 0i32;
282
283        // Sample systematically
284        for r in (0..=255u8).step_by(16) {
285            for g in (0..=255u8).step_by(16) {
286                for b in (0..=255u8).step_by(16) {
287                    let rgb = vec![r, g, b];
288                    let result = xyb_roundtrip(&rgb, 1, 1);
289
290                    let dr = (result[0] as i32 - r as i32).abs();
291                    let dg = (result[1] as i32 - g as i32).abs();
292                    let db = (result[2] as i32 - b as i32).abs();
293                    max_diff = max_diff.max(dr).max(dg).max(db);
294                }
295            }
296        }
297
298        // Should have some non-zero loss but bounded
299        assert!(
300            max_diff <= 30,
301            "Max diff {} exceeds expected bound",
302            max_diff
303        );
304    }
305}