Skip to main content

alpha2_render/
color.rs

1use alpha2_media::{ColorMetadata, ColorPrimaries, TransferFunction};
2
3use crate::hdr::{HdrToneMap, gamut_matrix, hdr_to_working};
4
5/// A normalized YUV sample and straight alpha value.
6#[derive(Copy, Clone, Debug, PartialEq)]
7pub struct YuvSample {
8    /// Luma code normalized to the storage maximum.
9    pub y: f32,
10    /// Blue-difference chroma code normalized to the storage maximum.
11    pub u: f32,
12    /// Red-difference chroma code normalized to the storage maximum.
13    pub v: f32,
14    /// Straight alpha.
15    pub alpha: f32,
16}
17
18/// SDR transfer used when encoding a render result.
19#[derive(Copy, Clone, Debug, Eq, PartialEq)]
20pub enum SdrOutputTransfer {
21    /// IEC sRGB.
22    Srgb,
23    /// BT.709 OETF.
24    Bt709,
25}
26
27/// What happens to an HDR (PQ or HLG) source.
28#[derive(Copy, Clone, Debug, Eq, PartialEq)]
29pub enum ColorPolicy {
30    /// Fail the conversion with [`crate::RenderError::UnsupportedHdr`].
31    RejectHdr,
32    /// Return a structured warning and a transparent picture.
33    WarnHdr,
34    /// Tone-map into the SDR working space ([`crate::hdr`]).
35    ToneMapHdr(HdrToneMap),
36}
37
38/// Whether `color` is an HDR signal: PQ or HLG, or declared HDR.
39#[must_use]
40pub const fn is_hdr(color: ColorMetadata) -> bool {
41    color.is_hdr()
42}
43
44/// Observable color-pipeline diagnostic.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub enum ColorDiagnostic {
47    /// No metadata was supplied and BT.709 limited SDR was selected.
48    MissingMetadataFallback,
49    /// An HDR source was refused or skipped by the policy.
50    UnsupportedHdr,
51    /// An HDR source was tone-mapped into the SDR working space.
52    ToneMappedHdr,
53}
54
55/// Color conversion failure.
56#[derive(Clone, Debug, Eq, PartialEq)]
57#[non_exhaustive]
58pub enum ColorError {
59    /// Bit depth is outside the supported 8-to-16-bit integer range.
60    InvalidBitDepth,
61    /// Sample contains NaN, infinity, or invalid alpha.
62    InvalidSample,
63    /// HDR was rejected by policy.
64    UnsupportedHdr,
65}
66
67impl std::fmt::Display for ColorError {
68    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        formatter.write_str(match self {
70            Self::InvalidBitDepth => "color bit depth must be within 8..=16",
71            Self::InvalidSample => "color sample is non-finite or out of range",
72            Self::UnsupportedHdr => "HDR input is unsupported by the selected policy",
73        })
74    }
75}
76
77impl std::error::Error for ColorError {}
78
79/// Converts one integer-code-normalized YUV sample to premultiplied linear RGB.
80///
81/// # Errors
82/// Returns an error for malformed samples, unsupported bit depth, or rejected HDR.
83pub fn convert_yuv_to_linear_rgba(
84    sample: YuvSample,
85    bit_depth: u8,
86    metadata: Option<ColorMetadata>,
87    policy: ColorPolicy,
88) -> Result<([f32; 4], Vec<ColorDiagnostic>), ColorError> {
89    if !(8..=16).contains(&bit_depth) {
90        return Err(ColorError::InvalidBitDepth);
91    }
92    if ![sample.y, sample.u, sample.v, sample.alpha]
93        .into_iter()
94        .all(f32::is_finite)
95        || !(0.0..=1.0).contains(&sample.alpha)
96    {
97        return Err(ColorError::InvalidSample);
98    }
99    let mut diagnostics = Vec::new();
100    let color = metadata.unwrap_or_else(|| {
101        diagnostics.push(ColorDiagnostic::MissingMetadataFallback);
102        ColorMetadata::UNDECLARED
103    });
104    let tone = if is_hdr(color) {
105        match policy {
106            ColorPolicy::RejectHdr => {
107                diagnostics.push(ColorDiagnostic::UnsupportedHdr);
108                return Err(ColorError::UnsupportedHdr);
109            }
110            ColorPolicy::WarnHdr => {
111                diagnostics.push(ColorDiagnostic::UnsupportedHdr);
112                return Ok(([0.0, 0.0, 0.0, 0.0], diagnostics));
113            }
114            ColorPolicy::ToneMapHdr(tone) => {
115                diagnostics.push(ColorDiagnostic::ToneMappedHdr);
116                Some(tone)
117            }
118        }
119    } else {
120        None
121    };
122
123    let encoded = if let Some((kr, kb)) = color.matrix.luma_weights() {
124        let levels = color.range.levels(bit_depth);
125        let (y, cb, cr) = (
126            (sample.y * levels.maximum - levels.luma_black) / levels.luma_span,
127            (sample.u * levels.maximum - levels.chroma_center) / levels.chroma_span,
128            (sample.v * levels.maximum - levels.chroma_center) / levels.chroma_span,
129        );
130        let kg = 1.0 - kr - kb;
131        [
132            y + 2.0 * (1.0 - kr) * cr,
133            y - 2.0 * kb * (1.0 - kb) / kg * cb - 2.0 * kr * (1.0 - kr) / kg * cr,
134            y + 2.0 * (1.0 - kb) * cb,
135        ]
136    } else {
137        [sample.y, sample.u, sample.v]
138    };
139    let encoded = encoded.map(|component| component.clamp(0.0, 1.0));
140    let linear = match tone {
141        Some(tone) if matches!(color.transfer, TransferFunction::Pq | TransferFunction::Hlg) => {
142            hdr_to_working(encoded, color.transfer, color.primaries, tone)
143        }
144        _ => {
145            let decoded = encoded.map(decode_sdr);
146            if color.primaries == ColorPrimaries::Bt2020 {
147                // Wide-gamut SDR: convert to BT.709 primaries, clipping out-of-gamut values.
148                gamut_matrix(ColorPrimaries::Bt2020, ColorPrimaries::Bt709).map(|row| {
149                    row.iter()
150                        .zip(decoded)
151                        .map(|(weight, value)| weight * value)
152                        .sum::<f32>()
153                        .clamp(0.0, 1.0)
154                })
155            } else {
156                decoded
157            }
158        }
159    };
160    let mut output = [0.0; 4];
161    for (destination, component) in output[..3].iter_mut().zip(linear) {
162        *destination = component * sample.alpha;
163    }
164    output[3] = sample.alpha;
165    Ok((output, diagnostics))
166}
167
168/// Encodes linear-light RGB for an SDR output target.
169#[must_use]
170pub fn encode_linear_rgb(rgb: [f32; 3], transfer: SdrOutputTransfer) -> [f32; 3] {
171    rgb.map(|value| {
172        let value = value.clamp(0.0, 1.0);
173        match transfer {
174            SdrOutputTransfer::Srgb => {
175                if value <= 0.003_130_8 {
176                    12.92 * value
177                } else {
178                    1.055 * libm::powf(value, 1.0 / 2.4) - 0.055
179                }
180            }
181            SdrOutputTransfer::Bt709 => {
182                if value < 0.018 {
183                    4.5 * value
184                } else {
185                    1.099 * libm::powf(value, 0.45) - 0.099
186                }
187            }
188        }
189    })
190}
191
192/// Linearizes an SDR component into the display-referred working space.
193///
194/// Every SDR transfer uses the sRGB EOTF: a BT.709 tag names the camera OETF, not the display,
195/// and export uses the exact inverse so import then export is the identity.
196fn decode_sdr(value: f32) -> f32 {
197    if value <= 0.040_45 {
198        value / 12.92
199    } else {
200        libm::powf((value + 0.055) / 1.055, 2.4)
201    }
202}
203
204/// A premultiplied sRGB storage value in `0..=1` with no color component above alpha; what an
205/// intermediate texture holds, not linear light.
206///
207/// [`crate::Renderer::render_solid`] clears to it as given, and a composite background
208/// ([`crate::RenderGraph::add_composite_over`]) is the bottom layer as stored. Encode linear
209/// colors first: mid-grey `#808080` is `0.502`, not its linear `0.216`.
210#[derive(Copy, Clone, Debug, PartialEq)]
211pub struct Color {
212    /// Red component.
213    pub red: f64,
214    /// Green component.
215    pub green: f64,
216    /// Blue component.
217    pub blue: f64,
218    /// Alpha component.
219    pub alpha: f64,
220}
221
222#[cfg(test)]
223#[allow(clippy::items_after_test_module)]
224mod tests {
225    use super::*;
226    use alpha2_media::{ColorMatrix, ColorPrimaries, ColorRange, DynamicRange};
227
228    fn metadata(matrix: ColorMatrix, range: ColorRange) -> ColorMetadata {
229        ColorMetadata {
230            primaries: ColorPrimaries::Bt709,
231            transfer: TransferFunction::Bt709,
232            matrix,
233            range,
234            dynamic_range: DynamicRange::Sdr,
235        }
236    }
237
238    #[test]
239    fn neutral_black_and_white_match_all_sdr_matrices_and_ranges() {
240        for matrix in [
241            ColorMatrix::Bt601,
242            ColorMatrix::Bt709,
243            ColorMatrix::Bt2020Ncl,
244        ] {
245            for range in [ColorRange::Limited, ColorRange::Full] {
246                let neutral = 128.0 / 255.0;
247                let black_y = if range == ColorRange::Limited {
248                    16.0 / 255.0
249                } else {
250                    0.0
251                };
252                let (black, _) = convert_yuv_to_linear_rgba(
253                    YuvSample {
254                        y: black_y,
255                        u: neutral,
256                        v: neutral,
257                        alpha: 1.0,
258                    },
259                    8,
260                    Some(metadata(matrix, range)),
261                    ColorPolicy::RejectHdr,
262                )
263                .expect("SDR");
264                assert!(black[..3].iter().all(|value| value.abs() < 1.0e-5));
265                let white_y = if range == ColorRange::Limited {
266                    235.0 / 255.0
267                } else {
268                    1.0
269                };
270                let (white, _) = convert_yuv_to_linear_rgba(
271                    YuvSample {
272                        y: white_y,
273                        u: neutral,
274                        v: neutral,
275                        alpha: 0.5,
276                    },
277                    8,
278                    Some(metadata(matrix, range)),
279                    ColorPolicy::RejectHdr,
280                )
281                .expect("SDR");
282                assert!(white[..3].iter().all(|value| (*value - 0.5).abs() < 2.0e-4));
283            }
284        }
285    }
286
287    #[test]
288    fn wide_gamut_sdr_converts_into_bt709_primaries_and_clips_what_they_cannot_hold() {
289        // The only SDR test with BT.2020 primaries.
290        let bt2020_sdr = ColorMetadata {
291            primaries: ColorPrimaries::Bt2020,
292            ..metadata(ColorMatrix::Rgb, ColorRange::Full)
293        };
294        let linear = |encoded: [f32; 3]| {
295            let (output, diagnostics) = convert_yuv_to_linear_rgba(
296                YuvSample {
297                    y: encoded[0],
298                    u: encoded[1],
299                    v: encoded[2],
300                    alpha: 1.0,
301                },
302                8,
303                Some(bt2020_sdr),
304                ColorPolicy::RejectHdr,
305            )
306            .expect("wide-gamut SDR is not HDR");
307            assert!(diagnostics.is_empty(), "{diagnostics:?}");
308            [output[0], output[1], output[2]]
309        };
310        // ITU-R BT.2087's linear BT.2020 to BT.709 matrix, to its four decimals.
311        let bt2087 = [
312            [1.6605, -0.5876, -0.0728],
313            [-0.1246, 1.1329, -0.0083],
314            [-0.0182, -0.1006, 1.1187],
315        ];
316        let encoded = [0.55_f32, 0.6, 0.5];
317        let decoded = encoded.map(decode_sdr);
318        let expected = bt2087.map(|row| row.iter().zip(decoded).map(|(m, v)| m * v).sum::<f32>());
319        assert!(expected.iter().all(|value| (0.0..=1.0).contains(value)));
320        let converted = linear(encoded);
321        for (got, want) in converted.iter().zip(expected) {
322            assert!((got - want).abs() < 2.0e-3, "{converted:?} != {expected:?}");
323        }
324        assert!(
325            (converted[0] - decoded[0]).abs() > 0.01,
326            "{converted:?}: the BT.2020 row is not the identity"
327        );
328        let gray = linear([0.5; 3]);
329        assert!(
330            gray.iter()
331                .all(|value| (value - decode_sdr(0.5)).abs() < 1.0e-4),
332            "{gray:?}"
333        );
334        // BT.2020's green lies outside BT.709, so red and blue go negative and clip to zero.
335        assert_eq!(
336            linear([0.0, 1.0, 0.0]).map(f32::to_bits),
337            [0.0_f32, 1.0, 0.0].map(f32::to_bits)
338        );
339        let (bt709, _) = convert_yuv_to_linear_rgba(
340            YuvSample {
341                y: encoded[0],
342                u: encoded[1],
343                v: encoded[2],
344                alpha: 1.0,
345            },
346            8,
347            Some(metadata(ColorMatrix::Rgb, ColorRange::Full)),
348            ColorPolicy::RejectHdr,
349        )
350        .expect("SDR");
351        assert_eq!(
352            [bt709[0], bt709[1], bt709[2]].map(f32::to_bits),
353            decoded.map(f32::to_bits)
354        );
355    }
356
357    #[test]
358    fn sdr_decode_is_the_inverse_of_the_srgb_export_encode_for_every_tag() {
359        for transfer in [TransferFunction::Bt709, TransferFunction::Srgb] {
360            for code in 16_u8..=235 {
361                let (linear, _) = convert_yuv_to_linear_rgba(
362                    YuvSample {
363                        y: f32::from(code) / 255.0,
364                        u: 128.0 / 255.0,
365                        v: 128.0 / 255.0,
366                        alpha: 1.0,
367                    },
368                    8,
369                    Some(ColorMetadata {
370                        transfer,
371                        ..metadata(ColorMatrix::Bt709, ColorRange::Limited)
372                    }),
373                    ColorPolicy::RejectHdr,
374                )
375                .expect("SDR");
376                let encoded =
377                    encode_linear_rgb([linear[0], linear[1], linear[2]], SdrOutputTransfer::Srgb);
378                let expected = (f32::from(code) - 16.0) / 219.0;
379                assert!(
380                    encoded
381                        .iter()
382                        .all(|value| (value - expected).abs() * 219.0 < 0.01),
383                    "{transfer:?} code {code}: {encoded:?} != {expected}"
384                );
385            }
386        }
387    }
388
389    #[test]
390    fn rgb_matrix_preserves_encoded_components() {
391        let (red, diagnostics) = convert_yuv_to_linear_rgba(
392            YuvSample {
393                y: 1.0,
394                u: 0.0,
395                v: 0.0,
396                alpha: 1.0,
397            },
398            8,
399            Some(metadata(ColorMatrix::Rgb, ColorRange::Limited)),
400            ColorPolicy::RejectHdr,
401        )
402        .expect("RGB SDR input");
403        assert_eq!(diagnostics, [] as [ColorDiagnostic; 0]);
404        assert_eq!(
405            red.map(f32::to_bits),
406            [1.0, 0.0, 0.0, 1.0].map(f32::to_bits)
407        );
408    }
409
410    #[test]
411    fn fallback_and_hdr_are_observable() {
412        let (_, diagnostics) = convert_yuv_to_linear_rgba(
413            YuvSample {
414                y: 16.0 / 255.0,
415                u: 128.0 / 255.0,
416                v: 128.0 / 255.0,
417                alpha: 1.0,
418            },
419            8,
420            None,
421            ColorPolicy::RejectHdr,
422        )
423        .expect("fallback");
424        assert_eq!(diagnostics, [ColorDiagnostic::MissingMetadataFallback]);
425        let hdr = ColorMetadata {
426            dynamic_range: DynamicRange::Hdr,
427            ..metadata(ColorMatrix::Bt2020Ncl, ColorRange::Limited)
428        };
429        assert_eq!(
430            convert_yuv_to_linear_rgba(
431                YuvSample {
432                    y: 0.5,
433                    u: 0.5,
434                    v: 0.5,
435                    alpha: 1.0
436                },
437                10,
438                Some(hdr),
439                ColorPolicy::RejectHdr
440            ),
441            Err(ColorError::UnsupportedHdr)
442        );
443    }
444}
445
446/// How far a premultiplied component may exceed alpha: a few `f32` rounding steps, far below
447/// the finest stored step (`rgba16float`'s 1/2048 near one).
448const PREMULTIPLIED_SLACK: f64 = 1e-6;
449
450impl Color {
451    /// An opaque black color.
452    pub const BLACK: Self = Self::rgba(0.0, 0.0, 0.0, 1.0);
453
454    /// Creates an RGBA color.
455    #[must_use]
456    pub const fn rgba(red: f64, green: f64, blue: f64, alpha: f64) -> Self {
457        Self {
458            red,
459            green,
460            blue,
461            alpha,
462        }
463    }
464
465    /// Whether every component is finite in `0..=1` and no color exceeds alpha by more than
466    /// [`PREMULTIPLIED_SLACK`].
467    pub(crate) fn is_valid(self) -> bool {
468        let in_range = [self.red, self.green, self.blue, self.alpha]
469            .into_iter()
470            .all(|component| component.is_finite() && (0.0..=1.0).contains(&component));
471        in_range
472            && [self.red, self.green, self.blue]
473                .into_iter()
474                .all(|component| component <= self.alpha + PREMULTIPLIED_SLACK)
475    }
476
477    /// Composites a premultiplied source over this premultiplied color with Porter-Duff
478    /// source-over: `out = source + destination * (1 - source_alpha)`, as the GPU blend state does.
479    #[must_use]
480    pub fn source_over(self, source: Self) -> Self {
481        let remaining = 1.0 - source.alpha;
482        Self::rgba(
483            source.red + self.red * remaining,
484            source.green + self.green * remaining,
485            source.blue + self.blue * remaining,
486            source.alpha + self.alpha * remaining,
487        )
488    }
489}