diff --git a/Cargo.toml b/Cargo.toml index ef81287517..3944661a9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ rgb = { version = "0.8.25", optional = true } mp4parse = { version = "0.11.5", optional = true } dav1d = { version = "0.6.0", optional = true } dcv-color-primitives = { version = "0.1.16", optional = true } +exr = { version = "1.3.0", optional = true } color_quant = "1.1" [dev-dependencies] @@ -47,7 +48,7 @@ criterion = "0.3" [features] # TODO: Add "avif" to this list while preparing for 0.24.0 -default = ["gif", "jpeg", "ico", "png", "pnm", "tga", "tiff", "webp", "bmp", "hdr", "dxt", "dds", "farbfeld", "jpeg_rayon"] +default = ["gif", "jpeg", "ico", "png", "pnm", "tga", "tiff", "webp", "bmp", "hdr", "dxt", "dds", "farbfeld", "jpeg_rayon", "openexr"] ico = ["bmp", "png"] pnm = [] @@ -58,6 +59,7 @@ hdr = ["scoped_threadpool"] dxt = [] dds = ["dxt"] farbfeld = [] +openexr = ["exr"] # Enables multi-threading. # Requires latest stable Rust. diff --git a/Cargo.toml.public-private-dependencies b/Cargo.toml.public-private-dependencies index a9c641a38d..a91044d1af 100644 --- a/Cargo.toml.public-private-dependencies +++ b/Cargo.toml.public-private-dependencies @@ -38,6 +38,7 @@ tiff = { version = "0.6.0", optional = true } ravif = { version = "0.6.0", optional = true } rgb = { version = "0.8.25", optional = true } color_quant = { version = "1.1", public = true } +exr = { version = "1.3.0", optional = true } [dev-dependencies] crc32fast = "1.2.0" @@ -46,7 +47,7 @@ glob = "0.3" quickcheck = "0.9" [features] -default = ["gif", "jpeg", "ico", "png", "pnm", "tga", "tiff", "webp", "bmp", "hdr", "dxt", "dds", "jpeg_rayon"] +default = ["gif", "jpeg", "ico", "png", "pnm", "tga", "tiff", "webp", "bmp", "hdr", "dxt", "dds", "jpeg_rayon", "openexr"] ico = ["bmp", "png"] pnm = [] @@ -57,5 +58,6 @@ hdr = ["scoped_threadpool"] dxt = [] dds = ["dxt"] jpeg_rayon = ["jpeg/rayon"] +openexr = ["exr"] benchmarks = [] diff --git a/README.md b/README.md index c589a78e97..344462ffc7 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,15 @@ All image processing functions provided operate on types that implement the `Gen | PNG | All supported color types | Same as decoding | | JPEG | Baseline and progressive | Baseline JPEG | | GIF | Yes | Yes | -| BMP | Yes | RGB(8), RGBA(8), Gray(8), GrayA(8) | +| BMP | Yes | Rgb8, Rgba8, Gray8, GrayA8 | | ICO | Yes | Yes | -| TIFF | Baseline(no fax support) + LZW + PackBits | RGB(8), RGBA(8), Gray(8) | +| TIFF | Baseline(no fax support) + LZW + PackBits | Rgb8, Rgba8, Gray8 | | WebP | Lossy(Luma channel only) | No | | AVIF | Only 8-bit | Lossy | | PNM | PBM, PGM, PPM, standard PAM | Yes | | DDS | DXT1, DXT3, DXT5 | No | -| TGA | Yes | RGB(8), RGBA(8), BGR(8), BGRA(8), Gray(8), GrayA(8) | +| TGA | Yes | Rgb8, Rgba8, Bgr8, Bgra8, Gray8, GrayA8 | +| OpenEXR | Rgb32F, Rgba32F (no dwa compression) | Rgb32F, Rgba32F (no dwa compression) | | farbfeld | Yes | Yes | ### The [`ImageDecoder`](https://docs.rs/image/*/image/trait.ImageDecoder.html) and [`ImageDecoderExt`](https://docs.rs/image/*/image/trait.ImageDecoderExt.html) Traits diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 98d1a04a0f..ce695c5125 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -60,3 +60,7 @@ path = "fuzzers/fuzzer_script_ico.rs" [[bin]] name = "fuzzer_script_hdr" path = "fuzzers/fuzzer_script_hdr.rs" + +[[bin]] +name = "fuzzer_script_exr" +path = "fuzzers/fuzzer_script_exr.rs" diff --git a/fuzz/fuzzers/fuzzer_script_exr.rs b/fuzz/fuzzers/fuzzer_script_exr.rs new file mode 100644 index 0000000000..e4b061aa8e --- /dev/null +++ b/fuzz/fuzzers/fuzzer_script_exr.rs @@ -0,0 +1,57 @@ +#![no_main] +#[macro_use] extern crate libfuzzer_sys; +extern crate image; + +use std::io::Cursor; +use image::ImageResult; +use image::codecs::openexr::*; +use std::io::Seek; +use std::io::BufRead; +use std::convert::TryFrom; +use image::ImageDecoder; +use image::ImageEncoder; +use image::ColorType; + +// "just dont panic" +fn roundtrip(bytes: &[u8]) -> ImageResult<()> { + use std::io::Write; + + /// Read the file from the specified path into an `Rgba32FImage`. + // TODO this method should probably already exist in the main image crate + fn read_as_rgba_byte_image(read: impl BufRead + Seek) -> ImageResult<(u32,u32,Vec)> { + let decoder = OpenExrDecoder::with_alpha_preference(read, Some(true))?; + let (width, height) = decoder.dimensions(); + + let mut buffer = vec![0; usize::try_from(decoder.total_bytes()).unwrap()]; + decoder.read_image(buffer.as_mut_slice())?; + + Ok((width, height, buffer)) + } + + /// Write an `Rgba32FImage`. + /// Assumes the writer is buffered. In most cases, + /// you should wrap your writer in a `BufWriter` for best performance. + // TODO this method should probably already exist in the main image crate + fn write_rgba_image(write: impl Write/* + Seek*/, (width, height, data): &(u32,u32,Vec)) -> ImageResult<()> { + OpenExrEncoder::new(write).write_image( + data.as_slice(), *width, *height, + ColorType::Rgba32F + ) + } + + + let decoded_image = read_as_rgba_byte_image(Cursor::new(bytes))?; + + let mut bytes = Vec::with_capacity(bytes.len() + 20); + write_rgba_image(Cursor::new(&mut bytes), &decoded_image)?; + + let redecoded_image = read_as_rgba_byte_image(Cursor::new(bytes))?; + + // if both images are valid, assert read/write consistency + assert_eq!(decoded_image, redecoded_image, "image was valid but was not reproducible"); + Ok(()) +} + +fuzz_target!(|data: &[u8]| { + let _img = roundtrip(data); // fixme not optimized away? +}); diff --git a/src/buffer.rs b/src/buffer.rs index 596fe29258..fc3d924873 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -896,8 +896,9 @@ where /// Saves the buffer to a file at the path specified. /// /// The image format is derived from the file extension. - /// Currently only jpeg, png, ico, pnm, bmp and + /// Currently only jpeg, png, ico, pnm, bmp and /// tiff files are supported. + // TODO exr supported, but Rgba32F is not yet pub fn save(&self, path: Q) -> ImageResult<()> where Q: AsRef, @@ -948,6 +949,9 @@ where { /// Writes the buffer to a writer in the specified format. /// + /// Assumes the writer is buffered. In most cases, + /// you should wrap your writer in a `BufWriter` for best performance. + /// /// See [`ImageOutputFormat`](../enum.ImageOutputFormat.html) for /// supported types. /// @@ -1340,6 +1344,14 @@ pub(crate) type Gray16Image = ImageBuffer, Vec>; /// Sendable 16-bit grayscale + alpha channel image buffer pub(crate) type GrayAlpha16Image = ImageBuffer, Vec>; +/// An image buffer for 32-bit float RGB pixels, +/// where the backing container is a flattened vector of floats. +pub type Rgb32FImage = ImageBuffer, Vec>; + +/// An image buffer for 32-bit float RGBA pixels, +/// where the backing container is a flattened vector of floats. +pub type Rgba32FImage = ImageBuffer, Vec>; + #[cfg(test)] mod test { use super::{ImageBuffer, RgbImage}; diff --git a/src/codecs/openexr.rs b/src/codecs/openexr.rs new file mode 100644 index 0000000000..ac0967bc83 --- /dev/null +++ b/src/codecs/openexr.rs @@ -0,0 +1,515 @@ +//! Decoding of OpenEXR (.exr) Images +//! +//! OpenEXR is an image format that is widely used, especially in VFX, +//! because it supports lossless and lossy compression for float data. +//! +//! This decoder only supports RGB and RGBA images. +//! If an image does not contain alpha information, +//! it is defaulted to `1.0` (no transparency). +//! +//! # Related Links +//! * - The OpenEXR reference. +//! +//! +//! Current limitations (July 2021): +//! - only pixel type `Rgba32F` and `Rgba16F` are supported +//! - only non-deep rgb/rgba files supported, no conversion from/to YCbCr or similar +//! - only the first non-deep rgb layer is used +//! - only the largest mip map level is used +//! - pixels outside display window are lost +//! - meta data is lost +//! - dwaa/dwab compressed images not supported yet by the exr library +//! - (chroma) subsampling not supported yet by the exr library + +extern crate exr; +use exr::prelude::*; + +use crate::{ImageDecoder, ImageResult, ColorType, Progress, ImageError, ImageFormat, ImageBuffer, Rgba, Rgb, ImageEncoder, ExtendedColorType}; +use std::io::{Write, Seek, BufRead, Cursor, BufReader}; +use crate::error::{DecodingError, ImageFormatHint, LimitError, LimitErrorKind, EncodingError}; +use crate::image::decoder_to_vec; +use std::path::Path; +use crate::buffer_::{Rgb32FImage, Rgba32FImage}; +use std::convert::TryInto; + + + +/// An OpenEXR decoder. Immediately reads the meta data from the file. +#[derive(Debug)] +pub struct OpenExrDecoder { + exr_reader: exr::block::reader::Reader, + + // select a header that is rgb and not deep + header_index: usize, + + // decode either rgb or rgba. + // can be specified to include or discard alpha channels. + // if none, the alpha channel will only be allocated where the file contains data for it. + alpha_preference: Option, + + alpha_present_in_file: bool, +} + + +impl OpenExrDecoder { + + + /// Create a decoder. Consumes the first few bytes of the source to extract image dimensions. + /// Assumes the reader is buffered. In most cases, + /// you should wrap your reader in a `BufReader` for best performance. + /// Loads an alpha channel if the file has alpha samples. + /// Use `with_alpha_preference` if you want to load or not load alpha unconditionally. + pub fn new(source: R) -> ImageResult { + Self::with_alpha_preference(source, None) + } + + /// Create a decoder. Consumes the first few bytes of the source to extract image dimensions. + /// Assumes the reader is buffered. In most cases, + /// you should wrap your reader in a `BufReader` for best performance. + /// If alpha preference is specified, an alpha channel will + /// always be present or always be not present in the returned image. + /// If alpha preference is none, the alpha channel will only be returned if it is found in the file. + pub fn with_alpha_preference(source: R, alpha_preference: Option) -> ImageResult { + + // read meta data, then wait for further instructions, keeping the file open and ready + let exr_reader = exr::block::read(source, false).map_err(to_image_err)?; + + let header_index = exr_reader.headers().into_iter() + .position(|header|{ + let has_rgb = ["R","G","B"].iter().all( // alpha will be optional + // check if r/g/b exists in the channels + |required| header.channels.list.iter() + .any(|chan| chan.name.eq(required)) // TODO use search("A") and eq_lowercase later when exrs supports it + ); + + // we currently dont support deep images, or images with other color spaces than rgb + !header.deep && has_rgb + }) + .ok_or_else(|| ImageError::Decoding(DecodingError::new( + ImageFormatHint::Exact(ImageFormat::OpenExr), + "image does not contain non-deep rgb channels" + )))?; + + let has_alpha = exr_reader.headers()[header_index] + .channels.list + .iter() + .any(|chan| chan.name.eq("A")); // TODO use search("A") and eq_lowercase later when exrs supports it + + Ok(Self { + alpha_preference, + exr_reader, header_index, + alpha_present_in_file: has_alpha + }) + } + + // does not leak exrs-specific meta data into public api, just does it for this module + fn selected_exr_header(&self) -> &exr::meta::header::Header { + &self.exr_reader.meta_data().headers[self.header_index] + } +} + + +impl<'a, R: 'a + BufRead + Seek> ImageDecoder<'a> for OpenExrDecoder { + type Reader = Cursor>; + + fn dimensions(&self) -> (u32, u32) { + let size = self.selected_exr_header().shared_attributes.display_window.size; + (size.width() as u32, size.height() as u32) + } + + fn color_type(&self) -> ColorType { + let returns_alpha = self.alpha_preference.unwrap_or(self.alpha_present_in_file); + if returns_alpha { ColorType::Rgba32F } else { ColorType::Rgb32F } + } + + fn original_color_type(&self) -> ExtendedColorType { + if self.alpha_present_in_file { ExtendedColorType::Rgba32F } else { ExtendedColorType::Rgb32F } + } + + /// Use `read_image` instead if possible, + /// as this method creates a whole new buffer just to contain the entire image. + fn into_reader(self) -> ImageResult { + Ok(Cursor::new(decoder_to_vec(self)?)) + } + + fn scanline_bytes(&self) -> u64 { + // we cannot always read individual scan lines for every file, + // as the tiles or lines in the file could be in random or reversed order. + // therefore we currently read all lines at once + // Todo: optimize for specific exr.line_order? + self.total_bytes() + } + + // reads with or without alpha, depending on `self.alpha_preference` and `self.alpha_present_in_file` + fn read_image_with_progress(self, unaligned_bytes: &mut [u8], progress_callback: F) -> ImageResult<()> { + let blocks_in_header = self.selected_exr_header().chunk_count as u64; + let channel_count = self.color_type().channel_count() as usize; + + let display_window = self.selected_exr_header().shared_attributes.display_window; + let data_window_offset = self.selected_exr_header().own_attributes.layer_position - display_window.position; + + { // check whether the buffer is large enough for the dimensions of the file + let (width, height) = self.dimensions(); + let bytes_per_pixel = self.color_type().bytes_per_pixel() as usize; + let expected_byte_count = (width as usize).checked_mul(height as usize) + .and_then(|size| size.checked_mul(bytes_per_pixel)); + + // if the width and height does not match the length of the bytes, the arguments are invalid + let has_invalid_size_or_overflowed = expected_byte_count + .map(|expected_byte_count | unaligned_bytes.len() != expected_byte_count) + + // otherwise, size calculation overflowed, is bigger than memory, + // therefore data is too small, so it is invalid. + .unwrap_or(true); + + if has_invalid_size_or_overflowed { + panic!("byte buffer not large enough for the specified dimensions and f32 pixels"); + } + } + + let result = read() + .no_deep_data().largest_resolution_level() + .rgba_channels( + move |_size, _channels| { + vec![0_f32; display_window.size.area() * channel_count] + }, + + move |buffer, index_in_data_window, (r,g,b,a_or_1): (f32,f32,f32,f32)| { + let index_in_display_window = index_in_data_window.to_i32() + data_window_offset; + + // only keep pixels inside the data window + // TODO filter chunks based on this + if index_in_display_window.x() >= 0 && index_in_display_window.y() >= 0 + && index_in_display_window.x() < display_window.size.width() as i32 + && index_in_display_window.y() < display_window.size.height() as i32 + { + let index_in_display_window = index_in_display_window.to_usize("index bug").unwrap(); + let first_f32_index = index_in_display_window.flat_index_for_size(display_window.size); + + buffer[first_f32_index * channel_count .. (first_f32_index + 1) * channel_count] + .copy_from_slice(&[r, g, b, a_or_1][0 .. channel_count]); + + // TODO white point chromaticities + srgb/linear conversion? + } + } + ) + .first_valid_layer() // TODO select exact layer by self.header_index? + .all_attributes() + .on_progress(|progress| { + progress_callback(Progress::new( + (progress*blocks_in_header as f64) as u64, blocks_in_header) // TODO precision errors? + ); + }) + .from_chunks(self.exr_reader).map_err(to_image_err)?; + + // TODO this copy is strictly not necessary, but the exr api is a little too simple for reading into a borrowed target slice + + // this cast is safe and works with any alignment, as bytes are copied, and not f32 values. + // note: buffer slice length is checked in the beginning of this function and will be correct at this point + unaligned_bytes.copy_from_slice(bytemuck::cast_slice(result.layer_data.channel_data.pixels.as_slice())); + Ok(()) + } +} + + + + +/// Write a raw byte buffer of pixels, +/// returning an Error if the buffer is not aligned to `f32` +/// or if it has an invalid length. +/// +/// Assumes the writer is buffered. In most cases, +/// you should wrap your writer in a `BufWriter` for best performance. +// private. access via `OpenExrEncoder` +fn write_buffer( + mut buffered_write: impl Write/* + Seek*/, unaligned_bytes: &[u8], + width: u32, height: u32, color_type: ColorType +) -> ImageResult<()> +{ + let width = width as usize; + let height = height as usize; + + { // check whether the buffer is large enough for the specified dimensions + let expected_byte_count = width.checked_mul(height) + .and_then(|size| size.checked_mul(color_type.bytes_per_pixel() as usize)); + + // if the width and height does not match the length of the bytes, the arguments are invalid + let has_invalid_size_or_overflowed = expected_byte_count + .map(|expected_byte_count | unaligned_bytes.len() < expected_byte_count) + + // otherwise, size calculation overflowed, is bigger than memory, + // therefore data is too small, so it is invalid. + .unwrap_or(true); + + if has_invalid_size_or_overflowed { + return Err(ImageError::Encoding(EncodingError::new( + ImageFormatHint::Exact(ImageFormat::OpenExr), + "byte buffer not large enough for the specified dimensions and f32 pixels" + ))); + } + } + + let mut seekable_write = Cursor::new(Vec::with_capacity(width*height*3*4)); // TODO remove + + // bytes might be unaligned so we cannot cast the whole thing, instead lookup each f32 individually + let lookup_f32 = move |f32_index: usize| { + let f32_bytes_slice = &unaligned_bytes[f32_index * 4 .. (f32_index + 1) * 4]; + f32::from_ne_bytes(f32_bytes_slice.try_into().expect("indexing error")) + }; + + match color_type { + ColorType::Rgb32F => { + exr::prelude::Image // TODO compression method zip?? + ::from_channels( + (width, height), + SpecificChannels::rgb(|pixel: Vec2| { + let pixel_index = 3 * pixel.flat_index_for_size(Vec2(width, height)); + (lookup_f32(pixel_index), lookup_f32(pixel_index+1), lookup_f32(pixel_index+2)) + }) + ) + .write() + // .on_progress(|progress| todo!()) + .to_buffered(&mut seekable_write).map_err(to_image_err)?; + } + + ColorType::Rgba32F => { + exr::prelude::Image // TODO compression method zip?? + ::from_channels( + (width, height), + SpecificChannels::rgba(|pixel: Vec2| { + let pixel_index = 4 * pixel.flat_index_for_size(Vec2(width, height)); + ( + lookup_f32(pixel_index), lookup_f32(pixel_index+1), + lookup_f32(pixel_index+2), lookup_f32(pixel_index+3) + ) + }) + ) + .write() + // .on_progress(|progress| todo!()) + .to_buffered(&mut seekable_write).map_err(to_image_err)?; + } + + // TODO other color types and channel types + unsupported_color_type => return Err(ImageError::Encoding(EncodingError::new( + ImageFormatHint::Exact(ImageFormat::OpenExr), + format!("color type {:?} not yet supported", unsupported_color_type) + ))) + } + + buffered_write.write_all(seekable_write.into_inner().as_slice())?; + Ok(()) +} + + +// TODO is this struct and trait actually used anywhere? +/// A thin wrapper that implements `ImageEncoder` for OpenEXR images. Will behave like `image::codecs::openexr::write_buffer`. +#[derive(Debug)] +pub struct OpenExrEncoder (W); + +impl OpenExrEncoder { + + /// Create an `ImageEncoder`. Does not write anything yet. Writing later will behave like `image::codecs::openexr::write_buffer`. + // use constructor, not public field, for future backwards-compatibility + pub fn new(write: W) -> Self { Self(write) } +} + +impl ImageEncoder for OpenExrEncoder where W: Write /*+ Seek*/ { + + /// Writes the complete image. + /// + /// Returns an Error if the buffer is not aligned to `f32` + /// or if it has an invalid length. + /// + /// Assumes the writer is buffered. In most cases, + /// you should wrap your writer in a `BufWriter` for best performance. + fn write_image(self, buf: &[u8], width: u32, height: u32, color_type: ColorType) -> ImageResult<()> { + write_buffer(self.0, buf, width, height, color_type) + } +} + +fn to_image_err(exr_error: Error) -> ImageError { + ImageError::Decoding(DecodingError::new( + ImageFormatHint::Exact(ImageFormat::OpenExr), + exr_error.to_string() + )) +} + + + +#[cfg(test)] +mod test { + use super::*; + use std::path::PathBuf; + + const BASE_PATH: &[&str] = &[".", "tests", "images", "exr"]; + + + + /// Write an `Rgb32FImage`. + /// Assumes the writer is buffered. In most cases, + /// you should wrap your writer in a `BufWriter` for best performance. + fn write_rgb_image(write: impl Write/* + Seek*/, image: &Rgb32FImage) -> ImageResult<()> { + write_buffer( + write, + bytemuck::cast_slice(image.as_raw().as_slice()), + image.width(), image.height(), + ColorType::Rgb32F + ) + } + + /// Write an `Rgba32FImage`. + /// Assumes the writer is buffered. In most cases, + /// you should wrap your writer in a `BufWriter` for best performance. + fn write_rgba_image(write: impl Write/* + Seek*/, image: &Rgba32FImage) -> ImageResult<()> { + write_buffer( + write, + bytemuck::cast_slice(image.as_raw().as_slice()), + image.width(), image.height(), + ColorType::Rgba32F + ) + } + + /// Read the file from the specified path into an `Rgba32FImage`. + fn read_as_rgba_image_from_file(path: impl AsRef) -> ImageResult { + read_as_rgba_image(BufReader::new(std::fs::File::open(path)?)) + } + + /// Read the file from the specified path into an `Rgb32FImage`. + fn read_as_rgb_image_from_file(path: impl AsRef) -> ImageResult { + read_as_rgb_image(BufReader::new(std::fs::File::open(path)?)) + } + + /// Read the file from the specified path into an `Rgb32FImage`. + fn read_as_rgb_image(read: impl BufRead + Seek) -> ImageResult { + let decoder = OpenExrDecoder::with_alpha_preference(read, Some(false))?; + let (width, height) = decoder.dimensions(); + let buffer: Vec = decoder_to_vec(decoder)?; + + ImageBuffer::from_raw(width, height, buffer) + + // this should be the only reason for the "from raw" call to fail, + // even though such a large allocation would probably cause an error much earlier + .ok_or_else(|| ImageError::Limits(LimitError::from_kind(LimitErrorKind::InsufficientMemory))) + } + + /// Read the file from the specified path into an `Rgba32FImage`. + fn read_as_rgba_image(read: impl BufRead + Seek) -> ImageResult { + let decoder = OpenExrDecoder::with_alpha_preference(read, Some(true))?; + let (width, height) = decoder.dimensions(); + let buffer: Vec = decoder_to_vec(decoder)?; + + ImageBuffer::from_raw(width, height, buffer) + + // this should be the only reason for the "from raw" call to fail, + // even though such a large allocation would probably cause an error much earlier + .ok_or_else(|| ImageError::Limits(LimitError::from_kind(LimitErrorKind::InsufficientMemory))) + } + + #[test] + fn compare_exr_hdr() { + assert!(cfg!(feature = "hdr"), "to run all the openexr tests, activate the hdr feature flag"); + + #[cfg(feature = "hdr")] + { + let folder = BASE_PATH.iter().collect::(); + let reference_path = folder.clone().join("overexposed gradient.hdr"); + let exr_path = folder.clone().join("overexposed gradient - data window equals display window.exr"); + + let hdr: Vec> = crate::codecs::hdr::HdrDecoder::new( + std::io::BufReader::new(std::fs::File::open(&reference_path).unwrap()) + ).unwrap().read_image_hdr().unwrap(); + + let exr_pixels: Rgb32FImage = read_as_rgb_image_from_file(exr_path).unwrap(); + assert_eq!(exr_pixels.dimensions().0 * exr_pixels.dimensions().1, hdr.len() as u32); + + for (expected, found) in hdr.iter().zip(exr_pixels.pixels()){ + for (expected, found) in expected.0.iter().zip(found.0.iter()) { + // the large tolerance seems to be caused by + // the RGBE u8x4 pixel quantization of the hdr image format + assert!((expected - found).abs() < 0.1, "expected {}, found {}", expected, found); + } + } + } + } + + #[test] + fn roundtrip_rgba() { + let mut next_random = vec![ 1.0, 0.0, -1.0, -3.14, 27.0, 11.0, 31.0 ].into_iter().cycle(); + let mut next_random = move || next_random.next().unwrap(); + + let generated_image: Rgba32FImage = ImageBuffer::from_fn(9, 31, |_x, _y|{ + Rgba([next_random(), next_random(), next_random(), next_random()]) + }); + + let mut bytes = vec![]; + write_rgba_image(Cursor::new(&mut bytes), &generated_image).unwrap(); + let decoded_image = read_as_rgba_image(Cursor::new(bytes)).unwrap(); + + debug_assert_eq!(generated_image, decoded_image); + } + + #[test] + fn roundtrip_rgb() { + let mut next_random = vec![ 1.0, 0.0, -1.0, -3.14, 27.0, 11.0, 31.0 ].into_iter().cycle(); + let mut next_random = move || next_random.next().unwrap(); + + let generated_image: Rgb32FImage = ImageBuffer::from_fn(9, 31, |_x, _y|{ + Rgb([next_random(), next_random(), next_random()]) + }); + + let mut bytes = vec![]; + write_rgb_image(Cursor::new(&mut bytes), &generated_image).unwrap(); + let decoded_image = read_as_rgb_image(Cursor::new(bytes)).unwrap(); + + debug_assert_eq!(generated_image, decoded_image); + } + + #[test] + fn compare_rgba_rgb() { + let exr_path = BASE_PATH.iter().collect::() + .join("overexposed gradient - data window equals display window.exr"); + + let rgb: Rgb32FImage = read_as_rgb_image_from_file(&exr_path).unwrap(); + let rgba: Rgba32FImage = read_as_rgba_image_from_file(&exr_path).unwrap(); + + assert_eq!(rgba.dimensions(), rgb.dimensions()); + + for (Rgb(rgb), Rgba(rgba)) in rgb.pixels().zip(rgba.pixels()) { + assert_eq!(rgb, &rgba[..3]); + } + } + + #[test] + fn compare_cropped() { + // like in photoshop, exr images may have layers placed anywhere in a canvas. + // we don't want to load the pixels from the layer, but we want to load the pixels from the canvas. + // a layer might be smaller than the canvas, in that case the canvas should be transparent black + // where no layer was covering it. a layer might also be larger than the canvas, + // these pixels should be discarded. + // + // in this test we want to make sure that an + // auto-cropped image will be reproduced to the original. + + let exr_path = BASE_PATH.iter().collect::(); + let original = exr_path.clone().join("cropping - uncropped original.exr"); + let cropped = exr_path.clone().join("cropping - data window differs display window.exr"); + + // smoke-check that the exr files are actually not the same + { + let original_exr = read_first_flat_layer_from_file(&original).unwrap(); + let cropped_exr = read_first_flat_layer_from_file(&cropped).unwrap(); + assert_eq!(original_exr.attributes.display_window, cropped_exr.attributes.display_window); + assert_ne!(original_exr.layer_data.attributes.layer_position, cropped_exr.layer_data.attributes.layer_position); + assert_ne!(original_exr.layer_data.size, cropped_exr.layer_data.size); + } + + // check that they result in the same image + let original: Rgba32FImage = read_as_rgba_image_from_file(&original).unwrap(); + let cropped: Rgba32FImage = read_as_rgba_image_from_file(&cropped).unwrap(); + assert_eq!(original.dimensions(), cropped.dimensions()); + + // the following is not a simple assert_eq, as in case of an error, + // the whole image would be printed to the console, which takes forever + assert!(original.pixels().zip(cropped.pixels()).all(|(a,b)| a == b)); + } +} diff --git a/src/dynimage.rs b/src/dynimage.rs index a4c1839dc8..5e7f334a31 100644 --- a/src/dynimage.rs +++ b/src/dynimage.rs @@ -896,7 +896,11 @@ impl DynamicImage { dynamic_map!(*self, ref p => imageops::rotate270(p)) } - /// Encode this image and write it to ```w``` + /// Encode this image and write it to ```w```. + /// + /// Assumes the writer is buffered. In most cases, + /// you should wrap your writer in a `BufWriter` for best performance. + /// /// **Note**: TIFF encoding uses buffered writing, /// which can lead to unexpected use of resources pub fn write_to>( @@ -1273,9 +1277,9 @@ where /// /// The image format is derived from the file extension. The buffer is assumed to have /// the correct format according to the specified color type. - +/// /// This will lead to corrupted files if the buffer contains malformed data. Currently only -/// jpeg, png, ico, pnm, bmp and tiff files are supported. +/// jpeg, png, ico, pnm, bmp, exr and tiff files are supported. pub fn save_buffer

( path: P, buf: &[u8], @@ -1296,7 +1300,7 @@ where /// The buffer is assumed to have the correct format according /// to the specified color type. /// This will lead to corrupted files if the buffer contains -/// malformed data. Currently only jpeg, png, ico, bmp and +/// malformed data. Currently only jpeg, png, ico, bmp, exr and /// tiff files are supported. pub fn save_buffer_with_format

( path: P, @@ -1323,6 +1327,9 @@ where /// See [`ImageOutputFormat`](../enum.ImageOutputFormat.html) for /// supported types. /// +/// Assumes the writer is buffered. In most cases, +/// you should wrap your writer in a `BufWriter` for best performance. +/// /// **Note**: TIFF encoding uses buffered writing, /// which can lead to unexpected use of resources pub fn write_buffer_with_format( diff --git a/src/image.rs b/src/image.rs index 38b70a4a4f..db409edc2b 100644 --- a/src/image.rs +++ b/src/image.rs @@ -55,6 +55,9 @@ pub enum ImageFormat { /// An Image in Radiance HDR Format Hdr, + /// An Image in OpenEXR Format + OpenExr, + /// An Image in farbfeld Format Farbfeld, @@ -94,6 +97,7 @@ impl ImageFormat { "bmp" => ImageFormat::Bmp, "ico" => ImageFormat::Ico, "hdr" => ImageFormat::Hdr, + "exr" => ImageFormat::OpenExr, "pbm" | "pam" | "ppm" | "pgm" => ImageFormat::Pnm, "ff" | "farbfeld" => ImageFormat::Farbfeld, _ => return None, @@ -149,6 +153,7 @@ impl ImageFormat { ImageFormat::Bmp => true, ImageFormat::Ico => true, ImageFormat::Hdr => true, + ImageFormat::OpenExr => true, ImageFormat::Pnm => true, ImageFormat::Farbfeld => true, ImageFormat::Avif => true, @@ -173,6 +178,7 @@ impl ImageFormat { ImageFormat::Avif => true, ImageFormat::WebP => false, ImageFormat::Hdr => false, + ImageFormat::OpenExr => true, ImageFormat::Dds => false, ImageFormat::__NonExhaustive(marker) => match marker._private {}, } @@ -200,6 +206,7 @@ impl ImageFormat { ImageFormat::Bmp => &["bmp"], ImageFormat::Ico => &["ico"], ImageFormat::Hdr => &["hdr"], + ImageFormat::OpenExr => &["exr"], ImageFormat::Farbfeld => &["ff"], // According to: https://aomediacodec.github.io/av1-avif/#mime-registration ImageFormat::Avif => &["avif"], @@ -243,6 +250,10 @@ pub enum ImageOutputFormat { /// An Image in TGA Format Tga, + #[cfg(feature = "openexr")] + /// An Image in OpenEXR Format + OpenExr, + #[cfg(feature = "tiff")] /// An Image in TIFF Format Tiff, @@ -279,6 +290,8 @@ impl From for ImageOutputFormat { ImageFormat::Farbfeld => ImageOutputFormat::Farbfeld, #[cfg(feature = "tga")] ImageFormat::Tga => ImageOutputFormat::Tga, + #[cfg(feature = "openexr")] + ImageFormat::OpenExr => ImageOutputFormat::OpenExr, #[cfg(feature = "tiff")] ImageFormat::Tiff => ImageOutputFormat::Tiff, @@ -511,6 +524,11 @@ pub struct Progress { } impl Progress { + /// Create Progress. Result in invalid progress if you provide a greater `current` than `total`. + pub(crate) fn new(current: u64, total: u64) -> Self { + Self { current, total } + } + /// A measure of completed decoding. pub fn current(self) -> u64 { self.current @@ -1273,6 +1291,7 @@ mod tests { assert_eq!(from_path("./a.bmp").unwrap(), ImageFormat::Bmp); assert_eq!(from_path("./a.Ico").unwrap(), ImageFormat::Ico); assert_eq!(from_path("./a.hdr").unwrap(), ImageFormat::Hdr); + assert_eq!(from_path("./a.exr").unwrap(), ImageFormat::OpenExr); assert_eq!(from_path("./a.pbm").unwrap(), ImageFormat::Pnm); assert_eq!(from_path("./a.pAM").unwrap(), ImageFormat::Pnm); assert_eq!(from_path("./a.Ppm").unwrap(), ImageFormat::Pnm); @@ -1373,7 +1392,7 @@ mod tests { #[test] fn image_formats_are_recognized() { use ImageFormat::*; - const ALL_FORMATS: &'static [ImageFormat] = &[Avif, Png, Jpeg, Gif, WebP, Pnm, Tiff, Tga, Dds, Bmp, Ico, Hdr, Farbfeld]; + const ALL_FORMATS: &'static [ImageFormat] = &[Avif, Png, Jpeg, Gif, WebP, Pnm, Tiff, Tga, Dds, Bmp, Ico, Hdr, Farbfeld, OpenExr]; for &format in ALL_FORMATS { let mut file = Path::new("file.nothing").to_owned(); for ext in format.extensions_str() { diff --git a/src/io/free_functions.rs b/src/io/free_functions.rs index ea72599424..d1a7d41530 100644 --- a/src/io/free_functions.rs +++ b/src/io/free_functions.rs @@ -3,32 +3,7 @@ use std::io::{BufRead, BufReader, BufWriter, Seek}; use std::path::Path; use std::u32; -#[cfg(feature = "bmp")] -use crate::codecs::bmp; -#[cfg(feature = "gif")] -use crate::codecs::gif; -#[cfg(feature = "hdr")] -use crate::codecs::hdr; -#[cfg(feature = "ico")] -use crate::codecs::ico; -#[cfg(feature = "jpeg")] -use crate::codecs::jpeg; -#[cfg(feature = "png")] -use crate::codecs::png; -#[cfg(feature = "pnm")] -use crate::codecs::pnm; -#[cfg(feature = "tga")] -use crate::codecs::tga; -#[cfg(feature = "dds")] -use crate::codecs::dds; -#[cfg(feature = "tiff")] -use crate::codecs::tiff; -#[cfg(feature = "webp")] -use crate::codecs::webp; -#[cfg(feature = "farbfeld")] -use crate::codecs::farbfeld; -#[cfg(any(feature = "avif-encoder", feature = "avif-decoder"))] -use crate::codecs::avif; +use crate::codecs::*; use crate::{ImageOutputFormat, color, error::{UnsupportedError, UnsupportedErrorKind}}; use crate::image; @@ -48,7 +23,10 @@ pub(crate) fn open_impl(path: &Path) -> ImageResult { load(fin, ImageFormat::from_path(path)?) } -/// Create a new image from a Reader +/// Create a new image from a Reader. +/// +/// Assumes the reader is already buffered. For optimal performance, +/// consider wrapping the reader with a `BufRead::new()`. /// /// Try [`io::Reader`] for more advanced uses. /// @@ -81,6 +59,8 @@ pub fn load(r: R, format: ImageFormat) -> ImageResult DynamicImage::from_decoder(ico::IcoDecoder::new(r)?), #[cfg(feature = "hdr")] image::ImageFormat::Hdr => DynamicImage::from_decoder(hdr::HdrAdapter::new(BufReader::new(r))?), + #[cfg(feature = "openexr")] + image::ImageFormat::OpenExr => DynamicImage::from_decoder(openexr::OpenExrDecoder::new(r)?), #[cfg(feature = "pnm")] image::ImageFormat::Pnm => DynamicImage::from_decoder(pnm::PnmDecoder::new(BufReader::new(r))?), #[cfg(feature = "farbfeld")] @@ -129,6 +109,8 @@ pub(crate) fn image_dimensions_with_format_impl(fin: R, forma image::ImageFormat::Ico => ico::IcoDecoder::new(fin)?.dimensions(), #[cfg(feature = "hdr")] image::ImageFormat::Hdr => hdr::HdrAdapter::new(fin)?.dimensions(), + #[cfg(feature = "openexr")] + image::ImageFormat::OpenExr => openexr::OpenExrDecoder::new(fin)?.dimensions(), #[cfg(feature = "pnm")] image::ImageFormat::Pnm => { pnm::PnmDecoder::new(fin)?.dimensions() @@ -218,6 +200,8 @@ pub(crate) fn write_buffer_impl( ImageOutputFormat::Farbfeld => farbfeld::FarbfeldEncoder::new(fout).write_image(buf, width, height, color), #[cfg(feature = "tga")] ImageOutputFormat::Tga => tga::TgaEncoder::new(fout).write_image(buf, width, height, color), + #[cfg(feature = "openexr")] + ImageOutputFormat::OpenExr => openexr::OpenExrEncoder::new(fout).write_image(buf, width, height, color), #[cfg(feature = "tiff")] ImageOutputFormat::Tiff => { let mut cursor = std::io::Cursor::new(Vec::new()); @@ -240,7 +224,7 @@ pub(crate) fn write_buffer_impl( } } -static MAGIC_BYTES: [(&[u8], ImageFormat); 20] = [ +static MAGIC_BYTES: [(&[u8], ImageFormat); 21] = [ (b"\x89PNG\r\n\x1a\n", ImageFormat::Png), (&[0xff, 0xd8, 0xff], ImageFormat::Jpeg), (b"GIF89a", ImageFormat::Gif), @@ -261,6 +245,7 @@ static MAGIC_BYTES: [(&[u8], ImageFormat); 20] = [ (b"P7", ImageFormat::Pnm), (b"farbfeld", ImageFormat::Farbfeld), (b"\0\0\0 ftypavif", ImageFormat::Avif), + (&[0x76, 0x2f, 0x31, 0x01], ImageFormat::OpenExr), // = &exr::meta::magic_number::BYTES ]; /// Guess image format from memory block diff --git a/src/io/reader.rs b/src/io/reader.rs index b1320fa6f2..58e641bb85 100644 --- a/src/io/reader.rs +++ b/src/io/reader.rs @@ -59,7 +59,7 @@ use super::free_functions; /// [`set_format`]: #method.set_format /// [`ImageDecoder`]: ../trait.ImageDecoder.html pub struct Reader { - /// The reader. + /// The reader. Should be buffered. inner: R, /// The format, if one has been set or deduced. format: Option, @@ -68,22 +68,28 @@ pub struct Reader { impl Reader { /// Create a new image reader without a preset format. /// + /// Assumes the reader is already buffered. For optimal performance, + /// consider wrapping the reader with a `BufRead::new()`. + /// /// It is possible to guess the format based on the content of the read object with /// [`with_guessed_format`], or to set the format directly with [`set_format`]. /// /// [`with_guessed_format`]: #method.with_guessed_format /// [`set_format`]: method.set_format - pub fn new(reader: R) -> Self { + pub fn new(buffered_reader: R) -> Self { Reader { - inner: reader, + inner: buffered_reader, format: None, } } /// Construct a reader with specified format. - pub fn with_format(reader: R, format: ImageFormat) -> Self { + /// + /// Assumes the reader is already buffered. For optimal performance, + /// consider wrapping the reader with a `BufRead::new()`. + pub fn with_format(buffered_reader: R, format: ImageFormat) -> Self { Reader { - inner: reader, + inner: buffered_reader, format: Some(format), } } diff --git a/src/lib.rs b/src/lib.rs index c0e5c1c9ca..a42c9ee0b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -109,26 +109,31 @@ pub use crate::color::{Luma, LumaA, Rgb, Rgba, Bgr, Bgra}; pub use crate::error::{ImageError, ImageResult}; -pub use crate::image::{AnimationDecoder, - GenericImage, - GenericImageView, - ImageDecoder, - ImageDecoderExt, - ImageEncoder, - ImageFormat, - ImageOutputFormat, - Progress, - // Iterators - Pixels, - SubImage}; +pub use crate::image::{ + AnimationDecoder, + GenericImage, + GenericImageView, + ImageDecoder, + ImageDecoderExt, + ImageEncoder, + ImageFormat, + ImageOutputFormat, + Progress, + // Iterators + Pixels, + SubImage +}; pub use crate::buffer_::{ - GrayAlphaImage, - GrayImage, - // Image types - ImageBuffer, - RgbImage, - RgbaImage}; + GrayAlphaImage, + GrayImage, + // Image types + ImageBuffer, + RgbImage, + RgbaImage, + Rgba32FImage, + Rgb32FImage, +}; pub use crate::flat::FlatSamples; @@ -184,14 +189,15 @@ pub mod flat; /// | PNG | All supported color types | Same as decoding | /// | JPEG | Baseline and progressive | Baseline JPEG | /// | GIF | Yes | Yes | -/// | BMP | Yes | RGB8, RGBA8, Gray8, GrayA8 | +/// | BMP | Yes | Rgb8, Rgba8, Gray8, GrayA8 | /// | ICO | Yes | Yes | -/// | TIFF | Baseline(no fax support) + LZW + PackBits | RGB8, RGBA8, Gray8 | +/// | TIFF | Baseline(no fax support) + LZW + PackBits | Rgb8, Rgba8, Gray8 | /// | WebP | Lossy(Luma channel only) | No | /// | AVIF | Only 8-bit | Lossy | /// | PNM | PBM, PGM, PPM, standard PAM | Yes | /// | DDS | DXT1, DXT3, DXT5 | No | -/// | TGA | Yes | RGB8, RGBA8, BGR8, BGRA8, Gray8, GrayA8 | +/// | TGA | Yes | Rgb8, Rgba8, Bgr8, Bgra8, Gray8, GrayA8 | +/// | OpenEXR | Rgb32F, Rgba32F (no dwa compression) | Rgb32F, Rgba32F (no dwa compression) | /// | farbfeld | Yes | Yes | /// /// ## A note on format specific features @@ -244,6 +250,8 @@ pub mod codecs { pub mod tiff; #[cfg(feature = "webp")] pub mod webp; + #[cfg(feature = "openexr")] + pub mod openexr; } #[cfg(feature = "avif-encoder")] diff --git a/tests/images/exr/cropping - data window differs display window.exr b/tests/images/exr/cropping - data window differs display window.exr new file mode 100644 index 0000000000..9a0a68654b Binary files /dev/null and b/tests/images/exr/cropping - data window differs display window.exr differ diff --git a/tests/images/exr/cropping - uncropped original.exr b/tests/images/exr/cropping - uncropped original.exr new file mode 100644 index 0000000000..3f614f5d47 Binary files /dev/null and b/tests/images/exr/cropping - uncropped original.exr differ diff --git a/tests/images/exr/overexposed gradient - data window equals display window.exr b/tests/images/exr/overexposed gradient - data window equals display window.exr new file mode 100644 index 0000000000..fd60a843f9 Binary files /dev/null and b/tests/images/exr/overexposed gradient - data window equals display window.exr differ diff --git a/tests/images/exr/overexposed gradient.hdr b/tests/images/exr/overexposed gradient.hdr new file mode 100644 index 0000000000..03f16e7b00 Binary files /dev/null and b/tests/images/exr/overexposed gradient.hdr differ diff --git a/tests/regression.rs b/tests/regression.rs index 94e7a53326..243c7dc0da 100644 --- a/tests/regression.rs +++ b/tests/regression.rs @@ -9,7 +9,7 @@ where F: Fn(PathBuf), { let base: PathBuf = BASE_PATH.iter().collect(); - let decoders = &["tga", "tiff", "png", "gif", "bmp", "ico", "jpg", "hdr", "pbm", "webp"]; + let decoders = &["tga", "tiff", "png", "gif", "bmp", "ico", "jpg", "hdr", "pbm", "webp", "exr"]; for decoder in decoders { let mut path = base.clone(); path.push(dir); diff --git a/tests/truncate_images.rs b/tests/truncate_images.rs index 8087d11549..ceda45a752 100644 --- a/tests/truncate_images.rs +++ b/tests/truncate_images.rs @@ -15,7 +15,7 @@ where F: Fn(PathBuf), { let base: PathBuf = BASE_PATH.iter().collect(); - let decoders = &["tga", "tiff", "png", "gif", "bmp", "ico", "jpg", "hdr", "farbfeld"]; + let decoders = &["tga", "tiff", "png", "gif", "bmp", "ico", "jpg", "hdr", "farbfeld", "exr"]; for decoder in decoders { let mut path = base.clone(); path.push(dir); @@ -91,3 +91,8 @@ fn truncate_hdr() { fn truncate_farbfeld() { truncate_images("farbfeld"); } + +#[test] +fn truncate_exr() { + truncate_images("exr"); +}