pub type Mat3 = [[f32; 3]; 3];
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Xy {
pub x: f32,
pub y: f32,
}
impl Xy {
pub const fn new(x: f32, y: f32) -> Self {
Self { x, y }
}
fn xyz_unit_luminance(self) -> [f32; 3] {
[self.x / self.y, 1.0, (1.0 - self.x - self.y) / self.y]
}
}
#[derive(Clone, Copy, Debug)]
pub struct Primaries {
pub red: Xy,
pub green: Xy,
pub blue: Xy,
pub white: Xy,
}
pub const WHITE_D65: Xy = Xy::new(0.3127, 0.3290);
pub const WHITE_ACES: Xy = Xy::new(0.32168, 0.33767);
pub const PRIMARIES_SRGB: Primaries = Primaries {
red: Xy::new(0.640, 0.330),
green: Xy::new(0.300, 0.600),
blue: Xy::new(0.150, 0.060),
white: WHITE_D65,
};
pub const PRIMARIES_DISPLAY_P3: Primaries = Primaries {
red: Xy::new(0.680, 0.320),
green: Xy::new(0.265, 0.690),
blue: Xy::new(0.150, 0.060),
white: WHITE_D65,
};
pub const PRIMARIES_BT2020: Primaries = Primaries {
red: Xy::new(0.708, 0.292),
green: Xy::new(0.170, 0.797),
blue: Xy::new(0.131, 0.046),
white: WHITE_D65,
};
pub const PRIMARIES_AP1: Primaries = Primaries {
red: Xy::new(0.713, 0.293),
green: Xy::new(0.165, 0.830),
blue: Xy::new(0.128, 0.044),
white: WHITE_ACES,
};
fn mat_mul(a: Mat3, b: Mat3) -> Mat3 {
let mut out = [[0.0f32; 3]; 3];
for r in 0..3 {
for c in 0..3 {
out[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c];
}
}
out
}
fn mat_vec(m: Mat3, v: [f32; 3]) -> [f32; 3] {
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
fn mat_inv(m: Mat3) -> Mat3 {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
let inv_det = 1.0 / det;
[
[
(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
(m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
(m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
],
[
(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
(m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
(m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
],
[
(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
(m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
(m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
],
]
}
fn mat_diag(d: [f32; 3]) -> Mat3 {
[[d[0], 0.0, 0.0], [0.0, d[1], 0.0], [0.0, 0.0, d[2]]]
}
const BRADFORD_CONE: Mat3 = [
[0.8951, 0.2664, -0.1614],
[-0.7502, 1.7135, 0.0367],
[0.0389, -0.0685, 1.0296],
];
pub fn chromatic_adaptation(src_white: Xy, dst_white: Xy) -> Mat3 {
let cone = BRADFORD_CONE;
let cone_inv = mat_inv(cone);
let src = mat_vec(cone, src_white.xyz_unit_luminance());
let dst = mat_vec(cone, dst_white.xyz_unit_luminance());
let scale = [dst[0] / src[0], dst[1] / src[1], dst[2] / src[2]];
mat_mul(mat_mul(cone_inv, mat_diag(scale)), cone)
}
pub fn rgb_to_xyz_matrix(primaries: Primaries) -> Mat3 {
let raw = [
[
primaries.red.xyz_unit_luminance()[0],
primaries.green.xyz_unit_luminance()[0],
primaries.blue.xyz_unit_luminance()[0],
],
[
primaries.red.xyz_unit_luminance()[1],
primaries.green.xyz_unit_luminance()[1],
primaries.blue.xyz_unit_luminance()[1],
],
[
primaries.red.xyz_unit_luminance()[2],
primaries.green.xyz_unit_luminance()[2],
primaries.blue.xyz_unit_luminance()[2],
],
];
let white_xyz = primaries.white.xyz_unit_luminance();
let s = mat_vec(mat_inv(raw), white_xyz);
mat_mul(raw, mat_diag(s))
}
pub fn rgb_to_rgb_matrix(src: Primaries, dst: Primaries) -> Mat3 {
let to_xyz = rgb_to_xyz_matrix(src);
let from_xyz = mat_inv(rgb_to_xyz_matrix(dst));
let middle = if src.white != dst.white {
chromatic_adaptation(src.white, dst.white)
} else {
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
};
mat_mul(mat_mul(from_xyz, middle), to_xyz)
}
#[inline]
fn apply_mat(m: Mat3, px: &mut [f32]) {
let r = px[0];
let g = px[1];
let b = px[2];
px[0] = m[0][0] * r + m[0][1] * g + m[0][2] * b;
px[1] = m[1][0] * r + m[1][1] * g + m[1][2] * b;
px[2] = m[2][0] * r + m[2][1] * g + m[2][2] * b;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum WorkingColorSpace {
#[default]
AcesCg,
SrgbLegacy,
}
impl WorkingColorSpace {
pub fn from_setting(value: &str) -> Self {
match value.to_ascii_lowercase().as_str() {
"srgb" | "srgb_legacy" | "legacy" => WorkingColorSpace::SrgbLegacy,
_ => WorkingColorSpace::AcesCg,
}
}
pub fn as_setting(self) -> &'static str {
match self {
WorkingColorSpace::AcesCg => "acescg",
WorkingColorSpace::SrgbLegacy => "srgb_legacy",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum OutputGamut {
#[default]
Srgb,
DisplayP3,
Bt2020,
}
impl OutputGamut {
pub fn from_setting(value: &str) -> Self {
match value.to_ascii_lowercase().as_str() {
"displayp3" | "p3" => OutputGamut::DisplayP3,
"bt2020" | "rec2020" | "2020" => OutputGamut::Bt2020,
_ => OutputGamut::Srgb,
}
}
pub fn as_setting(self) -> &'static str {
match self {
OutputGamut::Srgb => "srgb",
OutputGamut::DisplayP3 => "displayp3",
OutputGamut::Bt2020 => "bt2020",
}
}
pub fn primaries(self) -> Primaries {
match self {
OutputGamut::Srgb => PRIMARIES_SRGB,
OutputGamut::DisplayP3 => PRIMARIES_DISPLAY_P3,
OutputGamut::Bt2020 => PRIMARIES_BT2020,
}
}
pub fn av_color_primaries(self) -> i32 {
match self {
OutputGamut::Srgb => 1, OutputGamut::DisplayP3 => 12, OutputGamut::Bt2020 => 9, }
}
pub fn av_color_space(self) -> i32 {
match self {
OutputGamut::Srgb | OutputGamut::DisplayP3 => 1, OutputGamut::Bt2020 => 9, }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum OutputTransfer {
#[default]
Srgb,
Gamma22,
Pq,
Hlg,
}
impl OutputTransfer {
pub fn from_setting(value: &str) -> Self {
match value.to_ascii_lowercase().as_str() {
"gamma22" | "2.2" => OutputTransfer::Gamma22,
"pq" | "st2084" => OutputTransfer::Pq,
"hlg" => OutputTransfer::Hlg,
_ => OutputTransfer::Srgb,
}
}
pub fn as_setting(self) -> &'static str {
match self {
OutputTransfer::Srgb => "srgb",
OutputTransfer::Gamma22 => "gamma22",
OutputTransfer::Pq => "pq",
OutputTransfer::Hlg => "hlg",
}
}
pub fn av_color_trc(self) -> i32 {
match self {
OutputTransfer::Srgb => 13, OutputTransfer::Gamma22 => 4, OutputTransfer::Pq => 16, OutputTransfer::Hlg => 18, }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct OutputColorSpec {
pub gamut: OutputGamut,
pub transfer: OutputTransfer,
}
impl OutputColorSpec {
pub fn from_settings(gamut: &str, transfer: &str) -> Self {
Self {
gamut: OutputGamut::from_setting(gamut),
transfer: OutputTransfer::from_setting(transfer),
}
}
}
pub fn srgb_oetf(v: f32) -> f32 {
let sign = if v < 0.0 { -1.0 } else { 1.0 };
let v = v.abs();
sign * if v <= 0.0031308 {
12.92 * v
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
}
pub fn srgb_eotf(v: f32) -> f32 {
let sign = if v < 0.0 { -1.0 } else { 1.0 };
let v = v.abs();
sign * if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
pub fn gamma_oetf(v: f32, gamma: f32) -> f32 {
let sign = if v < 0.0 { -1.0 } else { 1.0 };
sign * v.abs().powf(1.0 / gamma)
}
pub fn gamma_eotf(v: f32, gamma: f32) -> f32 {
let sign = if v < 0.0 { -1.0 } else { 1.0 };
sign * v.abs().powf(gamma)
}
const PQ_M1: f32 = 2610.0 / 16384.0;
const PQ_M2: f32 = (2523.0 / 4096.0) * 128.0;
const PQ_C1: f32 = 3424.0 / 4096.0;
const PQ_C2: f32 = (2413.0 / 4096.0) * 32.0;
const PQ_C3: f32 = (2392.0 / 4096.0) * 32.0;
pub fn pq_eotf(v: f32) -> f32 {
let sign = if v < 0.0 { -1.0 } else { 1.0 };
let v = v.abs().clamp(0.0, 1.0).powf(1.0 / PQ_M2);
let num = (v - PQ_C1).max(0.0);
let den = PQ_C2 - PQ_C3 * v;
sign * (num / den).powf(1.0 / PQ_M1)
}
pub fn pq_oetf(v: f32) -> f32 {
let sign = if v < 0.0 { -1.0 } else { 1.0 };
let y = v.abs().powf(PQ_M1);
let num = PQ_C1 + PQ_C2 * y;
let den = 1.0 + PQ_C3 * y;
sign * (num / den).powf(PQ_M2)
}
const HLG_A: f32 = 0.17883277;
const HLG_B: f32 = 1.0 - 4.0 * HLG_A; const HLG_C: f32 = 0.55992546;
pub fn hlg_eotf(v: f32) -> f32 {
let sign = if v < 0.0 { -1.0 } else { 1.0 };
let v = v.abs();
sign * if v <= 0.5 {
v * v / 3.0
} else {
(((v - HLG_C) / HLG_A).exp() + HLG_B) / 12.0
}
}
pub fn hlg_oetf(v: f32) -> f32 {
let sign = if v < 0.0 { -1.0 } else { 1.0 };
let v = v.abs();
sign * if v <= 1.0 / 12.0 {
(3.0 * v).sqrt()
} else {
HLG_A * (12.0 * v - HLG_B).ln() + HLG_C
}
}
const LUT_N: usize = 4096;
macro_rules! tf_lut {
($name:ident, $f:expr) => {
static $name: std::sync::LazyLock<[f32; LUT_N + 1]> = std::sync::LazyLock::new(|| {
let f: fn(f32) -> f32 = $f;
let mut t = [0.0f32; LUT_N + 1];
for (i, e) in t.iter_mut().enumerate() {
*e = f(i as f32 / LUT_N as f32);
}
t
});
};
}
tf_lut!(SRGB_EOTF_LUT, srgb_eotf);
tf_lut!(SRGB_OETF_LUT, srgb_oetf);
tf_lut!(GAMMA22_EOTF_LUT, |v| gamma_eotf(v, 2.2));
tf_lut!(GAMMA22_OETF_LUT, |v| gamma_oetf(v, 2.2));
tf_lut!(GAMMA28_EOTF_LUT, |v| gamma_eotf(v, 2.8));
tf_lut!(PQ_EOTF_LUT, pq_eotf);
tf_lut!(PQ_OETF_LUT, pq_oetf);
tf_lut!(HLG_EOTF_LUT, hlg_eotf);
tf_lut!(HLG_OETF_LUT, hlg_oetf);
#[inline]
fn lut_tf(lut: &[f32; LUT_N + 1], exact: fn(f32) -> f32, v: f32) -> f32 {
let (sign, a) = if v < 0.0 { (-1.0, -v) } else { (1.0, v) };
let out = if a >= 1.0 {
exact(a)
} else {
let x = a * LUT_N as f32;
let i = (x as usize).min(LUT_N - 1);
let frac = x - i as f32;
lut[i] + frac * (lut[i + 1] - lut[i])
};
sign * out
}
#[inline]
fn srgb_eotf_fast(v: f32) -> f32 {
if v.abs() <= 0.04045 {
v / 12.92
} else {
lut_tf(&SRGB_EOTF_LUT, srgb_eotf, v)
}
}
#[inline]
fn srgb_oetf_fast(v: f32) -> f32 {
if v.abs() <= 0.0031308 {
12.92 * v
} else {
lut_tf(&SRGB_OETF_LUT, srgb_oetf, v)
}
}
fn decode_transfer_fn(transfer: SourceTransfer) -> fn(f32) -> f32 {
match transfer {
SourceTransfer::SdrGamma | SourceTransfer::Unknown => srgb_eotf_fast,
SourceTransfer::Gamma22 => |v| lut_tf(&GAMMA22_EOTF_LUT, |x| gamma_eotf(x, 2.2), v),
SourceTransfer::Gamma28 => |v| lut_tf(&GAMMA28_EOTF_LUT, |x| gamma_eotf(x, 2.8), v),
SourceTransfer::Pq => |v| lut_tf(&PQ_EOTF_LUT, pq_eotf, v),
SourceTransfer::Hlg => |v| lut_tf(&HLG_EOTF_LUT, hlg_eotf, v),
SourceTransfer::Linear => |v| v,
}
}
fn output_transfer_fn(transfer: OutputTransfer) -> fn(f32) -> f32 {
match transfer {
OutputTransfer::Srgb => srgb_oetf_fast,
OutputTransfer::Gamma22 => |v| lut_tf(&GAMMA22_OETF_LUT, |x| gamma_oetf(x, 2.2), v),
OutputTransfer::Pq => |v| lut_tf(&PQ_OETF_LUT, pq_oetf, v),
OutputTransfer::Hlg => |v| lut_tf(&HLG_OETF_LUT, hlg_oetf, v),
}
}
fn par_pixels_f32(samples: &mut [f32], f: impl Fn(&mut [f32]) + Sync) {
par_bands(samples, 4, f);
}
fn par_pixels_bytes(bytes: &mut [u8], f: impl Fn(&mut [u8]) + Sync) {
par_bands(bytes, 16, f);
}
fn par_bands<T: Send>(buf: &mut [T], align: usize, f: impl Fn(&mut [T]) + Sync) {
let len = buf.len();
let bands = if len / align >= (1 << 19) {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(8)
} else {
1
};
if bands <= 1 {
f(buf);
return;
}
let band_len = (len / bands).div_ceil(align) * align;
let f = &f;
std::thread::scope(|s| {
let mut rest = buf;
while !rest.is_empty() {
let (band, tail) = rest.split_at_mut(band_len.min(rest.len()));
s.spawn(move || f(band));
rest = tail;
}
});
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SourcePrimaries {
Bt709,
Bt2020,
DisplayP3,
Unknown,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SourceTransfer {
SdrGamma,
Gamma22,
Gamma28,
Pq,
Hlg,
Linear,
Unknown,
}
pub fn source_primaries_from_av(color_primaries: i32) -> SourcePrimaries {
match color_primaries {
1 => SourcePrimaries::Bt709, 9 => SourcePrimaries::Bt2020, 11 => SourcePrimaries::DisplayP3, 12 => SourcePrimaries::DisplayP3, _ => SourcePrimaries::Unknown,
}
}
pub fn source_transfer_from_av(color_trc: i32) -> SourceTransfer {
match color_trc {
1 => SourceTransfer::SdrGamma, 4 => SourceTransfer::Gamma22, 5 => SourceTransfer::Gamma28, 6 => SourceTransfer::SdrGamma, 13 => SourceTransfer::SdrGamma, 14 => SourceTransfer::SdrGamma, 15 => SourceTransfer::SdrGamma, 16 => SourceTransfer::Pq, 18 => SourceTransfer::Hlg, 8 => SourceTransfer::Linear, _ => SourceTransfer::Unknown,
}
}
impl SourcePrimaries {
pub fn primaries(self) -> Primaries {
match self {
SourcePrimaries::Bt709 | SourcePrimaries::Unknown => PRIMARIES_SRGB,
SourcePrimaries::Bt2020 => PRIMARIES_BT2020,
SourcePrimaries::DisplayP3 => PRIMARIES_DISPLAY_P3,
}
}
}
pub fn decode_to_acescg(samples: &mut [f32], primaries: SourcePrimaries, transfer: SourceTransfer) {
let linearize = decode_transfer_fn(transfer);
let matrix = rgb_to_rgb_matrix(primaries.primaries(), PRIMARIES_AP1);
par_pixels_f32(samples, move |band| {
for px in band.as_chunks_mut::<4>().0 {
for c in 0..3 {
px[c] = linearize(px[c]);
}
apply_mat(matrix, px);
}
});
}
pub fn decode_to_acescg_bytes(
bytes: &mut [u8],
pixels: usize,
primaries: SourcePrimaries,
transfer: SourceTransfer,
) {
if bytes.len() < pixels * 16 {
return;
}
let linearize = decode_transfer_fn(transfer);
let matrix = rgb_to_rgb_matrix(primaries.primaries(), PRIMARIES_AP1);
par_pixels_bytes(bytes, move |band| {
for px in band.as_chunks_mut::<16>().0 {
let mut v = [
f32::from_le_bytes(px[0..4].try_into().unwrap()),
f32::from_le_bytes(px[4..8].try_into().unwrap()),
f32::from_le_bytes(px[8..12].try_into().unwrap()),
f32::from_le_bytes(px[12..16].try_into().unwrap()),
];
for c in 0..3 {
v[c] = linearize(v[c]);
}
apply_mat(matrix, &mut v);
px[0..4].copy_from_slice(&v[0].to_le_bytes());
px[4..8].copy_from_slice(&v[1].to_le_bytes());
px[8..12].copy_from_slice(&v[2].to_le_bytes());
px[12..16].copy_from_slice(&v[3].to_le_bytes());
}
});
}
pub fn acescg_to_output(samples: &mut [f32], spec: OutputColorSpec) {
let matrix = rgb_to_rgb_matrix(PRIMARIES_AP1, spec.gamut.primaries());
let encode = output_transfer_fn(spec.transfer);
par_pixels_f32(samples, move |band| {
for px in band.as_chunks_mut::<4>().0 {
apply_mat(matrix, px);
for c in 0..3 {
px[c] = encode(px[c].clamp(0.0, 1.0));
}
}
});
}
pub fn acescg_to_output_bytes(bytes: &mut [u8], pixels: usize, spec: OutputColorSpec) {
if bytes.len() < pixels * 16 {
return;
}
let matrix = rgb_to_rgb_matrix(PRIMARIES_AP1, spec.gamut.primaries());
let encode = output_transfer_fn(spec.transfer);
par_pixels_bytes(bytes, move |band| {
for px in band.as_chunks_mut::<16>().0 {
let mut v = [
f32::from_le_bytes(px[0..4].try_into().unwrap()),
f32::from_le_bytes(px[4..8].try_into().unwrap()),
f32::from_le_bytes(px[8..12].try_into().unwrap()),
f32::from_le_bytes(px[12..16].try_into().unwrap()),
];
apply_mat(matrix, &mut v);
for c in 0..3 {
v[c] = encode(v[c].clamp(0.0, 1.0));
}
px[0..4].copy_from_slice(&v[0].to_le_bytes());
px[4..8].copy_from_slice(&v[1].to_le_bytes());
px[8..12].copy_from_slice(&v[2].to_le_bytes());
px[12..16].copy_from_slice(&v[3].to_le_bytes());
}
});
}
pub fn apply_transfer_oetf(samples: &mut [f32], transfer: OutputTransfer) {
let encode = output_transfer_fn(transfer);
par_pixels_f32(samples, move |band| {
for px in band.as_chunks_mut::<4>().0 {
for c in 0..3 {
px[c] = encode(px[c]);
}
}
});
}
pub fn working_to_display_target(
samples: &mut [f32],
working: WorkingColorSpace,
spec: OutputColorSpec,
) {
match working {
WorkingColorSpace::AcesCg => acescg_to_output(samples, spec),
WorkingColorSpace::SrgbLegacy => {}
}
}
pub fn output_spec_to_xyz_d65(samples: &mut [f32], spec: OutputColorSpec) {
let linearize = decode_transfer_fn(match spec.transfer {
OutputTransfer::Srgb => SourceTransfer::SdrGamma,
OutputTransfer::Gamma22 => SourceTransfer::Gamma22,
OutputTransfer::Pq => SourceTransfer::Pq,
OutputTransfer::Hlg => SourceTransfer::Hlg,
});
let matrix = rgb_to_xyz_matrix(spec.gamut.primaries());
par_pixels_f32(samples, move |band| {
for px in band.as_chunks_mut::<4>().0 {
for c in 0..3 {
px[c] = linearize(px[c]);
}
apply_mat(matrix, px);
}
});
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum YuvMatrix {
Bt601,
Bt709,
Bt2020,
}
impl YuvMatrix {
pub(crate) fn kr_kb(self) -> (f32, f32) {
match self {
YuvMatrix::Bt601 => (0.299, 0.114),
YuvMatrix::Bt709 => (0.2126, 0.0722),
YuvMatrix::Bt2020 => (0.2627, 0.0593),
}
}
}
pub fn yuv444p16_to_rgb_f32(
y_plane: &[u8],
y_stride: usize,
u_plane: &[u8],
u_stride: usize,
v_plane: &[u8],
v_stride: usize,
width: usize,
height: usize,
matrix: YuvMatrix,
full_range: bool,
out: &mut [f32],
) {
if out.len() < width.saturating_mul(height).saturating_mul(4) {
return;
}
let row_bytes = width.saturating_mul(2);
let rows_after = height.saturating_sub(1);
if y_plane.len()
< rows_after
.saturating_mul(y_stride)
.saturating_add(row_bytes)
|| u_plane.len()
< rows_after
.saturating_mul(u_stride)
.saturating_add(row_bytes)
|| v_plane.len()
< rows_after
.saturating_mul(v_stride)
.saturating_add(row_bytes)
{
return;
}
let (kr, kb) = matrix.kr_kb();
let kg = 1.0 - kr - kb;
let (y_offset, c_offset, scale) = if full_range {
(0.0f32, 32768.0f32, 1.0 / 65535.0)
} else {
(4096.0f32, 32768.0f32, 1.0 / 57344.0)
};
let y_scale = if full_range {
1.0 / 65535.0
} else {
1.0 / 56064.0
};
for row in 0..height {
let y_row = row * y_stride;
let u_row = row * u_stride;
let v_row = row * v_stride;
for x in 0..width {
let off = x * 2;
let y = u16::from_le_bytes([y_plane[y_row + off], y_plane[y_row + off + 1]]) as f32;
let cb = u16::from_le_bytes([u_plane[u_row + off], u_plane[u_row + off + 1]]) as f32;
let cr = u16::from_le_bytes([v_plane[v_row + off], v_plane[v_row + off + 1]]) as f32;
let yn = (y - y_offset) * y_scale;
let cbn = (cb - c_offset) * scale;
let crn = (cr - c_offset) * scale;
let r = yn + 2.0 * (1.0 - kr) * crn;
let b = yn + 2.0 * (1.0 - kb) * cbn;
let g = (yn - kr * r - kb * b) / kg;
let px = (row * width + x) * 4;
out[px] = r;
out[px + 1] = g;
out[px + 2] = b;
out[px + 3] = 1.0;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() < eps
}
#[test]
fn rgb_to_xyz_srgb_white_is_unit_luminance() {
let m = rgb_to_xyz_matrix(PRIMARIES_SRGB);
let white = mat_vec(m, [1.0, 1.0, 1.0]);
let expected = WHITE_D65.xyz_unit_luminance();
for i in 0..3 {
assert!(
approx(white[i], expected[i], 1e-4),
"sRGB white → XYZ mismatch at {i}: {} vs {}",
white[i],
expected[i]
);
}
}
#[test]
fn srgb_to_acescg_matches_published_matrix() {
let m = rgb_to_rgb_matrix(PRIMARIES_SRGB, PRIMARIES_AP1);
let expected: Mat3 = [
[0.613_132_4, 0.339_523_08, 0.047_344_502],
[0.070_192_28, 0.916_353_7, 0.013_454_046],
[0.020_615_771, 0.109_569_706, 0.869_814_5],
];
for r in 0..3 {
for c in 0..3 {
assert!(
approx(m[r][c], expected[r][c], 2e-3),
"matrix[{r}][{c}] = {} vs published {}",
m[r][c],
expected[r][c]
);
}
}
}
#[test]
fn srgb_red_maps_to_published_acescg() {
let mut samples = [1.0f32, 0.0, 0.0, 1.0];
decode_to_acescg(&mut samples, SourcePrimaries::Bt709, SourceTransfer::Linear);
assert!(approx(samples[0], 0.6131, 2e-3), "R = {}", samples[0]);
assert!(approx(samples[1], 0.0702, 2e-3), "G = {}", samples[1]);
assert!(approx(samples[2], 0.0206, 2e-3), "B = {}", samples[2]);
}
#[test]
fn round_trip_srgb_acescg_srgb() {
let original = [0.25f32, 0.5, 0.75, 1.0];
let mut samples = original;
decode_to_acescg(
&mut samples,
SourcePrimaries::Bt709,
SourceTransfer::SdrGamma,
);
acescg_to_output(
&mut samples,
OutputColorSpec {
gamut: OutputGamut::Srgb,
transfer: OutputTransfer::Srgb,
},
);
for i in 0..3 {
assert!(
approx(samples[i], original[i], 1e-4),
"round trip channel {i}: {} vs {}",
samples[i],
original[i]
);
}
}
#[test]
fn round_trip_bt2020_acescg_bt2020() {
let original = [0.2f32, 0.6, 0.9, 1.0];
let mut samples = original;
decode_to_acescg(
&mut samples,
SourcePrimaries::Bt2020,
SourceTransfer::SdrGamma,
);
acescg_to_output(
&mut samples,
OutputColorSpec {
gamut: OutputGamut::Bt2020,
transfer: OutputTransfer::Srgb,
},
);
for i in 0..3 {
assert!(
approx(samples[i], original[i], 1e-4),
"round trip channel {i}: {} vs {}",
samples[i],
original[i]
);
}
}
#[test]
fn white_stays_white_through_acescg() {
let mut samples = [1.0f32, 1.0, 1.0, 1.0];
decode_to_acescg(&mut samples, SourcePrimaries::Bt709, SourceTransfer::Linear);
for i in 0..3 {
assert!(
approx(samples[i], 1.0, 1e-3),
"white channel {i} = {}",
samples[i]
);
}
}
#[test]
fn srgb_transfer_anchors() {
assert!(approx(srgb_oetf(0.0), 0.0, 1e-6), "oetf(0)");
assert!(approx(srgb_oetf(1.0), 1.0, 1e-6), "oetf(1)");
assert!(approx(srgb_eotf(0.0), 0.0, 1e-6), "eotf(0)");
assert!(approx(srgb_eotf(1.0), 1.0, 1e-6), "eotf(1)");
assert!(
approx(srgb_oetf(0.18), 0.4613, 1e-3),
"oetf(0.18) = {}",
srgb_oetf(0.18)
);
for v in [0.0f32, 0.01, 0.18, 0.5, 0.99, 1.0] {
assert!(approx(srgb_eotf(srgb_oetf(v)), v, 1e-4), "round trip {v}");
}
}
#[test]
fn pq_transfer_anchors() {
assert!(
approx(pq_eotf(1.0), 1.0, 1e-4),
"pq eotf(1) = {}",
pq_eotf(1.0)
);
assert!(
approx(pq_eotf(0.5), 0.01008, 2e-3),
"pq eotf(0.5) = {}",
pq_eotf(0.5)
);
for v in [0.0f32, 0.25, 0.5, 0.75, 1.0] {
assert!(approx(pq_eotf(pq_oetf(v)), v, 1e-3), "pq round trip {v}");
}
}
#[test]
fn hlg_transfer_anchors() {
assert!(approx(hlg_eotf(0.0), 0.0, 1e-6), "hlg eotf(0)");
assert!(
approx(hlg_eotf(1.0), 1.0, 1e-3),
"hlg eotf(1) = {}",
hlg_eotf(1.0)
);
assert!(
approx(hlg_eotf(0.5), 1.0 / 12.0, 1e-3),
"hlg eotf(0.5) = {}",
hlg_eotf(0.5)
);
for v in [0.0f32, 0.1, 0.5, 0.9, 1.0] {
assert!(approx(hlg_eotf(hlg_oetf(v)), v, 1e-3), "hlg round trip {v}");
}
}
#[test]
fn av_color_code_mapping() {
assert_eq!(source_primaries_from_av(1), SourcePrimaries::Bt709);
assert_eq!(source_primaries_from_av(9), SourcePrimaries::Bt2020);
assert_eq!(source_primaries_from_av(12), SourcePrimaries::DisplayP3);
assert_eq!(source_primaries_from_av(2), SourcePrimaries::Unknown);
assert_eq!(source_transfer_from_av(1), SourceTransfer::SdrGamma);
assert_eq!(source_transfer_from_av(4), SourceTransfer::Gamma22);
assert_eq!(source_transfer_from_av(5), SourceTransfer::Gamma28);
assert_eq!(source_transfer_from_av(6), SourceTransfer::SdrGamma);
assert_eq!(source_transfer_from_av(13), SourceTransfer::SdrGamma);
assert_eq!(source_transfer_from_av(14), SourceTransfer::SdrGamma);
assert_eq!(source_transfer_from_av(15), SourceTransfer::SdrGamma);
assert_eq!(source_transfer_from_av(16), SourceTransfer::Pq);
assert_eq!(source_transfer_from_av(18), SourceTransfer::Hlg);
assert_eq!(source_transfer_from_av(8), SourceTransfer::Linear);
assert_eq!(source_transfer_from_av(3), SourceTransfer::Unknown);
assert_eq!(source_transfer_from_av(7), SourceTransfer::Unknown);
}
#[test]
fn setting_round_trips() {
assert_eq!(
WorkingColorSpace::from_setting("acescg"),
WorkingColorSpace::AcesCg
);
assert_eq!(
WorkingColorSpace::from_setting("srgb_legacy"),
WorkingColorSpace::SrgbLegacy
);
assert_eq!(
WorkingColorSpace::from_setting("bogus"),
WorkingColorSpace::AcesCg
);
assert_eq!(
OutputGamut::from_setting("displayp3"),
OutputGamut::DisplayP3
);
assert_eq!(OutputGamut::from_setting("bt2020"), OutputGamut::Bt2020);
assert_eq!(OutputGamut::from_setting(""), OutputGamut::Srgb);
assert_eq!(OutputTransfer::from_setting("pq"), OutputTransfer::Pq);
assert_eq!(OutputTransfer::from_setting("hlg"), OutputTransfer::Hlg);
assert_eq!(
OutputTransfer::from_setting("gamma22"),
OutputTransfer::Gamma22
);
assert_eq!(OutputTransfer::from_setting(""), OutputTransfer::Srgb);
assert_eq!(
OutputColorSpec::from_settings("displayp3", "pq"),
OutputColorSpec {
gamut: OutputGamut::DisplayP3,
transfer: OutputTransfer::Pq
}
);
}
#[test]
fn legacy_working_space_is_pass_through() {
let mut samples = [0.25f32, 0.5, 0.75, 1.0];
let original = samples;
working_to_display_target(
&mut samples,
WorkingColorSpace::SrgbLegacy,
OutputColorSpec::default(),
);
assert_eq!(samples, original);
}
#[test]
fn gamma_transfer_anchors() {
assert!(
approx(gamma_eotf(0.5, 2.4), 0.1894646, 1e-5),
"eotf(0.5, 2.4) = {}",
gamma_eotf(0.5, 2.4)
);
assert!(
approx(gamma_eotf(0.5, 2.2), 0.2176376, 1e-5),
"eotf(0.5, 2.2) = {}",
gamma_eotf(0.5, 2.2)
);
assert!(
approx(gamma_eotf(0.5, 2.8), 0.1435894, 1e-5),
"eotf(0.5, 2.8) = {}",
gamma_eotf(0.5, 2.8)
);
for gamma in [2.2f32, 2.4, 2.8] {
assert!(
approx(gamma_eotf(0.0, gamma), 0.0, 1e-6),
"eotf(0) g{gamma}"
);
assert!(
approx(gamma_eotf(1.0, gamma), 1.0, 1e-6),
"eotf(1) g{gamma}"
);
for v in [0.0f32, 0.18, 0.5, 0.9, 1.0] {
assert!(
approx(gamma_eotf(gamma_oetf(v, gamma), gamma), v, 1e-4),
"gamma round trip {v} @ {gamma}"
);
}
}
}
#[test]
fn lut_fast_transfer_matches_exact() {
let cases: &[(&std::sync::LazyLock<[f32; LUT_N + 1]>, fn(f32) -> f32, &str)] = &[
(&SRGB_EOTF_LUT, srgb_eotf, "srgb_eotf"),
(&SRGB_OETF_LUT, srgb_oetf, "srgb_oetf"),
(&GAMMA22_EOTF_LUT, |v| gamma_eotf(v, 2.2), "gamma22_eotf"),
(&GAMMA22_OETF_LUT, |v| gamma_oetf(v, 2.2), "gamma22_oetf"),
(&GAMMA28_EOTF_LUT, |v| gamma_eotf(v, 2.8), "gamma28_eotf"),
(&PQ_EOTF_LUT, pq_eotf, "pq_eotf"),
(&PQ_OETF_LUT, pq_oetf, "pq_oetf"),
(&HLG_EOTF_LUT, hlg_eotf, "hlg_eotf"),
(&HLG_OETF_LUT, hlg_oetf, "hlg_oetf"),
];
for &(lut, exact, name) in cases {
for k in 0..=1000 {
let v = k as f32 / 1000.0;
for v in [v, -v] {
let fast = lut_tf(lut, exact, v);
let want = exact(v);
assert!(
(fast - want).abs() < 2e-4,
"{name}({v}): fast {fast} vs exact {want}"
);
}
}
}
assert_eq!(lut_tf(&SRGB_OETF_LUT, srgb_oetf, 2.0), srgb_oetf(2.0));
assert_eq!(srgb_eotf_fast(0.02), 0.02 / 12.92);
assert_eq!(srgb_oetf_fast(0.001), 12.92 * 0.001);
}
#[test]
fn parallel_transform_matches_scalar_reference() {
let pixels = 1 << 20;
let mut samples: Vec<f32> = (0..pixels * 4)
.map(|i: usize| ((i.wrapping_mul(2654435761)) % 1000) as f32 / 999.0)
.collect();
let reference: Vec<f32> = samples
.as_chunks::<4>()
.0
.iter()
.flat_map(|px| {
let mut v = [srgb_eotf(px[0]), srgb_eotf(px[1]), srgb_eotf(px[2]), px[3]];
apply_mat(rgb_to_rgb_matrix(PRIMARIES_SRGB, PRIMARIES_AP1), &mut v);
v
})
.collect();
decode_to_acescg(
&mut samples,
SourcePrimaries::Bt709,
SourceTransfer::SdrGamma,
);
for (i, (&got, &want)) in samples.iter().zip(reference.iter()).enumerate() {
assert!(approx(got, want, 1e-4), "pixel {i}: {got} vs {want}");
}
}
#[test]
fn bt709_decode_output_round_trip_is_identity() {
for code in [0.05f32, 0.18, 0.5, 0.75, 1.0] {
let mut samples = [code, code, code, 1.0];
decode_to_acescg(
&mut samples,
SourcePrimaries::Bt709,
SourceTransfer::SdrGamma,
);
acescg_to_output(&mut samples, OutputColorSpec::default());
for i in 0..3 {
assert!(
approx(samples[i], code, 1e-4),
"BT.709 round trip code {code} channel {i}: {}",
samples[i]
);
}
}
let mut bytes = [0u8; 16];
bytes[0..4].copy_from_slice(&0.5f32.to_le_bytes());
bytes[4..8].copy_from_slice(&0.5f32.to_le_bytes());
bytes[8..12].copy_from_slice(&0.5f32.to_le_bytes());
decode_to_acescg_bytes(
&mut bytes,
1,
SourcePrimaries::Bt709,
SourceTransfer::SdrGamma,
);
let r = f32::from_le_bytes(bytes[0..4].try_into().unwrap());
let m = rgb_to_rgb_matrix(PRIMARIES_SRGB, PRIMARIES_AP1);
let expected = mat_vec(m, [srgb_eotf(0.5); 3]);
assert!(approx(r, expected[0], 1e-6), "bytes SdrGamma R: {r}");
}
#[test]
fn output_clamps_out_of_gamut_rgb() {
let mut samples = [1.0f32, 0.0, 0.0, 0.5];
acescg_to_output(
&mut samples,
OutputColorSpec {
gamut: OutputGamut::Srgb,
transfer: OutputTransfer::Srgb,
},
);
let m = rgb_to_rgb_matrix(PRIMARIES_AP1, PRIMARIES_SRGB);
let unclamped = mat_vec(m, [1.0, 0.0, 0.0]);
assert!(
unclamped[0] > 1.0 || unclamped[1] < 0.0 || unclamped[2] < 0.0,
"AP1 red should leave the sRGB gamut, got {unclamped:?}"
);
let expected = [
srgb_oetf(unclamped[0].clamp(0.0, 1.0)),
srgb_oetf(unclamped[1].clamp(0.0, 1.0)),
srgb_oetf(unclamped[2].clamp(0.0, 1.0)),
];
for c in 0..3 {
assert!(
approx(samples[c], expected[c], 1e-6),
"clamped channel {c}: {} vs {}",
samples[c],
expected[c]
);
assert!(
samples[c] >= 0.0 && samples[c] <= 1.0,
"channel {c} escaped [0, 1]: {}",
samples[c]
);
}
assert_eq!(samples[3], 0.5, "alpha must be untouched");
}
#[test]
fn output_bytes_clamps_out_of_gamut_rgb() {
let mut bytes = [0u8; 16];
bytes[0..4].copy_from_slice(&1.0f32.to_le_bytes()); acescg_to_output_bytes(
&mut bytes,
1,
OutputColorSpec {
gamut: OutputGamut::Srgb,
transfer: OutputTransfer::Gamma22,
},
);
let m = rgb_to_rgb_matrix(PRIMARIES_AP1, PRIMARIES_SRGB);
let unclamped = mat_vec(m, [1.0, 0.0, 0.0]);
let expected = gamma_oetf(unclamped[0].clamp(0.0, 1.0), 2.2);
let r = f32::from_le_bytes(bytes[0..4].try_into().unwrap());
assert!(approx(r, expected, 1e-6), "bytes R: {r} vs {expected}");
assert_eq!(f32::from_le_bytes(bytes[12..16].try_into().unwrap()), 0.0);
}
fn yuv_plane16(rows: &[&[u16]], width: usize, stride: usize) -> Vec<u8> {
let mut plane = vec![0u8; rows.len().saturating_sub(1) * stride + width * 2];
for (r, row) in rows.iter().enumerate() {
for (x, s) in row.iter().take(width).enumerate() {
let off = r * stride + x * 2;
plane[off] = s.to_le_bytes()[0];
plane[off + 1] = s.to_le_bytes()[1];
}
}
plane
}
#[test]
fn yuv_bt709_limited_gray_anchors() {
let width = 1;
let height = 2;
let stride = 4;
let y = yuv_plane16(&[&[235 << 8], &[16 << 8]], width, stride);
let u = yuv_plane16(&[&[128 << 8], &[128 << 8]], width, stride);
let v = u.clone();
let mut out = [0.0f32; 8];
yuv444p16_to_rgb_f32(
&y,
stride,
&u,
stride,
&v,
stride,
width,
height,
YuvMatrix::Bt709,
false,
&mut out,
);
for c in 0..3 {
assert!(approx(out[c], 1.0, 1e-4), "white channel {c}: {}", out[c]);
assert!(
approx(out[4 + c], 0.0, 1e-4),
"black channel {c}: {}",
out[4 + c]
);
}
assert_eq!(out[3], 1.0, "alpha");
assert_eq!(out[7], 1.0, "alpha");
}
#[test]
fn yuv_bt601_full_range_green_anchor() {
let width = 1;
let height = 1;
let stride = 2;
let y = yuv_plane16(&[&[38469]], width, stride);
let u = yuv_plane16(&[&[11059]], width, stride);
let v = yuv_plane16(&[&[5329]], width, stride);
let mut out = [0.0f32; 4];
yuv444p16_to_rgb_f32(
&y,
stride,
&u,
stride,
&v,
stride,
width,
height,
YuvMatrix::Bt601,
true,
&mut out,
);
assert!(approx(out[0], 0.0, 1e-3), "R = {}", out[0]);
assert!(approx(out[1], 1.0, 1e-3), "G = {}", out[1]);
assert!(approx(out[2], 0.0, 1e-3), "B = {}", out[2]);
}
#[test]
fn yuv_bt709_limited_green_anchor() {
let width = 1;
let height = 1;
let stride = 2;
let y = yuv_plane16(&[&[44193]], width, stride);
let u = yuv_plane16(&[&[10666]], width, stride);
let v = yuv_plane16(&[&[6725]], width, stride);
let mut out = [0.0f32; 4];
yuv444p16_to_rgb_f32(
&y,
stride,
&u,
stride,
&v,
stride,
width,
height,
YuvMatrix::Bt709,
false,
&mut out,
);
assert!(approx(out[0], 0.0, 1e-3), "R = {}", out[0]);
assert!(approx(out[1], 1.0, 1e-3), "G = {}", out[1]);
assert!(approx(out[2], 0.0, 1e-3), "B = {}", out[2]);
}
#[test]
fn yuv_short_planes_return_without_panic() {
let mut out = [7.0f32; 4];
yuv444p16_to_rgb_f32(
&[0u8; 4],
4,
&[0u8; 4],
4,
&[0u8; 4],
4,
2,
2,
YuvMatrix::Bt709,
false,
&mut out,
);
assert_eq!(out, [7.0; 4]);
let mut out2 = [7.0f32; 3];
yuv444p16_to_rgb_f32(
&[0u8; 8],
4,
&[0u8; 8],
4,
&[0u8; 8],
4,
2,
2,
YuvMatrix::Bt709,
false,
&mut out2,
);
assert_eq!(out2, [7.0; 3]);
}
#[test]
fn output_srgb_to_xyz_d65_white_and_red() {
let spec = OutputColorSpec {
gamut: OutputGamut::Srgb,
transfer: OutputTransfer::Srgb,
};
let mut white = [1.0f32, 1.0, 1.0, 1.0];
output_spec_to_xyz_d65(&mut white, spec);
let expected = WHITE_D65.xyz_unit_luminance();
for i in 0..3 {
assert!(
approx(white[i], expected[i], 1e-4),
"white → XYZ mismatch at {i}: {} vs {}",
white[i],
expected[i]
);
}
let mut red = [1.0f32, 0.0, 0.0, 1.0];
output_spec_to_xyz_d65(&mut red, spec);
let m = rgb_to_xyz_matrix(PRIMARIES_SRGB);
for i in 0..3 {
assert!(
approx(red[i], m[i][0], 1e-6),
"red → XYZ mismatch at {i}: {} vs {}",
red[i],
m[i][0]
);
}
assert!(approx(red[0], 0.4124, 1e-3), "X = {}", red[0]);
assert!(approx(red[1], 0.2126, 1e-3), "Y = {}", red[1]);
assert!(approx(red[2], 0.0193, 1e-3), "Z = {}", red[2]);
}
#[test]
fn output_spec_to_xyz_d65_honors_transfer() {
let spec = OutputColorSpec {
gamut: OutputGamut::Srgb,
transfer: OutputTransfer::Gamma22,
};
let mut s = [0.5f32, 0.5, 0.5, 1.0];
output_spec_to_xyz_d65(&mut s, spec);
let m = rgb_to_xyz_matrix(PRIMARIES_SRGB);
let expected = mat_vec(m, [gamma_eotf(0.5, 2.2); 3]);
for i in 0..3 {
assert!(
approx(s[i], expected[i], 1e-6),
"Gamma22 → XYZ channel {i}: {} vs {}",
s[i],
expected[i]
);
}
for transfer in [OutputTransfer::Pq, OutputTransfer::Hlg] {
let spec = OutputColorSpec {
gamut: OutputGamut::DisplayP3,
transfer,
};
let mut w = [1.0f32, 1.0, 1.0, 1.0];
output_spec_to_xyz_d65(&mut w, spec);
let expected = WHITE_D65.xyz_unit_luminance();
for i in 0..3 {
assert!(
approx(w[i], expected[i], 1e-4),
"{transfer:?} white → XYZ mismatch at {i}: {} vs {}",
w[i],
expected[i]
);
}
}
}
}