use crate::backend::svs::parse_slide;
use crate::decoder::JpegDecoder;
use crate::error::WsiError;
use crate::extraction::{ExtractionLevel, ExtractionOptions, Parallelism};
use crate::filter::{TissueMask, grayscale_histogram, otsu_threshold_from_histogram};
use crate::logging::{DualObserver, ExtractionObserver, ExtractionStats, ReportCollector};
use crate::metadata::Metadata;
use crate::tfrecord::TfRecordWriter;
use crate::tile::{Tile, TileDirectory, read_tile_bytes, read_tile_bytes_at};
use crate::{report, tfrecord};
use image::{DynamicImage, RgbImage};
use rayon::prelude::*;
use std::borrow::Cow;
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
const TILE_BUFFER_CAPACITY: usize = 1024 * 1024;
const RESIZE_SCALE_EPSILON: f64 = 1e-3;
#[derive(Debug, Clone, Default)]
pub struct SlideOutputs {
pub tile_dir: Option<PathBuf>,
pub tfrecord_file: Option<PathBuf>,
pub report_path: Option<PathBuf>,
}
impl SlideOutputs {
pub fn with_tile_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.tile_dir = Some(dir.into());
self
}
pub fn with_tfrecord_file(mut self, path: impl Into<PathBuf>) -> Self {
self.tfrecord_file = Some(path.into());
self
}
pub fn with_report_path(mut self, path: impl Into<PathBuf>) -> Self {
self.report_path = Some(path.into());
self
}
}
#[derive(Debug)]
pub struct Slide {
path: PathBuf,
metadata: Metadata,
tile_directories: Vec<TileDirectory>,
reader: Mutex<BufReader<File>>,
random_access_file: File,
}
impl Slide {
pub fn open(path: impl AsRef<Path>) -> Result<Self, WsiError> {
let path = path.as_ref();
let parsed = parse_slide(path)?;
let reader = BufReader::with_capacity(TILE_BUFFER_CAPACITY, File::open(path)?);
let random_access_file = File::open(path)?;
Ok(Self {
path: path.to_path_buf(),
metadata: parsed.metadata,
tile_directories: parsed.tile_directories,
reader: Mutex::new(reader),
random_access_file,
})
}
pub fn path(&self) -> &PathBuf {
&self.path
}
pub fn metadata(&self) -> &Metadata {
&self.metadata
}
pub fn level_count(&self) -> usize {
self.metadata.level_count()
}
fn locate_tile(
&self,
level_idx: usize,
tile_x: u32,
tile_y: u32,
) -> Result<(&TileDirectory, u64, u64), WsiError> {
let level = self
.metadata
.level(level_idx)
.ok_or(WsiError::LevelIndexOutOfBounds)?;
let directory = self
.tile_directories
.get(level_idx)
.ok_or(WsiError::LevelIndexOutOfBounds)?;
let (offset, byte_count) = directory.get_tile_location_by_coord(tile_x, tile_y, level)?;
Ok((directory, offset, byte_count))
}
pub fn read_tile_raw(
&self,
level_idx: usize,
tile_x: u32,
tile_y: u32,
) -> Result<Vec<u8>, WsiError> {
let (_, offset, byte_count) = self.locate_tile(level_idx, tile_x, tile_y)?;
let mut reader = self.reader.lock().expect("Mutex lock failed!");
read_tile_bytes(&mut reader, offset, byte_count)
}
pub fn decode_tile(
&self,
level_idx: usize,
tile_x: u32,
tile_y: u32,
) -> Result<Tile, WsiError> {
let (directory, offset, byte_count) = self.locate_tile(level_idx, tile_x, tile_y)?;
let mut reader = self.reader.lock().expect("Mutex lock failed!");
let decoded_tile = {
let tile = read_tile_bytes(&mut reader, offset, byte_count);
let color_transform = directory.photometric();
JpegDecoder::decode(directory.jpeg_reader(&tile?), color_transform)?
};
Ok(Tile::new(decoded_tile, level_idx, tile_x, tile_y))
}
fn decode_tile_concurrent(
&self,
level_idx: usize,
tile_x: u32,
tile_y: u32,
) -> Result<Tile, WsiError> {
let (directory, offset, byte_count) = self.locate_tile(level_idx, tile_x, tile_y)?;
let color_transform = directory.photometric();
let tile = read_tile_bytes_at(&self.random_access_file, offset, byte_count)?;
let decoded_tile = JpegDecoder::decode(directory.jpeg_reader(&tile), color_transform)?;
Ok(Tile::new(decoded_tile, level_idx, tile_x, tile_y))
}
pub fn tile_coords(
&self,
level_idx: usize,
) -> Result<impl Iterator<Item = (u32, u32)> + '_, WsiError> {
let level = self
.metadata
.level(level_idx)
.ok_or(WsiError::LevelIndexOutOfBounds)?;
let tiles_x = level.tiles_x();
let tiles_y = level.tiles_y();
Ok((0..tiles_y).flat_map(move |y| (0..tiles_x).map(move |x| (x, y))))
}
pub fn tiles(
&self,
level_idx: usize,
) -> Result<impl Iterator<Item = Result<Tile, WsiError>> + '_, WsiError> {
let level = self
.metadata
.level(level_idx)
.ok_or(WsiError::LevelIndexOutOfBounds)?;
let tiles_x = level.tiles_x();
let tiles_y = level.tiles_y();
Ok((0..tiles_y)
.flat_map(move |y| (0..tiles_x).map(move |x| self.decode_tile(level_idx, x, y))))
}
fn cropped_image<'a>(
&self,
level_idx: usize,
tile: &'a Tile,
) -> Result<Cow<'a, DynamicImage>, WsiError> {
let level = self
.metadata
.level(level_idx)
.ok_or(WsiError::LevelIndexOutOfBounds)?;
let tile_size = level.tile_size();
let (valid_width, valid_height) = level.valid_tile_dimensions(tile.tile_x(), tile.tile_y());
if valid_width == tile_size.width && valid_height == tile_size.height {
Ok(Cow::Borrowed(tile.image()))
} else {
Ok(Cow::Owned(tile.image().crop_imm(
0,
0,
valid_width,
valid_height,
)))
}
}
fn global_tissue_threshold(&self) -> Result<u8, WsiError> {
let level_idx = self.level_count() - 1;
let mut histogram = [0u32; 256];
for (x, y) in self.tile_coords(level_idx)? {
let tile = self.decode_tile(level_idx, x, y)?;
let cropped = self.cropped_image(level_idx, &tile)?;
for (bin, count) in grayscale_histogram(&cropped.to_luma8()).iter().enumerate() {
histogram[bin] += count;
}
}
Ok(otsu_threshold_from_histogram(&histogram))
}
pub(crate) fn resolve_resolution_level(
&self,
extraction_level: &Option<ExtractionLevel>,
) -> Result<usize, WsiError> {
match extraction_level {
Some(ExtractionLevel::Index(idx)) => Ok(*idx),
Some(ExtractionLevel::TargetMpp(target)) => self
.metadata
.best_level_for_target_mpp(*target)
.ok_or(WsiError::InvalidMetadata),
None => Err(WsiError::InvalidMetadata),
}
}
pub fn extract<F>(
&self,
options: &ExtractionOptions,
outputs: &SlideOutputs,
f: F,
) -> Result<(), WsiError>
where
F: Fn(Tile) -> Result<(), WsiError> + Sync,
{
let level_idx = self.resolve_resolution_level(&options.extraction_level)?;
let resize_factor = match &options.extraction_level {
Some(ExtractionLevel::TargetMpp(target)) => {
let base_mpp = self
.metadata
.microns_per_pixel()
.ok_or(WsiError::InvalidMetadata)?;
let level = self
.metadata
.level(level_idx)
.ok_or(WsiError::LevelIndexOutOfBounds)?;
let level_mpp = base_mpp * level.downsample_factor();
Some(level_mpp / target)
}
_ => None,
};
let coords: Vec<(u32, u32)> = self.tile_coords(level_idx)?.collect();
let total_tiles = coords.len();
let dropped_tiles = AtomicUsize::new(0);
let slide_name = self
.path()
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("slide")
.to_string();
if let Some(dir) = &outputs.tile_dir {
std::fs::create_dir_all(dir)?;
}
if let Some(dir) = outputs.tfrecord_file.as_deref().and_then(Path::parent) {
std::fs::create_dir_all(dir)?;
}
if let Some(dir) = outputs.report_path.as_deref().and_then(Path::parent) {
std::fs::create_dir_all(dir)?;
}
let mut tfrecord_writer: Option<Mutex<TfRecordWriter<File>>> = None;
if let Some(file) = &outputs.tfrecord_file {
tfrecord_writer = Some(Mutex::new(TfRecordWriter::new(File::create(file)?)));
}
let report_collector = outputs
.report_path
.as_ref()
.map(|_| Arc::new(ReportCollector::new()));
let effective_observer: Option<Arc<dyn ExtractionObserver>> =
match (&options.observer, &report_collector) {
(Some(existing), Some(collector)) => {
Some(Arc::new(DualObserver(existing.clone(), collector.clone())))
}
(Some(existing), None) => Some(existing.clone()),
(None, Some(collector)) => Some(collector.clone() as Arc<dyn ExtractionObserver>),
(None, None) => None,
};
if let Some(observer) = &effective_observer {
observer.on_extraction_start(level_idx, total_tiles);
}
let tissue_threshold = options
.min_tissue_fraction
.map(|_| self.global_tissue_threshold())
.transpose()?;
let process = |tile: Tile| -> Result<(), WsiError> {
if let (Some(min_fraction), Some(threshold)) =
(options.min_tissue_fraction, tissue_threshold)
{
let cropped = self.cropped_image(level_idx, &tile)?;
let tissue_mask = TissueMask::compute_with_threshold(&cropped, threshold);
if !tissue_mask.has_more_than_min_tissue(min_fraction) {
dropped_tiles.fetch_add(1, Ordering::Relaxed);
if let Some(observer) = &effective_observer {
observer.on_tile_dropped(
level_idx,
tile.tile_x(),
tile.tile_y(),
tissue_mask.tissue_fraction(),
min_fraction,
);
}
return Ok(());
}
}
let tile = if options.normalize_stain {
tile.normalize_stain()
} else {
tile
};
let tile = if let Some(scale) = resize_factor {
if (scale - 1.0).abs() > RESIZE_SCALE_EPSILON {
let (width, height) = (tile.image().width(), tile.image().height());
let new_width = ((width as f64) * scale).round().max(1.0) as u32;
let new_height = ((height as f64) * scale).round().max(1.0) as u32;
tile.resize(new_width, new_height)
} else {
tile
}
} else {
tile
};
if let Some(dir) = &outputs.tile_dir {
tile.save(dir.join(format!("{}_{}.jpg", tile.tile_x(), tile.tile_y())))?;
}
if let Some(writer) = &tfrecord_writer {
let bytes = tile.encode_jpeg()?;
let loc_x = tile.tile_x() as i64;
let loc_y = tile.tile_y() as i64;
let record = tfrecord::tile_record(&slide_name, bytes, loc_x, loc_y);
writer
.lock()
.expect("Mutex lock failed!")
.write_record(&record)?;
}
if let Some(observer) = &effective_observer {
observer.on_tile_extraction(level_idx, &tile);
}
f(tile)
};
let result = match options.parallelism {
Parallelism::Sequential => coords
.into_iter()
.try_for_each(|(x, y)| process(self.decode_tile(level_idx, x, y)?)),
Parallelism::Parallel(threads) => {
let run = || {
coords.into_par_iter().try_for_each(|(x, y)| {
process(self.decode_tile_concurrent(level_idx, x, y)?)
})
};
match threads {
Some(n) => rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build()?
.install(run),
None => run(),
}
}
};
if options.min_tissue_fraction.is_some()
&& let Some(observer) = &effective_observer
{
observer.on_extraction_complete(
level_idx,
ExtractionStats {
total: total_tiles,
dropped: dropped_tiles.load(Ordering::Relaxed),
},
);
}
if result.is_ok()
&& let (Some(path), Some(collector)) = (&outputs.report_path, &report_collector)
{
collector.report(options).write_pdf(self, path)?;
}
result
}
pub fn build_overview_image(&self) -> Result<DynamicImage, WsiError> {
let level_idx = self.level_count() - 1;
let level = self
.metadata
.level(level_idx)
.ok_or(WsiError::LevelIndexOutOfBounds)?;
let dimensions = level.dimensions();
let mut canvas = RgbImage::new(dimensions.width, dimensions.height);
for (x, y) in self.tile_coords(level_idx)? {
let tile = self.decode_tile(level_idx, x, y)?;
let cropped = self.cropped_image(level_idx, &tile)?;
let cropped_rgb = cropped.to_rgb8();
let origin_x = x * level.tile_size().width;
let origin_y = y * level.tile_size().height;
for (px, py, pixel) in cropped_rgb.enumerate_pixels() {
canvas.put_pixel(origin_x + px, origin_y + py, *pixel);
}
}
let overview = DynamicImage::ImageRgb8(canvas);
let max_dim = overview.width().max(overview.height());
Ok(if max_dim > report::OVERVIEW_MAX_PX {
overview.thumbnail(report::OVERVIEW_MAX_PX, report::OVERVIEW_MAX_PX)
} else {
overview
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
const FIXTURE: &str = "tests/fixtures/CMU-1-Small-Region.svs";
fn open_fixture() -> Slide {
Slide::open(FIXTURE).expect("fixture slide should open")
}
#[test]
fn open_reads_real_slide_metadata() {
let slide = open_fixture();
assert_eq!(slide.level_count(), 1);
let level = slide.metadata().level(0).unwrap();
assert_eq!(level.dimensions().width, 2220);
assert_eq!(level.dimensions().height, 2967);
}
#[test]
fn open_fails_on_missing_file() {
let result = Slide::open("tests/fixtures/does_not_exist.svs");
assert!(result.is_err());
}
#[test]
fn decode_tile_returns_expected_dimensions() {
let slide = open_fixture();
let tile = slide.decode_tile(0, 0, 0).unwrap();
assert_eq!(tile.image().width(), 240);
assert_eq!(tile.image().height(), 240);
}
#[test]
fn decode_tile_rejects_out_of_bounds_level() {
let slide = open_fixture();
let result = slide.decode_tile(5, 0, 0);
assert!(matches!(result, Err(WsiError::LevelIndexOutOfBounds)));
}
#[test]
fn decode_tile_rejects_out_of_bounds_tile_coord() {
let slide = open_fixture();
let level = slide.metadata().level(0).unwrap();
let result = slide.decode_tile(0, level.tiles_x(), 0);
assert!(matches!(result, Err(WsiError::TileIndexOutOfBounds)));
}
#[test]
fn extract_sequential_visits_every_tile_exactly_once() {
let slide = open_fixture();
let expected: HashSet<_> = slide.tile_coords(0).unwrap().collect();
let seen = Mutex::new(Vec::new());
let options = ExtractionOptions::sequential().with_level(0);
slide
.extract(&options, &SlideOutputs::default(), |tile| {
seen.lock().unwrap().push((tile.tile_x(), tile.tile_y()));
Ok(())
})
.unwrap();
let seen = seen.into_inner().unwrap();
let seen_set: HashSet<_> = seen.iter().copied().collect();
assert_eq!(seen.len(), expected.len(), "no duplicate or skipped tiles");
assert_eq!(seen_set, expected);
}
#[test]
fn extract_parallel_visits_every_tile_exactly_once() {
let slide = open_fixture();
let expected: HashSet<_> = slide.tile_coords(0).unwrap().collect();
let seen = Mutex::new(Vec::new());
let options = ExtractionOptions::parallel().with_level(0);
slide
.extract(&options, &SlideOutputs::default(), |tile| {
seen.lock().unwrap().push((tile.tile_x(), tile.tile_y()));
Ok(())
})
.unwrap();
let seen = seen.into_inner().unwrap();
let seen_set: HashSet<_> = seen.iter().copied().collect();
assert_eq!(
seen.len(),
expected.len(),
"no duplicate or skipped tiles under parallelism"
);
assert_eq!(seen_set, expected);
}
#[test]
fn extract_with_tissue_filter_only_emits_tiles_above_the_threshold() {
let slide = open_fixture();
let total = slide.tile_coords(0).unwrap().count();
let kept = Mutex::new(0usize);
let options = ExtractionOptions::sequential()
.with_level(0)
.with_min_tissue_fraction(0.5);
slide
.extract(&options, &SlideOutputs::default(), |_tile| {
*kept.lock().unwrap() += 1;
Ok(())
})
.unwrap();
let kept = kept.into_inner().unwrap();
assert!(kept > 0, "expected at least some tissue-containing tiles");
assert!(
kept < total,
"expected the filter to drop at least one tile"
);
}
#[test]
fn extract_with_stain_normalization_does_not_error() {
let slide = open_fixture();
let options = ExtractionOptions::sequential()
.with_level(0)
.with_stain_normalization();
let result = slide.extract(&options, &SlideOutputs::default(), |_tile| Ok(()));
assert!(result.is_ok());
}
#[test]
fn extract_with_target_mpp_resizes_tiles_to_match() {
let slide = open_fixture();
let native_mpp = slide.metadata().microns_per_pixel().unwrap();
let target_mpp = native_mpp * 2.0;
let sizes = Mutex::new(Vec::new());
let options = ExtractionOptions::sequential().with_target_mpp(target_mpp);
slide
.extract(&options, &SlideOutputs::default(), |tile| {
sizes.lock().unwrap().push(tile.image().width());
Ok(())
})
.unwrap();
let sizes = sizes.into_inner().unwrap();
assert!(!sizes.is_empty());
assert!(sizes.iter().all(|&w| w == 120));
}
#[test]
fn extract_writes_tile_dir_tfrecord_and_report_outputs() {
let slide = open_fixture();
let dir = tempfile::tempdir().unwrap();
let tile_dir = dir.path().join("tiles");
let tfrecord_path = dir.path().join("out.tfrecord");
let report_path = dir.path().join("report.pdf");
let options = ExtractionOptions::sequential().with_level(0);
let outputs = SlideOutputs::default()
.with_tile_dir(&tile_dir)
.with_tfrecord_file(&tfrecord_path)
.with_report_path(&report_path);
slide.extract(&options, &outputs, |_tile| Ok(())).unwrap();
let expected_tiles = slide.tile_coords(0).unwrap().count();
let written_tiles = std::fs::read_dir(&tile_dir).unwrap().count();
assert_eq!(written_tiles, expected_tiles);
let tfrecord_meta = std::fs::metadata(&tfrecord_path).unwrap();
assert!(tfrecord_meta.len() > 0);
let report_bytes = std::fs::read(&report_path).unwrap();
assert!(report_bytes.starts_with(b"%PDF"));
}
#[test]
fn build_overview_image_matches_lowest_level_aspect_ratio() {
let slide = open_fixture();
let overview = slide.build_overview_image().unwrap();
let level = slide.metadata().level(slide.level_count() - 1).unwrap();
assert!(overview.width() > 0 && overview.height() > 0);
let level_ratio = level.dimensions().width as f64 / level.dimensions().height as f64;
let overview_ratio = overview.width() as f64 / overview.height() as f64;
assert!((level_ratio - overview_ratio).abs() < 0.01);
}
}