use std::fs::File;
use std::io::BufReader;
use std::path::Path;
use tiff::decoder::Decoder;
use tiff::tags::Tag;
use crate::tile::TileDirectory;
use crate::{Dimensions, Level, Metadata, TileSize, error::WsiError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ImageKind {
PyramidLevel,
Thumbnail,
Label,
Macro,
}
#[derive(Debug)]
struct ParsedIfd {
description: String,
tile_size: Option<TileSize>,
kind: ImageKind,
}
pub struct ParsedSlide {
pub metadata: Metadata,
pub tile_directories: Vec<TileDirectory>,
}
#[derive(Debug, Default)]
struct AperioMetadata {
objective_power: Option<f32>,
microns_per_pixel: Option<f64>,
}
fn classify_ifd(description: &str, tile_size: Option<&TileSize>) -> ImageKind {
let description = description.to_ascii_lowercase();
if description.contains("label") {
return ImageKind::Label;
}
if description.contains("macro") {
return ImageKind::Macro;
}
if tile_size.is_some() {
return ImageKind::PyramidLevel;
}
ImageKind::Thumbnail
}
fn parse_ifd(decoder: &mut Decoder<BufReader<File>>) -> Result<ParsedIfd, WsiError> {
let description = decoder.get_tag_ascii_string(Tag::ImageDescription)?;
let tile_size = match (
decoder.get_tag_u32(Tag::TileWidth),
decoder.get_tag_u32(Tag::TileLength),
) {
(Ok(width), Ok(height)) => Some(TileSize::new(width, height)),
_ => None,
};
let kind = classify_ifd(&description, tile_size.as_ref());
Ok(ParsedIfd {
description,
tile_size,
kind,
})
}
fn parse_aperio_metadata(description: &str) -> AperioMetadata {
let mut metadata = AperioMetadata::default();
for field in description.split('|') {
let field = field.trim();
if let Some(value) = field.strip_prefix("AppMag = ") {
metadata.objective_power = value.parse::<f32>().ok();
}
if let Some(value) = field.strip_prefix("MPP = ") {
metadata.microns_per_pixel = value.parse::<f64>().ok();
}
}
metadata
}
fn read_level(
decoder: &mut Decoder<BufReader<File>>,
level_idx: u32,
base_width: u32,
tile_size: TileSize,
) -> Result<Level, WsiError> {
let (width, height) = decoder.dimensions()?;
let dimensions = Dimensions::new(width, height);
let downsample_factor = base_width as f64 / width as f64;
Ok(Level::new(
level_idx,
dimensions,
tile_size,
downsample_factor,
))
}
pub fn get_decoder(path: &Path) -> Result<Decoder<BufReader<File>>, WsiError> {
let file = File::open(path)?;
let reader = BufReader::new(file);
Ok(Decoder::new(reader)?)
}
pub fn read_metadata(path: &Path) -> Result<Metadata, WsiError> {
let mut decoder = get_decoder(path)?;
let base_dimensions = Dimensions::from_tuple(decoder.dimensions()?);
let mut levels = Vec::new();
let mut objective_power = None;
let mut microns_per_pixel = None;
loop {
let parsed_ifd = parse_ifd(&mut decoder)?;
if objective_power.is_none() || microns_per_pixel.is_none() {
let aperio = parse_aperio_metadata(&parsed_ifd.description);
objective_power = objective_power.or(aperio.objective_power);
microns_per_pixel = microns_per_pixel.or(aperio.microns_per_pixel);
}
if parsed_ifd.kind == ImageKind::PyramidLevel {
let tile_size = parsed_ifd
.tile_size
.expect("Pyramid levels must have tile metadata");
levels.push(read_level(
&mut decoder,
levels.len() as u32,
base_dimensions.width,
tile_size,
)?);
}
if !decoder.more_images() {
break;
}
decoder.next_image()?;
}
Ok(Metadata::new(levels, objective_power, microns_per_pixel))
}
pub fn parse_slide(path: &Path) -> Result<ParsedSlide, WsiError> {
let mut decoder = get_decoder(path)?;
let base_dimensions = Dimensions::from_tuple(decoder.dimensions()?);
let mut levels = Vec::new();
let mut tile_directories = Vec::new();
let mut objective_power = None;
let mut microns_per_pixel = None;
loop {
let parsed_ifd = parse_ifd(&mut decoder)?;
if objective_power.is_none() || microns_per_pixel.is_none() {
let aperio = parse_aperio_metadata(&parsed_ifd.description);
objective_power = objective_power.or(aperio.objective_power);
microns_per_pixel = microns_per_pixel.or(aperio.microns_per_pixel);
}
if parsed_ifd.kind == ImageKind::PyramidLevel {
let tile_size = parsed_ifd
.tile_size
.expect("Pyramid levels must have tile metadata");
levels.push(read_level(
&mut decoder,
levels.len() as u32,
base_dimensions.width,
tile_size,
)?);
tile_directories.push(TileDirectory::read_tile_directory(&mut decoder)?);
}
if !decoder.more_images() {
break;
}
decoder.next_image()?;
}
Ok(ParsedSlide {
metadata: Metadata::new(levels, objective_power, microns_per_pixel),
tile_directories,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_ifd_detects_label_regardless_of_case() {
assert_eq!(
classify_ifd(
"Aperio Image Library LABEL 100x100",
Some(&TileSize::new(240, 240))
),
ImageKind::Label
);
}
#[test]
fn classify_ifd_detects_macro() {
assert_eq!(classify_ifd("macro image", None), ImageKind::Macro);
}
#[test]
fn classify_ifd_treats_tiled_untagged_images_as_pyramid_levels() {
assert_eq!(
classify_ifd("", Some(&TileSize::new(240, 240))),
ImageKind::PyramidLevel
);
}
#[test]
fn classify_ifd_treats_untiled_untagged_images_as_thumbnails() {
assert_eq!(classify_ifd("", None), ImageKind::Thumbnail);
}
#[test]
fn parse_aperio_metadata_extracts_known_fields() {
let description = "Aperio Image Library v1.0|AppMag = 20|MPP = 0.4990|Other = ignored";
let metadata = parse_aperio_metadata(description);
assert_eq!(metadata.objective_power, Some(20.0));
assert_eq!(metadata.microns_per_pixel, Some(0.4990));
}
#[test]
fn parse_aperio_metadata_leaves_missing_fields_as_none() {
let metadata = parse_aperio_metadata("Aperio Image Library v1.0|MPP = 0.25");
assert_eq!(metadata.objective_power, None);
assert_eq!(metadata.microns_per_pixel, Some(0.25));
}
#[test]
fn parse_aperio_metadata_ignores_unparseable_values() {
let metadata = parse_aperio_metadata("AppMag = not-a-number|MPP = 0.25");
assert_eq!(metadata.objective_power, None);
assert_eq!(metadata.microns_per_pixel, Some(0.25));
}
}