Skip to content

Commit

Permalink
feat: add read_thumbnail (#154)
Browse files Browse the repository at this point in the history
  • Loading branch information
fabien-brulport authored Sep 12, 2024
1 parent bfa4050 commit c929fff
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 7 deletions.
32 changes: 30 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
32 changes: 32 additions & 0 deletions src/bindings.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Self, Self::Error> {
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}");
}
Expand Down
16 changes: 15 additions & 1 deletion src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +21,8 @@ pub enum PhilipsSlideError {
#[cfg(feature = "image")]
#[error(transparent)]
ImageError(#[from] ImageError),
#[error(transparent)]
DimensionsRangeToSizeError(#[from] DimensionsRangeToSizeError),
}

#[cfg(feature = "image")]
Expand All @@ -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,
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
149 changes: 149 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
@@ -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<RgbImage> {
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<F: FnMut(f32) -> 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 }
);
}
}
55 changes: 52 additions & 3 deletions src/view.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<RgbImage> {
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, &region_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<f64> = (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
}
}
Loading

2 comments on commit c929fff

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

philips-isyntax-rs Benchmark

Benchmark suite Current: c929fff Previous: bfa4050 Ratio
philips_i2syntax_read_region_256_lvl_0 1403494 ns/iter (± 117595) 1412270 ns/iter (± 62346) 0.99
philips_i2syntax_read_region_256_lvl_1 1195750 ns/iter (± 41157) 1159410 ns/iter (± 68066) 1.03
philips_i2syntax_read_region_512_lvl_0 1600868 ns/iter (± 83291) 1548425 ns/iter (± 62419) 1.03
philips_i2syntax_read_region_512_lvl_1 7555576 ns/iter (± 303817) 7328200 ns/iter (± 306020) 1.03
philips_read_region_256_lvl_0 527964 ns/iter (± 51115) 496517 ns/iter (± 36965) 1.06
philips_read_region_256_lvl_1 1275675 ns/iter (± 78150) 1231938 ns/iter (± 77419) 1.04
philips_read_region_512_lvl_0 3643864 ns/iter (± 185213) 3516623 ns/iter (± 186623) 1.04
philips_read_region_512_lvl_1 3216732 ns/iter (± 209597) 2741562 ns/iter (± 149690) 1.17

This comment was automatically generated by workflow using github-action-benchmark.

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

philips-isyntax-rs Benchmark

Benchmark suite Current: c929fff Previous: bfa4050 Ratio
philips_i2syntax_read_region_256_lvl_0 1361649 ns/iter (± 55947) 1412270 ns/iter (± 62346) 0.96
philips_i2syntax_read_region_256_lvl_1 1165858 ns/iter (± 64534) 1159410 ns/iter (± 68066) 1.01
philips_i2syntax_read_region_512_lvl_0 1579088 ns/iter (± 50537) 1548425 ns/iter (± 62419) 1.02
philips_i2syntax_read_region_512_lvl_1 7437832 ns/iter (± 382315) 7328200 ns/iter (± 306020) 1.01
philips_read_region_256_lvl_0 516789 ns/iter (± 54611) 496517 ns/iter (± 36965) 1.04
philips_read_region_256_lvl_1 1270381 ns/iter (± 98449) 1231938 ns/iter (± 77419) 1.03
philips_read_region_512_lvl_0 3610729 ns/iter (± 192869) 3516623 ns/iter (± 186623) 1.03
philips_read_region_512_lvl_1 3196911 ns/iter (± 167831) 2741562 ns/iter (± 149690) 1.17

This comment was automatically generated by workflow using github-action-benchmark.

Please sign in to comment.