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,
32 WarnHdr,
34 ToneMapHdr(HdrToneMap),
36}
37
38#[must_use]
40pub const fn is_hdr(color: ColorMetadata) -> bool {
41 color.is_hdr()
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
46pub enum ColorDiagnostic {
47 MissingMetadataFallback,
49 UnsupportedHdr,
51 ToneMappedHdr,
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
57#[non_exhaustive]
58pub enum ColorError {
59 InvalidBitDepth,
61 InvalidSample,
63 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
79pub 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 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#[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
192fn 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#[derive(Copy, Clone, Debug, PartialEq)]
211pub struct Color {
212 pub red: f64,
214 pub green: f64,
216 pub blue: f64,
218 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 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 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 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
446const PREMULTIPLIED_SLACK: f64 = 1e-6;
449
450impl Color {
451 pub const BLACK: Self = Self::rgba(0.0, 0.0, 0.0, 1.0);
453
454 #[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 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 #[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}