use crate::adapt::{convert_buffer_with_anchor, convert_into_with_anchor};
use crate::error::ConvertError;
use crate::{PixelBuffer, PixelDescriptor, PixelFormat, PixelSlice, TransferFunction};
use alloc::sync::Arc;
use whereat::At;
use zenpixels::{Cicp, ColorContext};
pub use zenpixels::hdr::{ContentLightLevel, MasteringDisplay};
use zenpixels::hdr::DiffuseWhite;
#[deprecated(
since = "0.2.14",
note = "redundant with zencodec::Metadata and frozen-shaped; carry ContentLightLevel / MasteringDisplay directly. Removal queued for 0.3.0."
)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HdrMetadata {
pub transfer: TransferFunction,
pub content_light_level: Option<ContentLightLevel>,
pub mastering_display: Option<MasteringDisplay>,
}
#[allow(deprecated)]
impl HdrMetadata {
#[must_use]
pub fn is_hdr(&self) -> bool {
matches!(self.transfer, TransferFunction::Pq | TransferFunction::Hlg)
}
#[must_use]
pub fn is_sdr(&self) -> bool {
!self.is_hdr()
}
pub fn hdr10(cll: ContentLightLevel) -> Self {
Self {
transfer: TransferFunction::Pq,
content_light_level: Some(cll),
mastering_display: Some(MasteringDisplay::HDR10_REFERENCE),
}
}
pub fn hlg() -> Self {
Self {
transfer: TransferFunction::Hlg,
content_light_level: None,
mastering_display: None,
}
}
}
#[inline]
#[must_use]
pub fn reinhard_tonemap(v: f32) -> f32 {
let v = v.max(0.0);
if v == f32::INFINITY {
return 1.0;
}
v / (1.0 + v)
}
#[inline]
#[must_use]
pub fn reinhard_inverse(v: f32) -> f32 {
let v = v.max(0.0);
if v >= 1.0 {
return f32::MAX;
}
v / (1.0 - v)
}
#[cfg(feature = "std")]
#[inline]
#[must_use]
#[allow(clippy::manual_clamp)]
pub fn exposure_tonemap(v: f32, exposure: f32) -> f32 {
(v * 2.0f32.powf(exposure)).max(0.0).min(1.0)
}
fn quantize_setup(
px: &PixelSlice<'_>,
target: PixelDescriptor,
) -> Result<(PixelDescriptor, DiffuseWhite, u32, u32), At<ConvertError>> {
let diffuse_white = px
.color_context()
.and_then(|c| c.diffuse_white)
.unwrap_or(DiffuseWhite::BT2408);
let desc = px.descriptor();
let src = match desc.pixel_format() {
PixelFormat::RgbF32 => PixelDescriptor::RGBF32_LINEAR,
PixelFormat::RgbaF32 => PixelDescriptor::RGBAF32_LINEAR,
_ => return Err(whereat::at!(ConvertError::NoMatch { source: desc })),
}
.with_primaries(target.primaries);
if desc.transfer != TransferFunction::Linear {
return Err(whereat::at!(ConvertError::UnsupportedTransfer {
from: desc.transfer,
to: TransferFunction::Linear,
}));
}
if target.transfer != TransferFunction::Pq {
return Err(whereat::at!(ConvertError::NoPath {
from: desc,
to: target,
}));
}
let w = px.width();
let h = px.rows();
if w == 0 || h == 0 {
return Err(whereat::at!(ConvertError::InvalidWidth(w)));
}
Ok((src, diffuse_white, w, h))
}
pub fn quantize_to(
px: PixelSlice<'_>,
target: PixelDescriptor,
) -> Result<PixelBuffer, At<ConvertError>> {
let (src, diffuse_white, w, h) = quantize_setup(&px, target)?;
let out = convert_buffer_with_anchor(
px.as_strided_bytes(),
w,
h,
px.stride(),
src,
target,
diffuse_white,
)?;
let context = match Cicp::from_descriptor(&target) {
Some(cicp) => ColorContext::from_cicp(cicp),
None => ColorContext::default(),
}
.with_diffuse_white(diffuse_white);
Ok(out.with_color_context(Arc::new(context)))
}
#[allow(dead_code)]
pub(crate) fn quantize_into(
px: PixelSlice<'_>,
target: PixelDescriptor,
dst: &mut [u8],
dst_stride: usize,
) -> Result<(), At<ConvertError>> {
let (src, diffuse_white, w, h) = quantize_setup(&px, target)?;
convert_into_with_anchor(
px.as_strided_bytes(),
w,
h,
px.stride(),
src,
target,
diffuse_white,
dst,
dst_stride,
)
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::*;
#[test]
fn reinhard_boundaries() {
assert_eq!(reinhard_tonemap(0.0), 0.0);
assert!((reinhard_tonemap(1.0) - 0.5).abs() < 1e-6);
assert!(reinhard_tonemap(1000.0) > 0.99);
assert!(reinhard_tonemap(1000.0) < 1.0);
}
#[test]
fn reinhard_roundtrip() {
for &v in &[0.0, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0] {
let mapped = reinhard_tonemap(v);
let unmapped = reinhard_inverse(mapped);
assert!(
(unmapped - v).abs() < 1e-4,
"Reinhard roundtrip failed for {v}: got {unmapped}"
);
}
}
#[test]
fn hdr_metadata_is_hdr() {
assert!(HdrMetadata::hdr10(ContentLightLevel::default()).is_hdr());
assert!(HdrMetadata::hlg().is_hdr());
assert!(
HdrMetadata {
transfer: TransferFunction::Srgb,
content_light_level: None,
mastering_display: None,
}
.is_sdr()
);
}
#[test]
fn hdr10_constructor() {
let cll = ContentLightLevel::new(4000, 1000);
let meta = HdrMetadata::hdr10(cll);
assert!(meta.is_hdr());
assert_eq!(meta.transfer, TransferFunction::Pq);
assert_eq!(meta.content_light_level, Some(cll));
assert!(meta.mastering_display.is_some());
}
#[test]
fn hlg_constructor() {
let meta = HdrMetadata::hlg();
assert!(meta.is_hdr());
assert_eq!(meta.transfer, TransferFunction::Hlg);
assert!(meta.content_light_level.is_none());
assert!(meta.mastering_display.is_none());
}
#[test]
#[cfg(feature = "std")]
fn exposure_tonemap_values() {
assert!((exposure_tonemap(0.5, 0.0) - 0.5).abs() < 1e-6);
assert!((exposure_tonemap(0.25, 1.0) - 0.5).abs() < 1e-5);
assert!((exposure_tonemap(0.5, -1.0) - 0.25).abs() < 1e-5);
assert_eq!(exposure_tonemap(0.8, 1.0), 1.0);
assert_eq!(exposure_tonemap(0.0, 5.0), 0.0);
}
#[test]
fn reinhard_inverse_at_one() {
assert_eq!(reinhard_inverse(1.0), f32::MAX);
}
#[test]
fn hdr_metadata_clone_partial_eq() {
let a = HdrMetadata::hlg();
let b = a;
assert_eq!(a, b);
}
fn reinhard_f64(v: f64) -> f64 {
v / (1.0 + v)
}
#[test]
fn reinhard_clamps_negatives_and_nan_to_zero() {
assert_eq!(reinhard_tonemap(-0.25), 0.0);
assert_eq!(reinhard_tonemap(-1.0), 0.0);
assert_eq!(reinhard_tonemap(-2.0), 0.0);
assert_eq!(reinhard_tonemap(f32::NEG_INFINITY), 0.0);
assert_eq!(reinhard_tonemap(f32::NAN), 0.0);
assert_eq!(reinhard_inverse(-0.25), 0.0);
assert_eq!(reinhard_inverse(-1.0), 0.0);
assert_eq!(reinhard_inverse(f32::NAN), 0.0);
}
#[test]
fn reinhard_infinity_saturates_to_one() {
assert_eq!(reinhard_tonemap(f32::INFINITY), 1.0);
assert_eq!(reinhard_tonemap(f32::MAX), 1.0);
}
#[test]
fn reinhard_output_range_and_monotonicity() {
let grid: [f32; 13] = [
0.0,
1e-6,
1e-3,
0.05,
0.1,
0.5,
1.0,
2.0,
10.0,
1e3,
1e6,
1e9,
f32::MAX,
];
let mut prev = -1.0f32;
for &v in &grid {
let out = reinhard_tonemap(v);
assert!(
(0.0..=1.0).contains(&out) && out.is_finite(),
"reinhard_tonemap({v}) = {out} escapes [0, 1]"
);
assert!(out >= prev, "not monotonic at {v}: {out} < {prev}");
if v <= 1e6 && prev >= 0.0 {
assert!(out > prev, "not strictly increasing at {v}");
}
prev = out;
}
}
#[test]
fn reinhard_matches_f64_oracle() {
for &v in &[0.0f32, 1e-6, 1e-3, 0.1, 0.5, 1.0, 2.0, 10.0, 1e3, 1e5] {
let got = reinhard_tonemap(v) as f64;
let want = reinhard_f64(v as f64);
assert!(
(got - want).abs() < 1e-6,
"f32 impl diverges from f64 oracle at {v}: {got} vs {want}"
);
}
}
#[test]
fn reinhard_roundtrip_relative_error_bound() {
let mut v = 1e-4f32;
while v <= 1e4 {
let rt = reinhard_inverse(reinhard_tonemap(v));
let rel = ((f64::from(rt) - f64::from(v)) / f64::from(v)).abs();
let bound = 4.0 * f64::from(f32::EPSILON) * (1.0 + f64::from(v));
assert!(
rel < bound,
"roundtrip rel err {rel} > bound {bound} at {v} (got {rt})"
);
v *= 3.7;
}
}
#[test]
#[cfg(feature = "std")]
fn exposure_tonemap_nan_maps_to_zero() {
assert_eq!(exposure_tonemap(f32::NAN, 0.0), 0.0);
assert_eq!(exposure_tonemap(f32::NAN, 2.0), 0.0);
assert_eq!(exposure_tonemap(-0.5, 0.0), 0.0);
}
use alloc::vec;
use alloc::vec::Vec;
fn pq_oracle(x: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
let m1 = 2610.0 / 16384.0;
let m2 = 2523.0 / 4096.0 * 128.0;
let c1 = 3424.0 / 4096.0;
let c2 = 2413.0 / 4096.0 * 32.0;
let c3 = 2392.0 / 4096.0 * 32.0;
let xp = x.powf(m1);
((c1 + c2 * xp) / (1.0 + c3 * xp)).powf(m2)
}
fn rgbf32(pixels: &[[f32; 3]], w: u32, h: u32) -> PixelBuffer {
let mut data = Vec::with_capacity(pixels.len() * 12);
for p in pixels {
for c in p {
data.extend_from_slice(&c.to_ne_bytes());
}
}
PixelBuffer::from_vec(data, w, h, PixelDescriptor::RGBF32_LINEAR).unwrap()
}
fn rgbaf32(pixels: &[[f32; 4]], w: u32, h: u32) -> PixelBuffer {
let mut data = Vec::with_capacity(pixels.len() * 16);
for p in pixels {
for c in p {
data.extend_from_slice(&c.to_ne_bytes());
}
}
PixelBuffer::from_vec(data, w, h, PixelDescriptor::RGBAF32_LINEAR).unwrap()
}
fn rgba16_pq() -> PixelDescriptor {
PixelDescriptor::RGBA16
.with_transfer(TransferFunction::Pq)
.with_primaries(PixelDescriptor::RGB16_BT2100_PQ.primaries)
}
#[test]
fn quantize_to_pq16_white_and_peak() {
let peak = 10_000.0 / 203.0;
let buf = rgbf32(&[[1.0; 3], [peak; 3]], 2, 1);
let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
assert_eq!(out.descriptor(), PixelDescriptor::RGB16_BT2100_PQ);
let bytes = out.as_slice().as_strided_bytes();
let code = |i: usize| u16::from_ne_bytes([bytes[2 * i], bytes[2 * i + 1]]);
let want_white = (pq_oracle(203.0 / 10_000.0) * 65535.0).round() as i64;
assert!((i64::from(code(0)) - want_white).abs() <= 1);
assert_eq!(code(3), 65535, "10000-nit peak clips to full code");
}
#[test]
fn quantize_to_pq16_matches_oracle_across_decades() {
let values = [0.001f32, 0.01, 0.1, 0.5, 1.0, 2.0, 8.0, 20.0, 49.0];
let pixels: Vec<[f32; 3]> = values.iter().map(|&v| [v; 3]).collect();
let buf = rgbf32(&pixels, values.len() as u32, 1);
let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
let bytes = out.as_slice().as_strided_bytes();
for (i, &v) in values.iter().enumerate() {
let got = i64::from(u16::from_ne_bytes([bytes[6 * i], bytes[6 * i + 1]]));
let x = f64::from(v) * 203.0 / 10_000.0;
let want = (pq_oracle(x) * 65535.0).round() as i64;
assert!(
(got - want).abs() <= 1,
"PQ16 at {v}: got {got}, oracle {want}"
);
}
}
#[test]
fn quantize_to_rejects_non_pq_target_and_non_linear_src() {
let buf = rgbf32(&[[0.5; 3]], 1, 1);
let err = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_HLG).unwrap_err();
assert!(matches!(*err.error(), ConvertError::NoPath { .. }));
let srgb = PixelDescriptor::RGBF32_LINEAR.with_transfer(TransferFunction::Srgb);
let mut d = Vec::new();
for c in [0.5f32; 3] {
d.extend_from_slice(&c.to_ne_bytes());
}
let nb = PixelBuffer::from_vec(d, 1, 1, srgb).unwrap();
assert!(quantize_to(nb.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).is_err());
}
#[test]
fn quantize_to_reads_anchor_from_color_context() {
use alloc::sync::Arc;
use zenpixels::{Cicp, ColorContext};
let buf = rgbf32(&[[1.0; 3]], 1, 1).with_color_context(Arc::new(
ColorContext::from_cicp(Cicp::BT2100_PQ).with_diffuse_white(DiffuseWhite::new(100.0)),
));
let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
let bytes = out.as_slice().as_strided_bytes();
let got = i64::from(u16::from_ne_bytes([bytes[0], bytes[1]]));
let want = (pq_oracle(100.0 / 10_000.0) * 65535.0).round() as i64;
assert!(
(got - want).abs() <= 1,
"100-nit anchor: got {got}, want {want}"
);
let want_203 = (pq_oracle(203.0 / 10_000.0) * 65535.0).round() as i64;
assert_ne!(want, want_203);
}
#[test]
fn quantize_to_preserves_alpha_for_rgba_target() {
let target = rgba16_pq();
let buf = rgbaf32(&[[1.0, 1.0, 1.0, 0.5], [2.0, 2.0, 2.0, 0.25]], 2, 1);
let out = quantize_to(buf.as_slice(), target).unwrap();
assert_eq!(out.descriptor(), target);
let bytes = out.as_slice().as_strided_bytes();
let code = |i: usize| u16::from_ne_bytes([bytes[2 * i], bytes[2 * i + 1]]);
for (px, g, a) in [(0usize, 1.0f64, 0.5f64), (1, 2.0, 0.25)] {
let r = i64::from(code(px * 4));
let want_rgb = (pq_oracle(g * 203.0 / 10_000.0) * 65535.0).round() as i64;
assert!(
(r - want_rgb).abs() <= 1,
"rgb @203: got {r} want {want_rgb}"
);
let alpha = code(px * 4 + 3);
let want_a = (a * 65535.0).round() as u16;
assert_eq!(
alpha, want_a,
"alpha linear passthrough: got {alpha} want {want_a}"
);
}
}
#[test]
fn quantize_to_honors_strided_input() {
let target = PixelDescriptor::RGB16_BT2100_PQ;
let stride = 2 * 12 + 12; let mut data = vec![0u8; stride * 2];
for y in 0..2usize {
let mut off = y * stride;
for c in [0.1f32, 0.1, 0.1, 1.0, 1.0, 1.0] {
data[off..off + 4].copy_from_slice(&c.to_ne_bytes());
off += 4;
}
data[off..off + 4].copy_from_slice(&999.0f32.to_ne_bytes()); }
let strided = PixelSlice::new(&data, 2, 2, stride, PixelDescriptor::RGBF32_LINEAR).unwrap();
let got = quantize_to(strided, target).unwrap();
let packed = rgbf32(&[[0.1; 3], [1.0; 3], [0.1; 3], [1.0; 3]], 2, 2);
let want = quantize_to(packed.as_slice(), target).unwrap();
assert_eq!(
got.as_slice().as_strided_bytes(),
want.as_slice().as_strided_bytes(),
"strided input must quantize identically to packed"
);
}
#[test]
fn quantize_into_matches_quantize_to() {
let target = PixelDescriptor::RGB16_BT2100_PQ;
let buf = rgbf32(&[[0.1; 3], [1.0; 3], [2.0; 3]], 3, 1);
let want = quantize_to(buf.as_slice(), target).unwrap();
let row = 3 * target.bytes_per_pixel();
let mut dst = vec![0u8; row];
quantize_into(buf.as_slice(), target, &mut dst, row).unwrap();
assert_eq!(dst, want.as_slice().as_strided_bytes());
}
#[test]
fn quantize_into_honors_dst_stride() {
let target = PixelDescriptor::RGB16_BT2100_PQ;
let buf = rgbf32(&[[0.1; 3], [1.0; 3], [0.1; 3], [1.0; 3]], 2, 2);
let want = quantize_to(buf.as_slice(), target).unwrap();
let want_bytes = want.as_slice().as_strided_bytes();
let row = 2 * target.bytes_per_pixel(); let dst_stride = row + 8; let mut dst = vec![0xAAu8; dst_stride * 2];
quantize_into(buf.as_slice(), target, &mut dst, dst_stride).unwrap();
for y in 0..2 {
assert_eq!(
&dst[y * dst_stride..y * dst_stride + row],
&want_bytes[y * row..(y + 1) * row]
);
assert!(
dst[y * dst_stride + row..y * dst_stride + dst_stride]
.iter()
.all(|&b| b == 0xAA),
"padding row {y} must be untouched"
);
}
}
#[test]
fn quantize_into_rejects_undersized_dst() {
let target = PixelDescriptor::RGB16_BT2100_PQ;
let buf = rgbf32(&[[1.0; 3]], 1, 1);
let mut dst = vec![0u8; 2]; let row = target.bytes_per_pixel();
let err = quantize_into(buf.as_slice(), target, &mut dst, row).unwrap_err();
assert!(matches!(*err.error(), ConvertError::BufferSize { .. }));
}
#[test]
fn quantize_to_carries_diffuse_white_anchor_onto_output() {
use alloc::sync::Arc;
use zenpixels::{Cicp, ColorContext};
let target = PixelDescriptor::RGB16_BT2100_PQ;
let buf = rgbf32(&[[1.0; 3]], 1, 1).with_color_context(Arc::new(
ColorContext::from_cicp(Cicp::BT2100_PQ).with_diffuse_white(DiffuseWhite::new(100.0)),
));
let out = quantize_to(buf.as_slice(), target).unwrap();
let ctx = out.color_context().expect("output carries a ColorContext");
assert_eq!(ctx.diffuse_white, Some(DiffuseWhite::new(100.0)));
assert!(ctx.cicp.is_some(), "target CICP rides along for re-encode");
let plain = rgbf32(&[[1.0; 3]], 1, 1);
let out = quantize_to(plain.as_slice(), target).unwrap();
assert_eq!(
out.color_context().unwrap().diffuse_white,
Some(DiffuseWhite::BT2408)
);
}
}