use zenpixels::{
AlphaMode, ChannelLayout, ChannelType, ColorPrimaries, PixelDescriptor, TransferFunction,
};
use zenpixels_convert::RowConverter;
#[allow(clippy::excessive_precision)]
const ADOBE_GAMMA: f32 = 2.19921875;
fn gamma22_to_linear(v: f32) -> f32 {
v.max(0.0).powf(ADOBE_GAMMA)
}
fn linear_to_gamma22(v: f32) -> f32 {
v.max(0.0).powf(1.0 / ADOBE_GAMMA)
}
fn f32_rgb_linear() -> PixelDescriptor {
PixelDescriptor::RGBF32_LINEAR.with_primaries(ColorPrimaries::AdobeRgb)
}
fn f32_rgb_gamma22() -> PixelDescriptor {
PixelDescriptor::new(
ChannelType::F32,
ChannelLayout::Rgb,
None,
TransferFunction::Gamma22,
)
.with_primaries(ColorPrimaries::AdobeRgb)
}
fn convert_f32_rgb(src_desc: PixelDescriptor, dst_desc: PixelDescriptor, src: &[f32]) -> Vec<f32> {
let mut conv = RowConverter::new(src_desc, dst_desc).unwrap();
let width = (src.len() / 3) as u32;
let mut dst = vec![0.0f32; src.len()];
conv.convert_row(
bytemuck::cast_slice(src),
bytemuck::cast_slice_mut(&mut dst),
width,
);
dst
}
#[test]
fn gamma22_to_linear_f32_matches_powf() {
let src_desc = f32_rgb_gamma22();
let dst_desc = f32_rgb_linear();
let src: Vec<f32> = (0..=20).flat_map(|i| [i as f32 / 20.0; 3]).collect();
let dst = convert_f32_rgb(src_desc, dst_desc, &src);
for (s, d) in src.iter().zip(dst.iter()) {
let expected = gamma22_to_linear(*s);
assert!(
(d - expected).abs() < 1e-3,
"EOTF mismatch: gamma22({s}) -> {d}, expected {expected}"
);
}
}
#[test]
fn linear_f32_to_gamma22_matches_powf() {
let src_desc = f32_rgb_linear();
let dst_desc = f32_rgb_gamma22();
let src: Vec<f32> = (0..=20).flat_map(|i| [i as f32 / 20.0; 3]).collect();
let dst = convert_f32_rgb(src_desc, dst_desc, &src);
for (s, d) in src.iter().zip(dst.iter()) {
let expected = linear_to_gamma22(*s);
assert!(
(d - expected).abs() < 1e-3,
"OETF mismatch: linear({s}) -> {d}, expected {expected}"
);
}
}
#[test]
fn gamma22_linear_roundtrip_f32() {
let src: Vec<f32> = (0..=100).flat_map(|i| [i as f32 / 100.0; 3]).collect();
let lin = convert_f32_rgb(f32_rgb_gamma22(), f32_rgb_linear(), &src);
let back = convert_f32_rgb(f32_rgb_linear(), f32_rgb_gamma22(), &lin);
for (s, b) in src.iter().zip(back.iter()) {
assert!(
(s - b).abs() < 5e-4,
"roundtrip drift: {s} -> {b} (delta {})",
(s - b).abs()
);
}
}
#[test]
fn gamma22_to_srgb_f32_via_linear() {
let src_desc = PixelDescriptor::new(
ChannelType::F32,
ChannelLayout::Rgb,
None,
TransferFunction::Gamma22,
);
let dst_desc = PixelDescriptor::new(
ChannelType::F32,
ChannelLayout::Rgb,
None,
TransferFunction::Srgb,
);
let src: Vec<f32> = (0..=10).flat_map(|i| [i as f32 / 10.0; 3]).collect();
let dst = convert_f32_rgb(src_desc, dst_desc, &src);
for (s, d) in src.iter().zip(dst.iter()) {
let lin = gamma22_to_linear(*s);
let expected = linear_srgb::tf::linear_to_srgb(lin);
assert!(
(d - expected).abs() < 2e-3,
"gamma22->srgb via linear: {s} -> {d}, expected {expected}"
);
}
}
#[test]
fn adobe_rgb_to_bt2020_pq_f32_preserves_neutral_gray() {
let src_desc = PixelDescriptor::new(
ChannelType::F32,
ChannelLayout::Rgb,
None,
TransferFunction::Gamma22,
)
.with_primaries(ColorPrimaries::AdobeRgb);
let dst_desc = PixelDescriptor::new(
ChannelType::F32,
ChannelLayout::Rgb,
None,
TransferFunction::Pq,
)
.with_primaries(ColorPrimaries::Bt2020);
let src: Vec<f32> = [0.25f32, 0.5, 0.75].iter().flat_map(|&v| [v; 3]).collect();
let dst = convert_f32_rgb(src_desc, dst_desc, &src);
for (chunk, &sv) in dst.chunks_exact(3).zip([0.25f32, 0.5, 0.75].iter()) {
let lin = gamma22_to_linear(sv);
let pq_expected = linear_srgb::tf::linear_to_pq(lin);
for &d in chunk {
assert!(
(d - pq_expected).abs() < 5e-3,
"gray axis not preserved: got {d}, expected {pq_expected} (src {sv})"
);
}
}
}
#[test]
fn adobe_rgb_u8_to_srgb_u8_neutral_gray() {
let src_desc = PixelDescriptor::new(
ChannelType::U8,
ChannelLayout::Rgb,
None,
TransferFunction::Gamma22,
)
.with_primaries(ColorPrimaries::AdobeRgb);
let dst_desc = PixelDescriptor::RGB8_SRGB.with_primaries(ColorPrimaries::Bt709);
let src = [64u8, 64, 64, 128, 128, 128, 200, 200, 200];
let mut dst = [0u8; 9];
let mut conv = RowConverter::new(src_desc, dst_desc).unwrap();
conv.convert_row(&src, &mut dst, 3);
for px in dst.chunks_exact(3) {
assert!(
(px[0] as i32 - px[1] as i32).abs() <= 1 && (px[1] as i32 - px[2] as i32).abs() <= 1,
"neutral gray should stay neutral: {px:?}"
);
}
assert!(
dst[3] >= src[3],
"mid gray should not darken: {} -> {}",
src[3],
dst[3]
);
}
#[test]
fn adobe_rgb_rgba_f32_to_bt709_rgba_f32_opaque_alpha_preserved() {
let src_desc = PixelDescriptor::new(
ChannelType::F32,
ChannelLayout::Rgba,
Some(AlphaMode::Straight),
TransferFunction::Gamma22,
)
.with_primaries(ColorPrimaries::AdobeRgb);
let dst_desc = PixelDescriptor::new(
ChannelType::F32,
ChannelLayout::Rgba,
Some(AlphaMode::Straight),
TransferFunction::Srgb,
)
.with_primaries(ColorPrimaries::Bt709);
let src = [0.5f32, 0.3, 0.7, 1.0, 0.9, 0.8, 0.6, 1.0];
let mut dst = [0.0f32; 8];
let mut conv = RowConverter::new(src_desc, dst_desc).unwrap();
conv.convert_row(
bytemuck::cast_slice(&src),
bytemuck::cast_slice_mut(&mut dst),
2,
);
assert!((dst[3] - 1.0).abs() < 1e-4, "alpha ch0: {}", dst[3]);
assert!((dst[7] - 1.0).abs() < 1e-4, "alpha ch1: {}", dst[7]);
let rgb_changed = dst[0] != src[0] || dst[1] != src[1] || dst[2] != src[2];
assert!(rgb_changed, "RGB channels should be transformed");
}