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