1const XYB_OPSIN_ABSORBANCE_MATRIX: [f32; 9] = [
34 0.30,
35 0.622,
36 0.078, 0.23,
38 0.692,
39 0.078, 0.243_422_69,
41 0.204_767_44,
42 0.551_809_87, ];
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, -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#[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#[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#[inline]
80fn srgb_u8_to_linear(v: u8) -> f32 {
81 srgb_to_linear_f32(f32::from(v) / 255.0)
82}
83
84#[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#[inline]
92fn mixed_cbrt(v: f32) -> f32 {
93 if v < 0.0 { -((-v).cbrt()) } else { v.cbrt() }
94}
95
96#[inline]
98fn mixed_cube(v: f32) -> f32 {
99 if v < 0.0 { -((-v).powi(3)) } else { v.powi(3) }
100}
101
102#[allow(clippy::many_single_char_names)] fn linear_rgb_to_xyb(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
105 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 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 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 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#[allow(clippy::many_single_char_names)] fn 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 let cbrt_r = y + x;
138 let cbrt_g = y - x;
139 let cbrt_b = b;
140
141 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 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 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 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
166fn 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
174fn 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
184const X_MIN: f32 = -0.016; const X_MAX: f32 = 0.029; const Y_MIN: f32 = 0.0;
188const Y_MAX: f32 = 0.846; const B_MIN: f32 = 0.0;
190const B_MAX: f32 = 0.846; #[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#[must_use]
224#[allow(clippy::many_single_char_names)] pub 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 let (x, y, b_xyb) = srgb_to_xyb(r, g, b);
238
239 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 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 let mut max_diff = 0i32;
279
280 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 assert!(
297 max_diff <= 30,
298 "Max diff {} exceeds expected bound",
299 max_diff
300 );
301 }
302}