#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Primaries {
#[default]
Bt709,
Bt601_525,
Bt601_625,
Bt2020,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Transfer {
#[default]
Bt709,
Srgb,
Pq,
Hlg,
}
impl Transfer {
pub fn is_hdr(self) -> bool {
matches!(self, Transfer::Pq | Transfer::Hlg)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Matrix {
#[default]
Bt709,
Bt601,
Bt2020Ncl,
Identity,
}
impl Matrix {
fn luma_weights(self) -> (f32, f32) {
match self {
Matrix::Bt709 => (0.2126, 0.0722),
Matrix::Bt601 => (0.299, 0.114),
Matrix::Bt2020Ncl => (0.2627, 0.0593),
Matrix::Identity => (0.0, 0.0),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Range {
#[default]
Limited,
Full,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct ColorSpace {
pub primaries: Primaries,
pub transfer: Transfer,
pub matrix: Matrix,
pub range: Range,
}
impl ColorSpace {
pub fn guess_for(width: u32, height: u32) -> Self {
let (primaries, matrix) = if height <= 576 {
let primaries = if height == 576 || height == 288 {
Primaries::Bt601_625
} else {
Primaries::Bt601_525
};
(primaries, Matrix::Bt601)
} else if width >= 3840 || height >= 2160 {
(Primaries::Bt2020, Matrix::Bt2020Ncl)
} else {
(Primaries::Bt709, Matrix::Bt709)
};
ColorSpace {
primaries,
transfer: Transfer::Bt709,
matrix,
range: Range::Limited,
}
}
pub fn srgb() -> Self {
ColorSpace {
primaries: Primaries::Bt709,
transfer: Transfer::Srgb,
matrix: Matrix::Identity,
range: Range::Full,
}
}
pub fn yuv_to_rgb(self, bit_depth: u32) -> [[f32; 4]; 3] {
let depth = bit_depth.clamp(8, 16);
let max = ((1_u32 << depth) - 1) as f32;
let step = (1_u32 << (depth - 8)) as f32;
let neutral = (1_u32 << (depth - 1)) as f32 / max;
let (y_scale, y_black, c_scale) = match self.range {
Range::Full => (1.0, 0.0, 1.0),
Range::Limited => (
max / (219.0 * step),
16.0 * step / max,
max / (224.0 * step),
),
};
if self.matrix == Matrix::Identity {
let offset = -y_scale * y_black;
return [
[y_scale, 0.0, 0.0, offset],
[0.0, y_scale, 0.0, offset],
[0.0, 0.0, y_scale, offset],
];
}
let (kr, kb) = self.matrix.luma_weights();
let kg = 1.0 - kr - kb;
let r_v = 2.0 * (1.0 - kr) * c_scale;
let b_u = 2.0 * (1.0 - kb) * c_scale;
let g_u = -2.0 * (1.0 - kb) * kb / kg * c_scale;
let g_v = -2.0 * (1.0 - kr) * kr / kg * c_scale;
let luma_offset = -y_scale * y_black;
[
[y_scale, 0.0, r_v, luma_offset - r_v * neutral],
[y_scale, g_u, g_v, luma_offset - (g_u + g_v) * neutral],
[y_scale, b_u, 0.0, luma_offset - b_u * neutral],
]
}
}
pub fn convert_pixel(space: ColorSpace, bit_depth: u32, yuv: [f32; 3]) -> [f32; 3] {
let matrix = space.yuv_to_rgb(bit_depth);
std::array::from_fn(|row| {
let [y, u, v, offset] = matrix[row];
(y * yuv[0] + u * yuv[1] + v * yuv[2] + offset).clamp(0.0, 1.0)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn close(actual: [f32; 3], expected: [f32; 3]) -> bool {
actual
.iter()
.zip(expected)
.all(|(a, e)| (a - e).abs() < 1e-3)
}
#[test]
fn limited_range_black_and_white_land_on_zero_and_one() {
let space = ColorSpace::default();
let black = convert_pixel(space, 8, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
let white = convert_pixel(space, 8, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
assert!(close(black, [0.0, 0.0, 0.0]), "{black:?}");
assert!(close(white, [1.0, 1.0, 1.0]), "{white:?}");
}
#[test]
fn ten_bit_limited_range_lands_in_the_same_place() {
let space = ColorSpace::default();
let black = convert_pixel(space, 10, [64.0 / 1023.0, 512.0 / 1023.0, 512.0 / 1023.0]);
let white = convert_pixel(space, 10, [940.0 / 1023.0, 512.0 / 1023.0, 512.0 / 1023.0]);
assert!(close(black, [0.0, 0.0, 0.0]), "{black:?}");
assert!(close(white, [1.0, 1.0, 1.0]), "{white:?}");
}
#[test]
fn full_range_black_and_white_land_on_zero_and_one() {
let space = ColorSpace {
range: Range::Full,
..ColorSpace::default()
};
let black = convert_pixel(space, 8, [0.0, 128.0 / 255.0, 128.0 / 255.0]);
let white = convert_pixel(space, 8, [1.0, 128.0 / 255.0, 128.0 / 255.0]);
assert!(close(black, [0.0, 0.0, 0.0]), "{black:?}");
assert!(close(white, [1.0, 1.0, 1.0]), "{white:?}");
}
#[test]
fn the_matrix_choice_actually_changes_the_colour() {
let samples = [0.5, 0.8, 0.3];
let bt709 = convert_pixel(ColorSpace::default(), 8, samples);
let bt601 = convert_pixel(
ColorSpace {
matrix: Matrix::Bt601,
..ColorSpace::default()
},
8,
samples,
);
assert!(
!close(bt709, bt601),
"709 and 601 gave the same answer: {bt709:?}"
);
}
#[test]
fn every_matrix_agrees_about_grey() {
let grey = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0];
let mut results = Vec::new();
for matrix in [Matrix::Bt709, Matrix::Bt601, Matrix::Bt2020Ncl] {
let space = ColorSpace {
matrix,
..ColorSpace::default()
};
let rgb = convert_pixel(space, 8, grey);
assert!(
(rgb[0] - rgb[1]).abs() < 1e-6 && (rgb[1] - rgb[2]).abs() < 1e-6,
"{matrix:?} tinted a neutral: {rgb:?}"
);
results.push(rgb[0]);
}
assert!(results
.windows(2)
.all(|pair| (pair[0] - pair[1]).abs() < 1e-6));
}
#[test]
fn identity_passes_colour_planes_through() {
let space = ColorSpace::srgb();
let rgb = convert_pixel(space, 8, [0.25, 0.5, 0.75]);
assert!(close(rgb, [0.25, 0.5, 0.75]), "{rgb:?}");
}
#[test]
fn the_guess_follows_the_resolution() {
assert_eq!(ColorSpace::guess_for(720, 480).matrix, Matrix::Bt601);
assert_eq!(
ColorSpace::guess_for(720, 576).primaries,
Primaries::Bt601_625
);
assert_eq!(ColorSpace::guess_for(1920, 1080).matrix, Matrix::Bt709);
assert_eq!(ColorSpace::guess_for(3840, 2160).matrix, Matrix::Bt2020Ncl);
}
#[test]
fn hdr_curves_are_flagged_as_such() {
assert!(Transfer::Pq.is_hdr() && Transfer::Hlg.is_hdr());
assert!(!Transfer::Bt709.is_hdr() && !Transfer::Srgb.is_hdr());
}
}