Skip to main content

wsi_rs/core/registry/
slide.rs

1use super::*;
2
3// ── Slide ──────────────────────────────────────────────────
4
5/// Top-level handle. Owns the SlideReader + shared cache.
6pub struct Slide {
7    source: Box<dyn SlideReader>,
8    cache: Arc<TileCache>,
9    display_cache: Arc<TileCache>,
10    max_region_pixels: u64,
11    decode_runtime: Arc<DecodeRuntime>,
12}
13
14impl std::fmt::Debug for Slide {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        f.debug_struct("Slide")
17            .field("dataset_id", &self.source.dataset().id)
18            .finish()
19    }
20}
21
22impl Slide {
23    /// Construct from an already-opened source and cache.
24    pub(crate) fn from_source(source: Box<dyn SlideReader>, cache: Arc<TileCache>) -> Self {
25        let decode_runtime = DecodeRuntime::default_arc();
26        Self {
27            source: Box::new(AdaptiveDecodeReader::new(source, decode_runtime.clone())),
28            cache,
29            display_cache: Arc::new(TileCache::display_default()),
30            max_region_pixels: DEFAULT_MAX_REGION_PIXELS,
31            decode_runtime,
32        }
33    }
34
35    pub(crate) fn from_source_with_config_and_runtime(
36        source: Box<dyn SlideReader>,
37        cache_config: CacheConfig,
38        max_region_pixels: u64,
39        decode_runtime: Arc<DecodeRuntime>,
40    ) -> Self {
41        let source_hint = source.recommended_shared_cache_bytes();
42        Self {
43            source: Box::new(AdaptiveDecodeReader::new(source, decode_runtime.clone())),
44            cache: Arc::new(TileCache::shared_with_config(cache_config, source_hint)),
45            display_cache: Arc::new(TileCache::display_with_config(cache_config)),
46            max_region_pixels,
47            decode_runtime,
48        }
49    }
50
51    /// Construct from an already-opened source with an internal cache budget.
52    pub fn from_source_with_cache_bytes(source: Box<dyn SlideReader>, cache_bytes: u64) -> Self {
53        Self::from_source(source, Arc::new(TileCache::new(cache_bytes)))
54    }
55
56    /// Zero-config entry point: builtin registry + source-aware default cache.
57    pub fn open(path: impl AsRef<Path>) -> Result<Self, WsiError> {
58        Self::open_with_options(path, SlideOpenOptions::default())
59    }
60
61    pub fn open_with_options(
62        path: impl AsRef<Path>,
63        options: SlideOpenOptions,
64    ) -> Result<Self, WsiError> {
65        let resolved_path = crate::formats::svcache::resolve_open_path_with_policy(
66            path.as_ref(),
67            options.svcache_policy,
68        )?;
69        let source = options.registry.open(&resolved_path)?;
70        let decode_runtime = DecodeRuntime::arc_for_options(options.decode_execution_options)?;
71        Ok(Self::from_source_with_config_and_runtime(
72            source,
73            options.cache_config,
74            options.max_region_pixels,
75            decode_runtime,
76        ))
77    }
78
79    /// Open with the given registry and cache.
80    ///
81    /// Reusing the same [`TileCache`] across multiple handles allows decoded
82    /// tiles from one handle to satisfy later reads from another handle that
83    /// targets the same dataset and plane.
84    pub(crate) fn open_with(
85        path: impl AsRef<Path>,
86        registry: &FormatRegistry,
87        cache: Arc<TileCache>,
88    ) -> Result<Self, WsiError> {
89        let source = registry.open(path.as_ref())?;
90        let mut slide = Self::from_source(source, cache);
91        slide.max_region_pixels = DEFAULT_MAX_REGION_PIXELS;
92        Ok(slide)
93    }
94
95    /// Open with the given registry and an internal cache budget.
96    pub fn open_with_cache_bytes(
97        path: impl AsRef<Path>,
98        registry: &FormatRegistry,
99        cache_bytes: u64,
100    ) -> Result<Self, WsiError> {
101        Self::open_with(path, registry, Arc::new(TileCache::new(cache_bytes)))
102    }
103
104    pub fn dataset(&self) -> &Dataset {
105        self.source.dataset()
106    }
107
108    pub fn decode_execution_options(&self) -> DecodeExecutionOptions {
109        self.decode_runtime.options()
110    }
111
112    pub fn level_source_kind(
113        &self,
114        scene: impl Into<SceneId>,
115        series: impl Into<SeriesId>,
116        level: impl Into<LevelIdx>,
117    ) -> Result<LevelSourceKind, WsiError> {
118        self.source
119            .level_source_kind(scene.into(), series.into(), level.into())
120    }
121
122    pub fn tile_codec_kind(&self, req: &TileRequest) -> TileCodecKind {
123        self.source.tile_codec_kind(req)
124    }
125
126    pub fn cached_tile_present(&self, req: &TileRequest) -> bool {
127        let key = CacheKey {
128            dataset_id: self.dataset().id,
129            scene: req.scene.get() as u32,
130            series: req.series.get() as u32,
131            level: req.level.get(),
132            z: req.plane.get().z,
133            c: req.plane.get().c,
134            t: req.plane.get().t,
135            tile_col: req.col,
136            tile_row: req.row,
137        };
138        self.cache.get(&key).is_some()
139    }
140
141    pub fn source(&self) -> &dyn SlideReader {
142        self.source.as_ref()
143    }
144
145    pub fn read_tile(
146        &self,
147        req: &TileRequest,
148        output: TileOutputPreference,
149    ) -> Result<TilePixels, WsiError> {
150        let device_decode_attempted = matches!(
151            output,
152            TileOutputPreference::PreferDevice { .. } | TileOutputPreference::RequireDevice { .. }
153        );
154        let span = tracing::debug_span!(
155            "wsi_read_tile",
156            device_decode_attempted,
157            fallback_to_cpu = tracing::field::Empty,
158            fallback_reason = tracing::field::Empty,
159            device_decoded_host_resident = tracing::field::Empty,
160        );
161        let _guard = span.enter();
162        let result = self.source.read_tile(req, output);
163        let mut fallback_to_cpu = false;
164        let mut fallback_reason = "none";
165        let device_decoded_host_resident = false;
166        match &result {
167            Ok(TilePixels::Cpu(_)) if device_decode_attempted => {
168                fallback_to_cpu = true;
169                fallback_reason = "j2k_auto_chose_cpu";
170                span.record("fallback_to_cpu", true);
171                span.record("fallback_reason", fallback_reason);
172                span.record("device_decoded_host_resident", false);
173            }
174            Ok(TilePixels::Cpu(_)) => {
175                span.record("fallback_to_cpu", false);
176                span.record("fallback_reason", "none");
177                span.record("device_decoded_host_resident", false);
178            }
179            Ok(TilePixels::Device(_)) => {
180                span.record("fallback_to_cpu", false);
181                span.record("fallback_reason", "none");
182                span.record("device_decoded_host_resident", false);
183            }
184            Err(WsiError::Unsupported { .. }) if device_decode_attempted => {
185                fallback_to_cpu = true;
186                fallback_reason = "no_device_backend_for_codec";
187                span.record("fallback_to_cpu", true);
188                span.record("fallback_reason", fallback_reason);
189                span.record("device_decoded_host_resident", false);
190            }
191            Err(_) => {
192                span.record("fallback_to_cpu", false);
193                span.record("fallback_reason", "none");
194                span.record("device_decoded_host_resident", false);
195            }
196        }
197        tracing::debug!(
198            device_decode_attempted,
199            fallback_to_cpu,
200            fallback_reason,
201            device_decoded_host_resident,
202            "wsi tile output preference resolved"
203        );
204        result
205    }
206
207    pub fn read_tiles(
208        &self,
209        reqs: &[TileRequest],
210        output: TileOutputPreference,
211    ) -> Result<Vec<TilePixels>, WsiError> {
212        self.source.read_tiles(reqs, output)
213    }
214
215    pub fn read_raw_compressed_tile(
216        &self,
217        req: &TileRequest,
218    ) -> Result<RawCompressedTile, WsiError> {
219        self.source.read_raw_compressed_tile(req)
220    }
221
222    pub fn read_raw_compressed_display_tile(
223        &self,
224        req: &TileViewRequest,
225    ) -> Result<RawCompressedTile, WsiError> {
226        self.source.read_raw_compressed_display_tile(req)
227    }
228
229    /// Read a pixel region, compositing from cached or freshly-decoded tiles.
230    ///
231    /// Validates all indices (scene, series, level, plane axes) before reading.
232    /// Output buffer metadata (color_space, channels, sample_type, layout) is
233    /// inherited from the first decoded tile -- no hardcoded assumptions.
234    ///
235    /// Only `CpuTileLayout::Interleaved` is supported for compositing. Planar
236    /// tiles return `WsiError::DisplayConversion`.
237    pub fn read_region(&self, req: &RegionRequest) -> Result<CpuTile, WsiError> {
238        check_region_pixel_limit(req.size_px.0, req.size_px.1, self.max_region_pixels)?;
239        let mut ctx = SlideReadContext::new(
240            Some(self.cache.as_ref()),
241            TileOutputPreference::cpu(),
242            self.max_region_pixels,
243        );
244        if let Some(result) = self.source.read_region_fastpath(&mut ctx, req) {
245            return result;
246        }
247        composite_region_from_source(
248            self.source.as_ref(),
249            Some(self.cache.as_ref()),
250            req,
251            self.max_region_pixels,
252        )
253    }
254
255    pub fn read_display_tile(&self, req: &TileViewRequest) -> Result<CpuTile, WsiError> {
256        // For Regular tile layouts, route through the generic composition path
257        // with cache so intermediate tile reads are reused. For WholeLevel and
258        // Irregular layouts, delegate to the source's override which may have
259        // format-specific fast paths (e.g. NDPI MCU-level JPEG access).
260        let is_regular = self
261            .source
262            .dataset()
263            .scenes
264            .get(req.scene.get())
265            .and_then(|s| s.series.get(req.series.get()))
266            .and_then(|s| s.levels.get(req.level.get() as usize))
267            .is_some_and(|level| matches!(level.tile_layout, TileLayout::Regular { .. }));
268        if is_regular {
269            let display_cache = self
270                .source
271                .use_display_tile_cache(req)
272                .then_some(self.display_cache.as_ref());
273            read_display_tile_from_source(
274                self.source.as_ref(),
275                display_cache,
276                req,
277                TileOutputPreference::cpu(),
278            )
279        } else {
280            self.source.read_display_tile(req)
281        }
282    }
283
284    pub fn read_display_tile_with_output(
285        &self,
286        req: &TileViewRequest,
287        output: TileOutputPreference,
288    ) -> Result<CpuTile, WsiError> {
289        let is_regular = self
290            .source
291            .dataset()
292            .scenes
293            .get(req.scene.get())
294            .and_then(|s| s.series.get(req.series.get()))
295            .and_then(|s| s.levels.get(req.level.get() as usize))
296            .is_some_and(|level| matches!(level.tile_layout, TileLayout::Regular { .. }));
297        if is_regular {
298            let display_cache = self
299                .source
300                .use_display_tile_cache(req)
301                .then_some(self.display_cache.as_ref());
302            read_display_tile_from_source(self.source.as_ref(), display_cache, req, output)
303        } else if matches!(output, TileOutputPreference::RequireDevice { .. }) {
304            Err(WsiError::Unsupported {
305                reason: "format-specific display tile fast paths return CPU pixels in Phase 2"
306                    .into(),
307            })
308        } else {
309            self.source.read_display_tile(req)
310        }
311    }
312
313    /// Convenience: read a region and convert to RgbaImage.
314    /// Only works for Uint8 data (brightfield). For Uint16/Float32,
315    /// use read_region() + to_rgba_windowed() with an explicit DisplayWindow.
316    pub fn read_region_rgba(&self, req: &RegionRequest) -> Result<image::RgbaImage, WsiError> {
317        self.read_region(req)?.to_rgba()
318    }
319
320    /// Read a region and convert to RgbaImage with explicit windowing.
321    /// For Uint16/Float32 data (fluorescence, computed images).
322    pub fn read_region_rgba_windowed(
323        &self,
324        req: &RegionRequest,
325        window: &DisplayWindow,
326    ) -> Result<image::RgbaImage, WsiError> {
327        self.read_region(req)?.to_rgba_windowed(window)
328    }
329
330    /// Read an associated image (label, macro, thumbnail).
331    /// Direct delegation to the underlying SlideReader. No caching.
332    pub fn read_associated(&self, name: &str) -> Result<CpuTile, WsiError> {
333        self.source.read_associated(name)
334    }
335}