use crate::format::{avif, jpeg, png, webp};
use anyhow::{Result, anyhow, bail, ensure};
use image::{DynamicImage, EncodableLayout, ImageBuffer};
use versatiles_core::{Blob, TileFormat};
use versatiles_derive::context;
pub trait DynamicImageTraitConvert {
fn from_fn<const N: usize>(width: usize, height: usize, f: impl FnMut(u32, u32) -> [u8; N]) -> DynamicImage;
fn from_raw(width: usize, height: usize, data: Vec<u8>) -> Result<DynamicImage>;
fn from_blob(blob: &Blob, format: TileFormat) -> Result<DynamicImage>;
fn to_blob(&self, format: TileFormat, quality: Option<u8>, effort: Option<u8>) -> Result<Blob>;
fn iter_pixels(&self) -> impl Iterator<Item = &[u8]>;
fn raw_pixel(&self, x: u32, y: u32) -> &[u8];
}
impl DynamicImageTraitConvert for DynamicImage {
fn from_fn<const N: usize>(width: usize, height: usize, mut f: impl FnMut(u32, u32) -> [u8; N]) -> DynamicImage {
assert!(N >= 1 && N <= 4, "Unsupported channel count for from_fn: {N}");
assert!(width > 0, "Width must be greater than 0");
assert!(height > 0, "Height must be greater than 0");
let px_count = width * height;
let mut data = Vec::with_capacity(px_count * N);
for y in 0..u32::try_from(height).expect("Height too large") {
for x in 0..u32::try_from(width).expect("Width too large") {
let p = f(x, y);
data.extend_from_slice(&p);
}
}
DynamicImage::from_raw(width, height, data).expect("from_fn: failed to construct image from raw data")
}
#[context("creating image from raw ({}x{})", width, height)]
fn from_raw(width: usize, height: usize, data: Vec<u8>) -> Result<DynamicImage> {
let channel_count = data.len() / (width * height);
ensure!(
channel_count * width * height == data.len(),
"Data length ({}) does not match width ({width}) * height ({height}) * channel_count ({channel_count}) = {}",
data.len(),
channel_count * width * height
);
let w = u32::try_from(width)?;
let h = u32::try_from(height)?;
Ok(match channel_count {
1 => DynamicImage::ImageLuma8(
ImageBuffer::from_vec(w, h, data)
.ok_or_else(|| anyhow!("Failed to create Luma8 image buffer with provided data"))?,
),
2 => DynamicImage::ImageLumaA8(
ImageBuffer::from_vec(w, h, data)
.ok_or_else(|| anyhow!("Failed to create LumaA8 image buffer with provided data"))?,
),
3 => DynamicImage::ImageRgb8(
ImageBuffer::from_vec(w, h, data)
.ok_or_else(|| anyhow!("Failed to create RGB8 image buffer with provided data"))?,
),
4 => DynamicImage::ImageRgba8(
ImageBuffer::from_vec(w, h, data)
.ok_or_else(|| anyhow!("Failed to create RGBA8 image buffer with provided data"))?,
),
_ => bail!("Unsupported channel count: {channel_count}"),
})
}
#[context("encoding {}x{} {:?} as {:?} (q={:?}, e={:?})", self.width(), self.height(), self.color(), format, quality, effort)]
fn to_blob(&self, format: TileFormat, quality: Option<u8>, effort: Option<u8>) -> Result<Blob> {
use TileFormat::{AVIF, JPG, PNG, WEBP};
match format {
AVIF => avif::encode(self, quality, effort),
JPG => jpeg::encode(self, quality),
PNG => png::encode(self, effort),
WEBP => webp::encode(self, quality, effort),
_ => bail!("Unsupported image format for encoding: {format:?}"),
}
}
#[context("decoding {:?} image ({} bytes)", format, blob.len())]
fn from_blob(blob: &Blob, format: TileFormat) -> Result<DynamicImage> {
use TileFormat::{AVIF, JPG, PNG, WEBP};
match format {
AVIF => avif::blob2image(blob),
JPG => jpeg::blob2image(blob),
PNG => png::blob2image(blob),
WEBP => webp::blob2image(blob),
_ => bail!("Unsupported image format for decoding: {format:?}"),
}
}
fn iter_pixels(&self) -> impl Iterator<Item = &[u8]> {
match self {
DynamicImage::ImageLuma8(img) => img.as_bytes().chunks_exact(1),
DynamicImage::ImageLumaA8(img) => img.as_bytes().chunks_exact(2),
DynamicImage::ImageRgb8(img) => img.as_bytes().chunks_exact(3),
DynamicImage::ImageRgba8(img) => img.as_bytes().chunks_exact(4),
_ => panic!("Unsupported image type for pixel iteration"),
}
}
fn raw_pixel(&self, x: u32, y: u32) -> &[u8] {
match self {
DynamicImage::ImageLuma8(i) => &i.get_pixel(x, y).0,
DynamicImage::ImageLumaA8(i) => &i.get_pixel(x, y).0,
DynamicImage::ImageRgb8(i) => &i.get_pixel(x, y).0,
DynamicImage::ImageRgba8(i) => &i.get_pixel(x, y).0,
_ => panic!("Unsupported image type for get_raw_pixel"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DynamicImageTraitInfo, DynamicImageTraitOperation};
use rstest::rstest;
fn sample_l8() -> DynamicImage {
#[allow(clippy::cast_possible_truncation)]
DynamicImage::from_fn(4, 3, |x, y| [((x + y) % 2) as u8])
}
fn sample_la8() -> DynamicImage {
#[allow(clippy::cast_possible_truncation)]
DynamicImage::from_fn(4, 3, |x, y| [((x * 2 + y) % 256) as u8, 255])
}
fn sample_rgb8() -> DynamicImage {
#[allow(clippy::cast_possible_truncation)]
DynamicImage::from_fn(4, 3, |x, y| [x as u8, y as u8, (x + y) as u8])
}
fn sample_rgba8() -> DynamicImage {
#[allow(clippy::cast_possible_truncation)]
DynamicImage::from_fn(4, 3, |x, y| [x as u8, y as u8, (x + y) as u8, 200])
}
#[rstest]
#[case::jpg(TileFormat::JPG, [0.4, 0.2, 0.5])]
#[case::png(TileFormat::PNG, [0.0; 3])]
#[case::webp(TileFormat::WEBP, [5.5,0.4,4.2])]
fn roundtrip_encode_decode(#[case] format: TileFormat, #[case] diff: [f64; 3]) {
let image = sample_rgb8();
let blob = image.to_blob(format, None, None).unwrap();
let decoded_image = DynamicImage::from_blob(&blob, format).expect("Failed to decode image");
assert_eq!(DynamicImageTraitInfo::diff(&image, &decoded_image).unwrap(), diff);
}
#[rstest]
#[case::l8(sample_l8(), 1usize)]
#[case::la8(sample_la8(), 2usize)]
#[case::rgb8(sample_rgb8(), 3usize)]
#[case::rgba8(sample_rgba8(), 4usize)]
fn iter_pixels_chunk_sizes_match_color_type(#[case] img: DynamicImage, #[case] chunk: usize) {
for px in img.iter_pixels() {
assert_eq!(px.len(), chunk);
}
}
#[rstest]
#[case::l8(1)]
#[case::la8(2)]
#[case::rgb8(3)]
#[case::rgba8(4)]
fn from_raw_accepts_supported_channel_counts(#[case] channels: usize) {
let w = 4usize;
let h = 3usize; #[allow(clippy::cast_possible_truncation)]
let data = (0..(w * h * channels)).map(|v| (v & 0xFF) as u8).collect::<Vec<_>>();
let img = DynamicImage::from_raw(w, h, data).expect("from_raw failed");
assert_eq!(img.color().channel_count() as usize, channels);
}
#[test]
fn from_raw_rejects_mismatched_len() {
let data_mismatch = vec![0u8; 5];
assert_eq!(
DynamicImage::from_raw(2, 2, data_mismatch)
.unwrap_err()
.chain()
.last()
.unwrap()
.to_string(),
"Data length (5) does not match width (2) * height (2) * channel_count (1) = 4"
);
}
#[test]
fn from_raw_rejects_unsupported_channel_counts() {
let data_unsupported = vec![0u8; 20];
assert_eq!(
DynamicImage::from_raw(2, 2, data_unsupported)
.unwrap_err()
.chain()
.last()
.unwrap()
.to_string(),
"Unsupported channel count: 5"
);
}
#[test]
fn to_blob_unsupported_format_is_error_if_any() {
let img = sample_rgb8();
let blob = img.to_blob(TileFormat::PNG, None, None).expect("PNG should encode");
assert!(!blob.is_empty());
}
#[rstest]
#[case::l8([10])]
#[case::la8([10, 20])]
#[case::rgb8([10, 20, 30])]
#[case::rgba8([10, 20, 30, 40])]
fn from_fn<const N: usize>(#[case] expected_pixel: [u8; N]) {
let img = DynamicImage::from_fn(2, 2, |_, _| expected_pixel);
assert_eq!(img.average_color(), expected_pixel);
}
}