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