diff --git a/benches/image.rs b/benches/image.rs index 237fca1..231f553 100644 --- a/benches/image.rs +++ b/benches/image.rs @@ -2,12 +2,13 @@ use criterion::BenchmarkId; use criterion::Throughput; use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use image::imageops::FilterType; +use image::imageops; use image::GenericImageView; use photosync::library::load_image; use photosync::library::resize; use photosync::library::save_to_jpeg_bytes; +use photosync::library::FilterType; pub fn criterion_benchmark(c: &mut Criterion) { const TEST_IMAGE_PATH: &'static str = "testdata/image.jpg"; @@ -28,9 +29,10 @@ pub fn criterion_benchmark(c: &mut Criterion) { { let (w, h) = size; for filter in [ + FilterType::Builtin(imageops::Nearest), + FilterType::Builtin(imageops::CatmullRom), + FilterType::Builtin(imageops::Lanczos3), FilterType::Nearest, - FilterType::CatmullRom, - FilterType::Lanczos3, ] .iter() { @@ -62,7 +64,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { ), size, |b, size| { - let small_img = resize(&img, *size, FilterType::Lanczos3); + let small_img = resize(&img, *size, FilterType::Builtin(imageops::Lanczos3)); b.iter(|| black_box(save_to_jpeg_bytes(&small_img))) }, ); @@ -80,7 +82,7 @@ pub fn criterion_benchmark(c: &mut Criterion) { |b, size| { b.iter(|| { let img = load_image(TEST_IMAGE_PATH).expect("failed to load test image"); - let small_img = resize(&img, *size, FilterType::Lanczos3); + let small_img = resize(&img, *size, FilterType::Builtin(imageops::Lanczos3)); black_box(save_to_jpeg_bytes(&small_img)) }) }, diff --git a/src/library.rs b/src/library.rs index 9623aa2..28ee28d 100644 --- a/src/library.rs +++ b/src/library.rs @@ -6,8 +6,9 @@ use std::path::PathBuf; use std::sync::Arc; use google_photoslibrary1 as photos; -use image::imageops::FilterType; +use image::imageops; use image::DynamicImage; +use image::GenericImage; use image::GenericImageView; use image::ImageFormat; use image::ImageResult; @@ -39,6 +40,53 @@ where .decode() } +#[derive(Clone, Copy, Debug)] +pub enum FilterType { + Builtin(imageops::FilterType), + Nearest, +} + +/// fill_size computes the largest rectangle that fits in src with the aspect ratio of dst. +fn fill_size(src: (u32, u32), dst: (u32, u32)) -> (u32, u32) { + debug_assert!(src.0 >= dst.0); + debug_assert!(src.1 >= dst.1); + let x_scale = src.0 as f32 / dst.0 as f32; + let y_scale = src.1 as f32 / dst.1 as f32; + if x_scale > y_scale { + // Height will fill, width will crop. + ( + (dst.0 as f32 * y_scale) as u32, + (dst.1 as f32 * y_scale) as u32, + ) + } else { + // Width will fill, height will crop. + ( + (dst.0 as f32 * x_scale) as u32, + (dst.1 as f32 * x_scale) as u32, + ) + } +} + +fn resize_to_fill_nearest(w: u32, h: u32, img: &DynamicImage) -> DynamicImage { + let mut dst = DynamicImage::new_rgb8(w, h); + let (src_w, src_h) = img.dimensions(); + let (crop_w, crop_h) = fill_size((src_w, src_h), (w, h)); + let off_x = (src_w - crop_w) / 2; + let off_y = (src_h - crop_h) / 2; + let src = img.view(off_x, off_y, crop_w, crop_h); + let x_scale = crop_w as f32 / w as f32; + let y_scale = crop_h as f32 / h as f32; + + for y in 0..h { + for x in 0..w { + let x_idx = (x as f32 * x_scale).round() as u32; + let y_idx = (y as f32 * y_scale).round() as u32; + dst.put_pixel(x, y, src.get_pixel(x_idx, y_idx)) + } + } + dst +} + pub fn resize( img: &DynamicImage, dimensions: (Option, Option), @@ -52,7 +100,10 @@ pub fn resize( (None, Some(h)) => (orig_w * h / orig_h, h), (None, None) => (orig_w, orig_h), }; - img.resize_to_fill(w, h, filter) + match filter { + FilterType::Builtin(filter) => img.resize_to_fill(w, h, filter), + FilterType::Nearest => resize_to_fill_nearest(w, h, img), + } } pub fn save_to_jpeg_bytes(img: &DynamicImage) -> ImageResult> { @@ -234,15 +285,17 @@ impl Library { // Cache miss, fill cache and return. Ok(None) => { info!("cache MISS {}", key); - let bytes = - match self.generate_thumbnail(media_items_id, dimensions, FilterType::Lanczos3) - { - Ok(bytes) => bytes, - Err(e) => { - error!("Failed to generate thumbnail for {}: {}", media_items_id, e); - return None; - } - }; + let bytes = match self.generate_thumbnail( + media_items_id, + dimensions, + FilterType::Builtin(imageops::FilterType::Lanczos3), + ) { + Ok(bytes) => bytes, + Err(e) => { + error!("Failed to generate thumbnail for {}: {}", media_items_id, e); + return None; + } + }; match db.put(key.as_bytes(), &bytes) { Ok(_) => Some(bytes), Err(e) => { @@ -265,6 +318,86 @@ mod test { use super::*; use tempdir::TempDir; + fn compare_images(lhs: DynamicImage, rhs: DynamicImage) { + let lhs = lhs.to_rgb(); + let rhs = rhs.to_rgb(); + // Based on https://en.wikipedia.org/wiki/Peak_signal-to-noise_ratio#Definition + // + let mut mse: [i64; 3] = [0, 0, 0]; + for (l, r) in lhs.pixels().zip(rhs.pixels()) { + let image::Rgb(l_pix) = l; + let image::Rgb(r_pix) = r; + + { + for i in 0..3 { + let d = l_pix[i] as i64 - r_pix[i] as i64; + let d2 = d * d; + mse[i] += d2; + } + } + + // assert_eq!(l_pix, r_pix, "{:?} != {:?} @ {} x {} ", l_pix, r_pix, x, y); + } + let (w, h) = lhs.dimensions(); + let mn = (w * h) as i64; + mse.iter_mut().for_each(|i| *i = *i / mn); + let psnr: Vec<_> = mse + .iter() + .map(|i| 20. * 255_f32.log10() - 10. * (*i as f32).log10()) + .collect(); + // Uncomment to explore differences + /* + lhs.save("/tmp/lhs.png").expect("failed to write lhs.png"); + rhs.save("/tmp/rhs.png").expect("failed to write rhs.png"); + assert!(false, "MSE {:?} PSNR {:?} dB", mse, psnr); + */ + } + + #[test] + fn fill_sizes() { + let srcs = vec![(400, 300), (300, 400)]; + + let dsts = vec![(225, 300), (300, 225), (100, 100)]; + + let want = vec![ + (225, 300), + (400, 300), + (300, 300), + (300, 400), + (300, 225), + (300, 300), + ]; + let mut i = 0; + for s in &srcs { + for d in &dsts { + let w = want[i]; + dbg!(s, d, w); + let got = fill_size(*s, *d); + assert_eq!( + got, w, + "{}. src {:?} dst {:?} want {:?} got {:?}", + i, s, d, w, got + ); + i += 1; + } + } + } + + #[test] + fn resize_to_fill_nearest() { + let w = 256; + let h = 256; + const TEST_IMAGE_PATH: &'static str = "testdata/image.jpg"; + let img = load_image(TEST_IMAGE_PATH).expect("failed to load test image"); + let reference = resize( + &img, + (Some(w), Some(h)), + FilterType::Builtin(imageops::FilterType::Nearest), + ); + let got = resize(&img, (Some(w), Some(h)), FilterType::Nearest); + compare_images(reference, got); + } + #[test] fn clean_db() { let td = TempDir::new("photosync_test").expect("failed to create temporary directory");