Source code for tiatoolbox.wsicore.wsimeta
"""This module defines a dataclass which holds metadata about a WSI.
With this class, metadata is in a normalized consistent format
which is quite useful when working with many different WSI formats.
The raw metadata is also preserved and accessible via a dictionary. The
format of this dictionary may vary between WSI formats.
"""
import warnings
from numbers import Number
from pathlib import Path
from typing import List, Mapping, Optional, Sequence, Tuple, Union
import numpy as np
Resolution = Union[Number, Tuple[Number, Number], np.ndarray]
[docs]class WSIMeta:
"""Whole slide image metadata class.
Args:
slide_dimensions (int, int):
Tuple containing the width and height of the WSI. These
are for the baseline (full resolution) image if the WSI
is a pyramid or multi-resolution.
level_dimensions (list):
A list of dimensions for each level of the pyramid or
for each resolution in the WSI.
objective_power (float, optional):
The power of the objective lens used to create the
image.
level_count: (int, optional):
The number of levels or resolutions in the WSI. If not
given this is assigned len(level_dimensions). Defaults
to None.
level_downsamples (:obj:`list` of :obj:`float`):
List of scale values which describe how many times
smaller the current level is compared with the baseline.
vendor (str, optional):
Scanner vendor/manufacturer description.
mpp (float, float, optional):
Microns per pixel.
file_path (Path, optional):
Path to the corresponding WSI file.
raw (dict, optional):
Dictionary of unprocessed metadata extracted from the
WSI format. For JPEG-2000 images this contains an xml
object under the key "xml".
Attributes:
slide_dimensions (tuple(int)):
Tuple containing the width and height of the WSI. These are
for the baseline (full resolution) image if the WSI is a
pyramid or multi-resolution. Required.
axes (str):
Axes ordering of the image. This is most relevant for
OME-TIFF images where the axes ordering can vary. For most
images this with be "YXS" i.e. the image is store in the
axis order of Y coordinates first, then X coordinates, and
colour channels last.
level_dimensions (list):
A list of dimensions for each level of the pyramid or for
each resolution in the WSI. Defaults to [slide_dimension].
objective_power (float):
The magnification power of the objective lens used to scan
the image. Not always present or accurate. Defaults to None.
level_count: (int):
The number of levels or resolutions in the WSI. If not given
this is assigned len(level_dimensions). Defaults to
len(level_dimensions).
level_downsamples (:obj:`list` of :obj:`float`):
List of scale values which describe how many times smaller
the current level is compared with the baseline. Defaults to
(1,).
vendor (str):
Scanner vendor/manufacturer description.
mpp (float, float, optional):
Microns per pixel. Derived from objective power and sensor
size. Not always present or accurate. Defaults to None.
file_path (Path):
Path to the corresponding WSI file. Defaults to None.
raw (dict):
Dictionary of unprocessed metadata extracted from the WSI
format. For JP2 images this contains an xml object under the
key "xml". Defaults to empty dictionary.
"""
_valid_axes_characters = "YXSTZ"
def __init__(
self,
slide_dimensions: Tuple[int, int],
axes: str,
level_dimensions: Optional[Sequence[Tuple[int, int]]] = None,
objective_power: Optional[float] = None,
level_count: Optional[int] = None,
level_downsamples: Optional[Sequence[float]] = (1,),
vendor: Optional[str] = None,
mpp: Optional[Sequence[float]] = None,
file_path: Optional[Path] = None,
raw: Optional[Mapping[str, str]] = None,
):
self.axes = axes
self.objective_power = float(objective_power) if objective_power else None
self.slide_dimensions = tuple(int(x) for x in slide_dimensions)
self.level_dimensions = (
tuple((int(w), int(h)) for w, h in level_dimensions)
if level_dimensions is not None
else [self.slide_dimensions]
)
self.level_downsamples = (
[float(x) for x in level_downsamples]
if level_downsamples is not None
else None
)
self.level_count = (
int(level_count) if level_count is not None else len(self.level_dimensions)
)
self.vendor = str(vendor)
self.mpp = np.array([float(x) for x in mpp]) if mpp is not None else None
self.file_path = Path(file_path) if file_path is not None else None
self.raw = raw if raw is not None else None
self.validate()
[docs] def validate(self):
"""Validate passed values and cast to Python types.
Metadata values are often given as strings and must be
parsed/cast to the appropriate python type e.g. "3.14" to 3.14
etc.
Returns:
bool:
True is validation passed, False otherwise.
"""
passed = True
# Fatal conditions: Should return False if not True
if len(set(self.axes) - set(self._valid_axes_characters)) > 0:
warnings.warn(
"Axes contains invalid characters. "
f"Valid characters are '{self._valid_axes_characters}'."
)
passed = False
if self.level_count < 1:
warnings.warn("Level count is not a positive integer")
passed = False
if self.level_dimensions is None:
warnings.warn("level_dimensions is None")
passed = False
elif len(self.level_dimensions) != self.level_count:
warnings.warn("Length of level dimensions != level count")
passed = False
if self.level_downsamples is None:
warnings.warn("Level downsamples is None")
passed = False
elif len(self.level_downsamples) != self.level_count:
warnings.warn("Length of level downsamples != level count")
passed = False
# Non-fatal conditions: Raise warning only, do not fail validation
if self.raw is None:
warnings.warn("Raw data is None")
if all(x is None for x in [self.objective_power, self.mpp]):
warnings.warn("Unknown scale (no objective_power or mpp)")
return passed # noqa
[docs] def level_downsample(
self,
level: Union[int, float],
) -> float:
"""Get the downsample factor for a level.
For non-integer values of `level`, the downsample factor is
linearly interpolated between from the downsample factors of the
level below and the level above.
Args:
level (int or float):
Level to get downsample factor for.
Returns:
float:
Downsample factor for the given level.
"""
level_downsamples = self.level_downsamples
if isinstance(level, int) or int(level) == level:
# Return the downsample for the level
return level_downsamples[int(level)]
# Linearly interpolate between levels
floor = int(np.floor(level))
ceil = int(np.ceil(level))
floor_downsample = level_downsamples[floor]
ceil_downsample = level_downsamples[ceil]
return np.interp(level, [floor, ceil], [floor_downsample, ceil_downsample])
[docs] def relative_level_scales(
self, resolution: Resolution, units: str
) -> List[np.ndarray]:
"""Calculate scale of each level in the WSI relative to given resolution.
Find the relative scale of each image pyramid / resolution level
of the WSI relative to the given resolution and units.
Values > 1 indicate that the level has a larger scale than the
target and < 1 indicates that it is smaller.
Args:
resolution (float or tuple(float)):
Scale to calculate relative to units.
units (str):
Units of the scale. Allowed values are: `"mpp"`,
`"power"`, `"level"`, `"baseline"`. Baseline refers to
the largest resolution in the WSI (level 0).
Raises:
ValueError:
Missing MPP metadata.
ValueError:
Missing objective power metadata.
ValueError:
Invalid units.
Returns:
list:
Scale for each level relative to the given scale and
units.
Examples:
>>> from tiatoolbox.wsicore.wsireader import WSIReader
>>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
>>> print(wsi.info.relative_level_scales(0.5, "mpp"))
[array([0.91282519, 0.91012514]), array([1.82565039, 1.82025028]) ...
>>> from tiatoolbox.wsicore.wsireader import WSIReader
>>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
>>> print(wsi.info.relative_level_scales(0.5, "baseline"))
[0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0]
"""
if units not in ("mpp", "power", "level", "baseline"):
raise ValueError("Invalid units")
level_downsamples = self.level_downsamples
def np_pair(x: Union[Number, np.array]) -> np.ndarray:
"""Ensure input x is a numpy array of length 2."""
# If one number is given, the same value is used for x and y
if isinstance(x, Number):
return np.array([x] * 2)
return np.array(x)
if units == "level":
if resolution >= len(level_downsamples):
raise ValueError(
f"Target scale level {resolution} "
f"> number of levels {len(level_downsamples)} in WSI"
)
base_scale, resolution = 1, self.level_downsample(resolution)
resolution = np_pair(resolution)
if units == "mpp":
if self.mpp is None:
raise ValueError("MPP is None. Cannot determine scale in terms of MPP.")
base_scale = self.mpp
if units == "power":
if self.objective_power is None:
raise ValueError(
"Objective power is None. "
"Cannot determine scale in terms of objective power."
)
base_scale, resolution = 1 / self.objective_power, 1 / resolution
if units == "baseline":
base_scale, resolution = 1, 1 / resolution
return [
(base_scale * downsample) / resolution for downsample in level_downsamples
]
[docs] def as_dict(self):
"""Convert WSIMeta to dictionary of Python types.
Returns:
dict:
Whole slide image meta data as dictionary.
"""
if self.mpp is None:
mpp = (self.mpp, self.mpp)
else:
mpp = tuple(self.mpp)
return {
"objective_power": self.objective_power,
"slide_dimensions": self.slide_dimensions,
"level_count": self.level_count,
"level_dimensions": self.level_dimensions,
"level_downsamples": self.level_downsamples,
"vendor": self.vendor,
"mpp": mpp,
"file_path": self.file_path,
"axes": self.axes,
}