diff --git a/Cargo.lock b/Cargo.lock index 0c087f0..7a40ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,12 +104,33 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0" +dependencies = [ + "litrs", +] + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "fast_image_resize" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b58827213977eabab8ee8d8258db8441338f3a1832a1c0f2de3372175531" +dependencies = [ + "cfg-if", + "document-features", + "num-traits", + "thiserror", +] + [[package]] name = "futures" version = "0.3.29" @@ -266,6 +287,12 @@ dependencies = [ "cc", ] +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + [[package]] name = "memchr" version = "2.6.4" @@ -274,9 +301,9 @@ checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" [[package]] name = "num-traits" -version = "0.2.17" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -294,6 +321,7 @@ dependencies = [ "bencher", "cxx", "cxx-build", + "fast_image_resize", "image", "rand", "rstest", diff --git a/Cargo.toml b/Cargo.toml index d5f0a19..b80ea53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,13 +14,14 @@ rust-version = "1.75.0" [features] default = ["image"] -image = ["dep:image"] +image = ["dep:image", "dep:fast_image_resize"] [dependencies] thiserror = "1.0" cxx = "1.0" rand = "0.8.5" image = { version = "0.25", optional = true, default-features = false, features = ["jpeg"] } +fast_image_resize = { version = "4.2.1", optional = true } [build-dependencies] cxx-build = "1.0" diff --git a/src/bindings.rs b/src/bindings.rs index e251f73..45a9e4b 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -1,6 +1,8 @@ //! This module contains the bindings to the Philips Open Pathology C++ library //! +use crate::errors::DimensionsRangeToSizeError; + #[cxx::bridge] pub(crate) mod ffi { /// Simple struct Size with width and height for an image/tile @@ -125,6 +127,36 @@ pub(crate) mod ffi { } } +impl ffi::Size { + pub fn new(w: u32, h: u32) -> Self { + Self { w, h } + } +} +impl TryFrom<&ffi::DimensionsRange> for ffi::Size { + type Error = DimensionsRangeToSizeError; + + fn try_from(value: &ffi::DimensionsRange) -> Result { + if value.step_x == 0 { + return Err(DimensionsRangeToSizeError::NullStepX); + } + if value.step_y == 0 { + return Err(DimensionsRangeToSizeError::NullStepY); + } + if let Some(width) = value.end_x.checked_sub(value.start_x) { + if let Some(height) = value.end_y.checked_sub(value.start_y) { + Ok(Self { + w: width / value.step_x, + h: height / value.step_y, + }) + } else { + Err(DimensionsRangeToSizeError::NegativeHeigh) + } + } else { + Err(DimensionsRangeToSizeError::NegativeWidth) + } + } +} + fn println(str: String) { println!("{str}"); } diff --git a/src/errors.rs b/src/errors.rs index 8fac344..090842c 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -5,7 +5,7 @@ use cxx::Exception; use std::str::Utf8Error; use thiserror::Error; -/// Enum defining all possible error when manipulating OpenSlide struct +/// Enum defining all possible error when manipulating Philips struct #[derive(Error, Debug)] pub enum PhilipsSlideError { /// CxxString to &str conversion error @@ -21,6 +21,8 @@ pub enum PhilipsSlideError { #[cfg(feature = "image")] #[error(transparent)] ImageError(#[from] ImageError), + #[error(transparent)] + DimensionsRangeToSizeError(#[from] DimensionsRangeToSizeError), } #[cfg(feature = "image")] @@ -33,3 +35,15 @@ pub enum ImageError { #[error("{0}")] Other(String), } + +#[derive(Error, Debug)] +pub enum DimensionsRangeToSizeError { + #[error("Step X is null")] + NullStepX, + #[error("Step Y is null")] + NullStepY, + #[error("End X is smaller than Start X")] + NegativeWidth, + #[error("End Y is smaller than Start Y")] + NegativeHeigh, +} diff --git a/src/lib.rs b/src/lib.rs index c14dbfd..b39d528 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,8 @@ pub mod errors; mod facade; mod pixel_engine; mod sub_image; +#[cfg(feature = "image")] +mod utils; mod view; pub type Size = bindings::ffi::Size; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..16f5ebe --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,149 @@ +use crate::{Result, Size}; +use fast_image_resize as fr; +use std::cmp; +use {crate::errors::ImageError, image::RgbImage}; + +// Get the appropriate level for the given dimensions: i.e. the level with at least one +// dimensions (i.e along one axis) greater than the dimension requested +pub fn get_best_level_for_dimensions( + dimension: &Size, + dimension_level_0: &Size, + level_count: u32, +) -> u32 { + let downsample = f64::max( + f64::from(dimension_level_0.w) / f64::from(dimension.w), + f64::from(dimension_level_0.h) / f64::from(dimension.h), + ); + (0..level_count) + .map(|level| 2_u32.pow(level) as f64) + .enumerate() + .rfind(|(_, ds)| ds <= &downsample) + .map_or(0, |(index, _)| index) as u32 +} + +pub(crate) fn resize_rgb_image(image: RgbImage, new_size: &Size) -> Result { + let src_image = fr::images::Image::from_vec_u8( + image.width(), + image.height(), + image.into_raw(), + fr::PixelType::U8x3, + ) + .map_err(|err| ImageError::Other(err.to_string()))?; + + let mut dst_image = fr::images::Image::new(new_size.w, new_size.h, fr::PixelType::U8x3); + let mut resizer = fr::Resizer::new(); + let option = fr::ResizeOptions { + algorithm: fr::ResizeAlg::Convolution(fr::FilterType::Lanczos3), + cropping: fr::SrcCropping::None, + mul_div_alpha: false, + }; + resizer + .resize(&src_image, &mut dst_image, &option) + .map_err(|err| ImageError::Other(err.to_string()))?; + let image = RgbImage::from_vec(new_size.w, new_size.h, dst_image.into_vec()).unwrap(); // safe because dst_image buffer is big enough + + Ok(image) +} + +pub(crate) fn preserve_aspect_ratio(size: &Size, dimension: &Size) -> Size { + // Code adapted from https://pillow.readthedocs.io/en/latest/_modules/PIL/Image.html#Image.thumbnail + fn round_aspect f32>(number: f32, mut key: F) -> u32 { + cmp::max( + cmp::min_by_key(number.floor() as u32, number.ceil() as u32, |n| { + key(*n as f32).round() as u32 + }), + 1, + ) + } + let w = size.w as f32; + let h = size.h as f32; + let aspect: f32 = dimension.w as f32 / dimension.h as f32; + if { w / h } >= aspect { + Size::new( + round_aspect(h * aspect, |n| (aspect - n / h).abs()), + h as u32, + ) + } else { + Size::new( + w as u32, + round_aspect(w / aspect, |n| { + if n == 0. { + 0. + } else { + (aspect - w / n).abs() + } + }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + // Note: the dimensions for each levels are: + // { + // 0: Size { w: 158726, h: 90627}, + // 1: Size { w: 79361, h: 45313 }, + // 2: Size { w: 39678, h: 22655 }, + // 3: Size { w: 19837, h: 11327 }, + // 4: Size { w: 9917, h: 5663 }, + // 5: Size { w: 4957, h: 2831 }, + // 6: Size { w: 2477, h: 1415 }, + // 7: Size { w: 1237, h: 707 }, + // 8: Size { w: 617, h: 353 }, + // 9: Size { w: 307, h: 175 }, + // } + #[rstest] + #[case(Size::new(100, 100), 9)] + #[case(Size::new(500, 500), 8)] + #[case(Size::new(800, 800), 7)] + #[case(Size::new(100000, 100000), 0)] + #[case(Size::new(200000, 200000), 0)] + fn test_get_best_level_for_dimensions(#[case] size: Size, #[case] expected_level: u32) { + let dimension_level_0 = Size::new(158726, 90627); + let level_count = 10; + assert_eq!( + get_best_level_for_dimensions(&size, &dimension_level_0, level_count), + expected_level + ); + } + + #[test] + fn test_preserve_aspect_ratio() { + assert_eq!( + preserve_aspect_ratio(&Size { w: 100, h: 100 }, &Size { w: 50, h: 50 }), + Size { w: 100, h: 100 } + ); + assert_eq!( + preserve_aspect_ratio(&Size { w: 100, h: 100 }, &Size { w: 25, h: 50 }), + Size { w: 50, h: 100 } + ); + assert_eq!( + // Edge case + preserve_aspect_ratio(&Size { w: 1, h: 1 }, &Size { w: 25, h: 50 }), + Size { w: 1, h: 1 } + ); + assert_eq!( + // Edge case + preserve_aspect_ratio(&Size { w: 100, h: 200 }, &Size { w: 1, h: 1 }), + Size { w: 100, h: 100 } + ); + assert_eq!( + // Edge case + preserve_aspect_ratio(&Size { w: 0, h: 5 }, &Size { w: 1, h: 10 }), + Size { w: 0, h: 1 } + ); + assert_eq!( + // Not round ratio + preserve_aspect_ratio(&Size { w: 33, h: 100 }, &Size { w: 12, h: 13 }), + Size { w: 33, h: 35 } + ); + assert_eq!( + // Not round ratio + preserve_aspect_ratio(&Size { w: 33, h: 15 }, &Size { w: 12, h: 13 }), + Size { w: 13, h: 15 } + ); + } +} diff --git a/src/view.rs b/src/view.rs index dfcfe16..fdae145 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,14 +1,13 @@ //! This module contains all functions related to Philips Views //! +#[cfg(feature = "image")] +use crate::utils::{get_best_level_for_dimensions, preserve_aspect_ratio, resize_rgb_image}; use crate::{DimensionsRange, PhilipsEngine, Rectangle, RegionRequest, Result, Size, View}; #[cfg(feature = "image")] use {crate::errors::ImageError, image::RgbImage}; -//#[cfg(feature = "image")] -//use {crate::errors::PhilipsSlideError, image::RgbImage}; - impl<'a> View<'a> { /// Returns the dimension ranges of the SubImage for a certain level /// For Macro and Label/ILE image this function return a result only for level 0 @@ -125,4 +124,54 @@ impl<'a> View<'a> { })?; Ok(image) } + + /// Read a thumbnail from a WSI SubImage. + /// + /// This function reads and decompresses a thumbnail of a whole slide image into an RgbImage + #[cfg(feature = "image")] + pub fn read_thumbnail(&self, engine: &PhilipsEngine, size: &Size) -> Result { + let level_count = self.num_derived_levels() + 1; + let dimension_level_0 = Size::try_from(&self.dimension_ranges(0)?)?; + let best_level = get_best_level_for_dimensions(&size, &dimension_level_0, level_count); + let dimensions_range = self.dimension_ranges(best_level)?; + let region_request = RegionRequest { + roi: Rectangle { + start_x: dimensions_range.start_x, + end_x: dimensions_range.end_x, + start_y: dimensions_range.start_y, + end_y: dimensions_range.end_y, + }, + level: best_level, + }; + let image = self.read_image(engine, ®ion_request)?; + let final_size = preserve_aspect_ratio(&size, &Size::try_from(&dimensions_range)?); + let image = resize_rgb_image(image, &final_size)?; + Ok(image) + } + + // Get the appropriate level for the given dimensions: i.e. the level with at least one + // dimensions greater than the dimension requested along one axis + pub fn get_best_level_for_dimensions( + &self, + dimension: &Size, + dimension_level_0: &Size, + level_count: u32, + ) -> u32 { + let downsample = f64::max( + f64::from(dimension_level_0.w) / f64::from(dimension.w), + f64::from(dimension_level_0.h) / f64::from(dimension.h), + ); + let level_dowsamples: Vec = (0..level_count) + .map(|level| 2_u32.pow(level) as f64) + .collect(); + if downsample < 1.0 { + return 0; + } + for i in 1..level_count { + if downsample < level_dowsamples[i as usize] { + return i - 1; + } + } + level_count - 1 + } } diff --git a/tests/test_read_thumbnail.rs b/tests/test_read_thumbnail.rs new file mode 100644 index 0000000..4fc0ce6 --- /dev/null +++ b/tests/test_read_thumbnail.rs @@ -0,0 +1,47 @@ +mod fixture; + +use fixture::{sample, sample_i2syntax}; +use std::path::Path; + +use philips_isyntax_rs::{ContainerName, ImageType, PhilipsEngine, Size}; +use rstest::rstest; + +#[cfg(feature = "image")] +#[rstest] +fn test_thumbnail( + #[values(sample(), sample_i2syntax())] filename: &Path, + #[values( + Size { w: 254, h: 254 }, + Size { w: 10, h: 100 }, + Size { w: 1000, h: 1000 }, + Size { w: 200, h: 10 } + )] + size: Size, +) { + let engine = PhilipsEngine::new(); + let facade = engine + .facade(filename, &ContainerName::CachingFicom) + .unwrap(); + let image = facade.image(&ImageType::WSI).unwrap(); + let view = image.view().unwrap(); + + let thumbnail = view.read_thumbnail(&engine, &size).unwrap(); + thumbnail + .save(format!( + "{0}_thumbnail_{1}.jpg", + filename + .file_stem() + .expect("Invalid file name") + .to_str() + .expect("Invalide file name"), + size.w + )) + .unwrap(); + + // Make sure one of the dimensions is equal to the requested one + // and the other one is smaller than the requested one + assert!( + (thumbnail.width() == size.w && thumbnail.height() <= size.h) + || (thumbnail.width() <= size.w && thumbnail.height() == size.h) + ); +}