Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/image-codecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ also passed to the underlying decoders. A violation returns `CodecError::LimitEx
defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm).
PNG also bounds the aggregate decoded text and XMP (UTF-8 keywords and values) by `max_alloc`,
including chunks after IDAT. This budget is separate from pixels, not a total process-memory cap.
PNG `cICP` and `cLLI` values are retained as image color metadata. `encode_png_pq` writes linear-light
Rec. 2020 RGB as 16-bit BT.2100 PQ, with measured content-light levels and the required chunks.

## Tests

Expand Down
39 changes: 37 additions & 2 deletions crates/image-codecs/src/cicp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ impl Cicp {
pub const TRANSFER_PQ: u16 = 16;
/// ARIB STD-B67 / BT.2100 hybrid log-gamma.
pub const TRANSFER_HLG: u16 = 18;
/// Linear light.
pub const TRANSFER_LINEAR: u16 = 8;

/// PQ or HLG: samples that [`linearize`] decodes to linear light.
pub fn is_hdr(self) -> bool {
Expand All @@ -41,6 +43,15 @@ pub const HLG_PEAK_NITS: f32 = 1000.0;
/// PQ's absolute peak.
pub const PQ_PEAK_NITS: f32 = 10_000.0;

/// HDR content light levels in the units used by PNG `cLLI` (0.0001 cd/m² per unit).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContentLightLevel {
/// Maximum content light level in 0.0001 cd/m² units.
pub max_content_light_level: u32,
/// Maximum frame-average light level in 0.0001 cd/m² units.
pub max_frame_average_light_level: u32,
}

/// BT.2100 HLG inverse OETF: non-linear signal `E'` (0..1) to normalised scene light `E` (0..1).
pub fn hlg_inverse_oetf(e: f32) -> f32 {
const A: f32 = 0.178_832_77;
Expand Down Expand Up @@ -74,6 +85,18 @@ pub fn pq_to_linear(e: f32) -> f32 {
y * PQ_PEAK_NITS / REFERENCE_WHITE_NITS
}

/// BT.2100 PQ signal for linear light relative to [`REFERENCE_WHITE_NITS`].
pub fn linear_to_pq(linear: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let nits = if linear.is_finite() { linear.max(0.0) * REFERENCE_WHITE_NITS } else { 0.0 };
let y = (nits / PQ_PEAK_NITS).clamp(0.0, 1.0).powf(M1);
((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2)
}

/// Relative luminance weights of the RGB primaries `code` (BT.2020's for anything else, the
/// space BT.2100 defines HLG in).
fn luma_weights(code: u16) -> [f32; 3] {
Expand Down Expand Up @@ -104,7 +127,8 @@ pub fn linearize(img: Image, cicp: Cicp) -> Result<Image, CodecError> {
let mut out = Image::from_raw(w, h, layout, SampleType::F32, v.iter().flat_map(|x| x.to_ne_bytes()).collect())?;
out.meta = img.meta;
out.warnings = img.warnings;
out.cicp = Some(cicp);
// CICP describes the samples, not their source encoding. These samples are now linear.
out.cicp = Some(Cicp { transfer: Cicp::TRANSFER_LINEAR, matrix: 0, full_range: true, ..cicp });
Ok(out)
}

Expand Down Expand Up @@ -156,7 +180,10 @@ mod tests {
assert!(close(pq_to_linear(1.0), 10_000.0 / 203.0, 0.05));
// 203 cd/m² is PQ signal ≈ 0.5807 (BT.2408).
assert!(close(pq_to_linear(0.5807), 1.0, 0.005), "{}", pq_to_linear(0.5807));
assert!(close(linear_to_pq(1.0), 0.5807, 0.0001));
assert!(close(pq_to_linear(linear_to_pq(4.0)), 4.0, 0.001));
assert!(pq_to_linear(f32::NAN).is_finite());
assert!(linear_to_pq(f32::NAN).is_finite());
}

#[test]
Expand All @@ -167,7 +194,15 @@ mod tests {
.unwrap()
.with_icc(Some(b"encoded-profile".to_vec()));
let out = linearize(img, hlg).unwrap();
assert_eq!((out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp), (SampleType::F32, ChannelLayout::Rgba, None, Some(hlg)));
assert_eq!(
(out.sample_type(), out.layout(), out.icc.as_deref(), out.cicp),
(
SampleType::F32,
ChannelLayout::Rgba,
None,
Some(Cicp { primaries: hlg.primaries, transfer: Cicp::TRANSFER_LINEAR, matrix: 0, full_range: true })
)
);
let v = out.to_normalized();
assert!(v[..3].iter().all(|&x| close(x, 1.0, 0.005)), "{v:?}");
assert!(close(v[3], 1.0, 1e-6) && v[4..7] == [0.0; 3] && close(v[7], 0.5, 1e-3), "{v:?}");
Expand Down
209 changes: 201 additions & 8 deletions crates/image-codecs/src/codecs/png.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,10 @@ impl<B: AsRef<[u8]>> PngDecodeRows<B> {
let info = self.reader.info();
let icc = info.icc_profile.as_ref().map(|c| c.to_vec());
let mut meta = Metadata { exif: info.exif_metadata.as_ref().map(|c| c.to_vec()), ..Default::default() };
meta.content_light_level = info.content_light_level.map(|light| crate::cicp::ContentLightLevel {
max_content_light_level: light.max_content_light_level,
max_frame_average_light_level: light.max_frame_average_light_level,
});
if let Some(d) = info.pixel_dims
&& d.unit == png::Unit::Meter
&& d.xppu > 0
Expand All @@ -597,7 +601,13 @@ impl<B: AsRef<[u8]>> PngDecodeRows<B> {
warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(self.images).ok() });
}
let orientation = meta.exif.as_deref().map_or(1, crate::orientation::exif_orientation);
PngExtras { icc, meta, warnings, orientation }
let cicp = info.coding_independent_code_points.map(|c| crate::cicp::Cicp {
primaries: u16::from(c.color_primaries),
transfer: u16::from(c.transfer_function),
matrix: u16::from(c.matrix_coefficients),
full_range: c.is_video_full_range_image,
});
PngExtras { icc, cicp, meta, warnings, orientation }
}

/// The image itself, with what the metadata scan accepted.
Expand All @@ -606,6 +616,7 @@ impl<B: AsRef<[u8]>> PngDecodeRows<B> {
let mut img = Image::from_raw(self.width, height, self.layout, self.sample, std::mem::take(&mut self.buf))?;
let extras = self.extras();
img.icc = extras.icc;
img.cicp = extras.cicp;
img.meta = extras.meta;
img.warnings = extras.warnings;
Ok(img)
Expand All @@ -624,6 +635,8 @@ fn to_native_endian(samples: &mut [u8]) {
/// they are complete only once the pixels have been read.
pub struct PngExtras {
pub icc: Option<Vec<u8>>,
/// The PNG `cICP` colour description, if present.
pub cicp: Option<crate::cicp::Cicp>,
pub meta: Metadata,
pub warnings: Vec<DecodeWarning>,
/// The EXIF orientation (1-8) the pixels are stored in; 1 when there is none.
Expand Down Expand Up @@ -1050,29 +1063,114 @@ impl Write for FallibleOutput<'_> {
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let img = src.converted(plan.layout, plan.sample);
let (w, h) = img.dimensions();
let bpp = img.layout().channels() * img.sample_type().bytes();
// Big-endian sample bytes.
let mut data = std::borrow::Cow::Borrowed(img.data());
if img.sample_type() == SampleType::U16 {
to_big_endian(data.to_mut());
}
encode_data(
RawPng {
width: w,
height: h,
layout: img.layout(),
sample: img.sample_type(),
icc: img.icc.as_ref(),
cicp: img.cicp,
Comment thread
dylanmtaylor marked this conversation as resolved.
Resolved
meta: &img.meta,
data: &data,
},
opts,
)
}

struct RawPng<'a> {
width: u32,
height: u32,
layout: ChannelLayout,
sample: SampleType,
icc: Option<&'a Vec<u8>>,
cicp: Option<crate::cicp::Cicp>,
meta: &'a Metadata,
data: &'a [u8],
}

fn encode_data(raw: RawPng<'_>, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
let RawPng { width: w, height: h, layout, sample, icc, cicp, meta, data } = raw;
let bpp = layout.channels() * sample.bytes();
let mut out = Vec::new();
{
let mut output = FallibleOutput(&mut out);
let mut writer = start(&mut output, (w, h), (img.layout(), img.sample_type()), img.icc.as_ref(), &img.meta, opts)?;
let mut writer = start(&mut output, (w, h), (layout, sample), icc, cicp, meta, opts)?;
if opts.png_interlaced {
let idat = adam7_idat(&data, w as usize, h as usize, bpp, opts.png_compression)?;
let idat = adam7_idat(data, w as usize, h as usize, bpp, opts.png_compression)?;
write_idat(&mut writer, &idat, MAX_IDAT)?;
} else if let Some(idat) = parallel_idat(&data, w as usize, h as usize, bpp, opts.png_compression) {
} else if let Some(idat) = parallel_idat(data, w as usize, h as usize, bpp, opts.png_compression) {
write_idat(&mut writer, &idat, MAX_IDAT)?;
} else {
writer.write_image_data(&data).map_err(|e| CodecError::encode(F, e))?;
writer.write_image_data(data).map_err(|e| CodecError::encode(F, e))?;
}
writer.finish().map_err(|e| CodecError::encode(F, e))?;
}
Ok(out)
}

/// Encodes linear-light Rec. 2020 RGB as 16-bit PNG with BT.2100 PQ (`cICP`) and measured
/// content-light levels (`cLLI`). Input samples are relative to 203 cd/m² diffuse white, as in
/// [`crate::cicp::linearize`]. Negative and non-finite values become black; values above 10,000
/// cd/m² are clipped to the PQ signal range. EXIF, XMP, and resolution metadata follow `opts`.
pub fn encode_pq(src: &Image, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
if src.layout() != ChannelLayout::Rgb || src.sample_type() != SampleType::F32 {
return Err(CodecError::encode(F, "PQ PNG input must be linear-light F32 RGB"));
}
let (width, height) = src.dimensions();
if width == 0 || height == 0 {
return Err(CodecError::InvalidImage("cannot encode an empty image".into()));
}
let pixels = usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| CodecError::encode(F, "image is too large"))?;
let samples = pixels.checked_mul(3).ok_or_else(|| CodecError::encode(F, "image is too large"))?;
let bytes = samples.checked_mul(2).ok_or_else(|| CodecError::encode(F, "image is too large"))?;
if src.data().len() != samples.checked_mul(4).ok_or_else(|| CodecError::encode(F, "image is too large"))? {
return Err(CodecError::encode(F, "RGB buffer length does not match the image dimensions"));
}
let mut output = Vec::new();
output.try_reserve_exact(bytes).map_err(|_| CodecError::encode(F, "not enough memory for PQ samples"))?;
let mut max_nits = 0.0f32;
let mut sum_luma = 0.0f64;
for rgb in src.data().as_chunks::<12>().0 {
let read = |at| -> Result<f32, CodecError> {
let bytes = rgb.get(at..at + 4).ok_or_else(|| CodecError::encode(F, "RGB buffer is truncated"))?;
let bytes: [u8; 4] = bytes.try_into().map_err(|_| CodecError::encode(F, "RGB sample is truncated"))?;
Ok(f32::from_ne_bytes(bytes))
};
let values = [read(0)?, read(4)?, read(8)?];
let nits = values.map(|v| if v.is_finite() { (v.max(0.0) * crate::cicp::REFERENCE_WHITE_NITS).min(crate::cicp::PQ_PEAK_NITS) } else { 0.0 });
max_nits = max_nits.max(nits[0]).max(nits[1]).max(nits[2]);
// CTA-861.3-A defines MaxFALL as the average of each pixel's maximum linear RGB channel.
sum_luma += f64::from(nits[0].max(nits[1]).max(nits[2]));
for value in nits {
let encoded = crate::cicp::linear_to_pq(value / crate::cicp::REFERENCE_WHITE_NITS);
output.extend_from_slice(&((encoded * 65535.0).round().clamp(0.0, 65535.0) as u16).to_be_bytes());
}
}
let max_cll = ((max_nits.ceil().clamp(1.0, crate::cicp::PQ_PEAK_NITS)) as u32).saturating_mul(10_000);
let max_fall = (((sum_luma / pixels as f64).ceil().clamp(1.0, f64::from(crate::cicp::PQ_PEAK_NITS))) as u32).saturating_mul(10_000);
let mut meta = src.meta.clone();
meta.content_light_level = Some(crate::cicp::ContentLightLevel { max_content_light_level: max_cll, max_frame_average_light_level: max_fall });
encode_data(
RawPng {
width,
height,
layout: ChannelLayout::Rgb,
sample: SampleType::U16,
icc: None,
cicp: Some(crate::cicp::Cicp { primaries: 9, transfer: crate::cicp::Cicp::TRANSFER_PQ, matrix: 0, full_range: true }),
meta: &meta,
data: &output,
},
opts,
)
}

/// 16-bit native-endian samples to PNG's big-endian order, in place.
fn to_big_endian(data: &mut [u8]) {
for c in data.as_chunks_mut::<2>().0 {
Expand All @@ -1088,6 +1186,7 @@ fn start<W: Write>(
(w, h): (u32, u32),
(layout, sample): (ChannelLayout, SampleType),
icc: Option<&Vec<u8>>,
cicp: Option<crate::cicp::Cicp>,
meta: &Metadata,
opts: &EncodeOptions,
) -> Result<png::Writer<W>, CodecError> {
Expand Down Expand Up @@ -1154,6 +1253,22 @@ fn start<W: Write>(
}
}
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
if let Some(cicp) = cicp {
let point = |value: u16, name: &str| {
u8::try_from(value).map_err(|_| CodecError::encode(F, format!("CICP {name} value {value} does not fit in a byte")))
};
// PNG stores RGB samples, so cICP's matrix coefficients byte is always zero.
let cicp_bytes = [point(cicp.primaries, "primaries")?, point(cicp.transfer, "transfer")?, 0, u8::from(cicp.full_range)];
writer.write_chunk(png::chunk::cICP, &cicp_bytes).map_err(|e| CodecError::encode(F, e))?;
}
if opts.embed_metadata
&& let Some(light) = meta.content_light_level
{
let mut bytes = [0; 8];
bytes[..4].copy_from_slice(&light.max_content_light_level.to_be_bytes());
bytes[4..].copy_from_slice(&light.max_frame_average_light_level.to_be_bytes());
writer.write_chunk(png::chunk::cLLI, &bytes).map_err(|e| CodecError::encode(F, e))?;
}
if let Some(stereo) = opts.png_stereo {
// Ancillary chunk before IDAT, as the spec recommends for `sTER`.
writer.write_chunk(png::chunk::ChunkType(*b"sTER"), &[stereo.mode()]).map_err(|e| CodecError::encode(F, e))?;
Expand Down Expand Up @@ -1208,7 +1323,7 @@ impl<W: Write> PngRows<W> {
return Err(CodecError::InvalidImage("cannot encode an empty image".into()));
}
let plan = crate::fidelity::plan(first, F, opts);
let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), &first.meta, opts)?;
let writer = start(out, (width, height), (plan.layout, plan.sample), first.icc.as_ref(), first.cicp, &first.meta, opts)?;
Ok(Self {
writer,
plan,
Expand Down Expand Up @@ -1599,6 +1714,83 @@ pub fn encode_indexed(
Ok(out)
}

#[cfg(test)]
mod hdr_tests {
use super::*;

#[test]
fn generic_png_roundtrip_keeps_cicp_and_content_light_level() {
let mut image = Image::from_raw(1, 1, ChannelLayout::Rgb, SampleType::U16, vec![0, 0, 0, 0, 0, 0]).unwrap();
image.cicp = Some(crate::cicp::Cicp { primaries: 9, transfer: 16, matrix: 9, full_range: true });
image.meta.content_light_level =
Some(crate::cicp::ContentLightLevel { max_content_light_level: 2_030_000, max_frame_average_light_level: 1_250_000 });

let encoded = encode(&image, Plan { layout: ChannelLayout::Rgb, sample: SampleType::U16 }, &EncodeOptions::default()).unwrap();
let decoded = decode(&encoded, &Limits::default()).unwrap();
assert_eq!(decoded.cicp, Some(crate::cicp::Cicp { matrix: 0, ..image.cicp.unwrap() }));
assert_eq!(decoded.meta.content_light_level, image.meta.content_light_level);
}

#[test]
fn generic_png_keeps_cicp_alongside_icc() {
let mut image = Image::from_raw(1, 1, ChannelLayout::Rgb, SampleType::U16, vec![0, 0, 0, 0, 0, 0]).unwrap();
image.cicp = Some(crate::cicp::Cicp { primaries: 9, transfer: 16, matrix: 0, full_range: true });
image.icc = Some(b"synthetic ICC profile".to_vec());

let encoded = encode(&image, Plan { layout: ChannelLayout::Rgb, sample: SampleType::U16 }, &EncodeOptions::default()).unwrap();
let decoded = decode(&encoded, &Limits::default()).unwrap();
assert_eq!(decoded.cicp, image.cicp);
assert_eq!(decoded.icc, image.icc);
}

#[test]
fn linearized_pq_pixels_are_tagged_as_linear_in_generic_png() {
let cicp = crate::cicp::Cicp { primaries: 9, transfer: crate::cicp::Cicp::TRANSFER_PQ, matrix: 0, full_range: true };
let encoded_white = (0.5807f32 * 65535.0).round() as u16;
let mut image = Image::from_u16(1, 1, ChannelLayout::Rgb, &[encoded_white; 3]).unwrap();
image.cicp = Some(cicp);
let linear = crate::cicp::linearize(image, cicp).unwrap();
let png = encode(&linear, Plan { layout: ChannelLayout::Rgb, sample: SampleType::U16 }, &EncodeOptions::default()).unwrap();
let decoded = decode(&png, &Limits::default()).unwrap();
assert_eq!(decoded.cicp.unwrap().transfer, crate::cicp::Cicp::TRANSFER_LINEAR);
assert!(decoded.to_normalized().iter().all(|&v| (v - 1.0).abs() < 0.001));
}

#[test]
fn pq_png_writes_rec2020_codes_and_measured_light_levels() {
let image = Image::from_f32(1, 1, ChannelLayout::Rgb, &[1.0, 1.0, 1.0]).unwrap();
let encoded = encode_pq(&image, &EncodeOptions::default()).unwrap();
let mut reader = png::Decoder::new(Cursor::new(&encoded)).read_info().unwrap();
let info = reader.info();
let cicp = info.coding_independent_code_points.unwrap();
assert_eq!((cicp.color_primaries, cicp.transfer_function, cicp.matrix_coefficients, cicp.is_video_full_range_image), (9, 16, 0, true));
let light = info.content_light_level.unwrap();
assert_eq!((light.max_content_light_level, light.max_frame_average_light_level), (2_030_000, 2_030_000));
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let frame = reader.next_frame(&mut bytes).unwrap();
let rgb = bytes.get(..frame.buffer_size()).unwrap().as_chunks::<2>().0.iter().map(|v| u16::from_be_bytes(*v)).collect::<Vec<_>>();
assert_eq!(rgb.len(), 3);
for sample in rgb {
assert!((f32::from(sample) / 65535.0 - 0.5807).abs() < 0.001);
}
}

#[test]
fn pq_png_maxfall_uses_highest_channel_for_saturated_pixels() {
let image = Image::from_f32(2, 1, ChannelLayout::Rgb, &[1.0, 0.0, 0.0, 0.0, 0.0, 1.0]).unwrap();
let encoded = encode_pq(&image, &EncodeOptions::default()).unwrap();
let reader = png::Decoder::new(Cursor::new(encoded)).read_info().unwrap();
let light = reader.info().content_light_level.unwrap();
assert_eq!((light.max_content_light_level, light.max_frame_average_light_level), (2_030_000, 2_030_000));
}

#[test]
fn pq_png_rejects_empty_images() {
let image = Image::from_f32(0, 1, ChannelLayout::Rgb, &[]).unwrap();
assert!(encode_pq(&image, &EncodeOptions::default()).is_err());
}
}

#[cfg(test)]
mod stereo_tests {
use super::*;
Expand Down Expand Up @@ -2030,7 +2222,8 @@ mod streamed_tests {
let whole = crate::encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
let (_, zlib, _) = split(&whole);
let mut out = Vec::new();
let mut w = start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, &Metadata::default(), &EncodeOptions::default()).unwrap();
let mut w =
start(&mut out, (64, 40), (ChannelLayout::Rgb, SampleType::U8), None, None, &Metadata::default(), &EncodeOptions::default()).unwrap();
write_idat(&mut w, &zlib, 100).unwrap();
w.finish().unwrap();
let (_, joined, count) = split(&out);
Expand Down
Loading
Loading