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 reference color conversions (BT.601, BT.709, BT.2020, BT.2100)
301// =============================================================================
302
303/// BT.601 RGB → YCbCr (studio swing: Y ∈ \[16,235\], Cb/Cr ∈ \[16,240\]).
304///
305/// Input: linear RGB in [0, 255].
306/// Output: (Y, Cb, Cr) in [0, 255] (offset and scaled per ITU-R BT.601).
307#[must_use]
308pub fn bt601_rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
309    let r = f64::from(r);
310    let g = f64::from(g);
311    let b = f64::from(b);
312    let y = 16.0 + (65.481 * r + 128.553 * g + 24.966 * b) / 255.0;
313    let cb = 128.0 + (-37.797 * r - 74.203 * g + 112.0 * b) / 255.0;
314    let cr = 128.0 + (112.0 * r - 93.786 * g - 18.214 * b) / 255.0;
315    (
316        y.round().clamp(0.0, 255.0) as u8,
317        cb.round().clamp(0.0, 255.0) as u8,
318        cr.round().clamp(0.0, 255.0) as u8,
319    )
320}
321
322/// BT.601 YCbCr → RGB (studio swing: Y ∈ \[16,235\], Cb/Cr ∈ \[16,240\]).
323///
324/// Output: linear RGB in [0, 255].
325#[must_use]
326pub fn bt601_ycbcr_to_rgb(y: u8, cb: u8, cr: u8) -> (u8, u8, u8) {
327    let y = f64::from(y) - 16.0;
328    let cb = f64::from(cb) - 128.0;
329    let cr = f64::from(cr) - 128.0;
330    let r = 255.0 * (1.164 * y + 1.596 * cr) / 255.0;
331    let g = 255.0 * (1.164 * y - 0.392 * cb - 0.813 * cr) / 255.0;
332    let b = 255.0 * (1.164 * y + 2.017 * cb) / 255.0;
333    (
334        r.round().clamp(0.0, 255.0) as u8,
335        g.round().clamp(0.0, 255.0) as u8,
336        b.round().clamp(0.0, 255.0) as u8,
337    )
338}
339
340/// BT.709 RGB → YCbCr (studio swing: Y ∈ \[16,235\], Cb/Cr ∈ \[16,240\]).
341///
342/// Input: linear RGB in [0, 255].
343/// Output: (Y, Cb, Cr) in [0, 255].
344#[must_use]
345pub fn bt709_rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
346    // BT.709 direct matrix form (ITU-R BT.709 Table B.3), studio swing.
347    // Kr = 0.2126, Kb = 0.0722, Kg = 1 - Kr - Kb = 0.7152
348    let r_n = f64::from(r) / 255.0;
349    let g_n = f64::from(g) / 255.0;
350    let b_n = f64::from(b) / 255.0;
351    let y = 16.0 + 219.0 * (0.2126 * r_n + 0.7152 * g_n + 0.0722 * b_n);
352    let cb = 128.0 + 224.0 * (-0.2126 / 1.8556 * r_n - 0.7152 / 1.8556 * g_n + 0.5 * b_n);
353    let cr = 128.0 + 224.0 * (0.5 * r_n - 0.7152 / 1.5748 * g_n - 0.0722 / 1.5748 * b_n);
354    (
355        y.round().clamp(0.0, 255.0) as u8,
356        cb.round().clamp(0.0, 255.0) as u8,
357        cr.round().clamp(0.0, 255.0) as u8,
358    )
359}
360
361/// BT.709 YCbCr → RGB (studio swing).
362///
363/// Output: linear RGB in [0, 255].
364#[must_use]
365pub fn bt709_ycbcr_to_rgb(y: u8, cb: u8, cr: u8) -> (u8, u8, u8) {
366    let y_n = (f64::from(y) - 16.0) / 219.0;
367    let cb_n = (f64::from(cb) - 128.0) / 224.0;
368    let cr_n = (f64::from(cr) - 128.0) / 224.0;
369    let r = y_n + 1.5748 * cr_n;
370    let g = y_n - 0.2126 / 0.7152 * 1.5748 * cr_n - 0.0722 / 0.7152 * 1.8556 * cb_n;
371    let b = y_n + 1.8556 * cb_n;
372    (
373        (r * 255.0).round().clamp(0.0, 255.0) as u8,
374        (g * 255.0).round().clamp(0.0, 255.0) as u8,
375        (b * 255.0).round().clamp(0.0, 255.0) as u8,
376    )
377}
378
379/// BT.2020 RGB → YCbCr (studio swing).
380///
381/// BT.2020 uses primaries for Ultra HD (UHD) content.
382/// Coefficients: Kr = 0.2627, Kb = 0.0593, Kg = 0.6780.
383///
384/// Input: linear RGB in [0, 255].
385/// Output: (Y, Cb, Cr) in [0, 255] studio swing.
386#[must_use]
387pub fn bt2020_rgb_to_ycbcr(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
388    let r_n = f64::from(r) / 255.0;
389    let g_n = f64::from(g) / 255.0;
390    let b_n = f64::from(b) / 255.0;
391    // BT.2020 luma coefficients (Kr, Kg, Kb)
392    let kr = 0.2627_f64;
393    let kb = 0.0593_f64;
394    let kg = 1.0 - kr - kb; // 0.6780
395    let y = 16.0 + 219.0 * (kr * r_n + kg * g_n + kb * b_n);
396    let cb = 128.0
397        + 224.0 * ((-kr / (2.0 * (1.0 - kb))) * r_n + (-kg / (2.0 * (1.0 - kb))) * g_n + 0.5 * b_n);
398    let cr = 128.0
399        + 224.0 * (0.5 * r_n + (-kg / (2.0 * (1.0 - kr))) * g_n + (-kb / (2.0 * (1.0 - kr))) * b_n);
400    (
401        y.round().clamp(0.0, 255.0) as u8,
402        cb.round().clamp(0.0, 255.0) as u8,
403        cr.round().clamp(0.0, 255.0) as u8,
404    )
405}
406
407/// BT.2020 YCbCr → RGB (studio swing).
408///
409/// Output: linear RGB in [0, 255].
410#[must_use]
411pub fn bt2020_ycbcr_to_rgb(y: u8, cb: u8, cr: u8) -> (u8, u8, u8) {
412    let y_n = (f64::from(y) - 16.0) / 219.0;
413    let cb_n = (f64::from(cb) - 128.0) / 224.0;
414    let cr_n = (f64::from(cr) - 128.0) / 224.0;
415    // BT.2020 inverse matrix
416    let kr = 0.2627_f64;
417    let kb = 0.0593_f64;
418    let kg = 1.0 - kr - kb;
419    let r_cr = 2.0 * (1.0 - kr); // 1.4746
420    let b_cb = 2.0 * (1.0 - kb); // 1.8814
421    let g_cr = -2.0 * kr * (1.0 - kr) / kg;
422    let g_cb = -2.0 * kb * (1.0 - kb) / kg;
423    let r = y_n + r_cr * cr_n;
424    let g = y_n + g_cr * cr_n + g_cb * cb_n;
425    let b = y_n + b_cb * cb_n;
426    (
427        (r * 255.0).round().clamp(0.0, 255.0) as u8,
428        (g * 255.0).round().clamp(0.0, 255.0) as u8,
429        (b * 255.0).round().clamp(0.0, 255.0) as u8,
430    )
431}
432
433/// BT.2100 PQ (Perceptual Quantizer) transfer function: linear → PQ-encoded.
434///
435/// Input: linear scene luminance normalised to [0, 1] where 1 = 10 000 nits.
436/// Output: PQ-encoded value in [0, 1].
437#[must_use]
438pub fn pq_oetf(l: f64) -> f64 {
439    // SMPTE ST 2084 PQ EOTF constants
440    const M1: f64 = 0.159_301_758_5;
441    const M2: f64 = 78.843_75;
442    const C1: f64 = 0.835_937_5;
443    const C2: f64 = 18.851_563;
444    const C3: f64 = 18.687_5;
445    let l_m1 = l.abs().powf(M1);
446    ((C1 + C2 * l_m1) / (1.0 + C3 * l_m1)).powf(M2)
447}
448
449/// BT.2100 PQ inverse transfer function: PQ-encoded → linear.
450///
451/// Input: PQ-encoded value in [0, 1].
452/// Output: linear scene luminance normalised to [0, 1] where 1 = 10 000 nits.
453#[must_use]
454pub fn pq_eotf(e: f64) -> f64 {
455    const M1: f64 = 0.159_301_758_5;
456    const M2: f64 = 78.843_75;
457    const C1: f64 = 0.835_937_5;
458    const C2: f64 = 18.851_563;
459    const C3: f64 = 18.687_5;
460    let e_m2 = e.abs().powf(1.0 / M2);
461    let num = (e_m2 - C1).max(0.0);
462    let den = C2 - C3 * e_m2;
463    (num / den).powf(1.0 / M1)
464}
465
466/// BT.2100 HLG (Hybrid Log-Gamma) transfer function: scene linear → HLG.
467///
468/// Input: normalised scene luminance in [0, 1].
469/// Output: HLG-encoded signal in [0, 1].
470#[must_use]
471pub fn hlg_oetf(l: f64) -> f64 {
472    const A: f64 = 0.178_832_77;
473    const B: f64 = 0.284_668_92;
474    const C: f64 = 0.559_910_73;
475    if l <= 1.0 / 12.0 {
476        (3.0 * l).sqrt()
477    } else {
478        A * (12.0 * l - B).ln() + C
479    }
480}
481
482/// BT.2100 HLG inverse transfer function: HLG → scene linear.
483///
484/// Input: HLG-encoded signal in [0, 1].
485/// Output: normalised scene luminance in [0, 1].
486#[must_use]
487pub fn hlg_eotf(e: f64) -> f64 {
488    const A: f64 = 0.178_832_77;
489    const B: f64 = 0.284_668_92;
490    const C: f64 = 0.559_910_73;
491    if e <= 0.5 {
492        e * e / 3.0
493    } else {
494        ((e - C) / A).exp() / 12.0 + B / 12.0
495    }
496}
497
498// =============================================================================
499// Tests: known color-conversion test vectors (Tasks 2 + 13)
500// =============================================================================
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    /// Helper: assert two u8 values are within `tol` of each other.
507    fn approx_eq(a: u8, b: u8, tol: u8, label: &str) {
508        assert!(
509            (a as i32 - b as i32).unsigned_abs() as u8 <= tol,
510            "{label}: got {a}, expected ~{b} (tol={tol})"
511        );
512    }
513
514    // ── BT.601 reference vectors ─────────────────────────────────────────────
515
516    #[test]
517    fn test_bt601_white_rgb_to_ycbcr() {
518        // White (255, 255, 255) → Y=235, Cb=128, Cr=128 (studio swing)
519        let (y, cb, cr) = bt601_rgb_to_ycbcr(255, 255, 255);
520        approx_eq(y, 235, 2, "Y for white");
521        approx_eq(cb, 128, 2, "Cb for white");
522        approx_eq(cr, 128, 2, "Cr for white");
523    }
524
525    #[test]
526    fn test_bt601_black_rgb_to_ycbcr() {
527        // Black (0, 0, 0) → Y=16, Cb=128, Cr=128 (studio swing)
528        let (y, cb, cr) = bt601_rgb_to_ycbcr(0, 0, 0);
529        approx_eq(y, 16, 2, "Y for black");
530        approx_eq(cb, 128, 2, "Cb for black");
531        approx_eq(cr, 128, 2, "Cr for black");
532    }
533
534    #[test]
535    fn test_bt601_red_rgb_to_ycbcr() {
536        // Pure red (255, 0, 0) → Y≈82, Cb≈90, Cr≈240 (per SMPTE test vectors)
537        let (y, cb, cr) = bt601_rgb_to_ycbcr(255, 0, 0);
538        approx_eq(y, 82, 3, "Y for red");
539        approx_eq(cb, 90, 4, "Cb for red");
540        approx_eq(cr, 240, 4, "Cr for red");
541    }
542
543    #[test]
544    fn test_bt601_green_rgb_to_ycbcr() {
545        // Pure green (0, 255, 0) → Y≈145, Cb≈54, Cr≈34
546        let (y, cb, cr) = bt601_rgb_to_ycbcr(0, 255, 0);
547        approx_eq(y, 145, 3, "Y for green");
548        approx_eq(cb, 54, 4, "Cb for green");
549        approx_eq(cr, 34, 4, "Cr for green");
550    }
551
552    #[test]
553    fn test_bt601_blue_rgb_to_ycbcr() {
554        // Pure blue (0, 0, 255) → Y≈41, Cb≈240, Cr≈110
555        let (y, cb, cr) = bt601_rgb_to_ycbcr(0, 0, 255);
556        approx_eq(y, 41, 3, "Y for blue");
557        approx_eq(cb, 240, 4, "Cb for blue");
558        approx_eq(cr, 110, 4, "Cr for blue");
559    }
560
561    #[test]
562    fn test_bt601_roundtrip_white() {
563        let (y, cb, cr) = bt601_rgb_to_ycbcr(255, 255, 255);
564        let (r, g, b) = bt601_ycbcr_to_rgb(y, cb, cr);
565        approx_eq(r, 255, 3, "R roundtrip white");
566        approx_eq(g, 255, 3, "G roundtrip white");
567        approx_eq(b, 255, 3, "B roundtrip white");
568    }
569
570    #[test]
571    fn test_bt601_roundtrip_black() {
572        let (y, cb, cr) = bt601_rgb_to_ycbcr(0, 0, 0);
573        let (r, g, b) = bt601_ycbcr_to_rgb(y, cb, cr);
574        approx_eq(r, 0, 3, "R roundtrip black");
575        approx_eq(g, 0, 3, "G roundtrip black");
576        approx_eq(b, 0, 3, "B roundtrip black");
577    }
578
579    #[test]
580    fn test_bt601_roundtrip_grey128() {
581        let (y, cb, cr) = bt601_rgb_to_ycbcr(128, 128, 128);
582        let (r, g, b) = bt601_ycbcr_to_rgb(y, cb, cr);
583        approx_eq(r, 128, 4, "R roundtrip grey");
584        approx_eq(g, 128, 4, "G roundtrip grey");
585        approx_eq(b, 128, 4, "B roundtrip grey");
586    }
587
588    // ── BT.709 reference vectors ─────────────────────────────────────────────
589
590    #[test]
591    fn test_bt709_white_rgb_to_ycbcr() {
592        let (y, cb, cr) = bt709_rgb_to_ycbcr(255, 255, 255);
593        approx_eq(y, 235, 2, "Y for white BT.709");
594        approx_eq(cb, 128, 2, "Cb for white BT.709");
595        approx_eq(cr, 128, 2, "Cr for white BT.709");
596    }
597
598    #[test]
599    fn test_bt709_black_rgb_to_ycbcr() {
600        let (y, cb, cr) = bt709_rgb_to_ycbcr(0, 0, 0);
601        approx_eq(y, 16, 2, "Y for black BT.709");
602        approx_eq(cb, 128, 2, "Cb for black BT.709");
603        approx_eq(cr, 128, 2, "Cr for black BT.709");
604    }
605
606    #[test]
607    fn test_bt709_red_rgb_to_ycbcr() {
608        // BT.709 red: Kr=0.2126 → Y≈63+16=63... actual: Y≈63, Cb≈102, Cr≈240
609        let (y, _cb, _cr) = bt709_rgb_to_ycbcr(255, 0, 0);
610        // Y for pure red in BT.709: 16 + 219 * 0.2126 ≈ 62.6 ≈ 63
611        approx_eq(y, 63, 3, "Y for red BT.709");
612    }
613
614    #[test]
615    fn test_bt709_roundtrip_white() {
616        let (y, cb, cr) = bt709_rgb_to_ycbcr(255, 255, 255);
617        let (r, g, b) = bt709_ycbcr_to_rgb(y, cb, cr);
618        approx_eq(r, 255, 4, "R roundtrip white BT.709");
619        approx_eq(g, 255, 4, "G roundtrip white BT.709");
620        approx_eq(b, 255, 4, "B roundtrip white BT.709");
621    }
622
623    #[test]
624    fn test_bt709_roundtrip_black() {
625        let (y, cb, cr) = bt709_rgb_to_ycbcr(0, 0, 0);
626        let (r, g, b) = bt709_ycbcr_to_rgb(y, cb, cr);
627        approx_eq(r, 0, 4, "R roundtrip black BT.709");
628        approx_eq(g, 0, 4, "G roundtrip black BT.709");
629        approx_eq(b, 0, 4, "B roundtrip black BT.709");
630    }
631
632    #[test]
633    fn test_bt709_roundtrip_colour() {
634        // Arbitrary colour roundtrip.
635        let (y, cb, cr) = bt709_rgb_to_ycbcr(100, 150, 200);
636        let (r, g, b) = bt709_ycbcr_to_rgb(y, cb, cr);
637        approx_eq(r, 100, 5, "R roundtrip colour BT.709");
638        approx_eq(g, 150, 5, "G roundtrip colour BT.709");
639        approx_eq(b, 200, 5, "B roundtrip colour BT.709");
640    }
641
642    // ── BT.2020 reference vectors ────────────────────────────────────────────
643
644    #[test]
645    fn test_bt2020_white_rgb_to_ycbcr() {
646        let (y, cb, cr) = bt2020_rgb_to_ycbcr(255, 255, 255);
647        approx_eq(y, 235, 2, "Y for white BT.2020");
648        approx_eq(cb, 128, 2, "Cb for white BT.2020");
649        approx_eq(cr, 128, 2, "Cr for white BT.2020");
650    }
651
652    #[test]
653    fn test_bt2020_black_rgb_to_ycbcr() {
654        let (y, cb, cr) = bt2020_rgb_to_ycbcr(0, 0, 0);
655        approx_eq(y, 16, 2, "Y for black BT.2020");
656        approx_eq(cb, 128, 2, "Cb for black BT.2020");
657        approx_eq(cr, 128, 2, "Cr for black BT.2020");
658    }
659
660    #[test]
661    fn test_bt2020_red_luma() {
662        // BT.2020 red: Kr = 0.2627 → Y = 16 + 219 * 0.2627 ≈ 73.6 ≈ 74
663        let (y, _, _) = bt2020_rgb_to_ycbcr(255, 0, 0);
664        approx_eq(y, 74, 3, "Y for red BT.2020");
665    }
666
667    #[test]
668    fn test_bt2020_roundtrip_white() {
669        let (y, cb, cr) = bt2020_rgb_to_ycbcr(255, 255, 255);
670        let (r, g, b) = bt2020_ycbcr_to_rgb(y, cb, cr);
671        approx_eq(r, 255, 4, "R roundtrip white BT.2020");
672        approx_eq(g, 255, 4, "G roundtrip white BT.2020");
673        approx_eq(b, 255, 4, "B roundtrip white BT.2020");
674    }
675
676    #[test]
677    fn test_bt2020_roundtrip_colour() {
678        let (y, cb, cr) = bt2020_rgb_to_ycbcr(100, 150, 200);
679        let (r, g, b) = bt2020_ycbcr_to_rgb(y, cb, cr);
680        approx_eq(r, 100, 5, "R roundtrip colour BT.2020");
681        approx_eq(g, 150, 5, "G roundtrip colour BT.2020");
682        approx_eq(b, 200, 5, "B roundtrip colour BT.2020");
683    }
684
685    // ── BT.2100 PQ / HLG transfer function tests ─────────────────────────────
686
687    #[test]
688    fn test_pq_oetf_zero() {
689        // PQ(0) = 0
690        let v = pq_oetf(0.0);
691        assert!(v.abs() < 1e-6, "pq_oetf(0) = {v}");
692    }
693
694    #[test]
695    fn test_pq_oetf_one() {
696        // PQ(1.0) = 1.0 (10 000 nits maps to code 1.0)
697        let v = pq_oetf(1.0);
698        assert!((v - 1.0).abs() < 1e-4, "pq_oetf(1) = {v}");
699    }
700
701    #[test]
702    fn test_pq_roundtrip() {
703        for nits_norm in [0.0, 0.01, 0.1, 0.5, 0.9, 1.0_f64] {
704            let encoded = pq_oetf(nits_norm);
705            let decoded = pq_eotf(encoded);
706            assert!(
707                (decoded - nits_norm).abs() < 1e-5,
708                "PQ roundtrip failed at {nits_norm}: got {decoded}"
709            );
710        }
711    }
712
713    #[test]
714    fn test_hlg_oetf_zero() {
715        let v = hlg_oetf(0.0);
716        assert!(v.abs() < 1e-6, "hlg_oetf(0) = {v}");
717    }
718
719    #[test]
720    fn test_hlg_oetf_range() {
721        // All outputs must be in [0, 1] for normalised scene linear input.
722        for i in 0..=20 {
723            let l = i as f64 / 20.0;
724            let e = hlg_oetf(l);
725            assert!((0.0..=1.0).contains(&e), "hlg_oetf({l}) = {e} out of [0,1]");
726        }
727    }
728
729    #[test]
730    fn test_hlg_roundtrip() {
731        for l in [0.0, 0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0_f64] {
732            let encoded = hlg_oetf(l);
733            let decoded = hlg_eotf(encoded);
734            assert!(
735                (decoded - l).abs() < 1e-6,
736                "HLG roundtrip failed at {l}: got {decoded}"
737            );
738        }
739    }
740
741    // ── GPU vs CPU comparison tests (Task 12) ────────────────────────────────
742
743    /// Verify that BT.601 full-image luma values are consistent between the
744    /// CPU reference implementation and the per-pixel formula.
745    #[test]
746    fn test_bt601_cpu_vs_reference_batch() {
747        let colours = [
748            (255u8, 0u8, 0u8),     // red
749            (0u8, 255u8, 0u8),     // green
750            (0u8, 0u8, 255u8),     // blue
751            (255u8, 255u8, 0u8),   // yellow
752            (128u8, 128u8, 128u8), // grey
753        ];
754        // Expected Y values from SMPTE RP 177 / ITU-R BT.601 test vectors
755        let expected_y: &[u8] = &[82, 145, 41, 210, 126];
756        for (i, ((r, g, b), &ey)) in colours.iter().zip(expected_y.iter()).enumerate() {
757            let (y, _, _) = bt601_rgb_to_ycbcr(*r, *g, *b);
758            assert!(
759                (y as i32 - ey as i32).unsigned_abs() <= 3,
760                "BT.601 Y mismatch for colour {i}: got {y}, expected ~{ey}"
761            );
762        }
763    }
764
765    /// Compare BT.709 and BT.2020 luma for the same colour: 2020 should
766    /// give different Y values than 601 for non-grey primaries.
767    #[test]
768    fn test_bt2020_vs_bt601_luma_differ_for_red() {
769        let (y601, _, _) = bt601_rgb_to_ycbcr(255, 0, 0);
770        let (y2020, _, _) = bt2020_rgb_to_ycbcr(255, 0, 0);
771        // BT.601: Kr=0.299, BT.2020: Kr=0.2627 — luma for pure red must differ
772        assert_ne!(y601, y2020, "BT.601 and BT.2020 Y for red must differ");
773    }
774
775    /// Verify the grey axis is luma-only: for equal RGB the colour difference
776    /// component (Cb, Cr) should both be 128 across all standards.
777    #[test]
778    fn test_grey_axis_chroma_neutral_all_standards() {
779        for v in [0u8, 64, 128, 192, 255] {
780            let (_, cb601, cr601) = bt601_rgb_to_ycbcr(v, v, v);
781            let (_, cb709, cr709) = bt709_rgb_to_ycbcr(v, v, v);
782            let (_, cb2020, cr2020) = bt2020_rgb_to_ycbcr(v, v, v);
783            approx_eq(cb601, 128, 2, &format!("Cb BT.601 grey {v}"));
784            approx_eq(cr601, 128, 2, &format!("Cr BT.601 grey {v}"));
785            approx_eq(cb709, 128, 2, &format!("Cb BT.709 grey {v}"));
786            approx_eq(cr709, 128, 2, &format!("Cr BT.709 grey {v}"));
787            approx_eq(cb2020, 128, 2, &format!("Cb BT.2020 grey {v}"));
788            approx_eq(cr2020, 128, 2, &format!("Cr BT.2020 grey {v}"));
789        }
790    }
791}