diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..c0cbb4e --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,44 @@ +name: Benchmarks + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + benchmarks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: | + sudo apt update + sudo apt install -y openslide-tools + sudo ln -s /usr/lib/x86_64-linux-gnu/libopenslide.so.0 /usr/lib/x86_64-linux-gnu/libopenslide.so + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Run cargo bench + run: cargo bench --workspace | tee bench-output.txt + + - name: Store benchmark result + uses: rhysd/github-action-benchmark@v1 + with: + name: openslide-rs Benchmark + tool: 'cargo' + save-data-file: ${{ github.event_name != 'pull_request' }} + output-file-path: bench-output.txt + benchmark-data-dir-path: '.' + max-items-in-chart: 30 + github-token: ${{ secrets.GITHUB_TOKEN }} + auto-push: true + alert-threshold: '200%' + comment-on-alert: true + fail-on-alert: false + alert-comment-cc-users: '@AzHicham' diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 07e4a53..04abdea 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -22,25 +22,21 @@ jobs: toolchain: nightly override: true components: rustfmt, clippy - - name: Run cargo clippy uses: actions-rs/cargo@v1 with: command: clippy args: --all --fix --all-targets --allow-dirty - - name: Run cargo fmt uses: actions-rs/cargo@v1 with: command: fmt args: --all - - name: Run cargo check uses: actions-rs/cargo@v1 with: command: check args: --workspace --verbose - - name: Commit and push changes continue-on-error: true uses: peter-evans/create-pull-request@v4 diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 98a4e5b..117a779 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -43,10 +43,9 @@ jobs: run: | sudo apt update sudo apt install -y openslide-tools - - name: Build & Test - run: | sudo ln -s /usr/lib/x86_64-linux-gnu/libopenslide.so.0 /usr/lib/x86_64-linux-gnu/libopenslide.so - cargo test --workspace ${{ matrix.build_type.flag }} --features "${{ matrix.features }}" + - name: Build & Test + run: cargo test --workspace ${{ matrix.build_type.flag }} --features "${{ matrix.features }}" pre-commit: runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 2f00100..0b6fd92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "bencher" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfdb4953a096c551ce9ace855a604d702e6e62d77fac690575ae347571717f5" + [[package]] name = "bit_field" version = "0.10.1" @@ -476,6 +482,7 @@ name = "openslide-rs" version = "0.1.0" dependencies = [ "assert_approx_eq", + "bencher", "image", "lazy_static", "libc", diff --git a/Cargo.toml b/Cargo.toml index 929b507..3901df5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,10 +19,15 @@ image = ["dep:image"] [dependencies] libc = "0.2.135" thiserror = "1.0.37" -regex = "1" +regex = "1.6.0" lazy_static = "1.4.0" image = { version = "0.24.4", optional = true} [dev-dependencies] rstest = "0.15.0" assert_approx_eq = "1.1.0" +bencher = "0.1.5" + +[[bench]] +name = "bench_read" +harness = false diff --git a/benches/bench_read.rs b/benches/bench_read.rs new file mode 100644 index 0000000..a94f00b --- /dev/null +++ b/benches/bench_read.rs @@ -0,0 +1,84 @@ +use bencher::{benchmark_group, benchmark_main, Bencher}; + +use image::imageops::FilterType; +use openslide_rs::{Address, DeepZoomGenerator, OpenSlide, Region, Size}; +use std::path::Path; + +fn openslide_read_region_256(bench: &mut Bencher) { + let slide = OpenSlide::new(Path::new("tests/assets/default.svs")).unwrap(); + + bench.iter(|| { + slide.read_region(&Region { + address: Address { x: 0, y: 0 }, + level: 0, + size: Size { w: 256, h: 256 }, + }) + }); +} + +fn openslide_read_region_512(bench: &mut Bencher) { + let slide = OpenSlide::new(Path::new("tests/assets/default.svs")).unwrap(); + + bench.iter(|| { + slide.read_region(&Region { + address: Address { x: 0, y: 0 }, + level: 0, + size: Size { w: 512, h: 512 }, + }) + }); +} + +fn openslide_read_image_256(bench: &mut Bencher) { + let slide = OpenSlide::new(Path::new("tests/assets/default.svs")).unwrap(); + + bench.iter(|| { + slide.read_image(&Region { + address: Address { x: 0, y: 0 }, + level: 0, + size: Size { w: 256, h: 256 }, + }) + }); +} + +fn openslide_read_image_512(bench: &mut Bencher) { + let slide = OpenSlide::new(Path::new("tests/assets/default.svs")).unwrap(); + + bench.iter(|| { + slide.read_image(&Region { + address: Address { x: 0, y: 0 }, + level: 0, + size: Size { w: 512, h: 512 }, + }) + }); +} + +fn deepzoom_read_image_256(bench: &mut Bencher) { + let slide = OpenSlide::new(Path::new("tests/assets/default.svs")).unwrap(); + let dz = DeepZoomGenerator::new(&slide, 256, 0, false).unwrap(); + + bench.iter(|| dz.get_tile(12, Address { x: 0, y: 0 }, FilterType::Lanczos3)); +} + +fn deepzoom_read_image_512(bench: &mut Bencher) { + let slide = OpenSlide::new(Path::new("tests/assets/default.svs")).unwrap(); + let dz = DeepZoomGenerator::new(&slide, 512, 0, false).unwrap(); + + bench.iter(|| dz.get_tile(12, Address { x: 0, y: 0 }, FilterType::Lanczos3)); +} + +benchmark_group!( + benches_region, + openslide_read_region_256, + openslide_read_region_512 +); +benchmark_group!( + benches_image, + openslide_read_image_256, + openslide_read_image_512 +); +benchmark_group!( + deepzoom_image, + deepzoom_read_image_256, + deepzoom_read_image_512 +); +benchmark_main!(benches_region, benches_image, deepzoom_image); diff --git a/rust-toolchain b/rust-toolchain deleted file mode 100644 index 8725364..0000000 --- a/rust-toolchain +++ /dev/null @@ -1 +0,0 @@ -1.64 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..cc8f987 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.64.0" +components = ["rustfmt", "clippy"] diff --git a/src/bindings.rs b/src/bindings.rs index eda1aef..7baf604 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -5,6 +5,7 @@ use crate::{errors::OpenSlideError, Result}; +use crate::errors::map_string_error; use std::{ffi, ops::Deref}; /// wrapper around openslide_t "C" type in OpenSlide @@ -107,23 +108,26 @@ extern "C" { } pub fn detect_vendor(filename: &str) -> Result { - let c_filename = ffi::CString::new(filename).map_err(OpenSlideError::FFINulError)?; - let vendor = unsafe { + let c_filename = ffi::CString::new(filename).map_err(map_string_error)?; + unsafe { let c_vendor = openslide_detect_vendor(c_filename.as_ptr()); - ffi::CStr::from_ptr(c_vendor).to_string_lossy().into_owned() - }; - Ok(vendor) + if !c_vendor.is_null() { + let vendor = ffi::CStr::from_ptr(c_vendor).to_string_lossy().into_owned(); + Ok(vendor) + } else { + Err(OpenSlideError::UnsupportedFile(filename.to_string())) + } + } } pub fn open(filename: &str) -> Result<*const OpenSlideT> { - let c_filename = ffi::CString::new(filename).map_err(OpenSlideError::FFINulError)?; + let c_filename = ffi::CString::new(filename).map_err(map_string_error)?; let slide = unsafe { openslide_open(c_filename.as_ptr()) }; if !slide.is_null() { get_error(slide)?; Ok(slide) } else { - let error = format!("Cannot open {filename}"); - Err(OpenSlideError::CoreError(error)) + Err(OpenSlideError::UnsupportedFile(filename.to_string())) } } @@ -136,7 +140,10 @@ pub fn close(osr: *const OpenSlideT) { pub fn get_level_count(osr: *const OpenSlideT) -> Result { let num_levels = unsafe { openslide_get_level_count(osr) }; if num_levels == -1 { - force_error(osr)? + get_error(osr)?; + return Err(OpenSlideError::CoreError( + "Cannot get level count".to_string(), + )); } Ok(num_levels) } @@ -148,7 +155,10 @@ pub fn get_level0_dimensions(osr: *const OpenSlideT) -> Result<(i64, i64)> { openslide_get_level0_dimensions(osr, &mut width, &mut height); } if width == -1 || height == -1 { - force_error(osr)? + get_error(osr)?; + return Err(OpenSlideError::CoreError( + "Cannot get dimensions of level 0".to_string(), + )); } Ok((width, height)) } @@ -160,7 +170,8 @@ pub fn get_level_dimensions(osr: *const OpenSlideT, level: i32) -> Result<(i64, openslide_get_level_dimensions(osr, level, &mut width, &mut height); } if width == -1 || height == -1 { - force_error(osr)? + get_error(osr)?; + return Err(OpenSlideError::CoreError(format!("Invalid level {level}"))); } Ok((width, height)) } @@ -168,7 +179,10 @@ pub fn get_level_dimensions(osr: *const OpenSlideT, level: i32) -> Result<(i64, pub fn get_level_downsample(osr: *const OpenSlideT, level: i32) -> Result { let downsampling_factor = unsafe { openslide_get_level_downsample(osr, level) }; if downsampling_factor == -1.0 { - force_error(osr)? + get_error(osr)?; + return Err(OpenSlideError::CoreError(format!( + "Cannot compute downsample for level {level}" + ))); } Ok(downsampling_factor) } @@ -176,7 +190,10 @@ pub fn get_level_downsample(osr: *const OpenSlideT, level: i32) -> Result { pub fn get_best_level_for_downsample(osr: *const OpenSlideT, downsample: f64) -> Result { let level = unsafe { openslide_get_best_level_for_downsample(osr, downsample) }; if level == -1 { - force_error(osr)? + get_error(osr)?; + return Err(OpenSlideError::CoreError(format!( + "Cannot compute level for downsample {downsample}" + ))); } Ok(level) } @@ -205,7 +222,10 @@ pub fn get_property_names(osr: *const OpenSlideT) -> Result> { let string_values = unsafe { let null_terminated_array_ptr = openslide_get_property_names(osr); if null_terminated_array_ptr.is_null() { - get_error(osr)? + get_error(osr)?; + return Err(OpenSlideError::CoreError( + "Cannot get property names".to_string(), + )); } let mut counter = 0; let mut loc = null_terminated_array_ptr; @@ -227,10 +247,17 @@ pub fn get_property_names(osr: *const OpenSlideT) -> Result> { } pub fn get_property_value(osr: *const OpenSlideT, name: &str) -> Result { - let c_name = ffi::CString::new(name).map_err(OpenSlideError::FFINulError)?; + let c_name = ffi::CString::new(name).map_err(map_string_error)?; let value = unsafe { let c_value = openslide_get_property_value(osr, c_name.as_ptr()); - ffi::CStr::from_ptr(c_value).to_string_lossy().into_owned() + if c_value.is_null() { + get_error(osr)?; + return Err(OpenSlideError::CoreError(format!( + "Error with property named {name}" + ))); + } else { + ffi::CStr::from_ptr(c_value).to_string_lossy().into_owned() + } }; Ok(value) } @@ -239,7 +266,10 @@ pub fn get_associated_image_names(osr: *const OpenSlideT) -> Result> let string_values = unsafe { let null_terminated_array_ptr = openslide_get_associated_image_names(osr); if null_terminated_array_ptr.is_null() { - get_error(osr)? + get_error(osr)?; + return Err(OpenSlideError::CoreError( + "Cannot get associated image names".to_string(), + )); } let mut counter = 0; let mut loc = null_terminated_array_ptr; @@ -261,20 +291,23 @@ pub fn get_associated_image_names(osr: *const OpenSlideT) -> Result> } pub fn get_associated_image_dimensions(osr: *const OpenSlideT, name: &str) -> Result<(i64, i64)> { - let c_name = ffi::CString::new(name).map_err(OpenSlideError::FFINulError)?; + let c_name = ffi::CString::new(name).map_err(map_string_error)?; let mut width: i64 = 0; let mut height: i64 = 0; unsafe { openslide_get_associated_image_dimensions(osr, c_name.as_ptr(), &mut width, &mut height); } if width == -1 || height == -1 { - force_error(osr)? + get_error(osr)?; + return Err(OpenSlideError::CoreError( + "Unknown associated image".to_string(), + )); } Ok((width, height)) } pub fn read_associated_image(osr: *const OpenSlideT, name: &str) -> Result<((i64, i64), Vec)> { - let c_name = ffi::CString::new(name).map_err(OpenSlideError::FFINulError)?; + let c_name = ffi::CString::new(name).map_err(map_string_error)?; let (width, height) = get_associated_image_dimensions(osr, name)?; let size = (width * height * 4) as usize; let mut buffer: Vec = Vec::with_capacity(size); @@ -300,8 +333,3 @@ pub fn get_error(osr: *const OpenSlideT) -> Result<()> { }; value } - -pub fn force_error(osr: *const OpenSlideT) -> Result<()> { - get_error(osr)?; - Err(OpenSlideError::CoreError("Unknown error".to_string())) -} diff --git a/src/deepzoom.rs b/src/deepzoom.rs index f3f4910..0a042fe 100644 --- a/src/deepzoom.rs +++ b/src/deepzoom.rs @@ -2,157 +2,156 @@ //! This module provides functionality for generating Deep Zoom images from OpenSlide objects. //! This is a simple translation of python DeepZoomGenerator implementation -use crate::{errors::OpenSlideError, DeepZoomGenerator, Offset, OpenSlide, Result, Size}; +use crate::{errors::OpenSlideError, Address, DeepZoomGenerator, OpenSlide, Region, Result, Size}; use image::{imageops::FilterType, RgbaImage}; -use std::cmp::{max, min}; impl<'a> DeepZoomGenerator<'a> { pub fn new( - osr: &'a OpenSlide, + slide: &'a OpenSlide, tile_size: u32, overlap: u32, limit_bounds: bool, ) -> Result { - let z_t_downsample = tile_size; - let overlap = overlap; - let limit_bounds = limit_bounds; + let mut slide_level_dimensions: Vec = Vec::new(); + let mut l0_offset = Address { x: 0, y: 0 }; - let openslide_properties = &osr.properties.openslide_properties; + if limit_bounds { + let os_property = &slide.properties.openslide_properties; + let bounds_x = os_property.bounds_x.unwrap_or(0); + let bounds_y = os_property.bounds_y.unwrap_or(0); - let (l_dimensions, l0_offset) = if limit_bounds { // Level 0 coordinate offset - let l0_offset = Offset { - x: openslide_properties.bounds_x.unwrap_or(0), - y: openslide_properties.bounds_y.unwrap_or(0), - }; - let l_dimensions = osr.get_all_level_dimensions()?; - // TODO : make sure len is at least 1 - let l0_dimensions = l_dimensions[0]; + l0_offset.x = bounds_x; + l0_offset.y = bounds_y; // Slide level dimensions scale factor in each axis - let size_scale: (f64, f64) = ( - openslide_properties - .bounds_width - .unwrap_or(l0_dimensions.width) as f64 - / (l0_dimensions.width as f64), - openslide_properties - .bounds_height - .unwrap_or(l0_dimensions.height) as f64 - / (l0_dimensions.height as f64), + let slide_dimensions = slide.get_level0_dimensions()?; + let slide_dimensions = &slide_dimensions; + + let bounds_width = os_property.bounds_width.unwrap_or(slide_dimensions.w); + let bounds_height = os_property.bounds_height.unwrap_or(slide_dimensions.h); + + let size_scale = ( + bounds_width as f32 / slide_dimensions.w as f32, + bounds_height as f32 / slide_dimensions.h as f32, ); - // Dimensions of active area - let l_dimensions = osr - .get_all_level_dimensions()? - .iter() - .map(|l_size| Size { - width: (l_size.width as f64 * size_scale.0).ceil() as u32, - height: (l_size.height as f64 * size_scale.1).ceil() as u32, - }) - .collect::>(); - (l_dimensions, l0_offset) + slide_level_dimensions.extend( + (0..slide.get_level_count()?) + .filter_map(|level| slide.get_level_dimensions(level).ok()) + .map(|dimensions| Size { + w: (dimensions.w as f32 * size_scale.0).ceil() as _, + h: (dimensions.h as f32 * size_scale.1).ceil() as _, + }), + ); } else { - let l_dimensions = osr.get_all_level_dimensions()?; - let l0_offset = Offset { x: 0, y: 0 }; - (l_dimensions, l0_offset) + slide_level_dimensions.extend( + (0..slide.get_level_count().unwrap()) + .map(|level| slide.get_level_dimensions(level).unwrap()), + ); + } + let slide_level0_dimensions = slide_level_dimensions[0]; + + // Deep Zooom levels + let mut z_size = Size { + w: slide_level0_dimensions.w, + h: slide_level0_dimensions.h, }; + let mut level_dimensions = vec![z_size]; + + while z_size.w > 1 || z_size.h > 1 { + z_size.w = ((z_size.w as f32 / 2.0).ceil() as u32).max(1) as _; + z_size.h = ((z_size.h as f32 / 2.0).ceil() as u32).max(1) as _; - let l0_dimensions = l_dimensions[0]; - - // Deep Zoom level - let mut z_size = l0_dimensions; - let mut z_dimensions = vec![z_size]; - while z_size.width > 1 || z_size.height > 1 { - z_size = Size { - width: max(1, (z_size.width as f32 / 2_f32).ceil() as u32), - height: max(1, (z_size.height as f32 / 2_f32).ceil() as u32), - }; - z_dimensions.push(z_size); + level_dimensions.push(z_size); } - let z_dimensions: Vec = z_dimensions.into_iter().rev().collect(); + level_dimensions.reverse(); // Tile - let tiles = |z_lim: u32| ((1_f32 * z_lim as f32 / z_t_downsample as f32).ceil()) as u32; - - let t_dimensions = z_dimensions + let level_tiles: Vec = level_dimensions .iter() - .map(|size| Size { - width: tiles(size.width), - height: tiles(size.height), + .map(|Size { w, h }| Size { + w: (*w as f32 / tile_size as f32).ceil() as _, + h: (*h as f32 / tile_size as f32).ceil() as _, }) - .collect::>(); + .collect(); // Deep Zoom level count - let dz_levels = z_dimensions.len(); + let level_count = level_dimensions.len(); // Total downsamples for each Deep Zoom level - let l0_z_downsamples = (0..dz_levels) - .into_iter() - .map(|level| 2_i32.pow(((dz_levels - level) - 1) as u32)) - .collect::>(); + let l0_z_downsamples: Vec = (0..level_count) + .map(|level| 2_u64.pow((level_count - level - 1) as _) as f64) + .collect(); // Preferred slide levels for each Deep Zoom level - let slide_from_dz_level = l0_z_downsamples + let slide_from_dz_level: Vec = l0_z_downsamples .iter() - .filter_map(|d| osr.get_best_level_for_downsample(*d as f64).ok()) - .collect::>(); + .filter_map(|downsample| slide.get_best_level_for_downsample(*downsample).ok() as _) + .collect(); + + // Piecewise downsamples + let l0_l_downsamples: Vec = (0..slide.get_level_count()?) + .map(|level| slide.get_level_downsample(level).unwrap()) + .collect(); - let l0_l_downsamples = osr.get_all_level_downsample()?; - let l_z_downsamples = (0..dz_levels) - .into_iter() + let l_z_downsamples: Vec = (0..level_count) .map(|dz_level| { - l0_z_downsamples[dz_level] as f64 + l0_z_downsamples[dz_level] / l0_l_downsamples[slide_from_dz_level[dz_level] as usize] }) - .collect::>(); - let deepzoom = DeepZoomGenerator { - osr, - t_dimensions, - l_dimensions, - z_dimensions, - slide_from_dz_level, - l0_offset, + .collect(); + + Ok(DeepZoomGenerator { + slide, + tile_size, overlap, - z_t_downsample, - l_z_downsamples, + l0_offset, + level_dimensions, + slide_level_dimensions, + level_tiles, + level_count, + slide_from_dz_level, l0_l_downsamples, - }; - Ok(deepzoom) + l_z_downsamples, + }) } pub fn level_count(&self) -> usize { - self.z_dimensions.len() + self.level_count } pub fn level_tiles(&self) -> &[Size] { - &self.t_dimensions + &self.level_tiles } pub fn level_dimensions(&self) -> &[Size] { - &self.z_dimensions + &self.level_dimensions } pub fn tile_count(&self) -> u32 { - self.t_dimensions - .iter() - .map(|&size| size.width * size.height) - .sum() + self.level_tiles.iter().map(|&size| size.w * size.h).sum() } pub fn get_tile( &self, level: u32, - location: Offset, + location: Address, resize_filter: FilterType, ) -> Result { - let (offset, level, size, final_size) = self.get_tile_info(level, location)?; - let image = self.osr.read_image(&offset, level, &size)?; + let (region, final_size) = self.get_tile_info(level, location)?; + let image = self.slide.read_image(®ion)?; + + let size = Size { + w: final_size.w, + h: final_size.h, + }; if final_size != size { Ok(image::imageops::resize( &image, - final_size.width, - final_size.height, + final_size.w, + final_size.h, resize_filter, )) } else { @@ -160,74 +159,91 @@ impl<'a> DeepZoomGenerator<'a> { } } - fn get_tile_info(&self, level: u32, location: Offset) -> Result<(Offset, u32, Size, Size)> { + fn get_tile_info(&self, level: u32, address: Address) -> Result<(Region, Size)> { if level as usize >= self.level_count() { return Err(OpenSlideError::CoreError("Invalid level".to_string())); } - if location.x >= self.t_dimensions[level as usize].width - || location.y >= self.t_dimensions[level as usize].height + if address.x >= self.level_tiles[level as usize].w + || address.y >= self.level_tiles[level as usize].h { return Err(OpenSlideError::CoreError("Invalid address".to_string())); } + + let level_tiles = self.level_tiles[level as usize]; + let level_dimensions = self.level_dimensions[level as usize]; + // Get preferred slide level - let slide_level: u32 = self.slide_from_dz_level[level as usize]; + let slide_level = self.slide_from_dz_level[level as usize]; + let slide_level_dimensions = self.slide_level_dimensions[slide_level as usize]; // Calculate top/left and bottom/right overlap - let z_overlap_tl = Size { - width: self.overlap * u32::from(location.x != 0), - height: self.overlap * u32::from(location.y != 0), + let z_overlap_topleft = Address { + x: if address.x != 0 { self.overlap } else { 0 }, + y: if address.y != 0 { self.overlap } else { 0 }, }; - let z_overlap_br = Size { - width: self.overlap * u32::from(location.x != self.t_dimensions[level as usize].width), - height: self.overlap - * u32::from(location.y != self.t_dimensions[level as usize].height), + + // Calculate top/left and bottom/right overlap + let z_overlap_bottomright = Address { + x: if address.x != (level_tiles.w - 1) { + self.overlap + } else { + 0 + }, + y: if address.y != (level_tiles.h - 1) { + self.overlap + } else { + 0 + }, }; // Get final size of the tile let z_size = Size { - width: min( - self.z_t_downsample, - self.z_dimensions[level as usize].width - self.z_t_downsample * location.x, - ) + z_overlap_tl.width - + z_overlap_br.width, - height: min( - self.z_t_downsample, - self.z_dimensions[level as usize].height - self.z_t_downsample * location.y, - ) + z_overlap_tl.height - + z_overlap_br.height, + w: self + .tile_size + .min(level_dimensions.w - self.tile_size * address.x) + + z_overlap_topleft.x + + z_overlap_bottomright.x, + h: self + .tile_size + .min(level_dimensions.h - self.tile_size * address.y) + + z_overlap_topleft.y + + z_overlap_bottomright.y, }; // Obtain the region coordinates - let z_location = Offset { - x: self.z_t_downsample * location.x, - y: self.z_t_downsample * location.y, + let z_location = Address { + x: address.x * self.tile_size, + y: address.y * self.tile_size, }; - // self._l_z_downsamples[dz_level] * z - let l_location = Offset { - x: (self.l_z_downsamples[level as usize] * (z_location.x - z_overlap_tl.width) as f64) - as u32, - y: (self.l_z_downsamples[level as usize] * (z_location.y - z_overlap_tl.height) as f64) - as u32, + + let l_location = Address { + x: (self.l_z_downsamples[level as usize] * (z_location.x - z_overlap_topleft.x) as f64) + .ceil() as _, + y: (self.l_z_downsamples[level as usize] * (z_location.y - z_overlap_topleft.y) as f64) + .ceil() as _, }; // Round location down and size up, and add offset of active area - let l0_location = Offset { + let l0_location = Address { x: (self.l0_l_downsamples[slide_level as usize] * l_location.x as f64 - + self.l0_offset.x as f64) as u32, + + self.l0_offset.x as f64) as _, y: (self.l0_l_downsamples[slide_level as usize] * l_location.y as f64 - + self.l0_offset.y as f64) as u32, + + self.l0_offset.y as f64) as _, }; let l_size = Size { - width: min( - (self.l_z_downsamples[level as usize] * z_size.width as f64).ceil() as u32, - self.l_dimensions[slide_level as usize].width, - ), - height: min( - (self.l_z_downsamples[level as usize] * z_size.height as f64).ceil() as u32, - self.l_dimensions[slide_level as usize].height, - ), + w: (slide_level_dimensions.w - l_location.x) + .min((self.l_z_downsamples[level as usize] * z_size.w as f64).ceil() as _), + h: (slide_level_dimensions.h - l_location.y) + .min((self.l_z_downsamples[level as usize] * z_size.h as f64).ceil() as _), }; - Ok((l0_location, slide_level, l_size, z_size)) + + let region = Region { + address: l0_location, + level: slide_level, + size: l_size, + }; + + Ok((region, z_size)) } } diff --git a/src/errors.rs b/src/errors.rs index fb78fdb..ef71e93 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,19 +1,33 @@ //! This module contains errors defined in this library //! -use std::{ffi::NulError, num::TryFromIntError, path::PathBuf}; +use std::{ffi::NulError, num::TryFromIntError}; use thiserror::Error; /// Enum defining all possible error when manipulating OpenSlide struct #[derive(Error, Debug)] pub enum OpenSlideError { - #[error(transparent)] - FFINulError(#[from] NulError), - #[error("Cannot convert Path {0} to String")] - PathToStringError(PathBuf), - #[error(transparent)] - ConversionError(#[from] TryFromIntError), + /// FFI string conversion error + /// Integer conversion error + #[error("Internal error: {0}")] + InternalError(String), + + #[error("File {0} does not exist")] + MissingFile(String), + + #[error("Unsupported file format: {0}")] + UnsupportedFile(String), + + /// OpenSlide lib error #[error("OpenSlide error: {0}")] - CoreError(String), // OpenSlide lib error + CoreError(String), +} + +pub(crate) fn map_try_error(err: TryFromIntError) -> OpenSlideError { + OpenSlideError::InternalError(err.to_string()) +} + +pub(crate) fn map_string_error(err: NulError) -> OpenSlideError { + OpenSlideError::InternalError(err.to_string()) } diff --git a/src/lib.rs b/src/lib.rs index 342d89c..d0a5d90 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,28 +29,41 @@ pub struct OpenSlide { #[cfg(feature = "deepzoom")] #[derive(Debug)] pub struct DeepZoomGenerator<'a> { - osr: &'a OpenSlide, - t_dimensions: Vec, - l_dimensions: Vec, - z_dimensions: Vec, - slide_from_dz_level: Vec, - l_z_downsamples: Vec, + slide: &'a OpenSlide, + + level_count: usize, + level_tiles: Vec, + level_dimensions: Vec, + + tile_size: u32, overlap: u32, - z_t_downsample: u32, - l0_offset: Offset, + + l0_offset: Address, + slide_level_dimensions: Vec, + slide_from_dz_level: Vec, l0_l_downsamples: Vec, + l_z_downsamples: Vec, +} + +/// Region struct +/// Used to retrieve a tile in a WSI +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Region { + pub size: Size, + pub level: u32, + pub address: Address, } -/// Size struct +/// Simple Size struct #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Size { - pub width: u32, - pub height: u32, + pub w: u32, + pub h: u32, } -/// Offset struct +/// Simple Address struct #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Offset { +pub struct Address { pub x: u32, pub y: u32, } diff --git a/src/wrapper.rs b/src/wrapper.rs index cbb3ffa..286e299 100644 --- a/src/wrapper.rs +++ b/src/wrapper.rs @@ -1,6 +1,9 @@ -use crate::{bindings, errors::OpenSlideError, Offset, OpenSlide, Properties, Result, Size}; +use crate::{ + bindings, errors::OpenSlideError, Address, OpenSlide, Properties, Region, Result, Size, +}; use std::path::Path; +use crate::errors::map_try_error; #[cfg(feature = "image")] use image::RgbaImage; @@ -16,11 +19,13 @@ impl OpenSlide { /// This function can be expensive; avoid calling it unnecessarily. For example, a tile server /// should not create a new object on every tile request. Instead, it should maintain a cache /// of OpenSlide objects and reuse them when possible. - pub fn new(filename: &Path) -> Result { - let filename = filename - .to_str() - .ok_or_else(|| OpenSlideError::PathToStringError(filename.to_path_buf()))?; - let osr = bindings::open(filename)?; + pub fn new(path: &Path) -> Result { + if !path.exists() { + return Err(OpenSlideError::MissingFile(path.display().to_string())); + } + + let filename = path.display().to_string(); + let osr = bindings::open(&filename)?; let property_names = bindings::get_property_names(osr)?; @@ -41,17 +46,19 @@ impl OpenSlide { } /// Quickly determine whether a whole slide image is recognized. - pub fn detect_vendor(filename: &Path) -> Result { - let filename = filename - .to_str() - .ok_or_else(|| OpenSlideError::PathToStringError(filename.to_path_buf()))?; - bindings::detect_vendor(filename) + pub fn detect_vendor(path: &Path) -> Result { + if !path.exists() { + return Err(OpenSlideError::MissingFile(path.display().to_string())); + } + let filename = path.display().to_string(); + bindings::detect_vendor(&filename) } /// Get the number of levels in the whole slide image. pub fn get_level_count(&self) -> Result { let level_count = bindings::get_level_count(*self.osr)?; - Ok(level_count.try_into()?) + let level_count: u32 = level_count.try_into().map_err(map_try_error)?; + Ok(level_count) } /// Get the dimensions of level 0 (the largest level). @@ -62,8 +69,8 @@ impl OpenSlide { pub fn get_level0_dimensions(&self) -> Result { let (width, height) = bindings::get_level0_dimensions(*self.osr)?; Ok(Size { - width: width.try_into()?, - height: height.try_into()?, + w: width.try_into().map_err(map_try_error)?, + h: height.try_into().map_err(map_try_error)?, }) } @@ -72,10 +79,11 @@ impl OpenSlide { /// This method returns the Size { width, height } number of pixels of the whole slide image at the /// specified level. Returns an error if the level is invalid pub fn get_level_dimensions(&self, level: u32) -> Result { - let (width, height) = bindings::get_level_dimensions(*self.osr, level.try_into()?)?; + let level: i32 = level.try_into().map_err(map_try_error)?; + let (width, height) = bindings::get_level_dimensions(*self.osr, level)?; Ok(Size { - width: width.try_into()?, - height: height.try_into()?, + w: width.try_into().map_err(map_try_error)?, + h: height.try_into().map_err(map_try_error)?, }) } @@ -84,10 +92,11 @@ impl OpenSlide { let nb_levels = self.get_level_count()?; let mut res = Vec::with_capacity(nb_levels as usize); for level in 0..nb_levels { - let (width, height) = bindings::get_level_dimensions(*self.osr, level.try_into()?)?; + let level: i32 = level.try_into().map_err(map_try_error)?; + let (width, height) = bindings::get_level_dimensions(*self.osr, level)?; res.push(Size { - width: width.try_into()?, - height: height.try_into()?, + w: width.try_into().map_err(map_try_error)?, + h: height.try_into().map_err(map_try_error)?, }); } Ok(res) @@ -95,7 +104,8 @@ impl OpenSlide { /// Get the downsampling factor of a given level. pub fn get_level_downsample(&self, level: u32) -> Result { - bindings::get_level_downsample(*self.osr, level.try_into()?) + let level: i32 = level.try_into().map_err(map_try_error)?; + bindings::get_level_downsample(*self.osr, level) } /// Get all downsampling factors for all available levels. @@ -111,9 +121,7 @@ impl OpenSlide { /// Get the best level to use for displaying the given downsample factor. pub fn get_best_level_for_downsample(&self, downsample: f64) -> Result { - bindings::get_best_level_for_downsample(*self.osr, downsample)? - .try_into() - .map_err(OpenSlideError::ConversionError) + Ok(bindings::get_best_level_for_downsample(*self.osr, downsample)? as u32) } /// Get the list of all available properties. @@ -136,14 +144,14 @@ impl OpenSlide { /// size: (width, height) in pixels of the outputted region /// /// Size of output Vec is Width * Height * 4 (RGBA pixels) - pub fn read_region(&self, offset: &Offset, level: u32, size: &Size) -> Result> { + pub fn read_region(&self, region: &Region) -> Result> { bindings::read_region( *self.osr, - offset.x as i64, - offset.y as i64, - level.try_into()?, - size.width as i64, - size.height as i64, + region.address.x as i64, + region.address.y as i64, + region.level.try_into().map_err(map_try_error)?, + region.size.w as i64, + region.size.h as i64, ) } @@ -163,8 +171,8 @@ impl OpenSlide { pub fn read_associated_buffer(&self, name: &str) -> Result<(Size, Vec)> { let ((width, height), buffer) = bindings::read_associated_image(*self.osr, name)?; let size = Size { - width: width.try_into()?, - height: height.try_into()?, + w: width.try_into().map_err(map_try_error)?, + h: height.try_into().map_err(map_try_error)?, }; Ok((size, buffer)) } @@ -173,8 +181,8 @@ impl OpenSlide { pub fn get_associated_image_dimensions(&self, name: &str) -> Result { let (width, height) = bindings::get_associated_image_dimensions(*self.osr, name)?; Ok(Size { - width: width.try_into()?, - height: height.try_into()?, + w: width.try_into().map_err(map_try_error)?, + h: height.try_into().map_err(map_try_error)?, }) } @@ -187,7 +195,7 @@ impl OpenSlide { #[cfg(feature = "image")] pub fn read_associated_image(&self, name: &str) -> Result { let (size, buffer) = self.read_associated_buffer(name)?; - let mut image = RgbaImage::from_vec(size.width, size.height, buffer).unwrap(); // Should be safe because buffer is big enough + let mut image = RgbaImage::from_vec(size.w, size.h, buffer).unwrap(); // Should be safe because buffer is big enough _bgra_to_rgba_inplace(&mut image); Ok(image) } @@ -201,12 +209,37 @@ impl OpenSlide { /// level: At which level to grab the region from /// size: (width, height) in pixels of the outputted region #[cfg(feature = "image")] - pub fn read_image(&self, offset: &Offset, level: u32, size: &Size) -> Result { - let buffer = self.read_region(offset, level, size)?; - let mut image = RgbaImage::from_vec(size.width, size.height, buffer).unwrap(); // Should be safe because buffer is big enough + pub fn read_image(&self, region: &Region) -> Result { + let buffer = self.read_region(region)?; + let size = region.size; + let mut image = RgbaImage::from_vec(size.w, size.h, buffer).unwrap(); // Should be safe because buffer is big enough _bgra_to_rgba_inplace(&mut image); Ok(image) } + + #[cfg(feature = "image")] + pub fn thumbnail(&self, size: &Size) -> Result { + let dimension_level0 = self.get_level0_dimensions()?; + + let downsample = ( + dimension_level0.w as f64 / size.w as f64, + dimension_level0.h as f64 / size.h as f64, + ); + let downsample = f64::max(downsample.0, downsample.1); + + let level = self.get_best_level_for_downsample(downsample)?; + + let region = Region { + size: self.get_level_dimensions(level)?, + level, + address: Address { x: 0, y: 0 }, + }; + + let image = self.read_image(®ion)?; + let image = image::imageops::thumbnail(&image, size.w, size.h); + + Ok(image) + } } #[cfg(feature = "image")] diff --git a/tests/data/boxes.png b/tests/assets/boxes.png similarity index 100% rename from tests/data/boxes.png rename to tests/assets/boxes.png diff --git a/tests/data/boxes.tiff b/tests/assets/boxes.tiff similarity index 100% rename from tests/data/boxes.tiff rename to tests/assets/boxes.tiff diff --git a/tests/assets/default.svs b/tests/assets/default.svs new file mode 100644 index 0000000..1125dcd Binary files /dev/null and b/tests/assets/default.svs differ diff --git a/tests/data/small.svs b/tests/assets/small.svs similarity index 100% rename from tests/data/small.svs rename to tests/assets/small.svs diff --git a/tests/data/unopenable.tiff b/tests/assets/unopenable.tiff similarity index 100% rename from tests/data/unopenable.tiff rename to tests/assets/unopenable.tiff diff --git a/tests/data/unreadable.svs b/tests/assets/unreadable.svs similarity index 100% rename from tests/data/unreadable.svs rename to tests/assets/unreadable.svs diff --git a/tests/deepzoom.rs b/tests/deepzoom.rs new file mode 100644 index 0000000..1878753 --- /dev/null +++ b/tests/deepzoom.rs @@ -0,0 +1,65 @@ +mod fixture; + +#[cfg(feature = "deepzoom")] +mod deepzoom { + + use image::imageops::FilterType; + use openslide_rs::{Address, DeepZoomGenerator, OpenSlide, Size}; + use rstest::rstest; + use std::path::Path; + + use super::fixture::boxes_tiff; + + #[rstest] + #[case(boxes_tiff())] + fn test_slide_metadata(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + let dz = DeepZoomGenerator::new(&slide, 254, 1, false).unwrap(); + + assert_eq!(dz.level_count(), 10); + assert_eq!(dz.tile_count(), 11); + assert_eq!( + dz.level_tiles(), + &[ + Size { w: 1, h: 1 }, + Size { w: 1, h: 1 }, + Size { w: 1, h: 1 }, + Size { w: 1, h: 1 }, + Size { w: 1, h: 1 }, + Size { w: 1, h: 1 }, + Size { w: 1, h: 1 }, + Size { w: 1, h: 1 }, + Size { w: 1, h: 1 }, + Size { w: 2, h: 1 } + ] + ); + + assert_eq!( + dz.level_dimensions(), + &[ + Size { w: 1, h: 1 }, + Size { w: 2, h: 1 }, + Size { w: 3, h: 2 }, + Size { w: 5, h: 4 }, + Size { w: 10, h: 8 }, + Size { w: 19, h: 16 }, + Size { w: 38, h: 32 }, + Size { w: 75, h: 63 }, + Size { w: 150, h: 125 }, + Size { w: 300, h: 250 } + ] + ); + + let image = dz + .get_tile(9, Address { x: 1, y: 0 }, FilterType::Lanczos3) + .unwrap(); + assert_eq!(image.width(), 47); + assert_eq!(image.height(), 250); + + let image = dz.get_tile(0, Address { x: 1, y: 0 }, FilterType::Lanczos3); + assert!(image.is_err()); + + let image = dz.get_tile(10, Address { x: 0, y: 0 }, FilterType::Lanczos3); + assert!(image.is_err()); + } +} diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs new file mode 100644 index 0000000..b472d28 --- /dev/null +++ b/tests/fixture/mod.rs @@ -0,0 +1,44 @@ +use rstest::fixture; +use std::path::Path; + +#[fixture] +#[once] +pub fn missing_file() -> &'static Path { + Path::new("missing_file") +} + +#[fixture] +#[once] +pub fn unsupported_file() -> &'static Path { + Path::new("Cargo.toml") +} + +#[fixture] +#[once] +pub fn boxes_tiff() -> &'static Path { + Path::new("tests/assets/boxes.tiff") +} + +#[fixture] +#[once] +pub fn default() -> &'static Path { + Path::new("tests/assets/default.svs") +} + +#[fixture] +#[once] +pub fn unopenable_tiff() -> &'static Path { + Path::new("tests/assets/unopenable.tiff") +} + +#[fixture] +#[once] +pub fn small_svs() -> &'static Path { + Path::new("tests/assets/small.svs") +} + +#[fixture] +#[once] +pub fn unreadable_svs() -> &'static Path { + Path::new("tests/assets/unreadable.svs") +} diff --git a/tests/mod.rs b/tests/mod.rs deleted file mode 100644 index c357419..0000000 --- a/tests/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod test_base; -mod test_properties; - -#[cfg(feature = "deepzoom")] -mod test_deepzoom; diff --git a/tests/openslide.rs b/tests/openslide.rs new file mode 100644 index 0000000..5e78996 --- /dev/null +++ b/tests/openslide.rs @@ -0,0 +1,188 @@ +use assert_approx_eq::assert_approx_eq; +use openslide_rs::{Address, OpenSlide, Region, Size}; +use rstest::rstest; +use std::path::Path; + +mod fixture; +use fixture::{boxes_tiff, missing_file, small_svs, unopenable_tiff, unsupported_file}; + +#[rstest] +#[should_panic(expected = "MissingFile(\"missing_file\")")] +#[case(missing_file())] +fn test_detect_file_not_found(#[case] filename: &Path) { + OpenSlide::detect_vendor(filename).unwrap(); +} + +#[rstest] +#[should_panic(expected = "MissingFile(\"missing_file\")")] +#[case(missing_file())] +fn test_open_file_not_found(#[case] filename: &Path) { + OpenSlide::new(filename).unwrap(); +} + +#[rstest] +#[should_panic(expected = "MissingFile(\"missing_file\")")] +#[case(missing_file())] +fn test_detect_unsupported_file(#[case] filename: &Path) { + OpenSlide::detect_vendor(filename).unwrap(); +} + +#[rstest] +#[should_panic(expected = "UnsupportedFile(\"Cargo.toml\")")] +#[case(unsupported_file())] +fn test_open_unsupported_file(#[case] filename: &Path) { + OpenSlide::new(filename).unwrap(); +} + +#[rstest] +#[should_panic(expected = "UnsupportedFile(\"Cargo.toml\")")] +#[case(unsupported_file())] +fn test_detect_format_unsupported(#[case] filename: &Path) { + OpenSlide::detect_vendor(filename).unwrap(); +} + +#[rstest] +#[case(boxes_tiff(), "generic-tiff")] +#[case(small_svs(), "aperio")] +fn test_detect_vendor(#[case] filename: &Path, #[case] expected_vendor: String) { + let vendor = OpenSlide::detect_vendor(filename).unwrap(); + assert_eq!(vendor, expected_vendor) +} + +#[rstest] +#[should_panic(expected = "CoreError(\"Unsupported TIFF compression: 52479\")")] +#[case(unopenable_tiff())] +fn test_open_unsupported_tiff(#[case] filename: &Path) { + OpenSlide::new(filename).unwrap(); +} + +#[rstest] +#[case(boxes_tiff())] +fn test_slide_info(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + + assert_eq!(slide.get_level_count().unwrap(), 4); + + // Level dimensions + assert_eq!( + slide.get_level0_dimensions().unwrap(), + Size { w: 300, h: 250 } + ); + assert_eq!( + slide.get_level_dimensions(0).unwrap(), + Size { w: 300, h: 250 } + ); + assert_eq!( + slide.get_level_dimensions(1).unwrap(), + Size { w: 150, h: 125 } + ); + assert_eq!( + slide.get_level_dimensions(2).unwrap(), + Size { w: 75, h: 62 } + ); + assert_eq!( + slide.get_level_dimensions(3).unwrap(), + Size { w: 37, h: 31 } + ); + assert_eq!( + slide.get_all_level_dimensions().unwrap(), + vec![ + Size { w: 300, h: 250 }, + Size { w: 150, h: 125 }, + Size { w: 75, h: 62 }, + Size { w: 37, h: 31 } + ] + ); + + // Level downsample + assert_approx_eq!(slide.get_level_downsample(0).unwrap(), 1.0); + assert_approx_eq!(slide.get_level_downsample(1).unwrap(), 2.0); + assert_approx_eq!(slide.get_level_downsample(2).unwrap(), 4.016129032258064); + assert_approx_eq!(slide.get_level_downsample(3).unwrap(), 8.086312118570184); + + let level_downsamples = slide.get_all_level_downsample().unwrap(); + let expect_level_downsamples = vec![1.0, 2.0, 4.016129032258064, 8.086312118570184]; + for index in 0..expect_level_downsamples.len() { + assert_approx_eq!(level_downsamples[index], expect_level_downsamples[index]); + } + + assert_eq!(slide.get_best_level_for_downsample(1.0).unwrap(), 0); + assert_eq!(slide.get_best_level_for_downsample(2.0).unwrap(), 1); + assert_eq!(slide.get_best_level_for_downsample(4.0).unwrap(), 1); + assert_eq!(slide.get_best_level_for_downsample(4.1).unwrap(), 2); + assert_eq!(slide.get_best_level_for_downsample(8.0).unwrap(), 2); + assert_eq!(slide.get_best_level_for_downsample(8.1).unwrap(), 3); +} + +#[rstest] +#[should_panic(expected = "CoreError(\"Invalid level 10\")")] +#[case(boxes_tiff())] +fn test_error_slide_level(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + slide.get_level_dimensions(10).unwrap(); +} + +#[rstest] +#[case(small_svs())] +fn test_associated_images(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + + assert_eq!( + slide.get_associated_image_names().unwrap(), + vec!["thumbnail".to_string()] + ); + + assert_eq!( + slide.get_associated_image_dimensions("thumbnail").unwrap(), + Size { w: 16, h: 16 } + ); + + let image = slide.read_associated_image("thumbnail").unwrap(); + assert_eq!(image.dimensions(), (16, 16)); +} + +#[rstest] +#[should_panic(expected = "CoreError(\"Unknown associated image\")")] +#[case(small_svs())] +fn test_error_associated_images_dimension(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + + slide.get_associated_image_dimensions("missing").unwrap(); +} + +#[rstest] +#[should_panic(expected = "CoreError(\"Unknown associated image\")")] +#[case(small_svs())] +fn test_error_read_associated_images(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + + slide.read_associated_image("missing").unwrap(); +} + +#[rstest] +#[case(boxes_tiff())] +fn test_slide_read_region(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + + let size = slide.get_level0_dimensions().unwrap(); + let address = Address { x: 0, y: 0 }; + let level = 0; + + let buffer = slide + .read_region(&Region { + size, + level, + address, + }) + .unwrap(); + assert_eq!(buffer.len(), (size.h * size.w * 4) as usize); +} + +#[rstest] +#[case(boxes_tiff())] +fn test_thumbnail(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + + let thumbnail = slide.thumbnail(&Size { w: 100, h: 80 }).unwrap(); + assert_eq!(thumbnail.dimensions(), (100, 80)); +} diff --git a/tests/properties.rs b/tests/properties.rs new file mode 100644 index 0000000..b7871ee --- /dev/null +++ b/tests/properties.rs @@ -0,0 +1,87 @@ +use openslide_rs::OpenSlide; +use rstest::rstest; +use std::path::Path; + +mod fixture; +use fixture::boxes_tiff; + +#[rstest] +#[case(boxes_tiff())] +fn test_slide_properties(#[case] filename: &Path) { + let slide = OpenSlide::new(filename).unwrap(); + + println!("{slide:?}"); + + assert_eq!( + slide.get_property_names(), + vec![ + "openslide.level-count", + "openslide.level[0].downsample", + "openslide.level[0].height", + "openslide.level[0].tile-height", + "openslide.level[0].tile-width", + "openslide.level[0].width", + "openslide.level[1].downsample", + "openslide.level[1].height", + "openslide.level[1].tile-height", + "openslide.level[1].tile-width", + "openslide.level[1].width", + "openslide.level[2].downsample", + "openslide.level[2].height", + "openslide.level[2].tile-height", + "openslide.level[2].tile-width", + "openslide.level[2].width", + "openslide.level[3].downsample", + "openslide.level[3].height", + "openslide.level[3].tile-height", + "openslide.level[3].tile-width", + "openslide.level[3].width", + "openslide.quickhash-1", + "openslide.vendor", + "tiff.ResolutionUnit", + "tiff.XResolution", + "tiff.YResolution" + ] + ); + + assert_eq!( + slide.get_property_value("tiff.YResolution").unwrap(), + "28.340000157438311" + ); + assert_eq!( + slide.get_property_value("tiff.XResolution").unwrap(), + "28.340000157438311" + ); + assert_eq!( + slide.get_property_value("tiff.YResolution").unwrap(), + "28.340000157438311" + ); + + let properties = &slide.properties; + + println!("{properties:?}"); + assert_eq!( + properties.openslide_properties.vendor, + Some("generic-tiff".to_string()) + ); + assert_eq!( + properties.openslide_properties.quickhash_1, + Some("c08b056490bac8bcb329d9b8fb175888083d4097952a55fee99997758c728c36".to_string()) + ); + assert_eq!(properties.openslide_properties.mpp_x, None); + assert_eq!(properties.openslide_properties.mpp_y, None); + assert_eq!(properties.openslide_properties.level_count, Some(4)); + assert_eq!( + properties.openslide_properties.levels[0].downsample, + Some(1.0) + ); + assert_eq!(properties.openslide_properties.levels[0].height, Some(250)); + assert_eq!(properties.openslide_properties.levels[0].width, Some(300)); + + assert_eq!( + properties.openslide_properties.levels[3].downsample, + Some(8.086312) + ); + assert_eq!(properties.openslide_properties.levels[3].height, Some(31)); + assert_eq!(properties.openslide_properties.levels[3].width, Some(37)); +} diff --git a/tests/test_base.rs b/tests/test_base.rs deleted file mode 100644 index bf97b74..0000000 --- a/tests/test_base.rs +++ /dev/null @@ -1,152 +0,0 @@ -use assert_approx_eq::assert_approx_eq; -use openslide_rs::{Offset, OpenSlide, Size}; -use rstest::rstest; -use std::path::Path; - -#[rstest] -#[case("tests/data/boxes.tiff")] -#[case("tests/data/small.svs")] -fn test_open_slide(#[case] filename: String) { - let filename = Path::new(filename.as_str()); - let slide = OpenSlide::new(filename); - - assert!(slide.is_ok()) -} - -#[rstest] -#[case("tests/data/boxes.tiff", "generic-tiff")] -#[case("tests/data/small.svs", "aperio")] -fn test_detect_vendor(#[case] filename: String, #[case] expected_vendor: String) { - let filename = Path::new(filename.as_str()); - let vendor = OpenSlide::detect_vendor(filename).unwrap(); - - assert_eq!(vendor, expected_vendor) -} - -#[rstest] -#[case("tests/data/boxes.tiff")] -fn test_slide_info(#[case] filename: String) { - let filename = Path::new(filename.as_str()); - let slide = OpenSlide::new(filename).unwrap(); - - assert_eq!(slide.get_level_count().unwrap(), 4); - assert_eq!( - slide.get_level0_dimensions().unwrap(), - Size { - width: 300, - height: 250 - } - ); - assert_eq!( - slide.get_level_dimensions(3).unwrap(), - Size { - width: 37, - height: 31 - } - ); - assert_eq!( - slide.get_all_level_dimensions().unwrap(), - vec![ - Size { - width: 300, - height: 250 - }, - Size { - width: 150, - height: 125 - }, - Size { - width: 75, - height: 62 - }, - Size { - width: 37, - height: 31 - } - ] - ); - assert_approx_eq!(slide.get_level_downsample(2).unwrap(), 4.016129032258064); - let level_downsamples = slide.get_all_level_downsample().unwrap(); - let expect_level_downsamples = vec![1.0, 2.0, 4.016129032258064, 8.086312118570184]; - for index in 0..expect_level_downsamples.len() { - assert_approx_eq!(level_downsamples[index], expect_level_downsamples[index]); - } - - assert_eq!( - slide.get_property_names(), - vec![ - "openslide.level-count", - "openslide.level[0].downsample", - "openslide.level[0].height", - "openslide.level[0].tile-height", - "openslide.level[0].tile-width", - "openslide.level[0].width", - "openslide.level[1].downsample", - "openslide.level[1].height", - "openslide.level[1].tile-height", - "openslide.level[1].tile-width", - "openslide.level[1].width", - "openslide.level[2].downsample", - "openslide.level[2].height", - "openslide.level[2].tile-height", - "openslide.level[2].tile-width", - "openslide.level[2].width", - "openslide.level[3].downsample", - "openslide.level[3].height", - "openslide.level[3].tile-height", - "openslide.level[3].tile-width", - "openslide.level[3].width", - "openslide.quickhash-1", - "openslide.vendor", - "tiff.ResolutionUnit", - "tiff.XResolution", - "tiff.YResolution" - ] - ); - - assert_eq!( - slide.get_property_value("tiff.YResolution").unwrap(), - "28.340000157438311" - ); - assert_eq!( - slide.get_property_value("tiff.XResolution").unwrap(), - "28.340000157438311" - ); - assert_eq!( - slide.get_property_value("tiff.YResolution").unwrap(), - "28.340000157438311" - ); - - assert_eq!(slide.get_best_level_for_downsample(1.0).unwrap(), 0); - assert_eq!(slide.get_best_level_for_downsample(2.0).unwrap(), 1); - assert_eq!(slide.get_best_level_for_downsample(4.0).unwrap(), 1); - assert_eq!(slide.get_best_level_for_downsample(4.1).unwrap(), 2); - assert_eq!(slide.get_best_level_for_downsample(8.0).unwrap(), 2); - assert_eq!(slide.get_best_level_for_downsample(8.1).unwrap(), 3); -} - -#[rstest] -#[case("tests/data/boxes.tiff")] -fn test_associated_images(#[case] filename: String) { - let filename = Path::new(filename.as_str()); - let slide = OpenSlide::new(filename).unwrap(); - - assert_eq!( - slide.get_associated_image_names().unwrap(), - Vec::::new() - ); -} - -#[rstest] -#[case("tests/data/boxes.tiff")] -fn test_slide_read_region(#[case] filename: String) { - let filename = Path::new(filename.as_str()); - let slide = OpenSlide::new(filename).unwrap(); - - let size = slide.get_level0_dimensions().unwrap(); - let offset = Offset { x: 0, y: 0 }; - let level = 0; - - let buffer = slide.read_region(&offset, level, &size).unwrap(); - assert_eq!(buffer.len(), (size.height * size.width * 4) as usize); -} diff --git a/tests/test_deepzoom.rs b/tests/test_deepzoom.rs deleted file mode 100644 index bd658e8..0000000 --- a/tests/test_deepzoom.rs +++ /dev/null @@ -1,119 +0,0 @@ -use assert_approx_eq::assert_approx_eq; -use image::imageops::FilterType; -use openslide_rs::{DeepZoomGenerator, Offset, OpenSlide, Size}; -use rstest::rstest; -use std::path::Path; - -#[rstest] -#[case("tests/data/boxes.tiff")] -fn test_slide_metadata(#[case] filename: String) { - let filename = Path::new(filename.as_str()); - let slide = OpenSlide::new(filename).unwrap(); - let dz = DeepZoomGenerator::new(&slide, 254, 1, false).unwrap(); - - assert_eq!(dz.level_count(), 10); - assert_eq!(dz.tile_count(), 11); - assert_eq!( - dz.level_tiles(), - &[ - Size { - width: 1, - height: 1 - }, - Size { - width: 1, - height: 1 - }, - Size { - width: 1, - height: 1 - }, - Size { - width: 1, - height: 1 - }, - Size { - width: 1, - height: 1 - }, - Size { - width: 1, - height: 1 - }, - Size { - width: 1, - height: 1 - }, - Size { - width: 1, - height: 1 - }, - Size { - width: 1, - height: 1 - }, - Size { - width: 2, - height: 1 - } - ] - ); - - assert_eq!( - dz.level_dimensions(), - &[ - Size { - width: 1, - height: 1 - }, - Size { - width: 2, - height: 1 - }, - Size { - width: 3, - height: 2 - }, - Size { - width: 5, - height: 4 - }, - Size { - width: 10, - height: 8 - }, - Size { - width: 19, - height: 16 - }, - Size { - width: 38, - height: 32 - }, - Size { - width: 75, - height: 63 - }, - Size { - width: 150, - height: 125 - }, - Size { - width: 300, - height: 250 - } - ] - ); - - let image = dz - .get_tile(9, Offset { x: 1, y: 0 }, FilterType::Lanczos3) - .unwrap(); - assert_eq!(image.width(), 48); - assert_eq!(image.height(), 251); - - let image = dz.get_tile(0, Offset { x: 1, y: 0 }, FilterType::Lanczos3); - assert!(image.is_err()); - - let image = dz.get_tile(10, Offset { x: 0, y: 0 }, FilterType::Lanczos3); - assert!(image.is_err()); -} diff --git a/tests/test_properties.rs b/tests/test_properties.rs deleted file mode 100644 index 54c5b82..0000000 --- a/tests/test_properties.rs +++ /dev/null @@ -1,40 +0,0 @@ -use openslide_rs::OpenSlide; -use rstest::rstest; -use std::path::Path; - -#[rstest] -#[case("tests/data/boxes.tiff")] -fn test_slide_properties(#[case] filename: String) { - let filename = Path::new(filename.as_str()); - let slide = OpenSlide::new(filename).unwrap(); - - println!("{slide:?}"); - - let properties = &slide.properties; - - println!("{properties:?}"); - assert_eq!( - properties.openslide_properties.vendor, - Some("generic-tiff".to_string()) - ); - assert_eq!( - properties.openslide_properties.quickhash_1, - Some("c08b056490bac8bcb329d9b8fb175888083d4097952a55fee99997758c728c36".to_string()) - ); - assert_eq!(properties.openslide_properties.mpp_x, None); - assert_eq!(properties.openslide_properties.mpp_y, None); - assert_eq!(properties.openslide_properties.level_count, Some(4)); - assert_eq!( - properties.openslide_properties.levels[0].downsample, - Some(1.0) - ); - assert_eq!(properties.openslide_properties.levels[0].height, Some(250)); - assert_eq!(properties.openslide_properties.levels[0].width, Some(300)); - - assert_eq!( - properties.openslide_properties.levels[3].downsample, - Some(8.086312) - ); - assert_eq!(properties.openslide_properties.levels[3].height, Some(31)); - assert_eq!(properties.openslide_properties.levels[3].width, Some(37)); -}