wsi-rs 0.5.1

wsi-rs whole-slide image reader
Documentation
use super::*;
use crate::core::types::*;
use crate::test_support::{regular_rgb_dataset_for_test, RegularLevelForTest};
use std::sync::atomic::{AtomicUsize, Ordering};

struct CountingAdaptiveSource {
    dataset: Dataset,
    batch_reads: Arc<AtomicUsize>,
    requested_tiles: Arc<AtomicUsize>,
}

impl CountingAdaptiveSource {
    fn new(batch_reads: Arc<AtomicUsize>, requested_tiles: Arc<AtomicUsize>) -> Self {
        Self {
            dataset: regular_rgb_dataset_for_test(
                DatasetId::new(42),
                "scene",
                "series",
                RegularLevelForTest {
                    dimensions: (128, 128),
                    tile_width: 128,
                    tile_height: 128,
                    tiles_across: 1,
                    tiles_down: 1,
                },
            ),
            batch_reads,
            requested_tiles,
        }
    }
}

impl SlideReader for CountingAdaptiveSource {
    fn dataset(&self) -> &Dataset {
        &self.dataset
    }

    fn tile_codec_kind(&self, _req: &TileRequest) -> TileCodecKind {
        TileCodecKind::Jp2k
    }

    fn read_tiles(
        &self,
        reqs: &[TileRequest],
        _output: TileOutputPreference,
    ) -> Result<Vec<TilePixels>, WsiError> {
        self.batch_reads.fetch_add(1, Ordering::SeqCst);
        self.requested_tiles.fetch_add(reqs.len(), Ordering::SeqCst);
        reqs.iter()
            .map(|req| self.read_tile_cpu(req).map(TilePixels::Cpu))
            .collect()
    }

    fn read_tile_cpu(&self, _req: &TileRequest) -> Result<CpuTile, WsiError> {
        Ok(CpuTile {
            width: 128,
            height: 128,
            channels: 3,
            color_space: ColorSpace::Rgb,
            layout: CpuTileLayout::Interleaved,
            data: CpuTileData::u8(vec![7; 128 * 128 * 3]),
        })
    }

    fn read_associated(&self, name: &str) -> Result<CpuTile, WsiError> {
        Err(WsiError::AssociatedImageNotFound(name.into()))
    }
}

#[test]
fn default_decode_options_reuse_shared_runtime() {
    let first = DecodeRuntime::arc_for_options(DecodeExecutionOptions::default()).expect("runtime");
    let second =
        DecodeRuntime::arc_for_options(DecodeExecutionOptions::default()).expect("runtime");

    assert!(Arc::ptr_eq(&first, &second));
}

#[test]
fn route_cache_is_bounded() {
    let runtime = DecodeRuntime::new(DecodeExecutionOptions::default()).expect("runtime");
    let first_key = route_key_for_test(0);

    for sequence in 0..ROUTE_CACHE_MAX_ENTRIES + 5 {
        runtime.store_route(
            route_key_for_test(sequence),
            DecodeRouteDecision::measured(1, Duration::from_millis(2), Duration::from_millis(1), 1),
        );
    }

    let cache_len = runtime
        .route_cache
        .lock()
        .unwrap_or_else(|err| err.into_inner())
        .len();
    assert_eq!(cache_len, ROUTE_CACHE_MAX_ENTRIES);
    assert!(runtime.cached_route(&first_key).is_none());
    assert!(runtime
        .cached_route(&route_key_for_test(ROUTE_CACHE_MAX_ENTRIES + 4))
        .is_some());
}

fn route_key_for_test(sequence: usize) -> DecodeRouteKey {
    DecodeRouteKey {
        dataset_id: sequence as u128,
        scene: 0,
        series: 0,
        level: 0,
        tile_grid: RouteTileGrid {
            tile_width: 128,
            tile_height: 128,
            tiles_across: 1,
            tiles_down: 1,
        },
        codec_kind: TileCodecKind::Jp2k,
        output_backend: OutputBackendRequest::Auto,
        device_backend_identity: format!("test-{sequence}"),
        sample_tile_count: 1,
    }
}

struct AdaptiveRouteFixture {
    batch_reads: Arc<AtomicUsize>,
    requested_tiles: Arc<AtomicUsize>,
    runtime: Arc<DecodeRuntime>,
    reader: AdaptiveDecodeReader,
    req: TileRequest,
}

fn adaptive_route_fixture(route_sample_size: usize) -> AdaptiveRouteFixture {
    let batch_reads = Arc::new(AtomicUsize::new(0));
    let requested_tiles = Arc::new(AtomicUsize::new(0));
    let runtime = Arc::new(
        DecodeRuntime::new(
            DecodeExecutionOptions::default().with_route_sample_size(route_sample_size),
        )
        .expect("decode runtime"),
    );
    let reader = AdaptiveDecodeReader::new(
        Box::new(CountingAdaptiveSource::new(
            batch_reads.clone(),
            requested_tiles.clone(),
        )),
        runtime.clone(),
    );
    let req = TileRequest {
        scene: 0usize.into(),
        series: 0usize.into(),
        level: 0u32.into(),
        plane: PlaneSelection::default().into(),
        col: 0,
        row: 0,
    };

    AdaptiveRouteFixture {
        batch_reads,
        requested_tiles,
        runtime,
        reader,
        req,
    }
}

#[test]
fn adaptive_route_reuses_device_cpu_fallback_sample_for_first_read() {
    let fixture = adaptive_route_fixture(4);

    let tiles = fixture
        .reader
        .read_tiles(
            &[fixture.req],
            TileOutputPreference::prefer_device_auto_with_compressed_decode(),
        )
        .expect("adaptive read");

    assert_eq!(tiles.len(), 1);
    assert_eq!(fixture.batch_reads.load(Ordering::SeqCst), 1);
    assert_eq!(fixture.requested_tiles.load(Ordering::SeqCst), 1);
}

#[test]
fn adaptive_route_keys_subsampled_batches_separately() {
    let fixture = adaptive_route_fixture(4);
    let output = TileOutputPreference::prefer_device_auto_with_compressed_decode();
    let single_key = route_key_for_batch(
        fixture.reader.inner.as_ref(),
        std::slice::from_ref(&fixture.req),
        &output,
        4,
    )
    .expect("route key is available for one-tile JP2K regular batch");
    let full_sample_key = route_key_for_batch(
        fixture.reader.inner.as_ref(),
        &[
            fixture.req.clone(),
            fixture.req.clone(),
            fixture.req.clone(),
            fixture.req.clone(),
        ],
        &output,
        4,
    )
    .expect("route key is available for JP2K regular tile");

    let tiles = fixture
        .reader
        .read_tiles(&[fixture.req], output)
        .expect("adaptive read");

    assert_eq!(tiles.len(), 1);
    assert_eq!(fixture.batch_reads.load(Ordering::SeqCst), 1);
    assert_eq!(fixture.requested_tiles.load(Ordering::SeqCst), 1);
    assert!(
        fixture.runtime.cached_route(&single_key).is_some(),
        "a one-tile read should cache its own route"
    );
    assert!(
        fixture.runtime.cached_route(&full_sample_key).is_none(),
        "a one-tile read must not poison the route for four-plus-tile batches"
    );
}

#[test]
fn default_route_sample_covers_viewer_sized_dicom_device_batches() {
    let batch_reads = Arc::new(AtomicUsize::new(0));
    let requested_tiles = Arc::new(AtomicUsize::new(0));
    let reader = CountingAdaptiveSource::new(batch_reads, requested_tiles);
    let reqs = (0..15)
        .map(|col| TileRequest {
            scene: 0usize.into(),
            series: 0usize.into(),
            level: 0u32.into(),
            plane: PlaneSelection::default().into(),
            col: col as i64,
            row: 0,
        })
        .collect::<Vec<_>>();
    let output = TileOutputPreference::prefer_device_auto_with_compressed_decode();

    let key = route_key_for_batch(
        &reader,
        &reqs,
        &output,
        DecodeExecutionOptions::default().route_sample_size(),
    )
    .expect("route key is available for a viewer-sized JP2K batch");

    assert_eq!(
            key.sample_tile_count, 15,
            "default adaptive sampling must measure a real visible-tile batch instead of undersampling into the CPU path"
        );
}

#[test]
fn adaptive_route_sends_large_jp2k_batches_to_device_preferred_reader_without_sampling() {
    let batch_reads = Arc::new(AtomicUsize::new(0));
    let requested_tiles = Arc::new(AtomicUsize::new(0));
    let runtime = Arc::new(DecodeRuntime::new(DecodeExecutionOptions::default()).unwrap());
    let reader = AdaptiveDecodeReader::new(
        Box::new(CountingAdaptiveSource::new(
            batch_reads.clone(),
            requested_tiles.clone(),
        )),
        runtime.clone(),
    );
    let reqs = (0..15)
        .map(|col| TileRequest {
            scene: 0usize.into(),
            series: 0usize.into(),
            level: 0u32.into(),
            plane: PlaneSelection::default().into(),
            col: col as i64,
            row: 0,
        })
        .collect::<Vec<_>>();
    let output = TileOutputPreference::prefer_device_auto_with_compressed_decode();
    let key = route_key_for_batch(
        reader.inner.as_ref(),
        &reqs,
        &output,
        DecodeExecutionOptions::default().route_sample_size(),
    )
    .expect("route key is available for a viewer-sized JP2K batch");

    let tiles = reader.read_tiles(&reqs, output).expect("adaptive read");

    assert_eq!(tiles.len(), 15);
    assert_eq!(
        batch_reads.load(Ordering::SeqCst),
        1,
        "large JP2K batches should avoid cold adaptive double-decode"
    );
    assert_eq!(requested_tiles.load(Ordering::SeqCst), 15);
    assert!(
        runtime.cached_route(&key).is_none(),
        "direct large-batch routing should not cache a CPU-biased sample"
    );
}

#[test]
fn adaptive_route_samples_uncached_subthreshold_batches_before_routing_remainder() {
    let batch_reads = Arc::new(AtomicUsize::new(0));
    let requested_tiles = Arc::new(AtomicUsize::new(0));
    let runtime = Arc::new(
        DecodeRuntime::new(DecodeExecutionOptions::default().with_route_sample_size(4))
            .expect("decode runtime"),
    );
    let reader = AdaptiveDecodeReader::new(
        Box::new(CountingAdaptiveSource::new(
            batch_reads.clone(),
            requested_tiles.clone(),
        )),
        runtime.clone(),
    );
    let reqs = (0..7)
        .map(|col| TileRequest {
            scene: 0usize.into(),
            series: 0usize.into(),
            level: 0u32.into(),
            plane: PlaneSelection::default().into(),
            col: col as i64,
            row: 0,
        })
        .collect::<Vec<_>>();
    let output = TileOutputPreference::prefer_device_auto_with_compressed_decode();
    let key = route_key_for_batch(reader.inner.as_ref(), &reqs, &output, 4)
        .expect("route key is available for JP2K regular tile");

    let tiles = reader
        .read_tiles(&reqs, output.clone())
        .expect("adaptive read");

    assert_eq!(tiles.len(), 7);
    assert_eq!(
        batch_reads.load(Ordering::SeqCst),
        2,
        "uncached subthreshold batches should sample, cache a route, then route the remainder"
    );
    assert_eq!(requested_tiles.load(Ordering::SeqCst), 7);
    assert!(
        runtime.cached_route(&key).is_some(),
        "subthreshold auto routing should cache a measured route"
    );

    batch_reads.store(0, Ordering::SeqCst);
    requested_tiles.store(0, Ordering::SeqCst);
    let tiles = reader
        .read_tiles(&reqs, output)
        .expect("cached adaptive read");

    assert_eq!(tiles.len(), 7);
    assert_eq!(
        batch_reads.load(Ordering::SeqCst),
        1,
        "cached large-batch routes should not resample"
    );
    assert_eq!(requested_tiles.load(Ordering::SeqCst), 7);
}