Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add read_thumbnail #154

Merged
merged 5 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod errors;
mod facade;
mod pixel_engine;
mod sub_image;
mod utils;
mod view;

pub type Size = bindings::ffi::Size;
Expand Down
107 changes: 107 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use crate::{Result, Size};
use std::cmp;

#[cfg(feature = "image")]
use fast_image_resize as fr;
#[cfg(feature = "image")]
AzHicham marked this conversation as resolved.
Show resolved Hide resolved
use {crate::errors::ImageError, image::RgbImage};

#[cfg(feature = "image")]
pub fn resize_rgb_image(image: RgbImage, new_size: &Size) -> Result<RgbImage> {
AzHicham marked this conversation as resolved.
Show resolved Hide resolved
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)
}

#[cfg(feature = "image")]
pub fn preserve_aspect_ratio(size: &Size, dimension: &Size) -> Size {
AzHicham marked this conversation as resolved.
Show resolved Hide resolved
// 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)]
#[cfg(feature = "image")]
mod tests {
use super::*;
#[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 }
);
}
}
62 changes: 59 additions & 3 deletions src/view.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
//! This module contains all functions related to Philips Views
//!

use crate::utils::{preserve_aspect_ratio, resize_rgb_image};
AzHicham marked this conversation as resolved.
Show resolved Hide resolved
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 +123,62 @@
})?;
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 best_level = self.get_best_level_for_dimensions(&size)?;
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::from_dimensions_range(&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) -> Result<u32> {
AzHicham marked this conversation as resolved.
Show resolved Hide resolved
let level_count = self.num_derived_levels() + 1;
let dimension_level0 = Size::from_dimensions_range(&self.dimension_ranges(0)?);
let downsample = f64::max(
f64::from(dimension_level0.w) / f64::from(dimension.w),
f64::from(dimension_level0.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 Ok(0);

Check warning on line 163 in src/view.rs

View check run for this annotation

Codecov / codecov/patch

src/view.rs#L163

Added line #L163 was not covered by tests
}
for i in 1..level_count {
if downsample < level_dowsamples[i as usize] {
return Ok(i - 1);
}
}
Ok(level_count - 1)
}
}

impl Size {
pub fn new(w: u32, h: u32) -> Self {
Self { w, h }
}
pub fn from_dimensions_range(range: &DimensionsRange) -> Self {
Self {
w: (range.end_x - range.start_x) / range.step_x,
h: (range.end_y - range.start_y) / range.step_y,
}
}
AzHicham marked this conversation as resolved.
Show resolved Hide resolved
}
36 changes: 36 additions & 0 deletions tests/test_read_thumbnail.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
mod fixture;

use fixture::{sample, sample_i2syntax};
use std::path::Path;

use philips_isyntax_rs::{ContainerName, ImageType, PhilipsEngine, Size};
use rstest::rstest;

#[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!("thumbnail{0}.jpg", 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)
);
}
39 changes: 38 additions & 1 deletion tests/test_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ mod fixture;
use fixture::sample;
use std::path::Path;

use philips_isyntax_rs::{ContainerName, DimensionsRange, ImageType, PhilipsEngine, Rectangle};
use philips_isyntax_rs::{
ContainerName, DimensionsRange, ImageType, PhilipsEngine, Rectangle, Size,
};
use rstest::rstest;

#[rstest]
Expand Down Expand Up @@ -214,3 +216,38 @@ fn test_envelopes(#[case] filename: &Path) {
}
);
}

// 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(sample(), Size::new(500, 500), 8)]
#[case(sample(), Size::new(100, 100), 9)]
#[case(sample(), Size::new(800, 800), 7)]
#[case(sample(), Size::new(100000, 100000), 0)]
fn test_get_best_level_for_dimensions(
#[case] filename: &Path,
#[case] size: Size,
#[case] expected_level: u32,
) {
let engine = PhilipsEngine::new();
let facade = engine
.facade(filename, &ContainerName::CachingFicom)
.unwrap();
let image = facade.image(&ImageType::WSI).unwrap();
let view = image.view().unwrap();
assert_eq!(
view.get_best_level_for_dimensions(&size).unwrap(),
expected_level
);
}
Loading