mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
image-codecs: preserve PNG HDR metadata and PQ output (#1249)
* image-codecs: preserve PNG HDR colour metadata and PQ output * image-codecs: keep PQ PNG dimension checks overflow-safe * image-codecs: test empty PQ PNG input rejection * fix(png): preserve HDR color metadata semantics
This commit is contained in:
1 parent
b423529943
commit
f0f4758c01
5 files changed
+247
-12
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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] {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
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 {
|
||||
@@ -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> {
|
||||
@@ -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))?;
|
||||
@@ -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,
|
||||
@@ -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::*;
|
||||
@@ -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);
|
||||
|
||||
@@ -142,6 +142,9 @@ pub struct Metadata {
|
||||
pub dpi: Option<(f32, f32)>,
|
||||
/// Free-form key/value text (PNG tEXt/iTXt, TIFF ASCII tags).
|
||||
pub text: Vec<(String, String)>,
|
||||
/// HDR content light levels carried by formats such as PNG `cLLI`, in units of
|
||||
/// 0.0001 cd/m² per value. A zero value means that level is unknown.
|
||||
pub content_light_level: Option<crate::cicp::ContentLightLevel>,
|
||||
/// Photoshop image resources (`8BIM` resource blocks): TIFF tag 34377. Kept opaque here;
|
||||
/// `photocraft-io` reads and writes them through `photocraft-psd`.
|
||||
pub photoshop_resources: Option<Vec<u8>>,
|
||||
@@ -174,6 +177,7 @@ impl Metadata {
|
||||
&& self.xmp.is_none()
|
||||
&& self.dpi.is_none()
|
||||
&& self.text.is_empty()
|
||||
&& self.content_light_level.is_none()
|
||||
&& self.photoshop_resources.is_none()
|
||||
&& self.photoshop_layers.is_none()
|
||||
&& self.extra_samples.is_empty()
|
||||
|
||||
@@ -32,11 +32,12 @@ pub mod source_settings;
|
||||
pub mod web;
|
||||
pub use crate::allocation::probe_allocation_size;
|
||||
pub use crate::cicp::Cicp;
|
||||
pub use crate::cicp::ContentLightLevel;
|
||||
pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color};
|
||||
pub use crate::codecs::gif_frames::{AnimationFrame, MAX_GIF_FRAMES};
|
||||
pub use crate::codecs::png::{
|
||||
PngDecodeRows, PngExtras, PngHeader, PngPalette, PngRowSink, PngRows, SharedBytes, encode_indexed as encode_png_indexed, palette as png_palette,
|
||||
stereo as png_stereo,
|
||||
PngDecodeRows, PngExtras, PngHeader, PngPalette, PngRowSink, PngRows, SharedBytes, encode_indexed as encode_png_indexed,
|
||||
encode_pq as encode_png_pq, palette as png_palette, stereo as png_stereo,
|
||||
};
|
||||
pub use crate::codecs::tiff::{
|
||||
PhotoshopTags as TiffPhotoshopTags, TiffBands, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian,
|
||||
|
||||
Reference in new issue
Block a user