wsi_streamer/format/
svs.rs

1//! Aperio SVS format reader.
2//!
3//! This module provides support for reading Aperio SVS files, a TIFF-based
4//! format commonly used for whole slide imaging.
5//!
6//! # SVS File Structure
7//!
8//! SVS files are TIFF files containing:
9//! - **Pyramid levels**: Full resolution image and progressively smaller versions
10//! - **Label image**: Small image of the slide label
11//! - **Macro image**: Overview of the entire slide
12//! - **Thumbnail**: Small preview image
13//!
14//! # JPEGTables Handling
15//!
16//! SVS files use "abbreviated JPEG streams" to save space. Each tile's JPEG
17//! data lacks the quantization and Huffman tables needed for decoding. These
18//! tables are stored in the `JPEGTables` TIFF tag and must be merged with
19//! each tile's data before decoding.
20//!
21//! # Metadata
22//!
23//! SVS files store rich metadata in the ImageDescription tag, including:
24//! - Microns per pixel (MPP)
25//! - Objective magnification
26//! - Scanner information
27
28use async_trait::async_trait;
29use bytes::Bytes;
30use std::collections::HashMap;
31
32use crate::error::TiffError;
33use crate::io::RangeReader;
34use crate::slide::SlideReader;
35
36use super::jpeg::prepare_tile_jpeg;
37use super::tiff::{
38    validate_pyramid, PyramidLevel, TiffHeader, TiffPyramid, TiffTag, TileData, ValueReader,
39};
40
41// =============================================================================
42// SVS Metadata
43// =============================================================================
44
45/// Parsed metadata from an SVS file.
46///
47/// SVS files store metadata in the ImageDescription tag as a pipe-separated
48/// string with key=value pairs.
49#[derive(Debug, Clone, Default)]
50pub struct SvsMetadata {
51    /// Microns per pixel (resolution)
52    pub mpp: Option<f64>,
53
54    /// Objective magnification (e.g., 20, 40)
55    pub magnification: Option<f64>,
56
57    /// Scanner vendor name
58    pub vendor: Option<String>,
59
60    /// Full ImageDescription string
61    pub image_description: Option<String>,
62
63    /// Additional key-value pairs from ImageDescription
64    pub properties: HashMap<String, String>,
65}
66
67impl SvsMetadata {
68    /// Parse metadata from an ImageDescription string.
69    ///
70    /// SVS ImageDescription format:
71    /// ```text
72    /// Aperio Image Library vXX.X.X
73    /// width x height (macro dimensions)|AppMag = 20|MPP = 0.5|...
74    /// ```
75    ///
76    /// The first line identifies the format, subsequent parts are pipe-separated
77    /// with key=value pairs.
78    pub fn parse(description: &str) -> Self {
79        let mut metadata = SvsMetadata {
80            image_description: Some(description.to_string()),
81            ..Default::default()
82        };
83
84        // Check for Aperio marker
85        if description.contains("Aperio") {
86            metadata.vendor = Some("Aperio".to_string());
87        }
88
89        // Parse pipe-separated key=value pairs
90        for part in description.split('|') {
91            let part = part.trim();
92
93            // Try to parse as key=value
94            if let Some(eq_pos) = part.find('=') {
95                let key = part[..eq_pos].trim();
96                let value = part[eq_pos + 1..].trim();
97
98                // Store in properties
99                metadata
100                    .properties
101                    .insert(key.to_string(), value.to_string());
102
103                // Parse known keys
104                match key {
105                    "MPP" => {
106                        if let Ok(mpp) = value.parse::<f64>() {
107                            metadata.mpp = Some(mpp);
108                        }
109                    }
110                    "AppMag" => {
111                        if let Ok(mag) = value.parse::<f64>() {
112                            metadata.magnification = Some(mag);
113                        }
114                    }
115                    _ => {}
116                }
117            }
118        }
119
120        metadata
121    }
122}
123
124// =============================================================================
125// SVS Level Data
126// =============================================================================
127
128/// Data for a single pyramid level in an SVS file.
129///
130/// This includes the level metadata plus cached tile location data
131/// and JPEGTables for merging with abbreviated tile streams.
132#[derive(Debug, Clone)]
133pub struct SvsLevelData {
134    /// The pyramid level metadata
135    pub level: PyramidLevel,
136
137    /// Tile offsets and byte counts
138    pub tile_data: TileData,
139}
140
141impl SvsLevelData {
142    /// Get the offset and size for a specific tile.
143    pub fn get_tile_location(&self, tile_x: u32, tile_y: u32) -> Option<(u64, u64)> {
144        let tile_index = self.level.tile_index(tile_x, tile_y)?;
145        self.tile_data.get_tile_location(tile_index)
146    }
147
148    /// Get the JPEGTables for this level.
149    pub fn jpeg_tables(&self) -> Option<&Bytes> {
150        self.tile_data.jpeg_tables.as_ref()
151    }
152}
153
154// =============================================================================
155// SVS Reader
156// =============================================================================
157
158/// Reader for Aperio SVS files.
159///
160/// This provides access to the image pyramid and handles the JPEGTables
161/// merging required for decoding tiles.
162#[derive(Debug)]
163pub struct SvsReader {
164    /// Parsed TIFF pyramid structure
165    pyramid: TiffPyramid,
166
167    /// Level data including tile offsets and JPEGTables
168    levels: Vec<SvsLevelData>,
169
170    /// Parsed SVS metadata
171    metadata: SvsMetadata,
172}
173
174impl SvsReader {
175    /// Open an SVS file and parse its structure.
176    ///
177    /// This reads the TIFF structure, identifies pyramid levels,
178    /// loads tile offset arrays, and caches JPEGTables for each level.
179    pub async fn open<R: RangeReader>(reader: &R) -> Result<Self, TiffError> {
180        // Parse the TIFF pyramid structure
181        let pyramid = TiffPyramid::parse(reader).await?;
182
183        // Validate the pyramid meets our requirements
184        let validation = validate_pyramid(&pyramid);
185        if !validation.is_valid {
186            return Err(validation.into_result().unwrap_err());
187        }
188
189        // Load tile data for each pyramid level
190        let mut levels = Vec::with_capacity(pyramid.levels.len());
191        for level in &pyramid.levels {
192            let tile_data = TileData::load(reader, level, &pyramid.header).await?;
193            levels.push(SvsLevelData {
194                level: level.clone(),
195                tile_data,
196            });
197        }
198
199        // Parse metadata from first IFD's ImageDescription
200        let metadata = Self::parse_metadata(reader, &pyramid).await?;
201
202        Ok(SvsReader {
203            pyramid,
204            levels,
205            metadata,
206        })
207    }
208
209    /// Parse SVS metadata from the first pyramid level's ImageDescription.
210    async fn parse_metadata<R: RangeReader>(
211        reader: &R,
212        pyramid: &TiffPyramid,
213    ) -> Result<SvsMetadata, TiffError> {
214        // Get the first pyramid level's IFD
215        let first_level = match pyramid.levels.first() {
216            Some(level) => level,
217            None => return Ok(SvsMetadata::default()),
218        };
219
220        // Check for ImageDescription tag
221        let entry = match first_level.ifd.get_entry_by_tag(TiffTag::ImageDescription) {
222            Some(e) => e,
223            None => return Ok(SvsMetadata::default()),
224        };
225
226        // Read the ImageDescription
227        let value_reader = ValueReader::new(reader, &pyramid.header);
228        let description = value_reader.read_string(entry).await?;
229
230        Ok(SvsMetadata::parse(&description))
231    }
232
233    /// Get the TIFF header.
234    pub fn header(&self) -> &TiffHeader {
235        &self.pyramid.header
236    }
237
238    /// Get the parsed SVS metadata.
239    pub fn metadata(&self) -> &SvsMetadata {
240        &self.metadata
241    }
242
243    /// Get the number of pyramid levels.
244    pub fn level_count(&self) -> usize {
245        self.levels.len()
246    }
247
248    /// Get data for a specific pyramid level.
249    pub fn get_level(&self, level: usize) -> Option<&SvsLevelData> {
250        self.levels.get(level)
251    }
252
253    /// Get dimensions of the full-resolution (level 0) image.
254    pub fn dimensions(&self) -> Option<(u32, u32)> {
255        self.levels.first().map(|l| (l.level.width, l.level.height))
256    }
257
258    /// Get dimensions of a specific level.
259    pub fn level_dimensions(&self, level: usize) -> Option<(u32, u32)> {
260        self.levels
261            .get(level)
262            .map(|l| (l.level.width, l.level.height))
263    }
264
265    /// Get the downsample factor for a level.
266    pub fn level_downsample(&self, level: usize) -> Option<f64> {
267        self.levels.get(level).map(|l| l.level.downsample)
268    }
269
270    /// Get tile size for a level.
271    pub fn tile_size(&self, level: usize) -> Option<(u32, u32)> {
272        self.levels
273            .get(level)
274            .map(|l| (l.level.tile_width, l.level.tile_height))
275    }
276
277    /// Get the number of tiles in X and Y directions for a level.
278    pub fn tile_count(&self, level: usize) -> Option<(u32, u32)> {
279        self.levels
280            .get(level)
281            .map(|l| (l.level.tiles_x, l.level.tiles_y))
282    }
283
284    /// Read raw tile data from the file.
285    ///
286    /// This reads the raw bytes from the file without any processing.
287    /// For SVS files, this is typically an abbreviated JPEG stream.
288    pub async fn read_raw_tile<R: RangeReader>(
289        &self,
290        reader: &R,
291        level: usize,
292        tile_x: u32,
293        tile_y: u32,
294    ) -> Result<Bytes, TiffError> {
295        let level_data = self.levels.get(level).ok_or(TiffError::InvalidTagValue {
296            tag: "level",
297            message: format!("level {} out of range (max {})", level, self.levels.len()),
298        })?;
299
300        let (offset, size) =
301            level_data
302                .get_tile_location(tile_x, tile_y)
303                .ok_or(TiffError::InvalidTagValue {
304                    tag: "tile",
305                    message: format!(
306                        "tile ({}, {}) out of range for level {}",
307                        tile_x, tile_y, level
308                    ),
309                })?;
310
311        let data = reader.read_exact_at(offset, size as usize).await?;
312        Ok(data)
313    }
314
315    /// Read a tile and prepare it for JPEG decoding.
316    ///
317    /// This reads the tile data and merges it with JPEGTables if the tile
318    /// contains an abbreviated JPEG stream (common in SVS files).
319    ///
320    /// # Arguments
321    /// * `reader` - Range reader for the file
322    /// * `level` - Pyramid level index
323    /// * `tile_x` - Tile X coordinate
324    /// * `tile_y` - Tile Y coordinate
325    ///
326    /// # Returns
327    /// Complete JPEG data ready for decoding.
328    pub async fn read_tile<R: RangeReader>(
329        &self,
330        reader: &R,
331        level: usize,
332        tile_x: u32,
333        tile_y: u32,
334    ) -> Result<Bytes, TiffError> {
335        // Read raw tile data
336        let raw_data = self.read_raw_tile(reader, level, tile_x, tile_y).await?;
337
338        // Get JPEGTables for this level
339        let level_data = self.levels.get(level).ok_or(TiffError::InvalidTagValue {
340            tag: "level",
341            message: format!("level {} out of range", level),
342        })?;
343
344        let tables = level_data.jpeg_tables();
345
346        // Prepare the JPEG data (merge tables if needed)
347        let jpeg_data = prepare_tile_jpeg(tables.map(|t| t.as_ref()), &raw_data);
348
349        Ok(jpeg_data)
350    }
351
352    /// Find the best level for a given downsample factor.
353    ///
354    /// Returns the level with the smallest downsample that is >= the requested factor.
355    pub fn best_level_for_downsample(&self, downsample: f64) -> Option<usize> {
356        self.pyramid
357            .best_level_for_downsample(downsample)
358            .map(|l| l.level_index)
359    }
360}
361
362// =============================================================================
363// SlideReader Implementation
364// =============================================================================
365
366#[async_trait]
367impl SlideReader for SvsReader {
368    fn level_count(&self) -> usize {
369        self.levels.len()
370    }
371
372    fn dimensions(&self) -> Option<(u32, u32)> {
373        self.levels.first().map(|l| (l.level.width, l.level.height))
374    }
375
376    fn level_dimensions(&self, level: usize) -> Option<(u32, u32)> {
377        self.levels
378            .get(level)
379            .map(|l| (l.level.width, l.level.height))
380    }
381
382    fn level_downsample(&self, level: usize) -> Option<f64> {
383        self.levels.get(level).map(|l| l.level.downsample)
384    }
385
386    fn tile_size(&self, level: usize) -> Option<(u32, u32)> {
387        self.levels
388            .get(level)
389            .map(|l| (l.level.tile_width, l.level.tile_height))
390    }
391
392    fn tile_count(&self, level: usize) -> Option<(u32, u32)> {
393        self.levels
394            .get(level)
395            .map(|l| (l.level.tiles_x, l.level.tiles_y))
396    }
397
398    fn best_level_for_downsample(&self, downsample: f64) -> Option<usize> {
399        SvsReader::best_level_for_downsample(self, downsample)
400    }
401
402    async fn read_tile<R: RangeReader>(
403        &self,
404        reader: &R,
405        level: usize,
406        tile_x: u32,
407        tile_y: u32,
408    ) -> Result<Bytes, TiffError> {
409        SvsReader::read_tile(self, reader, level, tile_x, tile_y).await
410    }
411}
412
413// =============================================================================
414// Tests
415// =============================================================================
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    // -------------------------------------------------------------------------
422    // SvsMetadata parsing tests
423    // -------------------------------------------------------------------------
424
425    #[test]
426    fn test_parse_metadata_basic() {
427        let description = "Aperio Image Library v12.0.15\n46920x33600 (256x256) JPEG/RGB Q=70|AppMag = 20|MPP = 0.499";
428
429        let metadata = SvsMetadata::parse(description);
430
431        assert_eq!(metadata.vendor, Some("Aperio".to_string()));
432        assert!((metadata.mpp.unwrap() - 0.499).abs() < 0.001);
433        assert!((metadata.magnification.unwrap() - 20.0).abs() < 0.1);
434    }
435
436    #[test]
437    fn test_parse_metadata_with_many_fields() {
438        let description = "Aperio Image Library v12.0.15\n\
439            46920x33600 (256x256) JPEG/RGB Q=70|\
440            AppMag = 40|\
441            StripeWidth = 2040|\
442            ScanScope ID = SS1234|\
443            Filename = test.svs|\
444            MPP = 0.25|\
445            Left = 25.5|\
446            Top = 18.2";
447
448        let metadata = SvsMetadata::parse(description);
449
450        assert_eq!(metadata.vendor, Some("Aperio".to_string()));
451        assert!((metadata.mpp.unwrap() - 0.25).abs() < 0.001);
452        assert!((metadata.magnification.unwrap() - 40.0).abs() < 0.1);
453        assert_eq!(
454            metadata.properties.get("Filename"),
455            Some(&"test.svs".to_string())
456        );
457        assert_eq!(
458            metadata.properties.get("StripeWidth"),
459            Some(&"2040".to_string())
460        );
461    }
462
463    #[test]
464    fn test_parse_metadata_no_mpp() {
465        let description = "Aperio Image Library v12.0.15\n46920x33600|AppMag = 20";
466
467        let metadata = SvsMetadata::parse(description);
468
469        assert_eq!(metadata.vendor, Some("Aperio".to_string()));
470        assert!(metadata.mpp.is_none());
471        assert!((metadata.magnification.unwrap() - 20.0).abs() < 0.1);
472    }
473
474    #[test]
475    fn test_parse_metadata_empty() {
476        let metadata = SvsMetadata::parse("");
477
478        assert!(metadata.vendor.is_none());
479        assert!(metadata.mpp.is_none());
480        assert!(metadata.magnification.is_none());
481    }
482
483    #[test]
484    fn test_parse_metadata_non_aperio() {
485        let description = "Generic TIFF image\nSome other format";
486
487        let metadata = SvsMetadata::parse(description);
488
489        assert!(metadata.vendor.is_none());
490    }
491
492    #[test]
493    fn test_parse_metadata_invalid_mpp() {
494        let description = "Aperio Image Library|MPP = invalid|AppMag = 20";
495
496        let metadata = SvsMetadata::parse(description);
497
498        assert!(metadata.mpp.is_none()); // Invalid value should be None
499        assert!((metadata.magnification.unwrap() - 20.0).abs() < 0.1);
500    }
501
502    #[test]
503    fn test_parse_metadata_whitespace() {
504        let description = "Aperio Image Library | MPP = 0.5 | AppMag = 40 ";
505
506        let metadata = SvsMetadata::parse(description);
507
508        assert!((metadata.mpp.unwrap() - 0.5).abs() < 0.001);
509        assert!((metadata.magnification.unwrap() - 40.0).abs() < 0.1);
510    }
511}