1use alpha2_media::{ColorMetadata, ColorPrimaries, TransferFunction};
2
3use crate::hdr::{HdrToneMap, gamut_matrix, hdr_to_working};
4
5#[derive(Copy, Clone, Debug, PartialEq)]
7pub struct YuvSample {
8 pub y: f32,
10 pub u: f32,
12 pub v: f32,
14 pub alpha: f32,
16}
17
18#[derive(Copy, Clone, Debug, Eq, PartialEq)]
20pub enum SdrOutputTransfer {
21 Srgb,
23 Bt709,
25}
26
27#[derive(Copy, Clone, Debug, Eq, PartialEq)]
29pub enum ColorPolicy {
30 RejectHdr,
33 WarnHdr,
36 ToneMapHdr(HdrToneMap),
39}
40
41#[must_use]
43pub const fn is_hdr(color: ColorMetadata) -> bool {
44 color.is_hdr()
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
49pub enum ColorDiagnostic {
50 MissingMetadataFallback,
52 UnsupportedHdr,
54 ToneMappedHdr,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60#[non_exhaustive]
61pub enum ColorError {
62 InvalidBitDepth,
64 InvalidSample,
66 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
82pub 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 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#[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
195fn 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#[derive(Copy, Clone, Debug, PartialEq)]
219pub struct Color {
220 pub red: f64,
222 pub green: f64,
224 pub blue: f64,
226 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 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 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 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 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 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
457const PREMULTIPLIED_SLACK: f64 = 1e-6;
461
462impl Color {
463 pub const BLACK: Self = Self::rgba(0.0, 0.0, 0.0, 1.0);
465
466 #[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 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 #[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}