1const XYB_OPSIN_ABSORBANCE_MATRIX: [f32; 9] = [
34 0.30, 0.622, 0.078, 0.23, 0.692, 0.078, 0.243_422_69, 0.204_767_44, 0.551_809_87, ];
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, -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#[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#[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#[inline]
75fn srgb_u8_to_linear(v: u8) -> f32 {
76 srgb_to_linear_f32(f32::from(v) / 255.0)
77}
78
79#[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#[inline]
87fn mixed_cbrt(v: f32) -> f32 {
88 if v < 0.0 {
89 -((-v).cbrt())
90 } else {
91 v.cbrt()
92 }
93}
94
95#[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#[allow(clippy::many_single_char_names)] fn linear_rgb_to_xyb(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
108 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 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 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 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#[allow(clippy::many_single_char_names)] fn 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 let cbrt_r = y + x;
141 let cbrt_g = y - x;
142 let cbrt_b = b;
143
144 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 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 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 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
169fn 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
177fn 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
187const X_MIN: f32 = -0.016; const X_MAX: f32 = 0.029; const Y_MIN: f32 = 0.0;
191const Y_MAX: f32 = 0.846; const B_MIN: f32 = 0.0;
193const B_MAX: f32 = 0.846; #[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#[must_use]
227#[allow(clippy::many_single_char_names)] pub 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 let (x, y, b_xyb) = srgb_to_xyb(r, g, b);
241
242 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 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 let mut max_diff = 0i32;
282
283 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 assert!(
300 max_diff <= 30,
301 "Max diff {} exceeds expected bound",
302 max_diff
303 );
304 }
305}