Skip to main content

oximedia_gpu/ops/
colorspace.rs

1//! Color space conversion operations (RGB ↔ YUV)
2
3use crate::{
4    shader::{BindGroupLayoutBuilder, ShaderCompiler, ShaderSource},
5    GpuDevice, Result,
6};
7use bytemuck::{Pod, Zeroable};
8use once_cell::sync::OnceCell;
9use wgpu::{BindGroup, BindGroupLayout, ComputePipeline};
10
11use super::utils;
12
13/// Color space standards
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ColorSpace {
16    /// BT.601 (SD video)
17    BT601,
18    /// BT.709 (HD video)
19    BT709,
20    /// BT.2020 (UHD video)
21    BT2020,
22}
23
24impl ColorSpace {
25    fn to_format_id(self) -> u32 {
26        match self {
27            Self::BT601 => 0,
28            Self::BT709 => 1,
29            Self::BT2020 => 2,
30        }
31    }
32}
33
34#[repr(C)]
35#[derive(Copy, Clone, Pod, Zeroable)]
36struct ConversionParams {
37    width: u32,
38    height: u32,
39    stride: u32,
40    format: u32,
41}
42
43/// Color space conversion operations
44pub struct ColorSpaceConversion;
45
46impl ColorSpaceConversion {
47    /// Convert RGB to YUV
48    ///
49    /// # Arguments
50    ///
51    /// * `device` - GPU device
52    /// * `input` - Input RGB buffer (packed RGBA format)
53    /// * `output` - Output YUV buffer (packed YUVA format)
54    /// * `width` - Image width
55    /// * `height` - Image height
56    /// * `color_space` - Color space standard (BT.601, BT.709, BT.2020)
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if buffer sizes are invalid or if the GPU operation fails.
61    #[allow(clippy::too_many_arguments)]
62    pub fn rgb_to_yuv(
63        device: &GpuDevice,
64        input: &[u8],
65        output: &mut [u8],
66        width: u32,
67        height: u32,
68        color_space: ColorSpace,
69    ) -> Result<()> {
70        utils::validate_dimensions(width, height)?;
71        utils::validate_buffer_size(input, width, height, 4)?;
72        utils::validate_buffer_size(output, width, height, 4)?;
73
74        let pipeline = Self::get_rgb_to_yuv_pipeline(device)?;
75        let layout = Self::get_bind_group_layout(device)?;
76
77        Self::execute_conversion(
78            device,
79            pipeline,
80            layout,
81            input,
82            output,
83            width,
84            height,
85            color_space,
86        )
87    }
88
89    /// Convert YUV to RGB
90    ///
91    /// # Arguments
92    ///
93    /// * `device` - GPU device
94    /// * `input` - Input YUV buffer (packed YUVA format)
95    /// * `output` - Output RGB buffer (packed RGBA format)
96    /// * `width` - Image width
97    /// * `height` - Image height
98    /// * `color_space` - Color space standard (BT.601, BT.709, BT.2020)
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if buffer sizes are invalid or if the GPU operation fails.
103    #[allow(clippy::too_many_arguments)]
104    pub fn yuv_to_rgb(
105        device: &GpuDevice,
106        input: &[u8],
107        output: &mut [u8],
108        width: u32,
109        height: u32,
110        color_space: ColorSpace,
111    ) -> Result<()> {
112        utils::validate_dimensions(width, height)?;
113        utils::validate_buffer_size(input, width, height, 4)?;
114        utils::validate_buffer_size(output, width, height, 4)?;
115
116        let pipeline = Self::get_yuv_to_rgb_pipeline(device)?;
117        let layout = Self::get_bind_group_layout(device)?;
118
119        Self::execute_conversion(
120            device,
121            pipeline,
122            layout,
123            input,
124            output,
125            width,
126            height,
127            color_space,
128        )
129    }
130
131    #[allow(clippy::too_many_arguments)]
132    fn execute_conversion(
133        device: &GpuDevice,
134        pipeline: &ComputePipeline,
135        layout: &BindGroupLayout,
136        input: &[u8],
137        output: &mut [u8],
138        width: u32,
139        height: u32,
140        color_space: ColorSpace,
141    ) -> Result<()> {
142        // Create buffers
143        let input_buffer = utils::create_storage_buffer(device, input.len() as u64)?;
144        let output_buffer = utils::create_storage_buffer(device, output.len() as u64)?;
145
146        // Upload input data
147        device.queue().write_buffer(input_buffer.buffer(), 0, input);
148
149        // Create uniform buffer for parameters
150        let params = ConversionParams {
151            width,
152            height,
153            stride: width,
154            format: color_space.to_format_id(),
155        };
156        let params_bytes = bytemuck::bytes_of(&params);
157        let params_buffer = utils::create_uniform_buffer(device, params_bytes)?;
158
159        // Create bind group
160        let compiler = ShaderCompiler::new(device);
161        let bind_group = compiler.create_bind_group(
162            "ColorSpace Bind Group",
163            layout,
164            &[
165                wgpu::BindGroupEntry {
166                    binding: 0,
167                    resource: input_buffer.buffer().as_entire_binding(),
168                },
169                wgpu::BindGroupEntry {
170                    binding: 1,
171                    resource: output_buffer.buffer().as_entire_binding(),
172                },
173                wgpu::BindGroupEntry {
174                    binding: 2,
175                    resource: params_buffer.buffer().as_entire_binding(),
176                },
177            ],
178        );
179
180        // Execute compute pass
181        Self::dispatch_compute(device, pipeline, &bind_group, width, height)?;
182
183        // Read back results
184        let readback_buffer = utils::create_readback_buffer(device, output.len() as u64)?;
185        let mut encoder = device
186            .device()
187            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
188                label: Some("ColorSpace Copy Encoder"),
189            });
190
191        output_buffer.copy_to(&mut encoder, &readback_buffer, 0, 0, output.len() as u64)?;
192
193        device.queue().submit(Some(encoder.finish()));
194        device.wait();
195
196        let result = readback_buffer.read(device, 0, output.len() as u64)?;
197        output.copy_from_slice(&result);
198
199        Ok(())
200    }
201
202    fn dispatch_compute(
203        device: &GpuDevice,
204        pipeline: &ComputePipeline,
205        bind_group: &BindGroup,
206        width: u32,
207        height: u32,
208    ) -> Result<()> {
209        let mut encoder = device
210            .device()
211            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
212                label: Some("ColorSpace Compute Encoder"),
213            });
214
215        {
216            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
217                label: Some("ColorSpace Compute Pass"),
218                timestamp_writes: None,
219            });
220
221            compute_pass.set_pipeline(pipeline);
222            compute_pass.set_bind_group(0, bind_group, &[]);
223
224            let (dispatch_x, dispatch_y) = utils::calculate_dispatch_size(width, height, (16, 16));
225            compute_pass.dispatch_workgroups(dispatch_x, dispatch_y, 1);
226        }
227
228        device.queue().submit(Some(encoder.finish()));
229        Ok(())
230    }
231
232    fn get_bind_group_layout(device: &GpuDevice) -> Result<&'static BindGroupLayout> {
233        static LAYOUT: OnceCell<BindGroupLayout> = OnceCell::new();
234
235        Ok(LAYOUT.get_or_init(|| {
236            let compiler = ShaderCompiler::new(device);
237            let entries = BindGroupLayoutBuilder::new()
238                .add_storage_buffer_read_only(0) // input
239                .add_storage_buffer(1) // output
240                .add_uniform_buffer(2) // params
241                .build();
242
243            compiler.create_bind_group_layout("ColorSpace Bind Group Layout", &entries)
244        }))
245    }
246
247    fn init_pipeline(
248        device: &GpuDevice,
249        name: &str,
250        entry_point: &str,
251    ) -> std::result::Result<ComputePipeline, String> {
252        let compiler = ShaderCompiler::new(device);
253        let shader = compiler
254            .compile(
255                "ColorSpace Shader",
256                ShaderSource::Embedded(crate::shader::embedded::COLORSPACE_SHADER),
257            )
258            .map_err(|e| format!("Failed to compile colorspace shader: {e}"))?;
259
260        let layout = Self::get_bind_group_layout(device)
261            .map_err(|e| format!("Failed to create bind group layout: {e}"))?;
262
263        compiler
264            .create_pipeline(name, &shader, entry_point, layout)
265            .map_err(|e| format!("Failed to create pipeline: {e}"))
266    }
267
268    fn get_rgb_to_yuv_pipeline(device: &GpuDevice) -> Result<&'static ComputePipeline> {
269        static PIPELINE: OnceCell<std::result::Result<ComputePipeline, String>> = OnceCell::new();
270
271        PIPELINE
272            .get_or_init(|| {
273                ColorSpaceConversion::init_pipeline(
274                    device,
275                    "RGB to YUV Pipeline",
276                    "rgb_to_yuv_main",
277                )
278            })
279            .as_ref()
280            .map_err(|e| crate::GpuError::PipelineCreation(e.clone()))
281    }
282
283    fn get_yuv_to_rgb_pipeline(device: &GpuDevice) -> Result<&'static ComputePipeline> {
284        static PIPELINE: OnceCell<std::result::Result<ComputePipeline, String>> = OnceCell::new();
285
286        PIPELINE
287            .get_or_init(|| {
288                ColorSpaceConversion::init_pipeline(
289                    device,
290                    "YUV to RGB Pipeline",
291                    "yuv_to_rgb_main",
292                )
293            })
294            .as_ref()
295            .map_err(|e| crate::GpuError::PipelineCreation(e.clone()))
296    }
297}
298
299// =============================================================================
300// CPU-side HSV / Lab / sRGB↔Linear color conversions
301// =============================================================================
302
303impl ColorSpaceConversion {
304    /// Convert interleaved RGBA pixels from RGB to HSV encoding.
305    ///
306    /// Input layout: 4 bytes per pixel — R, G, B, A.
307    /// Output layout: 4 bytes per pixel — H_enc, S_enc, V_enc, A (pass-through).
308    ///
309    /// Encoding:
310    /// * H → `(H / 360.0 * 255.0) as u8`  (hue 0°–360° mapped to 0–255)
311    /// * S → `(S * 255.0) as u8`           (saturation 0.0–1.0)
312    /// * V → `(V * 255.0) as u8`           (value 0.0–1.0)
313    ///
314    /// # Panics
315    ///
316    /// Does not panic; invalid pixel counts are handled by truncating to complete
317    /// 4-byte pixels.
318    #[must_use]
319    pub fn rgb_to_hsv(data: &[u8], width: u32, height: u32) -> Vec<u8> {
320        let pixel_count = (width as usize) * (height as usize);
321        let mut out = vec![0u8; pixel_count * 4];
322
323        for i in 0..pixel_count {
324            let base = i * 4;
325            if base + 3 >= data.len() {
326                break;
327            }
328            let r = f64::from(data[base]) / 255.0;
329            let g = f64::from(data[base + 1]) / 255.0;
330            let b = f64::from(data[base + 2]) / 255.0;
331            let alpha = data[base + 3];
332
333            let max = r.max(g).max(b);
334            let min = r.min(g).min(b);
335            let delta = max - min;
336
337            let v = max;
338            let s = if max > 0.0 { delta / max } else { 0.0 };
339
340            let h = if delta < 1e-10 {
341                0.0_f64
342            } else if (max - r).abs() < 1e-10 {
343                let sector = (g - b) / delta;
344                // fmod equivalent for f64 — keep in [0, 6)
345                let sector = sector - (sector / 6.0).floor() * 6.0;
346                60.0 * sector
347            } else if (max - g).abs() < 1e-10 {
348                60.0 * ((b - r) / delta + 2.0)
349            } else {
350                60.0 * ((r - g) / delta + 4.0)
351            };
352            let h = if h < 0.0 { h + 360.0 } else { h };
353
354            out[base] = (h / 360.0 * 255.0).clamp(0.0, 255.0).round() as u8;
355            out[base + 1] = (s * 255.0).clamp(0.0, 255.0).round() as u8;
356            out[base + 2] = (v * 255.0).clamp(0.0, 255.0).round() as u8;
357            out[base + 3] = alpha;
358        }
359        out
360    }
361
362    /// Convert interleaved RGBA pixels from HSV to RGB encoding.
363    ///
364    /// Input layout: 4 bytes per pixel — H_enc, S_enc, V_enc, A.
365    /// Output layout: 4 bytes per pixel — R, G, B, A (pass-through).
366    ///
367    /// Decoding: H = byte × 360 / 255, S = byte / 255, V = byte / 255.
368    #[must_use]
369    pub fn hsv_to_rgb(data: &[u8], width: u32, height: u32) -> Vec<u8> {
370        let pixel_count = (width as usize) * (height as usize);
371        let mut out = vec![0u8; pixel_count * 4];
372
373        for i in 0..pixel_count {
374            let base = i * 4;
375            if base + 3 >= data.len() {
376                break;
377            }
378            let h = f64::from(data[base]) * 360.0 / 255.0; // 0.0 .. 360.0
379            let s = f64::from(data[base + 1]) / 255.0; // 0.0 .. 1.0
380            let v = f64::from(data[base + 2]) / 255.0; // 0.0 .. 1.0
381            let alpha = data[base + 3];
382
383            let c = v * s;
384            let h_prime = h / 60.0;
385            // |h_prime mod 2 - 1|
386            let h_mod2 = h_prime - (h_prime / 2.0).floor() * 2.0;
387            let x = c * (1.0 - (h_mod2 - 1.0).abs());
388            let m = v - c;
389
390            let sector = (h_prime as u32) % 6;
391            let (r1, g1, b1) = match sector {
392                0 => (c, x, 0.0),
393                1 => (x, c, 0.0),
394                2 => (0.0, c, x),
395                3 => (0.0, x, c),
396                4 => (x, 0.0, c),
397                _ => (c, 0.0, x),
398            };
399
400            out[base] = ((r1 + m) * 255.0).clamp(0.0, 255.0).round() as u8;
401            out[base + 1] = ((g1 + m) * 255.0).clamp(0.0, 255.0).round() as u8;
402            out[base + 2] = ((b1 + m) * 255.0).clamp(0.0, 255.0).round() as u8;
403            out[base + 3] = alpha;
404        }
405        out
406    }
407
408    /// Convert interleaved RGBA pixels from sRGB to CIE L*a*b*.
409    ///
410    /// Input layout: 4 bytes per pixel — R, G, B, A.
411    /// Output layout: 4 bytes per pixel:
412    /// * L*  (0–100) → byte = `(L * 255.0 / 100.0) as u8`
413    /// * a*  (−128–127) → byte = `(a + 128.0) as u8` (clamped 0–255)
414    /// * b*  (−128–127) → byte = `(b + 128.0) as u8` (clamped 0–255)
415    /// * A: pass-through
416    #[must_use]
417    pub fn rgb_to_lab(data: &[u8], width: u32, height: u32) -> Vec<u8> {
418        // D65 reference white
419        const XN: f64 = 0.95047;
420        const YN: f64 = 1.00000;
421        const ZN: f64 = 1.08883;
422
423        let pixel_count = (width as usize) * (height as usize);
424        let mut out = vec![0u8; pixel_count * 4];
425
426        for i in 0..pixel_count {
427            let base = i * 4;
428            if base + 3 >= data.len() {
429                break;
430            }
431            let r_lin = Self::srgb_channel_to_linear(f64::from(data[base]) / 255.0);
432            let g_lin = Self::srgb_channel_to_linear(f64::from(data[base + 1]) / 255.0);
433            let b_lin = Self::srgb_channel_to_linear(f64::from(data[base + 2]) / 255.0);
434            let alpha = data[base + 3];
435
436            // Linear sRGB → CIE XYZ (D65, IEC 61966-2-1 matrix)
437            let x = 0.4124564 * r_lin + 0.3575761 * g_lin + 0.1804375 * b_lin;
438            let y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin;
439            let z = 0.0193339 * r_lin + 0.1191920 * g_lin + 0.9503041 * b_lin;
440
441            // XYZ → Lab (using the standard cube-root / linear piece-wise f)
442            let fx = Self::lab_f(x / XN);
443            let fy = Self::lab_f(y / YN);
444            let fz = Self::lab_f(z / ZN);
445
446            let l_star = 116.0 * fy - 16.0;
447            let a_star = 500.0 * (fx - fy);
448            let b_star = 200.0 * (fy - fz);
449
450            // Encode to u8
451            out[base] = (l_star * 255.0 / 100.0).clamp(0.0, 255.0).round() as u8;
452            out[base + 1] = (a_star + 128.0).clamp(0.0, 255.0).round() as u8;
453            out[base + 2] = (b_star + 128.0).clamp(0.0, 255.0).round() as u8;
454            out[base + 3] = alpha;
455        }
456        out
457    }
458
459    /// Convert interleaved RGBA pixels from CIE L*a*b* back to sRGB.
460    ///
461    /// Input layout: 4 bytes per pixel — L_enc, a_enc, b_enc, A.
462    /// Output layout: 4 bytes per pixel — R, G, B, A (pass-through).
463    #[must_use]
464    pub fn lab_to_rgb(data: &[u8], width: u32, height: u32) -> Vec<u8> {
465        const XN: f64 = 0.95047;
466        const YN: f64 = 1.00000;
467        const ZN: f64 = 1.08883;
468
469        let pixel_count = (width as usize) * (height as usize);
470        let mut out = vec![0u8; pixel_count * 4];
471
472        for i in 0..pixel_count {
473            let base = i * 4;
474            if base + 3 >= data.len() {
475                break;
476            }
477            let l_star = f64::from(data[base]) * 100.0 / 255.0;
478            let a_star = f64::from(data[base + 1]) - 128.0;
479            let b_star = f64::from(data[base + 2]) - 128.0;
480            let alpha = data[base + 3];
481
482            // Lab → XYZ
483            let fy = (l_star + 16.0) / 116.0;
484            let fx = a_star / 500.0 + fy;
485            let fz = fy - b_star / 200.0;
486
487            let x = Self::lab_f_inv(fx) * XN;
488            let y = Self::lab_f_inv(fy) * YN;
489            let z = Self::lab_f_inv(fz) * ZN;
490
491            // XYZ → Linear sRGB (inverse of the IEC 61966-2-1 matrix)
492            let r_lin = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z;
493            let g_lin = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z;
494            let b_lin = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z;
495
496            // Linear sRGB → sRGB (gamma encoding)
497            let r_srgb = Self::linear_channel_to_srgb(r_lin);
498            let g_srgb = Self::linear_channel_to_srgb(g_lin);
499            let b_srgb = Self::linear_channel_to_srgb(b_lin);
500
501            out[base] = (r_srgb * 255.0).clamp(0.0, 255.0).round() as u8;
502            out[base + 1] = (g_srgb * 255.0).clamp(0.0, 255.0).round() as u8;
503            out[base + 2] = (b_srgb * 255.0).clamp(0.0, 255.0).round() as u8;
504            out[base + 3] = alpha;
505        }
506        out
507    }
508
509    /// Convert interleaved RGBA pixels from sRGB to linear light (remove gamma).
510    ///
511    /// Input/output: 4 bytes per pixel — R, G, B, A.  Alpha is passed through.
512    /// The linear value (0.0–1.0 f64) is scaled back to u8 (0–255).
513    #[must_use]
514    pub fn srgb_to_linear(data: &[u8], width: u32, height: u32) -> Vec<u8> {
515        let pixel_count = (width as usize) * (height as usize);
516        let mut out = vec![0u8; pixel_count * 4];
517
518        for i in 0..pixel_count {
519            let base = i * 4;
520            if base + 3 >= data.len() {
521                break;
522            }
523            for ch in 0..3 {
524                let c = f64::from(data[base + ch]) / 255.0;
525                let lin = Self::srgb_channel_to_linear(c);
526                out[base + ch] = (lin * 255.0).clamp(0.0, 255.0).round() as u8;
527            }
528            out[base + 3] = data[base + 3];
529        }
530        out
531    }
532
533    /// Convert interleaved RGBA pixels from linear light to sRGB (apply gamma).
534    ///
535    /// Input/output: 4 bytes per pixel — R, G, B, A.  Alpha is passed through.
536    #[must_use]
537    pub fn linear_to_srgb(data: &[u8], width: u32, height: u32) -> Vec<u8> {
538        let pixel_count = (width as usize) * (height as usize);
539        let mut out = vec![0u8; pixel_count * 4];
540
541        for i in 0..pixel_count {
542            let base = i * 4;
543            if base + 3 >= data.len() {
544                break;
545            }
546            for ch in 0..3 {
547                let c = f64::from(data[base + ch]) / 255.0;
548                let enc = Self::linear_channel_to_srgb(c);
549                out[base + ch] = (enc * 255.0).clamp(0.0, 255.0).round() as u8;
550            }
551            out[base + 3] = data[base + 3];
552        }
553        out
554    }
555
556    // ── Private helpers ──────────────────────────────────────────────────────
557
558    /// sRGB electro-optical transfer function (inverse gamma): sRGB → linear.
559    ///
560    /// IEC 61966-2-1: for `c ≤ 0.04045` → `c / 12.92`, else `((c+0.055)/1.055)^2.4`.
561    #[inline]
562    fn srgb_channel_to_linear(c: f64) -> f64 {
563        if c <= 0.04045 {
564            c / 12.92
565        } else {
566            ((c + 0.055) / 1.055).powf(2.4)
567        }
568    }
569
570    /// sRGB opto-electronic transfer function (gamma): linear → sRGB.
571    ///
572    /// IEC 61966-2-1: for `c ≤ 0.0031308` → `c * 12.92`, else `1.055*c^(1/2.4) - 0.055`.
573    #[inline]
574    fn linear_channel_to_srgb(c: f64) -> f64 {
575        let c = c.clamp(0.0, 1.0);
576        if c <= 0.0031308 {
577            c * 12.92
578        } else {
579            1.055 * c.powf(1.0 / 2.4) - 0.055
580        }
581    }
582
583    /// CIE Lab piecewise cube-root function `f(t)`.
584    ///
585    /// `f(t) = t^(1/3)` if `t > ε`, else `(7.787 * t) + 16/116`.
586    #[inline]
587    fn lab_f(t: f64) -> f64 {
588        // ε = (6/29)^3 ≈ 0.008856
589        if t > 0.008_856 {
590            t.cbrt()
591        } else {
592            7.787 * t + 16.0 / 116.0
593        }
594    }
595
596    /// Inverse of `lab_f`.
597    #[inline]
598    fn lab_f_inv(t: f64) -> f64 {
599        // δ = 6/29 ≈ 0.2069
600        const DELTA: f64 = 6.0 / 29.0;
601        if t > DELTA {
602            t * t * t
603        } else {
604            3.0 * DELTA * DELTA * (t - 16.0 / 116.0)
605        }
606    }
607}
608
609// =============================================================================
610// CPU-side reference color conversions (BT.601, BT.709, BT.2020, BT.2100)
611// =============================================================================
612
613/// BT.601 RGB → YCbCr (studio swing: Y ∈ \[16,235\], Cb/Cr ∈ \[16,240\]).
614///
615/// Input: linear RGB in [0, 255].
616/// Output: (Y, Cb, Cr) in [0, 255] (offset and scaled per ITU-R BT.601).
617#[must_use]
618pub fn bt601_rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
619    let r = f64::from(r);
620    let g = f64::from(g);
621    let b = f64::from(b);
622    let y = 16.0 + (65.481 * r + 128.553 * g + 24.966 * b) / 255.0;
623    let cb = 128.0 + (-37.797 * r - 74.203 * g + 112.0 * b) / 255.0;
624    let cr = 128.0 + (112.0 * r - 93.786 * g - 18.214 * b) / 255.0;
625    (
626        y.round().clamp(0.0, 255.0) as u8,
627        cb.round().clamp(0.0, 255.0) as u8,
628        cr.round().clamp(0.0, 255.0) as u8,
629    )
630}
631
632/// BT.601 YCbCr → RGB (studio swing: Y ∈ \[16,235\], Cb/Cr ∈ \[16,240\]).
633///
634/// Output: linear RGB in [0, 255].
635#[must_use]
636pub fn bt601_ycbcr_to_rgb(y: u8, cb: u8, cr: u8) -> (u8, u8, u8) {
637    let y = f64::from(y) - 16.0;
638    let cb = f64::from(cb) - 128.0;
639    let cr = f64::from(cr) - 128.0;
640    let r = 255.0 * (1.164 * y + 1.596 * cr) / 255.0;
641    let g = 255.0 * (1.164 * y - 0.392 * cb - 0.813 * cr) / 255.0;
642    let b = 255.0 * (1.164 * y + 2.017 * cb) / 255.0;
643    (
644        r.round().clamp(0.0, 255.0) as u8,
645        g.round().clamp(0.0, 255.0) as u8,
646        b.round().clamp(0.0, 255.0) as u8,
647    )
648}
649
650/// BT.709 RGB → YCbCr (studio swing: Y ∈ \[16,235\], Cb/Cr ∈ \[16,240\]).
651///
652/// Input: linear RGB in [0, 255].
653/// Output: (Y, Cb, Cr) in [0, 255].
654#[must_use]
655pub fn bt709_rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
656    // BT.709 direct matrix form (ITU-R BT.709 Table B.3), studio swing.
657    // Kr = 0.2126, Kb = 0.0722, Kg = 1 - Kr - Kb = 0.7152
658    let r_n = f64::from(r) / 255.0;
659    let g_n = f64::from(g) / 255.0;
660    let b_n = f64::from(b) / 255.0;
661    let y = 16.0 + 219.0 * (0.2126 * r_n + 0.7152 * g_n + 0.0722 * b_n);
662    let cb = 128.0 + 224.0 * (-0.2126 / 1.8556 * r_n - 0.7152 / 1.8556 * g_n + 0.5 * b_n);
663    let cr = 128.0 + 224.0 * (0.5 * r_n - 0.7152 / 1.5748 * g_n - 0.0722 / 1.5748 * b_n);
664    (
665        y.round().clamp(0.0, 255.0) as u8,
666        cb.round().clamp(0.0, 255.0) as u8,
667        cr.round().clamp(0.0, 255.0) as u8,
668    )
669}
670
671/// BT.709 YCbCr → RGB (studio swing).
672///
673/// Output: linear RGB in [0, 255].
674#[must_use]
675pub fn bt709_ycbcr_to_rgb(y: u8, cb: u8, cr: u8) -> (u8, u8, u8) {
676    let y_n = (f64::from(y) - 16.0) / 219.0;
677    let cb_n = (f64::from(cb) - 128.0) / 224.0;
678    let cr_n = (f64::from(cr) - 128.0) / 224.0;
679    let r = y_n + 1.5748 * cr_n;
680    let g = y_n - 0.2126 / 0.7152 * 1.5748 * cr_n - 0.0722 / 0.7152 * 1.8556 * cb_n;
681    let b = y_n + 1.8556 * cb_n;
682    (
683        (r * 255.0).round().clamp(0.0, 255.0) as u8,
684        (g * 255.0).round().clamp(0.0, 255.0) as u8,
685        (b * 255.0).round().clamp(0.0, 255.0) as u8,
686    )
687}
688
689/// BT.2020 RGB → YCbCr (studio swing).
690///
691/// BT.2020 uses primaries for Ultra HD (UHD) content.
692/// Coefficients: Kr = 0.2627, Kb = 0.0593, Kg = 0.6780.
693///
694/// Input: linear RGB in [0, 255].
695/// Output: (Y, Cb, Cr) in [0, 255] studio swing.
696#[must_use]
697pub fn bt2020_rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
698    let r_n = f64::from(r) / 255.0;
699    let g_n = f64::from(g) / 255.0;
700    let b_n = f64::from(b) / 255.0;
701    // BT.2020 luma coefficients (Kr, Kg, Kb)
702    let kr = 0.2627_f64;
703    let kb = 0.0593_f64;
704    let kg = 1.0 - kr - kb; // 0.6780
705    let y = 16.0 + 219.0 * (kr * r_n + kg * g_n + kb * b_n);
706    let cb = 128.0
707        + 224.0 * ((-kr / (2.0 * (1.0 - kb))) * r_n + (-kg / (2.0 * (1.0 - kb))) * g_n + 0.5 * b_n);
708    let cr = 128.0
709        + 224.0 * (0.5 * r_n + (-kg / (2.0 * (1.0 - kr))) * g_n + (-kb / (2.0 * (1.0 - kr))) * b_n);
710    (
711        y.round().clamp(0.0, 255.0) as u8,
712        cb.round().clamp(0.0, 255.0) as u8,
713        cr.round().clamp(0.0, 255.0) as u8,
714    )
715}
716
717/// BT.2020 YCbCr → RGB (studio swing).
718///
719/// Output: linear RGB in [0, 255].
720#[must_use]
721pub fn bt2020_ycbcr_to_rgb(y: u8, cb: u8, cr: u8) -> (u8, u8, u8) {
722    let y_n = (f64::from(y) - 16.0) / 219.0;
723    let cb_n = (f64::from(cb) - 128.0) / 224.0;
724    let cr_n = (f64::from(cr) - 128.0) / 224.0;
725    // BT.2020 inverse matrix
726    let kr = 0.2627_f64;
727    let kb = 0.0593_f64;
728    let kg = 1.0 - kr - kb;
729    let r_cr = 2.0 * (1.0 - kr); // 1.4746
730    let b_cb = 2.0 * (1.0 - kb); // 1.8814
731    let g_cr = -2.0 * kr * (1.0 - kr) / kg;
732    let g_cb = -2.0 * kb * (1.0 - kb) / kg;
733    let r = y_n + r_cr * cr_n;
734    let g = y_n + g_cr * cr_n + g_cb * cb_n;
735    let b = y_n + b_cb * cb_n;
736    (
737        (r * 255.0).round().clamp(0.0, 255.0) as u8,
738        (g * 255.0).round().clamp(0.0, 255.0) as u8,
739        (b * 255.0).round().clamp(0.0, 255.0) as u8,
740    )
741}
742
743/// BT.2100 PQ (Perceptual Quantizer) transfer function: linear → PQ-encoded.
744///
745/// Input: linear scene luminance normalised to [0, 1] where 1 = 10 000 nits.
746/// Output: PQ-encoded value in [0, 1].
747#[must_use]
748pub fn pq_oetf(l: f64) -> f64 {
749    // SMPTE ST 2084 PQ EOTF constants
750    const M1: f64 = 0.159_301_758_5;
751    const M2: f64 = 78.843_75;
752    const C1: f64 = 0.835_937_5;
753    const C2: f64 = 18.851_563;
754    const C3: f64 = 18.687_5;
755    let l_m1 = l.abs().powf(M1);
756    ((C1 + C2 * l_m1) / (1.0 + C3 * l_m1)).powf(M2)
757}
758
759/// BT.2100 PQ inverse transfer function: PQ-encoded → linear.
760///
761/// Input: PQ-encoded value in [0, 1].
762/// Output: linear scene luminance normalised to [0, 1] where 1 = 10 000 nits.
763#[must_use]
764pub fn pq_eotf(e: f64) -> f64 {
765    const M1: f64 = 0.159_301_758_5;
766    const M2: f64 = 78.843_75;
767    const C1: f64 = 0.835_937_5;
768    const C2: f64 = 18.851_563;
769    const C3: f64 = 18.687_5;
770    let e_m2 = e.abs().powf(1.0 / M2);
771    let num = (e_m2 - C1).max(0.0);
772    let den = C2 - C3 * e_m2;
773    (num / den).powf(1.0 / M1)
774}
775
776/// BT.2100 HLG (Hybrid Log-Gamma) transfer function: scene linear → HLG.
777///
778/// Input: normalised scene luminance in [0, 1].
779/// Output: HLG-encoded signal in [0, 1].
780#[must_use]
781pub fn hlg_oetf(l: f64) -> f64 {
782    const A: f64 = 0.178_832_77;
783    const B: f64 = 0.284_668_92;
784    const C: f64 = 0.559_910_73;
785    if l <= 1.0 / 12.0 {
786        (3.0 * l).sqrt()
787    } else {
788        A * (12.0 * l - B).ln() + C
789    }
790}
791
792/// BT.2100 HLG inverse transfer function: HLG → scene linear.
793///
794/// Input: HLG-encoded signal in [0, 1].
795/// Output: normalised scene luminance in [0, 1].
796#[must_use]
797pub fn hlg_eotf(e: f64) -> f64 {
798    const A: f64 = 0.178_832_77;
799    const B: f64 = 0.284_668_92;
800    const C: f64 = 0.559_910_73;
801    if e <= 0.5 {
802        e * e / 3.0
803    } else {
804        ((e - C) / A).exp() / 12.0 + B / 12.0
805    }
806}
807
808// =============================================================================
809// Tests: known color-conversion test vectors (Tasks 2 + 13)
810// =============================================================================
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    /// Helper: assert two u8 values are within `tol` of each other.
817    fn approx_eq(a: u8, b: u8, tol: u8, label: &str) {
818        assert!(
819            (a as i32 - b as i32).unsigned_abs() as u8 <= tol,
820            "{label}: got {a}, expected ~{b} (tol={tol})"
821        );
822    }
823
824    // ── BT.601 reference vectors ─────────────────────────────────────────────
825
826    #[test]
827    fn test_bt601_white_rgb_to_ycbcr() {
828        // White (255, 255, 255) → Y=235, Cb=128, Cr=128 (studio swing)
829        let (y, cb, cr) = bt601_rgb_to_ycbcr(255, 255, 255);
830        approx_eq(y, 235, 2, "Y for white");
831        approx_eq(cb, 128, 2, "Cb for white");
832        approx_eq(cr, 128, 2, "Cr for white");
833    }
834
835    #[test]
836    fn test_bt601_black_rgb_to_ycbcr() {
837        // Black (0, 0, 0) → Y=16, Cb=128, Cr=128 (studio swing)
838        let (y, cb, cr) = bt601_rgb_to_ycbcr(0, 0, 0);
839        approx_eq(y, 16, 2, "Y for black");
840        approx_eq(cb, 128, 2, "Cb for black");
841        approx_eq(cr, 128, 2, "Cr for black");
842    }
843
844    #[test]
845    fn test_bt601_red_rgb_to_ycbcr() {
846        // Pure red (255, 0, 0) → Y≈82, Cb≈90, Cr≈240 (per SMPTE test vectors)
847        let (y, cb, cr) = bt601_rgb_to_ycbcr(255, 0, 0);
848        approx_eq(y, 82, 3, "Y for red");
849        approx_eq(cb, 90, 4, "Cb for red");
850        approx_eq(cr, 240, 4, "Cr for red");
851    }
852
853    #[test]
854    fn test_bt601_green_rgb_to_ycbcr() {
855        // Pure green (0, 255, 0) → Y≈145, Cb≈54, Cr≈34
856        let (y, cb, cr) = bt601_rgb_to_ycbcr(0, 255, 0);
857        approx_eq(y, 145, 3, "Y for green");
858        approx_eq(cb, 54, 4, "Cb for green");
859        approx_eq(cr, 34, 4, "Cr for green");
860    }
861
862    #[test]
863    fn test_bt601_blue_rgb_to_ycbcr() {
864        // Pure blue (0, 0, 255) → Y≈41, Cb≈240, Cr≈110
865        let (y, cb, cr) = bt601_rgb_to_ycbcr(0, 0, 255);
866        approx_eq(y, 41, 3, "Y for blue");
867        approx_eq(cb, 240, 4, "Cb for blue");
868        approx_eq(cr, 110, 4, "Cr for blue");
869    }
870
871    #[test]
872    fn test_bt601_roundtrip_white() {
873        let (y, cb, cr) = bt601_rgb_to_ycbcr(255, 255, 255);
874        let (r, g, b) = bt601_ycbcr_to_rgb(y, cb, cr);
875        approx_eq(r, 255, 3, "R roundtrip white");
876        approx_eq(g, 255, 3, "G roundtrip white");
877        approx_eq(b, 255, 3, "B roundtrip white");
878    }
879
880    #[test]
881    fn test_bt601_roundtrip_black() {
882        let (y, cb, cr) = bt601_rgb_to_ycbcr(0, 0, 0);
883        let (r, g, b) = bt601_ycbcr_to_rgb(y, cb, cr);
884        approx_eq(r, 0, 3, "R roundtrip black");
885        approx_eq(g, 0, 3, "G roundtrip black");
886        approx_eq(b, 0, 3, "B roundtrip black");
887    }
888
889    #[test]
890    fn test_bt601_roundtrip_grey128() {
891        let (y, cb, cr) = bt601_rgb_to_ycbcr(128, 128, 128);
892        let (r, g, b) = bt601_ycbcr_to_rgb(y, cb, cr);
893        approx_eq(r, 128, 4, "R roundtrip grey");
894        approx_eq(g, 128, 4, "G roundtrip grey");
895        approx_eq(b, 128, 4, "B roundtrip grey");
896    }
897
898    // ── BT.709 reference vectors ─────────────────────────────────────────────
899
900    #[test]
901    fn test_bt709_white_rgb_to_ycbcr() {
902        let (y, cb, cr) = bt709_rgb_to_ycbcr(255, 255, 255);
903        approx_eq(y, 235, 2, "Y for white BT.709");
904        approx_eq(cb, 128, 2, "Cb for white BT.709");
905        approx_eq(cr, 128, 2, "Cr for white BT.709");
906    }
907
908    #[test]
909    fn test_bt709_black_rgb_to_ycbcr() {
910        let (y, cb, cr) = bt709_rgb_to_ycbcr(0, 0, 0);
911        approx_eq(y, 16, 2, "Y for black BT.709");
912        approx_eq(cb, 128, 2, "Cb for black BT.709");
913        approx_eq(cr, 128, 2, "Cr for black BT.709");
914    }
915
916    #[test]
917    fn test_bt709_red_rgb_to_ycbcr() {
918        // BT.709 red: Kr=0.2126 → Y≈63+16=63... actual: Y≈63, Cb≈102, Cr≈240
919        let (y, _cb, _cr) = bt709_rgb_to_ycbcr(255, 0, 0);
920        // Y for pure red in BT.709: 16 + 219 * 0.2126 ≈ 62.6 ≈ 63
921        approx_eq(y, 63, 3, "Y for red BT.709");
922    }
923
924    #[test]
925    fn test_bt709_roundtrip_white() {
926        let (y, cb, cr) = bt709_rgb_to_ycbcr(255, 255, 255);
927        let (r, g, b) = bt709_ycbcr_to_rgb(y, cb, cr);
928        approx_eq(r, 255, 4, "R roundtrip white BT.709");
929        approx_eq(g, 255, 4, "G roundtrip white BT.709");
930        approx_eq(b, 255, 4, "B roundtrip white BT.709");
931    }
932
933    #[test]
934    fn test_bt709_roundtrip_black() {
935        let (y, cb, cr) = bt709_rgb_to_ycbcr(0, 0, 0);
936        let (r, g, b) = bt709_ycbcr_to_rgb(y, cb, cr);
937        approx_eq(r, 0, 4, "R roundtrip black BT.709");
938        approx_eq(g, 0, 4, "G roundtrip black BT.709");
939        approx_eq(b, 0, 4, "B roundtrip black BT.709");
940    }
941
942    #[test]
943    fn test_bt709_roundtrip_colour() {
944        // Arbitrary colour roundtrip.
945        let (y, cb, cr) = bt709_rgb_to_ycbcr(100, 150, 200);
946        let (r, g, b) = bt709_ycbcr_to_rgb(y, cb, cr);
947        approx_eq(r, 100, 5, "R roundtrip colour BT.709");
948        approx_eq(g, 150, 5, "G roundtrip colour BT.709");
949        approx_eq(b, 200, 5, "B roundtrip colour BT.709");
950    }
951
952    // ── BT.2020 reference vectors ────────────────────────────────────────────
953
954    #[test]
955    fn test_bt2020_white_rgb_to_ycbcr() {
956        let (y, cb, cr) = bt2020_rgb_to_ycbcr(255, 255, 255);
957        approx_eq(y, 235, 2, "Y for white BT.2020");
958        approx_eq(cb, 128, 2, "Cb for white BT.2020");
959        approx_eq(cr, 128, 2, "Cr for white BT.2020");
960    }
961
962    #[test]
963    fn test_bt2020_black_rgb_to_ycbcr() {
964        let (y, cb, cr) = bt2020_rgb_to_ycbcr(0, 0, 0);
965        approx_eq(y, 16, 2, "Y for black BT.2020");
966        approx_eq(cb, 128, 2, "Cb for black BT.2020");
967        approx_eq(cr, 128, 2, "Cr for black BT.2020");
968    }
969
970    #[test]
971    fn test_bt2020_red_luma() {
972        // BT.2020 red: Kr = 0.2627 → Y = 16 + 219 * 0.2627 ≈ 73.6 ≈ 74
973        let (y, _, _) = bt2020_rgb_to_ycbcr(255, 0, 0);
974        approx_eq(y, 74, 3, "Y for red BT.2020");
975    }
976
977    #[test]
978    fn test_bt2020_roundtrip_white() {
979        let (y, cb, cr) = bt2020_rgb_to_ycbcr(255, 255, 255);
980        let (r, g, b) = bt2020_ycbcr_to_rgb(y, cb, cr);
981        approx_eq(r, 255, 4, "R roundtrip white BT.2020");
982        approx_eq(g, 255, 4, "G roundtrip white BT.2020");
983        approx_eq(b, 255, 4, "B roundtrip white BT.2020");
984    }
985
986    #[test]
987    fn test_bt2020_roundtrip_colour() {
988        let (y, cb, cr) = bt2020_rgb_to_ycbcr(100, 150, 200);
989        let (r, g, b) = bt2020_ycbcr_to_rgb(y, cb, cr);
990        approx_eq(r, 100, 5, "R roundtrip colour BT.2020");
991        approx_eq(g, 150, 5, "G roundtrip colour BT.2020");
992        approx_eq(b, 200, 5, "B roundtrip colour BT.2020");
993    }
994
995    // ── BT.2100 PQ / HLG transfer function tests ─────────────────────────────
996
997    #[test]
998    fn test_pq_oetf_zero() {
999        // PQ(0) = 0
1000        let v = pq_oetf(0.0);
1001        assert!(v.abs() < 1e-6, "pq_oetf(0) = {v}");
1002    }
1003
1004    #[test]
1005    fn test_pq_oetf_one() {
1006        // PQ(1.0) = 1.0 (10 000 nits maps to code 1.0)
1007        let v = pq_oetf(1.0);
1008        assert!((v - 1.0).abs() < 1e-4, "pq_oetf(1) = {v}");
1009    }
1010
1011    #[test]
1012    fn test_pq_roundtrip() {
1013        for nits_norm in [0.0, 0.01, 0.1, 0.5, 0.9, 1.0_f64] {
1014            let encoded = pq_oetf(nits_norm);
1015            let decoded = pq_eotf(encoded);
1016            assert!(
1017                (decoded - nits_norm).abs() < 1e-5,
1018                "PQ roundtrip failed at {nits_norm}: got {decoded}"
1019            );
1020        }
1021    }
1022
1023    #[test]
1024    fn test_hlg_oetf_zero() {
1025        let v = hlg_oetf(0.0);
1026        assert!(v.abs() < 1e-6, "hlg_oetf(0) = {v}");
1027    }
1028
1029    #[test]
1030    fn test_hlg_oetf_range() {
1031        // All outputs must be in [0, 1] for normalised scene linear input.
1032        for i in 0..=20 {
1033            let l = i as f64 / 20.0;
1034            let e = hlg_oetf(l);
1035            assert!((0.0..=1.0).contains(&e), "hlg_oetf({l}) = {e} out of [0,1]");
1036        }
1037    }
1038
1039    #[test]
1040    fn test_hlg_roundtrip() {
1041        for l in [0.0, 0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0_f64] {
1042            let encoded = hlg_oetf(l);
1043            let decoded = hlg_eotf(encoded);
1044            assert!(
1045                (decoded - l).abs() < 1e-6,
1046                "HLG roundtrip failed at {l}: got {decoded}"
1047            );
1048        }
1049    }
1050
1051    // ── GPU vs CPU comparison tests (Task 12) ────────────────────────────────
1052
1053    /// Verify that BT.601 full-image luma values are consistent between the
1054    /// CPU reference implementation and the per-pixel formula.
1055    #[test]
1056    fn test_bt601_cpu_vs_reference_batch() {
1057        let colours = [
1058            (255u8, 0u8, 0u8),     // red
1059            (0u8, 255u8, 0u8),     // green
1060            (0u8, 0u8, 255u8),     // blue
1061            (255u8, 255u8, 0u8),   // yellow
1062            (128u8, 128u8, 128u8), // grey
1063        ];
1064        // Expected Y values from SMPTE RP 177 / ITU-R BT.601 test vectors
1065        let expected_y: &[u8] = &[82, 145, 41, 210, 126];
1066        for (i, ((r, g, b), &ey)) in colours.iter().zip(expected_y.iter()).enumerate() {
1067            let (y, _, _) = bt601_rgb_to_ycbcr(*r, *g, *b);
1068            assert!(
1069                (y as i32 - ey as i32).unsigned_abs() <= 3,
1070                "BT.601 Y mismatch for colour {i}: got {y}, expected ~{ey}"
1071            );
1072        }
1073    }
1074
1075    /// Compare BT.709 and BT.2020 luma for the same colour: 2020 should
1076    /// give different Y values than 601 for non-grey primaries.
1077    #[test]
1078    fn test_bt2020_vs_bt601_luma_differ_for_red() {
1079        let (y601, _, _) = bt601_rgb_to_ycbcr(255, 0, 0);
1080        let (y2020, _, _) = bt2020_rgb_to_ycbcr(255, 0, 0);
1081        // BT.601: Kr=0.299, BT.2020: Kr=0.2627 — luma for pure red must differ
1082        assert_ne!(y601, y2020, "BT.601 and BT.2020 Y for red must differ");
1083    }
1084
1085    /// Verify the grey axis is luma-only: for equal RGB the colour difference
1086    /// component (Cb, Cr) should both be 128 across all standards.
1087    #[test]
1088    fn test_grey_axis_chroma_neutral_all_standards() {
1089        for v in [0u8, 64, 128, 192, 255] {
1090            let (_, cb601, cr601) = bt601_rgb_to_ycbcr(v, v, v);
1091            let (_, cb709, cr709) = bt709_rgb_to_ycbcr(v, v, v);
1092            let (_, cb2020, cr2020) = bt2020_rgb_to_ycbcr(v, v, v);
1093            approx_eq(cb601, 128, 2, &format!("Cb BT.601 grey {v}"));
1094            approx_eq(cr601, 128, 2, &format!("Cr BT.601 grey {v}"));
1095            approx_eq(cb709, 128, 2, &format!("Cb BT.709 grey {v}"));
1096            approx_eq(cr709, 128, 2, &format!("Cr BT.709 grey {v}"));
1097            approx_eq(cb2020, 128, 2, &format!("Cb BT.2020 grey {v}"));
1098            approx_eq(cr2020, 128, 2, &format!("Cr BT.2020 grey {v}"));
1099        }
1100    }
1101
1102    // ─── Task G: Additional GPU vs CPU comparison reference tests ────────────
1103
1104    /// BT.601 reference test vectors from SMPTE RP 177 / ITU-R BT.601.
1105    /// Verifies luma Y and chroma Cb/Cr against known standard values.
1106    #[test]
1107    fn test_bt601_reference_vectors() {
1108        // (R, G, B) → (Y, Cb, Cr) reference values (±2 tolerance)
1109        let cases: &[((u8, u8, u8), (u8, u8, u8))] = &[
1110            ((255, 0, 0), (82, 90, 240)),       // Red
1111            ((0, 255, 0), (145, 54, 34)),       // Green
1112            ((0, 0, 255), (41, 240, 110)),      // Blue
1113            ((255, 255, 255), (235, 128, 128)), // White
1114            ((0, 0, 0), (16, 128, 128)),        // Black
1115            ((128, 128, 128), (126, 128, 128)), // Mid-grey
1116        ];
1117        for &((r, g, b), (ey, ecb, ecr)) in cases {
1118            let (y, cb, cr) = bt601_rgb_to_ycbcr(r, g, b);
1119            approx_eq(y, ey, 3, &format!("Y  for ({r},{g},{b}) BT.601"));
1120            approx_eq(cb, ecb, 4, &format!("Cb for ({r},{g},{b}) BT.601"));
1121            approx_eq(cr, ecr, 4, &format!("Cr for ({r},{g},{b}) BT.601"));
1122        }
1123    }
1124
1125    /// BT.709 reference test vectors from ITU-R BT.709-6 Table 1.
1126    #[test]
1127    fn test_bt709_reference_vectors() {
1128        // Key reference points for BT.709
1129        let cases: &[((u8, u8, u8), (u8, u8, u8))] = &[
1130            ((255, 255, 255), (235, 128, 128)), // White
1131            ((0, 0, 0), (16, 128, 128)),        // Black
1132            ((255, 0, 0), (63, 102, 240)),      // Red (Kr=0.2126)
1133            ((0, 255, 0), (173, 42, 26)),       // Green (Kg=0.7152)
1134            ((0, 0, 255), (32, 240, 118)),      // Blue (Kb=0.0722)
1135        ];
1136        for &((r, g, b), (ey, ecb, ecr)) in cases {
1137            let (y, cb, cr) = bt709_rgb_to_ycbcr(r, g, b);
1138            approx_eq(y, ey, 4, &format!("Y  for ({r},{g},{b}) BT.709"));
1139            approx_eq(cb, ecb, 5, &format!("Cb for ({r},{g},{b}) BT.709"));
1140            approx_eq(cr, ecr, 5, &format!("Cr for ({r},{g},{b}) BT.709"));
1141        }
1142    }
1143
1144    /// Verify that BT.601 and BT.709 give different Y for the same colour.
1145    /// The two standards use different luma coefficients (Kr, Kg, Kb).
1146    #[test]
1147    fn test_bt601_vs_bt709_differ_for_primaries() {
1148        let test_colours = [(255u8, 0, 0), (0, 255, 0), (0, 0, 255)];
1149        for (r, g, b) in test_colours {
1150            let (y601, _, _) = bt601_rgb_to_ycbcr(r, g, b);
1151            let (y709, _, _) = bt709_rgb_to_ycbcr(r, g, b);
1152            assert_ne!(
1153                y601, y709,
1154                "BT.601 and BT.709 Y should differ for ({r},{g},{b})"
1155            );
1156        }
1157    }
1158
1159    /// CPU↔CPU path consistency: calling bt601 twice on same input gives same result.
1160    #[test]
1161    fn test_bt601_deterministic() {
1162        let (y1, cb1, cr1) = bt601_rgb_to_ycbcr(100, 150, 200);
1163        let (y2, cb2, cr2) = bt601_rgb_to_ycbcr(100, 150, 200);
1164        assert_eq!(y1, y2);
1165        assert_eq!(cb1, cb2);
1166        assert_eq!(cr1, cr2);
1167    }
1168
1169    /// Round-trip BT.709 for a batch of arbitrary colours; max drift ≤ 5.
1170    #[test]
1171    fn test_bt709_batch_roundtrip_within_tolerance() {
1172        let colours = [
1173            (10u8, 20u8, 30u8),
1174            (200, 100, 50),
1175            (64, 128, 192),
1176            (0, 255, 128),
1177            (255, 128, 0),
1178            (77, 77, 77),
1179        ];
1180        for (r, g, b) in colours {
1181            let (y, cb, cr) = bt709_rgb_to_ycbcr(r, g, b);
1182            let (ro, go, bo) = bt709_ycbcr_to_rgb(y, cb, cr);
1183            let dr = (r as i32 - ro as i32).unsigned_abs();
1184            let dg = (g as i32 - go as i32).unsigned_abs();
1185            let db = (b as i32 - bo as i32).unsigned_abs();
1186            assert!(
1187                dr <= 5 && dg <= 5 && db <= 5,
1188                "BT.709 roundtrip ({r},{g},{b}) → ({ro},{go},{bo}): diff=({dr},{dg},{db})"
1189            );
1190        }
1191    }
1192
1193    // ─── HSV conversion tests ────────────────────────────────────────────────
1194
1195    /// Helper: build a single RGBA pixel as a 4-element array.
1196    fn rgba_pixel(r: u8, g: u8, b: u8) -> Vec<u8> {
1197        vec![r, g, b, 255u8]
1198    }
1199
1200    /// Pure red (255, 0, 0) in HSV should give H≈0, S≈255, V≈255.
1201    #[test]
1202    fn test_rgb_to_hsv_red() {
1203        let data = rgba_pixel(255, 0, 0);
1204        let out = ColorSpaceConversion::rgb_to_hsv(&data, 1, 1);
1205        // H encoded: 0/360*255 = 0
1206        assert!(out[0] <= 2, "H for pure red should be ~0, got {}", out[0]);
1207        // S = 255
1208        let diff_s = (out[1] as i32 - 255).unsigned_abs();
1209        assert!(diff_s <= 2, "S for pure red should be ~255, got {}", out[1]);
1210        // V = 255
1211        let diff_v = (out[2] as i32 - 255).unsigned_abs();
1212        assert!(diff_v <= 2, "V for pure red should be ~255, got {}", out[2]);
1213        // Alpha pass-through
1214        assert_eq!(out[3], 255);
1215    }
1216
1217    /// Round-trip: RGB → HSV → RGB should be within ±2 per channel.
1218    #[test]
1219    fn test_hsv_round_trip() {
1220        let test_colours: &[(u8, u8, u8)] = &[
1221            (255, 0, 0),
1222            (0, 255, 0),
1223            (0, 0, 255),
1224            (128, 64, 192),
1225            (200, 150, 100),
1226        ];
1227        for &(r, g, b) in test_colours {
1228            let data = rgba_pixel(r, g, b);
1229            let hsv = ColorSpaceConversion::rgb_to_hsv(&data, 1, 1);
1230            let rgb = ColorSpaceConversion::hsv_to_rgb(&hsv, 1, 1);
1231            let dr = (r as i32 - rgb[0] as i32).unsigned_abs();
1232            let dg = (g as i32 - rgb[1] as i32).unsigned_abs();
1233            let db = (b as i32 - rgb[2] as i32).unsigned_abs();
1234            assert!(
1235                dr <= 2 && dg <= 2 && db <= 2,
1236                "HSV round-trip ({r},{g},{b}) → ({},{},{}) diff=({dr},{dg},{db})",
1237                rgb[0],
1238                rgb[1],
1239                rgb[2]
1240            );
1241        }
1242    }
1243
1244    // ─── Lab conversion tests ────────────────────────────────────────────────
1245
1246    /// Near-grey (127,127,127) → L≈50, a≈0, b≈0.
1247    ///
1248    /// Encoding: L byte = L*255/100, a byte = a+128, b byte = b+128.
1249    #[test]
1250    fn test_rgb_to_lab_gray() {
1251        let data = rgba_pixel(127, 127, 127);
1252        let out = ColorSpaceConversion::rgb_to_lab(&data, 1, 1);
1253
1254        // L* ≈ 50 for mid-grey → encoded as 50*255/100 ≈ 127
1255        let l_decoded = f64::from(out[0]) * 100.0 / 255.0;
1256        assert!(
1257            (l_decoded - 50.0).abs() < 4.0,
1258            "L* for mid-grey should be ~50, got {l_decoded:.2}"
1259        );
1260
1261        // a* ≈ 0 → encoded as ≈128
1262        let a_decoded = f64::from(out[1]) - 128.0;
1263        assert!(
1264            a_decoded.abs() < 4.0,
1265            "a* for grey should be ~0, got {a_decoded:.2}"
1266        );
1267
1268        // b* ≈ 0 → encoded as ≈128
1269        let b_decoded = f64::from(out[2]) - 128.0;
1270        assert!(
1271            b_decoded.abs() < 4.0,
1272            "b* for grey should be ~0, got {b_decoded:.2}"
1273        );
1274    }
1275
1276    // ─── sRGB ↔ Linear round-trip tests ─────────────────────────────────────
1277
1278    /// Convert 10 representative values through sRGB→Linear→sRGB within 0.01.
1279    #[test]
1280    fn test_srgb_linear_round_trip() {
1281        let test_values: &[u8] = &[0, 10, 30, 64, 100, 128, 180, 200, 230, 255];
1282        for &v in test_values {
1283            let data = vec![v, v, v, 255u8];
1284            // sRGB → Linear
1285            let linear = ColorSpaceConversion::srgb_to_linear(&data, 1, 1);
1286            // Linear → sRGB
1287            let recovered = ColorSpaceConversion::linear_to_srgb(&linear, 1, 1);
1288            let diff = (v as i32 - recovered[0] as i32).unsigned_abs();
1289            assert!(
1290                diff <= 3,
1291                "sRGB↔Linear round-trip failed for v={v}: recovered={}, diff={diff}",
1292                recovered[0]
1293            );
1294        }
1295    }
1296
1297    /// Verify that `srgb_to_linear` monotonically increases.
1298    #[test]
1299    fn test_srgb_to_linear_monotone() {
1300        let mut prev = 0u8;
1301        for v in 1u8..=255 {
1302            let data = vec![v, v, v, 255u8];
1303            let lin = ColorSpaceConversion::srgb_to_linear(&data, 1, 1);
1304            assert!(
1305                lin[0] >= prev,
1306                "sRGB→Linear not monotone at v={v}: prev={prev}, got={}",
1307                lin[0]
1308            );
1309            prev = lin[0];
1310        }
1311    }
1312}