mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
The decoders keep the alpha plane, but to_working converted only the colour, so transparent pixels kept the black most encoders store under them: renders, previews, exports and contact sheets showed it. The working image is now flattened onto white, in linear light like the rest of the pipeline.
836 lines
39 KiB
Rust
836 lines
39 KiB
Rust
//! Encode → decode round trips on generated fixtures.
|
||
|
||
use lightcraft_codecs::exif::minimal_exif;
|
||
use lightcraft_codecs::icc::{self, write_named};
|
||
use lightcraft_codecs::*;
|
||
use lightcraft_color::{DISPLAY_P3, REC2020, transfer::srgb_to_linear};
|
||
use lightcraft_raster::Rgba8;
|
||
|
||
fn gradient(w: usize, h: usize) -> Rgba8 {
|
||
Rgba8::from_fn(w, h, |x, y| [(x * 255 / w.max(2).saturating_sub(1).max(1)) as u8, (y * 255 / h.max(2)) as u8, ((x + y) % 256) as u8, 255])
|
||
}
|
||
|
||
fn smooth(w: usize, h: usize) -> Rgba8 {
|
||
Rgba8::from_fn(w, h, |x, y| {
|
||
let fx = x as f32 / w as f32;
|
||
let fy = y as f32 / h as f32;
|
||
[(fx * 255.0) as u8, (fy * 255.0) as u8, ((1.0 - fx) * 200.0) as u8, (((fx + fy) * 0.5) * 255.0) as u8]
|
||
})
|
||
}
|
||
|
||
fn psnr8(a: &Rgba8, b: &Rgba8) -> f64 {
|
||
let mut se = 0.0f64;
|
||
for (p, q) in a.data.iter().zip(&b.data) {
|
||
for c in 0..3 {
|
||
let d = p[c] as f64 - q[c] as f64;
|
||
se += d * d;
|
||
}
|
||
}
|
||
let mse = se / (a.data.len() * 3) as f64;
|
||
if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0 * 255.0 / mse).log10() }
|
||
}
|
||
|
||
#[test]
|
||
fn jpeg_roundtrip_with_metadata() {
|
||
let img = smooth(97, 61);
|
||
let icc = write_named(NamedSpace::Srgb);
|
||
let exif = minimal_exif(6);
|
||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
|
||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
|
||
for sub in [ChromaSubsampling::S444, ChromaSubsampling::S422, ChromaSubsampling::S420] {
|
||
let bytes = encode_jpeg(&EncodeImage::rgba8(&img), 95, sub, &meta).unwrap();
|
||
assert_eq!(sniff(&bytes), Some(Format::Jpeg));
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert_eq!((d.width, d.height), (97, 61));
|
||
assert_eq!(d.orientation, 6, "orientation passes through, not applied");
|
||
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
|
||
assert_eq!(d.xmp.as_deref(), Some(xmp));
|
||
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
|
||
assert_eq!(d.space.named, Some(NamedSpace::Srgb));
|
||
assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
|
||
assert!(!d.has_alpha);
|
||
let back = d.to_srgb8();
|
||
let p = psnr8(&img, &back);
|
||
assert!(p > 32.0, "{sub:?} psnr {p}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn jpeg_gray_and_scaled_decode() {
|
||
let g: Vec<u8> = (0..128 * 96).map(|i| (i % 128 * 2) as u8).collect();
|
||
let bytes = encode_jpeg(&EncodeImage::new(128, 96, 1, Samples::U8(&g)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert!(d.grayscale);
|
||
assert_eq!(d.space.origin, SpaceOrigin::Untagged);
|
||
let p = d.image.get(64, 10);
|
||
assert!((p[0] - p[1]).abs() < 1e-6 && (p[1] - p[2]).abs() < 1e-6);
|
||
|
||
let big = smooth(1000, 600);
|
||
let bytes = encode_jpeg(&EncodeImage::rgba8(&big), 85, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::fit(100, 100)).unwrap();
|
||
assert_eq!((d.width, d.height), (100, 60));
|
||
assert_eq!((d.source_width, d.source_height), (1000, 600));
|
||
// Colour of the scaled decode matches a box-filtered full decode.
|
||
let full = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
let a = d.image.get(50, 30);
|
||
let b = full.image.get(505, 305);
|
||
for c in 0..3 {
|
||
assert!((a[c] - b[c]).abs() < 0.03, "{a:?} {b:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn jpeg_cmyk_converts() {
|
||
// 0 = no ink: white, cyan, black.
|
||
let px: Vec<u8> = [[0u8, 0, 0, 0], [255, 0, 0, 0], [0, 0, 0, 255]].iter().flat_map(|p| p.repeat(16 * 16)).collect();
|
||
let img: Vec<u8> = px;
|
||
let mut out = Vec::new();
|
||
let enc = jpeg_encoder::Encoder::new(&mut out, 95);
|
||
enc.encode(&img, 16, 48, jpeg_encoder::ColorType::Cmyk).unwrap();
|
||
let d = decode(&out, DecodeOptions::default()).unwrap();
|
||
assert!(d.cmyk);
|
||
assert_eq!(d.space.origin, SpaceOrigin::Naive);
|
||
let white = d.image.get(8, 8);
|
||
let cyan = d.image.get(8, 24);
|
||
let black = d.image.get(8, 40);
|
||
assert!(white.iter().all(|&v| v > 0.9), "{white:?}");
|
||
assert!(cyan[0] < 0.05 && cyan[1] > 0.8 && cyan[2] > 0.8, "{cyan:?}");
|
||
assert!(black.iter().all(|&v| v < 0.02), "{black:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn png_8_and_16_bit_exact() {
|
||
let img = gradient(33, 17);
|
||
let bytes = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert!(d.has_alpha);
|
||
assert_eq!(d.bit_depth, 8);
|
||
assert_eq!(d.to_srgb8(), img);
|
||
|
||
// 16-bit: precision preserved exactly through linearization.
|
||
let (w, h) = (64u32, 8u32);
|
||
let data: Vec<u16> = (0..w * h * 3).map(|i| (i * 1021 % 65536) as u16).collect();
|
||
let bytes = encode_png(&EncodeImage::new(w, h, 3, Samples::U16(&data)), &EncodeMeta::default()).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.bit_depth, 16);
|
||
for (i, p) in d.image.data.iter().enumerate() {
|
||
for c in 0..3 {
|
||
let want = Trc::Srgb.to_linear(data[i * 3 + c] as f32 / 65535.0);
|
||
assert!((p[c] - want).abs() < 1e-6);
|
||
}
|
||
}
|
||
// Two adjacent 16-bit codes stay distinct.
|
||
let data = [30000u16, 30000, 30000, 30001, 30001, 30001];
|
||
let bytes = encode_png(&EncodeImage::new(2, 1, 3, Samples::U16(&data)), &EncodeMeta::default()).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert!(d.image.get(1, 0)[0] > d.image.get(0, 0)[0]);
|
||
}
|
||
|
||
#[test]
|
||
fn png_metadata_and_gray_alpha() {
|
||
let data: Vec<u8> = (0..20 * 10).flat_map(|i| [(i % 256) as u8, 128]).collect();
|
||
let exif = minimal_exif(3);
|
||
let meta = EncodeMeta { exif: Some(&exif), xmp: Some("<xmp/>"), ..Default::default() };
|
||
let bytes = encode_png(&EncodeImage::new(20, 10, 2, Samples::U8(&data)), &meta).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert!(d.grayscale && d.has_alpha);
|
||
assert_eq!(d.orientation, 3);
|
||
assert_eq!(d.xmp.as_deref(), Some("<xmp/>"));
|
||
let a = d.alpha.as_ref().unwrap().get(0, 0);
|
||
assert!((a - 128.0 / 255.0).abs() < 1e-6);
|
||
}
|
||
|
||
/// The working image has no alpha: transparency is flattened onto white, in linear light (issue #1212:
|
||
/// transparent areas, stored black, rendered black).
|
||
#[test]
|
||
fn png_transparency_develops_on_white() {
|
||
// opaque red, transparent black (as most encoders store it), half-transparent red
|
||
let data = [200, 40, 40, 255, 0, 0, 0, 0, 200, 40, 40, 128];
|
||
let bytes = encode_png(&EncodeImage::new(3, 1, 4, Samples::U8(&data)), &EncodeMeta::default()).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert!(d.has_alpha);
|
||
let opaque = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&[200, 40, 40])), &EncodeMeta::default()).unwrap();
|
||
let red = decode(&opaque, DecodeOptions::default()).unwrap().to_working().get(0, 0);
|
||
let w = d.to_working();
|
||
let a = 128.0 / 255.0;
|
||
for (got, want) in [(w.get(0, 0), red), (w.get(1, 0), [1.0; 3]), (w.get(2, 0), red.map(|c| a * c + (1.0 - a)))] {
|
||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "{got:?} != {want:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn p3_icc_to_working() {
|
||
let icc = write_named(NamedSpace::DisplayP3);
|
||
let data = [255u8, 0, 0, 0, 255, 0, 128, 128, 128];
|
||
let bytes = encode_png(&EncodeImage::new(3, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&icc), ..Default::default() }).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.space.named, Some(NamedSpace::DisplayP3));
|
||
assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc);
|
||
let wk = to_working(&d);
|
||
let m = DISPLAY_P3.to_space(&REC2020);
|
||
for (i, px) in [[1.0f32, 0.0, 0.0], [0.0, 1.0, 0.0], [srgb_to_linear(128.0 / 255.0); 3]].iter().enumerate() {
|
||
let want = m.apply_f32(*px);
|
||
let got = wk.get(i, 0);
|
||
for c in 0..3 {
|
||
assert!((got[c] - want[c]).abs() < 1e-3, "{i}: {got:?} vs {want:?}");
|
||
}
|
||
}
|
||
// Gray stays neutral in the working space.
|
||
let g = wk.get(2, 0);
|
||
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4);
|
||
}
|
||
|
||
#[test]
|
||
fn custom_matrix_icc_is_exact() {
|
||
// A non-standard space: goes through the generic matrix/TRC path, not the named one.
|
||
let space = lightcraft_color::RgbSpace {
|
||
name: "Custom",
|
||
r: lightcraft_color::Xy::new(0.66, 0.32),
|
||
g: lightcraft_color::Xy::new(0.25, 0.65),
|
||
b: lightcraft_color::Xy::new(0.14, 0.07),
|
||
white: lightcraft_color::D65,
|
||
};
|
||
let icc = icc::write_matrix_trc(&space, &Trc::Gamma(2.0));
|
||
let data = [255u8, 0, 0];
|
||
let bytes = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&icc), ..Default::default() }).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.space.named, None);
|
||
let got = to_working(&d).get(0, 0);
|
||
let want = space.to_space(&REC2020).apply_f32([1.0, 0.0, 0.0]);
|
||
for c in 0..3 {
|
||
assert!((got[c] - want[c]).abs() < 2e-3, "{got:?} vs {want:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tiff_depths_and_compressions() {
|
||
let (w, h) = (40u32, 24u32);
|
||
let n = (w * h) as usize;
|
||
let u8s: Vec<u8> = (0..n * 3).map(|i| (i * 7 % 256) as u8).collect();
|
||
let u16s: Vec<u16> = (0..n * 4).map(|i| (i * 977 % 65536) as u16).collect();
|
||
let f32s: Vec<f32> = (0..n * 3).map(|i| i as f32 / 100.0 - 3.0).collect();
|
||
let icc = write_named(NamedSpace::AdobeRgb);
|
||
for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||
let b = encode_tiff(&EncodeImage::new(w, h, 3, Samples::U8(&u8s)), comp, &EncodeMeta::default()).unwrap();
|
||
assert_eq!(sniff(&b), Some(Format::Tiff));
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.bit_depth, 8);
|
||
for (i, p) in d.image.data.iter().enumerate() {
|
||
assert!((p[1] - srgb_to_linear(u8s[i * 3 + 1] as f32 / 255.0)).abs() < 1e-6);
|
||
}
|
||
|
||
let meta = EncodeMeta { icc: Some(&icc), xmp: Some("<x/>"), ..Default::default() };
|
||
let b = encode_tiff(&EncodeImage::new(w, h, 4, Samples::U16(&u16s)), comp, &meta).unwrap();
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.bit_depth, 16);
|
||
assert!(d.has_alpha);
|
||
assert_eq!(d.space.named, Some(NamedSpace::AdobeRgb));
|
||
assert_eq!(d.xmp.as_deref(), Some("<x/>"));
|
||
let a = d.alpha.as_ref().unwrap();
|
||
for i in 0..n {
|
||
assert!((a.data[i] - u16s[i * 4 + 3] as f32 / 65535.0).abs() < 1e-6);
|
||
let want = Trc::Gamma(563.0 / 256.0).to_linear(u16s[i * 4] as f32 / 65535.0);
|
||
assert!((d.image.data[i][0] - want).abs() < 1e-4, "{} vs {want}", d.image.data[i][0]);
|
||
}
|
||
|
||
// Float: exact (untagged float is taken as linear), including out-of-range values.
|
||
let b = encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&f32s)), comp, &EncodeMeta::default()).unwrap();
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
assert!(d.float);
|
||
assert_eq!(d.bit_depth, 32);
|
||
for (i, p) in d.image.data.iter().enumerate() {
|
||
assert_eq!(p[2], f32s[i * 3 + 2]);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn tiff_cmyk_and_orientation() {
|
||
use tiff::encoder::{TiffEncoder, colortype};
|
||
let mut cur = std::io::Cursor::new(Vec::new());
|
||
{
|
||
let mut enc = TiffEncoder::new(&mut cur).unwrap();
|
||
let mut im = enc.new_image::<colortype::CMYK8>(2, 1).unwrap();
|
||
im.encoder().write_tag(tiff::tags::Tag::Orientation, 8u16).unwrap();
|
||
im.write_data(&[0u8, 0, 0, 0, 0, 255, 255, 0]).unwrap();
|
||
}
|
||
let d = decode(&cur.into_inner(), DecodeOptions::default()).unwrap();
|
||
assert!(d.cmyk);
|
||
assert_eq!(d.orientation, 8);
|
||
let white = d.image.get(0, 0);
|
||
let red = d.image.get(1, 0);
|
||
assert!(white.iter().all(|&v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
||
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
|
||
}
|
||
|
||
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
|
||
/// already packed into `strip`, one padded row after another.
|
||
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> {
|
||
const STRIP_AT: u32 = 8 + 2 + 9 * 12 + 4;
|
||
let short = |tag: u16, v: u16| [&tag.to_le_bytes()[..], &3u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes(), &[0, 0]].concat();
|
||
let long = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat();
|
||
let mut b = b"II*\0".to_vec();
|
||
b.extend(8u32.to_le_bytes());
|
||
b.extend(9u16.to_le_bytes());
|
||
b.extend(short(256, width));
|
||
b.extend(short(257, height));
|
||
b.extend(short(258, bits));
|
||
b.extend(short(259, 1));
|
||
b.extend(short(262, photometric));
|
||
b.extend(long(273, STRIP_AT));
|
||
b.extend(short(277, 1));
|
||
b.extend(short(278, height));
|
||
b.extend(long(279, strip.len() as u32));
|
||
b.extend(0u32.to_le_bytes());
|
||
assert_eq!(b.len(), STRIP_AT as usize);
|
||
b.extend_from_slice(strip);
|
||
b
|
||
}
|
||
|
||
/// WhiteIsZero (photometric 0) gray: the lowest sample is white. The `tiff` crate already inverts these
|
||
/// samples, and a second inversion here used to show every WhiteIsZero scan as a negative.
|
||
#[test]
|
||
fn tiff_white_is_zero_is_inverted_once() {
|
||
// Two pixels: the lowest sample, then the highest, at 8 and 16 bits.
|
||
for (bits, strip) in [(8, vec![0u8, 255]), (16, [0u16.to_le_bytes(), 65535u16.to_le_bytes()].concat())] {
|
||
for (photometric, first) in [(0, 1.0), (1, 0.0)] {
|
||
let d = decode(&packed_tiff(2, 1, bits, photometric, &strip), DecodeOptions::default()).unwrap();
|
||
let (a, b) = (d.image.get(0, 0)[1], d.image.get(1, 0)[1]);
|
||
assert!((a - first).abs() < 1e-6 && (b - (1.0 - first)).abs() < 1e-6, "{bits}-bit, photometric {photometric}: {a} {b}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 1-, 2- and 4-bit samples (bilevel scans, fax images, low-depth gray) arrive packed eight, four or
|
||
/// two to a byte with each row padded to a byte; they used to be rejected as "sample buffer too short".
|
||
#[test]
|
||
fn tiff_samples_narrower_than_a_byte() {
|
||
let gray = |d: &Decoded, x: usize, y: usize| d.image.get(x, y)[1];
|
||
|
||
// 10 × 2 bilevel: rows are 2 bytes, the last 6 bits of each are padding (set, to prove they are ignored).
|
||
// Row 0 alternates set/clear bits; row 1 is clear but for its last pixel.
|
||
let rows = [0b1010_1010, 0b1011_1111, 0b0000_0000, 0b0111_1111];
|
||
// What a set bit decodes to: white for BlackIsZero (1), black for WhiteIsZero (0).
|
||
for (photometric, set_bit) in [(1, 1.0), (0, 0.0)] {
|
||
let d = decode(&packed_tiff(10, 2, 1, photometric, &rows), DecodeOptions::default()).unwrap();
|
||
assert_eq!((d.image.width, d.image.height, d.bit_depth), (10, 2, 1));
|
||
assert!(d.grayscale);
|
||
let clear_bit = 1.0 - set_bit;
|
||
for x in 0..10 {
|
||
let want = (if x % 2 == 0 { set_bit } else { clear_bit }, if x == 9 { set_bit } else { clear_bit });
|
||
assert!((gray(&d, x, 0) - want.0).abs() < 1e-6, "row 0, x {x}, photometric {photometric}");
|
||
assert!((gray(&d, x, 1) - want.1).abs() < 1e-6, "row 1, x {x}, photometric {photometric}");
|
||
}
|
||
}
|
||
|
||
// 2-bit: 0, 1, 2, 3 → 0, 85, 170, 255. 4-bit: 0, 15, 8 → 0, 255, 136 (the row's last nibble is padding).
|
||
let d = decode(&packed_tiff(4, 1, 2, 1, &[0b00_01_10_11]), DecodeOptions::default()).unwrap();
|
||
for (x, v) in [0, 85, 170, 255].into_iter().enumerate() {
|
||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "2-bit x {x}");
|
||
}
|
||
let d = decode(&packed_tiff(3, 1, 4, 1, &[0x0F, 0x8F]), DecodeOptions::default()).unwrap();
|
||
for (x, v) in [0, 255, 136].into_iter().enumerate() {
|
||
assert!((gray(&d, x, 0) - srgb_to_linear(v as f32 / 255.0)).abs() < 1e-6, "4-bit x {x}");
|
||
}
|
||
|
||
// A strip shorter than its rows is an error, never a panic.
|
||
assert!(decode(&packed_tiff(10, 2, 1, 1, &rows[..3]), DecodeOptions::default()).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn webp_lossless_exact() {
|
||
let img = gradient(31, 19);
|
||
let icc = write_named(NamedSpace::Srgb);
|
||
let exif = minimal_exif(2);
|
||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("<w/>"), ..Default::default() };
|
||
let bytes = encode_webp_lossless(&EncodeImage::rgba8(&img), &meta).unwrap();
|
||
assert_eq!(sniff(&bytes), Some(Format::WebP));
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.to_srgb8(), img);
|
||
assert_eq!(d.orientation, 2);
|
||
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
|
||
assert_eq!(d.xmp.as_deref(), Some("<w/>"));
|
||
}
|
||
|
||
#[test]
|
||
fn gif_and_bmp_decode() {
|
||
use image::ImageEncoder;
|
||
let img = gradient(16, 8);
|
||
let rgba = img.as_bytes();
|
||
let mut bmp = Vec::new();
|
||
image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&rgba, 16, 8, image::ExtendedColorType::Rgba8).unwrap();
|
||
assert_eq!(sniff(&bmp), Some(Format::Bmp));
|
||
let d = decode(&bmp, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.to_srgb8(), img);
|
||
|
||
let mut gif = Vec::new();
|
||
{
|
||
let mut enc = image::codecs::gif::GifEncoder::new(&mut gif);
|
||
let flat = Rgba8::filled(16, 8, [10, 200, 30, 255]);
|
||
let buf = image::RgbaImage::from_raw(16, 8, flat.as_bytes()).unwrap();
|
||
enc.encode_frame(image::Frame::new(buf)).unwrap();
|
||
}
|
||
assert_eq!(sniff(&gif), Some(Format::Gif));
|
||
let d = decode(&gif, DecodeOptions::default()).unwrap();
|
||
let p = d.to_srgb8().get(3, 3);
|
||
assert!((p[0] as i32 - 10).abs() <= 2 && (p[1] as i32 - 200).abs() <= 2, "{p:?}");
|
||
}
|
||
|
||
/// Hand-built PSD: RGB 8-bit, raw and RLE merged data, ICC resource, negative layer count (alpha).
|
||
fn build_psd(rle: bool, with_alpha: bool, icc: &[u8]) -> Vec<u8> {
|
||
let (w, h) = (3u32, 2u32);
|
||
let channels: u16 = if with_alpha { 4 } else { 3 };
|
||
let mut b = b"8BPS".to_vec();
|
||
b.extend_from_slice(&1u16.to_be_bytes());
|
||
b.extend_from_slice(&[0; 6]);
|
||
b.extend_from_slice(&channels.to_be_bytes());
|
||
b.extend_from_slice(&h.to_be_bytes());
|
||
b.extend_from_slice(&w.to_be_bytes());
|
||
b.extend_from_slice(&8u16.to_be_bytes());
|
||
b.extend_from_slice(&3u16.to_be_bytes());
|
||
b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data
|
||
// resources: ICC
|
||
let mut res = b"8BIM".to_vec();
|
||
res.extend_from_slice(&1039u16.to_be_bytes());
|
||
res.extend_from_slice(&[0, 0]); // empty pascal name, padded
|
||
res.extend_from_slice(&(icc.len() as u32).to_be_bytes());
|
||
res.extend_from_slice(icc);
|
||
if icc.len() % 2 == 1 {
|
||
res.push(0);
|
||
}
|
||
b.extend_from_slice(&(res.len() as u32).to_be_bytes());
|
||
b.extend_from_slice(&res);
|
||
// layer & mask info: layer info length + negative layer count
|
||
if with_alpha {
|
||
let mut lm = Vec::new();
|
||
lm.extend_from_slice(&2u32.to_be_bytes());
|
||
lm.extend_from_slice(&(-1i16).to_be_bytes());
|
||
b.extend_from_slice(&(lm.len() as u32).to_be_bytes());
|
||
b.extend_from_slice(&lm);
|
||
} else {
|
||
b.extend_from_slice(&0u32.to_be_bytes());
|
||
}
|
||
let planes: Vec<[u8; 6]> = vec![[255, 0, 0, 10, 20, 30], [0, 255, 0, 40, 50, 60], [0, 0, 255, 70, 80, 90], [255, 255, 128, 0, 64, 255]];
|
||
let planes = &planes[..channels as usize];
|
||
if rle {
|
||
b.extend_from_slice(&1u16.to_be_bytes());
|
||
// Each row: literal run of 3 bytes = [2, a, b, c] (4 bytes)
|
||
for _ in 0..channels as usize * h as usize {
|
||
b.extend_from_slice(&4u16.to_be_bytes());
|
||
}
|
||
for p in planes {
|
||
for row in p.chunks(3) {
|
||
b.push(2);
|
||
b.extend_from_slice(row);
|
||
}
|
||
}
|
||
} else {
|
||
b.extend_from_slice(&0u16.to_be_bytes());
|
||
for p in planes {
|
||
b.extend_from_slice(p);
|
||
}
|
||
}
|
||
b
|
||
}
|
||
|
||
#[test]
|
||
fn psd_composite() {
|
||
let icc = write_named(NamedSpace::ProPhoto);
|
||
for rle in [false, true] {
|
||
for alpha in [false, true] {
|
||
let b = build_psd(rle, alpha, &icc);
|
||
assert_eq!(sniff(&b), Some(Format::Psd));
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
assert_eq!((d.width, d.height), (3, 2));
|
||
assert_eq!(d.has_alpha, alpha);
|
||
assert_eq!(d.space.named, Some(NamedSpace::ProPhoto));
|
||
let p = d.image.get(0, 0);
|
||
assert!((p[0] - 1.0).abs() < 1e-5 && p[1].abs() < 1e-6 && p[2].abs() < 1e-6);
|
||
let q = d.image.get(1, 1);
|
||
assert!((q[0] - Trc::Gamma(1.8).to_linear(20.0 / 255.0)).abs() < 1e-4);
|
||
if alpha {
|
||
assert!((d.alpha.as_ref().unwrap().get(2, 1) - 1.0).abs() < 1e-6);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn thumbnail_prefers_embedded_exif() {
|
||
// Main image 800x600 with a 200x150 EXIF thumbnail of a different colour.
|
||
let main = Rgba8::filled(800, 600, [200, 30, 30, 255]);
|
||
let thumb = Rgba8::filled(200, 150, [30, 30, 200, 255]);
|
||
let thumb_jpeg = encode_jpeg(&EncodeImage::rgba8(&thumb), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||
let exif = exif_with_thumbnail(&thumb_jpeg, 6);
|
||
let bytes =
|
||
encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
|
||
|
||
let t = decode_thumbnail(&bytes, 128).unwrap();
|
||
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
|
||
assert_eq!(t.orientation, 6);
|
||
assert_eq!((t.image.width, t.image.height), (128, 96));
|
||
assert!(t.image.get(10, 10)[2] > 150);
|
||
assert_eq!((t.source_width, t.source_height), (800, 600));
|
||
|
||
// Asking for more than the thumbnail offers falls back to a scaled decode.
|
||
let t = decode_thumbnail(&bytes, 256).unwrap();
|
||
assert_eq!(t.source, ThumbnailSource::Scaled);
|
||
assert_eq!((t.image.width, t.image.height), (256, 192));
|
||
assert!(t.image.get(10, 10)[0] > 150);
|
||
}
|
||
|
||
/// Little-endian EXIF with IFD0 (orientation) → IFD1 (JPEGInterchangeFormat/Length) + the thumbnail.
|
||
fn exif_with_thumbnail(jpeg: &[u8], orientation: u16) -> Vec<u8> {
|
||
let mut v = b"II*\0".to_vec();
|
||
v.extend_from_slice(&8u32.to_le_bytes());
|
||
// IFD0 at 8: 1 entry, next IFD at 26
|
||
v.extend_from_slice(&1u16.to_le_bytes());
|
||
v.extend_from_slice(&0x0112u16.to_le_bytes());
|
||
v.extend_from_slice(&3u16.to_le_bytes());
|
||
v.extend_from_slice(&1u32.to_le_bytes());
|
||
v.extend_from_slice(&orientation.to_le_bytes());
|
||
v.extend_from_slice(&[0, 0]);
|
||
v.extend_from_slice(&26u32.to_le_bytes());
|
||
// IFD1 at 26: 2 entries (30 bytes), data at 56
|
||
v.extend_from_slice(&2u16.to_le_bytes());
|
||
v.extend_from_slice(&0x0201u16.to_le_bytes());
|
||
v.extend_from_slice(&4u16.to_le_bytes());
|
||
v.extend_from_slice(&1u32.to_le_bytes());
|
||
v.extend_from_slice(&56u32.to_le_bytes());
|
||
v.extend_from_slice(&0x0202u16.to_le_bytes());
|
||
v.extend_from_slice(&4u16.to_le_bytes());
|
||
v.extend_from_slice(&1u32.to_le_bytes());
|
||
v.extend_from_slice(&(jpeg.len() as u32).to_le_bytes());
|
||
v.extend_from_slice(&0u32.to_le_bytes());
|
||
assert_eq!(v.len(), 56);
|
||
v.extend_from_slice(jpeg);
|
||
v
|
||
}
|
||
|
||
#[test]
|
||
fn working_space_identity_for_srgb_white() {
|
||
let img = Rgba8::filled(4, 4, [255, 255, 255, 255]);
|
||
let b = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
let w = to_working(&d).get(1, 1);
|
||
for c in w {
|
||
assert!((c - 1.0).abs() < 1e-5);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn avif_encode_native() {
|
||
let img = smooth(64, 48);
|
||
let r = encode_avif(&EncodeImage::rgba8(&img), 70, 10, &EncodeMeta::default());
|
||
if cfg!(feature = "avif") {
|
||
let b = r.unwrap();
|
||
assert_eq!(sniff(&b), Some(Format::Avif));
|
||
assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::Avif, _))));
|
||
} else {
|
||
assert!(r.is_err());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn raw_routing() {
|
||
let mut b = b"II*\0\x08\0\0\0".to_vec();
|
||
b.extend_from_slice(&1u16.to_le_bytes());
|
||
b.extend_from_slice(&0xC612u16.to_le_bytes());
|
||
b.extend_from_slice(&1u16.to_le_bytes());
|
||
b.extend_from_slice(&4u32.to_le_bytes());
|
||
b.extend_from_slice(&[1, 4, 0, 0]);
|
||
b.extend_from_slice(&0u32.to_le_bytes());
|
||
assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::RawTiffLike, _))));
|
||
}
|
||
|
||
#[test]
|
||
fn thumbnail_accepts_smaller_preview_when_asked() {
|
||
let main = Rgba8::filled(800, 600, [200, 30, 30, 255]);
|
||
let thumb = Rgba8::filled(160, 120, [30, 30, 200, 255]);
|
||
let thumb_jpeg = encode_jpeg(&EncodeImage::rgba8(&thumb), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||
let exif = exif_with_thumbnail(&thumb_jpeg, 1);
|
||
let bytes =
|
||
encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap();
|
||
let t = decode_thumbnail(&bytes, 256).unwrap();
|
||
assert_eq!(t.source, ThumbnailSource::Scaled);
|
||
let t = decode_thumbnail_with(&bytes, &ThumbnailOptions { min_embedded_edge: 160, ..ThumbnailOptions::new(256) }).unwrap();
|
||
assert_eq!(t.source, ThumbnailSource::ExifThumbnail);
|
||
assert_eq!((t.image.width, t.image.height), (160, 120));
|
||
}
|
||
|
||
#[test]
|
||
fn unusable_icc_falls_back_to_srgb_with_flag() {
|
||
let bogus = vec![0x42u8; 300];
|
||
let data = [128u8, 64, 32];
|
||
let bytes = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&bogus), ..Default::default() }).unwrap();
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.space.origin, SpaceOrigin::IccUnsupported);
|
||
assert_eq!(d.space.named, Some(NamedSpace::Srgb));
|
||
assert_eq!(d.icc.as_deref(), Some(&bogus[..]));
|
||
assert!((d.image.get(0, 0)[0] - srgb_to_linear(128.0 / 255.0)).abs() < 1e-6);
|
||
}
|
||
|
||
/// Splice an MPF APP2 segment right after SOI and append `preview` after the primary image.
|
||
fn with_mpf_preview(primary: &[u8], preview: &[u8]) -> Vec<u8> {
|
||
// MPF segment payload: "MPF\0" + TIFF (LE) with one IFD holding 3 entries + 2 MP entries.
|
||
let mut t = b"II*\0".to_vec();
|
||
t.extend_from_slice(&8u32.to_le_bytes());
|
||
t.extend_from_slice(&3u16.to_le_bytes());
|
||
let ifd_end = 8 + 2 + 3 * 12 + 4; // 50
|
||
let entries_off = ifd_end as u32;
|
||
let push = |t: &mut Vec<u8>, tag: u16, typ: u16, count: u32, val: [u8; 4]| {
|
||
t.extend_from_slice(&tag.to_le_bytes());
|
||
t.extend_from_slice(&typ.to_le_bytes());
|
||
t.extend_from_slice(&count.to_le_bytes());
|
||
t.extend_from_slice(&val);
|
||
};
|
||
push(&mut t, 0xB000, 7, 4, *b"0100");
|
||
push(&mut t, 0xB001, 4, 1, 2u32.to_le_bytes());
|
||
push(&mut t, 0xB002, 7, 32, entries_off.to_le_bytes());
|
||
t.extend_from_slice(&0u32.to_le_bytes());
|
||
assert_eq!(t.len(), ifd_end);
|
||
let seg_len = 2 + 4 + t.len() + 32;
|
||
let mpf_header_pos = 2 + 4 + 4; // SOI, FFE2+len, "MPF\0"
|
||
let primary_len = primary.len() + 2 + seg_len;
|
||
let preview_off = (primary_len - mpf_header_pos) as u32;
|
||
let mut e = Vec::new();
|
||
for (size, off) in [(primary_len as u32, 0u32), (preview.len() as u32, preview_off)] {
|
||
e.extend_from_slice(&0u32.to_le_bytes());
|
||
e.extend_from_slice(&size.to_le_bytes());
|
||
e.extend_from_slice(&off.to_le_bytes());
|
||
e.extend_from_slice(&[0, 0, 0, 0]);
|
||
}
|
||
t.extend_from_slice(&e);
|
||
let mut out = primary[..2].to_vec();
|
||
out.extend_from_slice(&[0xFF, 0xE2]);
|
||
out.extend_from_slice(&(seg_len as u16).to_be_bytes());
|
||
out.extend_from_slice(b"MPF\0");
|
||
out.extend_from_slice(&t);
|
||
out.extend_from_slice(&primary[2..]);
|
||
assert_eq!(out.len(), primary_len);
|
||
out.extend_from_slice(preview);
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn thumbnail_from_mpf_preview() {
|
||
let main = Rgba8::filled(1600, 1200, [200, 30, 30, 255]);
|
||
let prev = Rgba8::filled(640, 480, [30, 200, 30, 255]);
|
||
let enc = |i: &Rgba8| encode_jpeg(&EncodeImage::rgba8(i), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||
let bytes = with_mpf_preview(&enc(&main), &enc(&prev));
|
||
// Still a valid JPEG for the full decoder.
|
||
let d = decode(&bytes, DecodeOptions::default()).unwrap();
|
||
assert_eq!((d.width, d.height), (1600, 1200));
|
||
let t = decode_thumbnail(&bytes, 256).unwrap();
|
||
assert_eq!(t.source, ThumbnailSource::MpfPreview);
|
||
assert_eq!((t.image.width, t.image.height), (256, 192));
|
||
assert!(t.image.get(5, 5)[1] > 150);
|
||
}
|
||
|
||
#[test]
|
||
fn print_resolution_is_written() {
|
||
let data = vec![128u8; 4 * 4 * 3];
|
||
let img = EncodeImage::new(4, 4, 3, Samples::U8(&data));
|
||
let meta = EncodeMeta { ppi: Some(300), ..Default::default() };
|
||
for sub in [ChromaSubsampling::S444, ChromaSubsampling::S422] {
|
||
let j = encode_jpeg(&img, 90, sub, &meta).unwrap();
|
||
// SOI, APP0 "JFIF\0" v1.2, units = 1 (dpi), Xdensity, Ydensity
|
||
assert_eq!(&j[2..4], &[0xFF, 0xE0], "{sub:?}");
|
||
assert_eq!(&j[6..11], b"JFIF\0");
|
||
assert_eq!(&j[13..18], &[1, 1, 44, 1, 44], "{sub:?}");
|
||
assert_eq!(j.windows(4).filter(|w| *w == [0xFF, 0xE0, 0, 16]).count(), 1, "one APP0");
|
||
}
|
||
let png = encode_png(&img, &meta).unwrap();
|
||
let i = png.windows(4).position(|w| w == b"pHYs").expect("pHYs chunk");
|
||
assert_eq!(u32::from_be_bytes(png[i + 4..i + 8].try_into().unwrap()), 11811, "300 ppi in pixels per metre");
|
||
assert_eq!(png[i + 12], 1, "unit = metre");
|
||
let tif = encode_tiff(&img, TiffCompression::Deflate, &meta).unwrap();
|
||
let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(tif)).unwrap();
|
||
let x = d.get_tag(tiff::tags::Tag::XResolution).unwrap();
|
||
assert_eq!(x.into_u32_vec().ok(), Some(vec![300, 1]));
|
||
assert_eq!(d.get_tag_u32(tiff::tags::Tag::ResolutionUnit).unwrap(), 2, "inch");
|
||
// without a ppi: JFIF density stays an aspect ratio
|
||
let j = encode_jpeg(&img, 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||
assert_eq!(&j[13..18], &[0, 0, 1, 0, 1]);
|
||
}
|
||
|
||
#[test]
|
||
fn pq_png_is_16_bit_rec2020_pq_with_cicp_and_content_light() {
|
||
// linear Rec. 2020, SDR white at 1.0, up to 8x SDR white along x
|
||
let (w, h) = (64u32, 4u32);
|
||
let rgb: Vec<f32> = (0..w * h)
|
||
.flat_map(|i| {
|
||
let v = 0.01 * 1.11f32.powi((i % w) as i32);
|
||
[v, v * 0.5, v * 0.25]
|
||
})
|
||
.collect();
|
||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"/>"#;
|
||
let bytes = encode_png_pq(w, h, &rgb, &EncodeMeta { xmp: Some(xmp), ppi: Some(240), ..Default::default() }).unwrap();
|
||
let mut r = png::Decoder::new(std::io::Cursor::new(bytes.as_slice())).read_info().unwrap();
|
||
let cicp = r.info().coding_independent_code_points.expect("cICP");
|
||
assert_eq!((cicp.color_primaries, cicp.transfer_function), (9, 16), "BT.2020, PQ");
|
||
let mut buf = vec![0; r.output_buffer_size().unwrap()];
|
||
let f = r.next_frame(&mut buf).unwrap();
|
||
assert_eq!((f.bit_depth, f.color_type, f.width, f.height), (png::BitDepth::Sixteen, png::ColorType::Rgb, w, h));
|
||
// every sample decodes back to its luminance
|
||
for x in [0usize, 20, 63] {
|
||
for c in 0..3 {
|
||
let o = (x * 3 + c) * 2;
|
||
let e = u16::from_be_bytes([buf[o], buf[o + 1]]) as f32 / 65535.0;
|
||
let want = rgb[x * 3 + c] * HDR_REFERENCE_WHITE_NITS;
|
||
assert!((encode::pq_decode(e) - want).abs() <= want * 0.01 + 0.01, "x {x} c {c}: {} vs {want}", encode::pq_decode(e));
|
||
}
|
||
}
|
||
// cLLI: MaxCLL is the brightest channel, in 0.0001 cd/m²
|
||
let at = bytes.windows(4).position(|w| w == b"cLLI").expect("cLLI");
|
||
let max_cll = u32::from_be_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as f32 / 10_000.0;
|
||
let brightest = rgb.iter().copied().fold(0.0f32, f32::max) * HDR_REFERENCE_WHITE_NITS;
|
||
assert!((max_cll - brightest.ceil()).abs() <= 1.0, "{max_cll} vs {brightest}");
|
||
assert!(bytes.windows(17).any(|w| w == b"XML:com.adobe.xmp"), "XMP kept");
|
||
assert!(bytes.windows(4).any(|w| w == b"pHYs"), "print resolution kept");
|
||
assert!(!bytes.windows(4).any(|w| w == b"iCCP"), "no ICC profile next to cICP");
|
||
}
|
||
|
||
#[test]
|
||
fn pq_png_rejects_short_buffers_and_survives_hostile_samples() {
|
||
assert!(encode_png_pq(4, 4, &[1.0; 3], &EncodeMeta::default()).is_err());
|
||
assert!(encode_png_pq(0, 4, &[], &EncodeMeta::default()).is_err());
|
||
assert!(encode_png_pq(u32::MAX, u32::MAX, &[], &EncodeMeta::default()).is_err());
|
||
let hostile = [f32::NAN, f32::INFINITY, -5.0, 1e30, 0.5, 2.0];
|
||
assert!(encode_png_pq(2, 1, &hostile, &EncodeMeta::default()).is_ok());
|
||
}
|
||
|
||
/// Largest per-channel difference between two linear images.
|
||
fn max_diff(a: &lightcraft_raster::Rgb32f, b: &lightcraft_raster::Rgb32f) -> f32 {
|
||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(feature = "jxl")]
|
||
fn jxl_lossless_exact_with_metadata() {
|
||
let img = gradient(97, 61);
|
||
let icc = write_named(NamedSpace::Srgb);
|
||
let exif = minimal_exif(1);
|
||
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF/></x:xmpmeta>"#;
|
||
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp), ..Default::default() };
|
||
let o = JxlOptions { lossless: true, ..Default::default() };
|
||
let b = encode_jxl(&EncodeImage::rgba8(&img), &o, &meta).unwrap();
|
||
assert_eq!(sniff(&b), Some(Format::Jxl));
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
assert_eq!((d.width, d.height), (97, 61));
|
||
assert!(!d.has_alpha, "opaque alpha is dropped");
|
||
assert_eq!(d.icc.as_deref(), Some(&icc[..]));
|
||
assert_eq!(d.exif.as_deref(), Some(&exif[..]));
|
||
assert_eq!(d.xmp.as_deref(), Some(xmp));
|
||
assert_eq!(d.to_srgb8().data, img.data, "lossless round trip is exact");
|
||
|
||
// 16-bit lossless keeps every code value
|
||
let px: Vec<u16> = (0..40 * 30 * 3).map(|i| (i as u32 * 2741 % 65536) as u16).collect();
|
||
let b = encode_jxl(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &o, &meta).unwrap();
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.bit_depth, 16);
|
||
let png = encode_png(&EncodeImage::new(40, 30, 3, Samples::U16(&px)), &meta).unwrap();
|
||
let want = decode(&png, DecodeOptions::default()).unwrap();
|
||
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(feature = "jxl")]
|
||
fn jxl_lossy_quality_and_effort() {
|
||
let img = smooth(160, 120);
|
||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||
let enc = |o: JxlOptions| encode_jxl(&EncodeImage::rgba8(&opaque), &o, &EncodeMeta::default()).unwrap();
|
||
let hi = enc(JxlOptions { quality: 95, ..Default::default() });
|
||
let lo = enc(JxlOptions { quality: 40, ..Default::default() });
|
||
assert!(lo.len() < hi.len(), "q40 {} ≥ q95 {}", lo.len(), hi.len());
|
||
for (q, b) in [(95, &hi), (40, &lo)] {
|
||
let d = decode(b, DecodeOptions::default()).unwrap();
|
||
let p = psnr8(&opaque, &d.to_srgb8());
|
||
assert!(p > if q == 95 { 38.0 } else { 28.0 }, "q{q} psnr {p}");
|
||
}
|
||
for effort in [JxlEffort::Fast, JxlEffort::Normal] {
|
||
for progressive in [false, true] {
|
||
let b = enc(JxlOptions { effort, progressive, ..Default::default() });
|
||
let p = psnr8(&opaque, &decode(&b, DecodeOptions::default()).unwrap().to_srgb8());
|
||
assert!(p > 34.0, "{effort:?} progressive={progressive} psnr {p}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(feature = "jxl")]
|
||
fn jxl_lossy_wide_gamut_matches_lossless_icc() {
|
||
// the same display-encoded samples, lossy with the enum encoding and lossless with the ICC
|
||
// profile, decode to the same colours in the working space
|
||
let img = smooth(96, 64);
|
||
let opaque = Rgba8 { data: img.data.iter().map(|p| [p[0], p[1], p[2], 255]).collect(), ..img.clone() };
|
||
for (colour, named) in [(JxlColour::DisplayP3, NamedSpace::DisplayP3), (JxlColour::Rec2020, NamedSpace::Rec2020)] {
|
||
let icc = write_named(named);
|
||
let meta = EncodeMeta { icc: Some(&icc), ..Default::default() };
|
||
let e = EncodeImage::rgba8(&opaque);
|
||
let lossy = encode_jxl(&e, &JxlOptions { quality: 98, colour, ..Default::default() }, &meta).unwrap();
|
||
let exact = encode_jxl(&e, &JxlOptions { lossless: true, colour, ..Default::default() }, &meta).unwrap();
|
||
let a = decode(&lossy, DecodeOptions::default()).unwrap();
|
||
assert!(a.icc.is_none(), "lossy files carry the enum encoding");
|
||
let b = decode(&exact, DecodeOptions::default()).unwrap();
|
||
let d = max_diff(&a.to_working(), &b.to_working());
|
||
assert!(d < 0.03, "{colour:?}: lossy differs from lossless by {d}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(feature = "jxl")]
|
||
fn jxl_alpha_and_gray() {
|
||
let mut img = gradient(32, 24);
|
||
img.data[5][3] = 0;
|
||
let b = encode_jxl(&EncodeImage::rgba8(&img), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
assert!(d.has_alpha);
|
||
assert_eq!(d.alpha.as_ref().map(|a| a.data[5]), Some(0.0));
|
||
let g: Vec<u8> = (0..32 * 24).map(|i| (i % 256) as u8).collect();
|
||
let b = encode_jxl(&EncodeImage::new(32, 24, 1, Samples::U8(&g)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default())
|
||
.unwrap();
|
||
let d = decode(&b, DecodeOptions::default()).unwrap();
|
||
assert_eq!(d.to_srgb8().data.iter().map(|p| p[0]).collect::<Vec<_>>(), g);
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(feature = "jxl")]
|
||
fn jxl_hdr_pq() {
|
||
// linear Rec. 2020, SDR white = 1.0: the left half at SDR white, the right half four times brighter
|
||
let (w, h) = (64usize, 32usize);
|
||
let px: Vec<f32> = (0..w * h).flat_map(|i| [if i % w < w / 2 { 1.0 } else { 4.0 }; 3]).collect();
|
||
for lossless in [false, true] {
|
||
let o = JxlOptions { lossless, quality: 95, colour: JxlColour::Rec2020Pq, ..Default::default() };
|
||
let b = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &o, &EncodeMeta::default()).unwrap();
|
||
// the file's own samples: PQ code values for 203 and 812 cd/m²
|
||
let img = jxl_oxide::JxlImage::builder().read(std::io::Cursor::new(&b)).unwrap();
|
||
let m = &img.image_header().metadata;
|
||
assert_eq!(m.tone_mapping.intensity_target, 10_000.0);
|
||
assert!(format!("{:?}", m.colour_encoding).contains("Pq"), "{:?}", m.colour_encoding);
|
||
let r = img.render_frame(0).unwrap();
|
||
let fb = r.image_all_channels();
|
||
let at = |x: usize| fb.buf()[((h / 2) * w + x) * fb.channels() + 1];
|
||
for (x, nits) in [(w / 4, HDR_REFERENCE_WHITE_NITS), (w * 3 / 4, 4.0 * HDR_REFERENCE_WHITE_NITS)] {
|
||
let (got, want) = (at(x), lightcraft_codecs::encode::pq_encode(nits));
|
||
assert!((got - want).abs() < 0.005, "lossless={lossless}: {nits} cd/m² written as {got}, want {want}");
|
||
}
|
||
}
|
||
// float samples are HDR only
|
||
let sdr = encode_jxl(&EncodeImage::new(w as u32, h as u32, 3, Samples::F32(&px)), &JxlOptions::default(), &EncodeMeta::default());
|
||
assert!(matches!(sdr, Err(Error::Encode(_))));
|
||
}
|
||
|
||
#[test]
|
||
#[cfg(feature = "jxl")]
|
||
fn jxl_bad_input_is_an_error() {
|
||
let px = [0u8; 12];
|
||
assert!(encode_jxl(&EncodeImage::new(4, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||
assert!(encode_jxl(&EncodeImage::new(0, 4, 3, Samples::U8(&px)), &JxlOptions::default(), &EncodeMeta::default()).is_err());
|
||
// a 1×1 image and quality 1 still encode
|
||
let one = [10u8, 20, 30];
|
||
let b =
|
||
encode_jxl(&EncodeImage::new(1, 1, 3, Samples::U8(&one)), &JxlOptions { quality: 1, ..Default::default() }, &EncodeMeta::default()).unwrap();
|
||
assert_eq!(decode(&b, DecodeOptions::default()).unwrap().width, 1);
|
||
}
|