import multiprocessing
import warnings
from abc import ABC, abstractmethod
from contextlib import nullcontext, suppress
from pathlib import Path
from typing import Dict, Iterable, Iterator, Optional, Tuple, Union
import numpy as np
import xarray as xr
import zarr
from wsic.codecs import register_codecs
from wsic.enums import Codec, ColorSpace
from wsic.magic import summon_file_types
from wsic.metadata import ngff
from wsic.typedefs import PathLike
from wsic.utils import (
TimeoutWarning,
block_downsample_shape,
main_process,
mean_pool,
mosaic_shape,
ppu2mpp,
resize_array,
scale_to_fit,
tile_slices,
)
[docs]class Reader(ABC):
"""Base class for readers."""
def __init__(self, path: PathLike):
"""Initialize reader.
Args:
path (PathLike):
Path to file.
"""
self.path = path
@abstractmethod
def __getitem__(self, index: Tuple[Union[int, slice], ...]) -> np.ndarray:
"""Get pixel data at index."""
raise NotImplementedError # pragma: no cover
[docs] @classmethod
def from_file(cls, path: Path) -> "Reader":
"""Return reader for file.
Args:
path (Path): Path to file.
Returns:
Reader: Reader for file.
"""
path = Path(path)
file_types = summon_file_types(path)
if ("jp2",) in file_types:
return JP2Reader(path)
with suppress(ImportError):
import openslide
with suppress(openslide.OpenSlideError):
return OpenSlideReader(path)
if ("tiff",) in file_types:
return TIFFReader(path)
if ("dicom",) in file_types or ("dcm",) in file_types:
return DICOMWSIReader(path)
if ("zarr",) in file_types:
return ZarrReader(path)
raise ValueError(f"Unsupported file type: {path}")
[docs] def thumbnail(self, shape: Tuple[int, ...], approx_ok: bool = False) -> np.ndarray:
"""Generate a thumbnail image of (or near) the requested shape.
Args:
shape (Tuple[int, ...]):
Shape of the thumbnail.
approx_ok (bool):
If True, return a thumbnail that is approximately the
requested shape. It will be equal to or larger than the
requested shape (the next largest shape possible via
an integer block downsampling).
Returns:
np.ndarray: Thumbnail.
"""
# NOTE: Assuming first two are Y and X
yx_shape = self.shape[:2]
yx_tile_shape = (
self.tile_shape[:2] if self.tile_shape else np.minimum(yx_shape, (256, 256))
)
self_mosaic_shape = self.mosaic_shape or mosaic_shape(yx_shape, yx_tile_shape)
(
downsample_shape,
downsample_tile_shape,
downsample,
) = self._find_thumbnail_downsample(shape, yx_shape, yx_tile_shape)
# NOTE: Assuming channels last
channels = self.shape[-1]
thumbnail = np.zeros(downsample_shape + (channels,), dtype=np.uint8)
# Resize tiles to new_downsample_tile_shape and combine
tile_indexes = list(np.ndindex(self_mosaic_shape))
for tile_index in self.pbar(tile_indexes, desc="Generating thumbnail"):
try:
tile = self.get_tile(tile_index)
except (ValueError, NotImplementedError): # e.g. Not tiled
tile = self[tile_slices(tile_index, yx_tile_shape)]
tile = mean_pool(tile.astype(float), downsample).astype(np.uint8)
# Make sure the tile being written will not exceed the
# bounds of the thumbnail
yx_position = tuple(
i * size for i, size in zip(tile_index, downsample_tile_shape)
)
max_y, max_x = (
min(tile_max, thumb_max - position)
for tile_max, thumb_max, position in zip(
tile.shape, thumbnail.shape, yx_position
)
)
sub_tile = tile[:max_y, :max_x]
thumbnail[tile_slices(tile_index, downsample_tile_shape)] = sub_tile
return thumbnail if approx_ok else resize_array(thumbnail, shape, "bicubic")
@staticmethod
def _find_thumbnail_downsample(
thumbnail_shape: Tuple[int, int],
yx_shape: Tuple[int, int],
yx_tile_shape: Tuple[int, int],
) -> Tuple[Tuple[int, int], Tuple[int, int], int]:
"""Find the downsample and tile shape for a thumbnail.
Args:
thumbnail_shape (Tuple[int, int]):
Shape of the thumbnail to be generated.
yx_shape (Tuple[int, int]):
Shape of the image in Y and X.
yx_tile_shape (Tuple[int, int]):
Shape of the tiles in Y and X which will be used to
generate the thumbnail.
Returns:
Tuple[Tuple[int, int], Tuple[int, int], int]:
Shape of the downsampled image, shape of the downsampled
tiles, and the downsample factor.
"""
downsample_shape = yx_shape
downsample_tile_shape = yx_tile_shape
downsample = 0
while True:
new_downsample = downsample + 1
next_downsample_shape, new_downsample_tile_shape = block_downsample_shape(
yx_shape, new_downsample, yx_tile_shape
)
if all(x <= max(0, y) for x, y in zip(downsample_shape, thumbnail_shape)):
break
downsample_shape = next_downsample_shape
downsample_tile_shape = new_downsample_tile_shape
downsample = new_downsample
return downsample_shape, downsample_tile_shape, downsample
[docs] def get_tile(self, index: Tuple[int, int], decode: bool = True) -> np.ndarray:
"""Get tile at index.
Args:
index (Tuple[int, int]):
The index of the tile to get.
decode (bool, optional):
Whether to decode the tile. Defaults to True.
Returns:
np.ndarray:
The tile at index.
"""
# Naive base implementation using __getitem__
if not decode:
raise NotImplementedError(
"Fetching tiles without decoding is not supported."
)
if not hasattr(self, "tile_shape"):
raise ValueError(
"Cannot get tile from a non-tiled reader"
" (must have attr 'tile_shape')."
)
slices = tile_slices(index, self.tile_shape)
return self[slices]
[docs] @staticmethod
def pbar(iterable: Iterable, *args, **kwargs) -> Iterator:
"""Return an iterator that displays a progress bar.
Uses tqdm if installed, otherwise falls back to a simple iterator.
Args:
iterable (Iterable):
Iterable to iterate over.
args (tuple):
Positional arguments to pass to tqdm.
kwargs (dict):
Keyword arguments to pass to tqdm.
Returns:
Iterator: Iterator that displays a progress bar.
"""
try:
from tqdm.auto import tqdm
except ImportError:
def tqdm(x, *args, **kwargs):
return x
return tqdm(iterable, *args, **kwargs)
@property
def original_shape(self) -> Tuple[int, ...]:
"""Return the original shape of the image."""
return self.shape
[docs]class JP2Reader(Reader):
"""Reader for JP2 files using glymur.
Args:
path (Path): Path to file.
"""
def __init__(self, path: Path) -> None:
super().__init__(path)
import glymur
# Enable multithreading
if glymur.options.version.openjpeg_version_tuple >= (2, 2, 0):
glymur.set_option("lib.num_threads", multiprocessing.cpu_count())
self.jp2 = glymur.Jp2k(str(path))
self.shape = self.jp2.shape
self.dtype = np.uint8
self.axes = "YXS"
self.microns_per_pixel = self._get_mpp()
self.tile_shape = self._get_tile_shape()
def _get_mpp(self) -> Optional[Tuple[float, float]]:
"""Get the microns per pixel for the image.
Returns:
Optional[Tuple[float, float]]:
The resolution of the image in microns per pixel.
If the resolution is not available, this will be None.
"""
import glymur
boxes = {type(box): box for box in self.jp2.box}
if not boxes:
warnings.warn(
"Cannot get MPP. No boxes found, invalid JP2 file.", stacklevel=2
)
return None
header_box = boxes[glymur.jp2box.JP2HeaderBox]
header_sub_boxes = {type(box): box for box in header_box.box}
resolution_box = header_sub_boxes.get(glymur.jp2box.ResolutionBox)
if resolution_box is None:
return None
resolution_sub_boxes = {type(box): box for box in resolution_box.box}
capture_resolution_box = resolution_sub_boxes.get(
glymur.jp2box.CaptureResolutionBox
)
if capture_resolution_box is None:
return None
# Read the resolution capture box in grid points (pixels) / meter
pixels_per_meter_y = capture_resolution_box.vertical_resolution
pixels_per_meter_x = capture_resolution_box.horizontal_resolution
return ppu2mpp(pixels_per_meter_x, "m"), ppu2mpp(pixels_per_meter_y, "m")
def _get_tile_shape(self) -> Tuple[int, int]:
"""Get the tile shape as a (height, width) tuple.
Returns:
Tuple[int, int]:
The tile shape as (height, width).
"""
import glymur
boxes = {type(box): box for box in self.jp2.box}
ccb = boxes.get(glymur.jp2box.ContiguousCodestreamBox)
if ccb is None:
raise ValueError("No codestream box found.")
segments = {type(segment): segment for segment in ccb.codestream.segment}
siz = segments.get(glymur.codestream.SIZsegment)
if siz is None:
raise ValueError("No SIZ segment found.")
return (siz.ytsiz, siz.xtsiz)
def __getitem__(self, index: tuple) -> np.ndarray:
"""Get pixel data at index."""
return self.jp2[index]
[docs] def thumbnail(self, shape: Tuple[int, ...], approx_ok: bool = False) -> np.ndarray:
scale = scale_to_fit(self.shape[:2], shape)
downsample = 1 / scale
out_shape = tuple(int(x * scale) for x in self.shape[:2])
# Glymur requires a power of two stride
pow_2_downsample = 2 ** np.floor(np.log2(downsample))
# Get the power of two downsample
thumbnail = self.jp2[::pow_2_downsample, ::pow_2_downsample]
# Resize the thumbnail if required
if approx_ok:
return thumbnail
return resize_array(thumbnail, out_shape)
[docs]class TIFFReader(Reader):
"""Reader for TIFF files using tifffile."""
def __init__(self, path: Path) -> None:
"""Initialize reader.
Args:
path (Path): Path to file.
"""
import tifffile
super().__init__(path)
self._tiff = tifffile.TiffFile(str(path))
self._tiff_page = self._tiff.pages[0]
self.microns_per_pixel = self._get_mpp()
# Handle reading as an xarray dataset
self._zarr_tiff_store = self._tiff.aszarr()
# Ensure that the zarr store is a group
if zarr.storage.contains_array(self._zarr_tiff_store):
# Create an in memory group
group = zarr.group()
# Add the array to the group under the key "0"
array = zarr.open(self._zarr_tiff_store, mode="r")
group[0] = array
# Copy attrs over to the group member (a zarr bug?)
if "_ARRAY_DIMENSIONS" not in array.attrs:
raise ValueError(
f"No _ARRAY_DIMENSIONS found in {self._zarr_tiff_store}."
)
for key, value in array.attrs.items():
group[0].attrs[key] = value
self._zarr_tiff_store = group.store
# Open the store as an xarray dataset
self._zarr = zarr.open(self._zarr_tiff_store, mode="r")
self._dataset = xr.open_zarr(self._zarr_tiff_store, consolidated=False)
# Copy over the dtype of the dataset (xarray bug?)
for key, array in self._zarr.items():
self._dataset[key] = self._dataset[key].astype(array.dtype)
# Rename S to C
if "S" in self._dataset["0"].dims:
self._dataset["0"] = self._dataset["0"].rename({"S": "C"})
# Normalise axes to TZYXC
self._tzyxc_dataset = self._dataset.copy()
self._tzyxc_dataset["0"] = self._tzyxc_dataset["0"].expand_dims(
dim=[a for a in "TCZYX" if a not in self._tzyxc_dataset["0"].dims],
)
self._tzyxc_dataset["0"] = self._tzyxc_dataset["0"].transpose(
"T", "Z", "Y", "X", "C"
)
# Set default time and depth
self.default_t = 0
self.default_z = 0
# Set standard Reader attributes
self.shape = self._dataset["0"].shape
self.dtype = self._dataset["0"].dtype
self.is_tiled = self._tiff_page.is_tiled
self.tile_shape = None
self.mosaic_shape = None
self.mosaic_byte_offsets = None
self.mosaic_byte_counts = None
# Read tile shape if tiled
if self.is_tiled:
self.tile_shape = (self._tiff_page.tilelength, self._tiff_page.tilewidth)
self.mosaic_shape = mosaic_shape(
array_shape=self._tiff_page.shape, tile_shape=self.tile_shape
)
self.mosaic_byte_offsets = np.array(self._tiff_page.dataoffsets).reshape(
self.mosaic_shape
)
self.mosaic_byte_counts = np.array(self._tiff_page.databytecounts).reshape(
self.mosaic_shape
)
self.jpeg_tables = self._tiff_page.jpegtables
self.color_space: ColorSpace = ColorSpace.from_tiff(self._tiff_page.photometric)
self.codec: Codec = Codec.from_tiff(self._tiff_page.compression)
self.compression_level = None # To be filled in if known later
@property
def original_shape(self) -> Tuple[int, ...]:
return self._tiff_page.shape
def _get_mpp(self) -> Optional[Tuple[float, float]]:
"""Get the microns per pixel for the image.
This checks the resolution and resolution unit TIFF tags.
Returns:
Optional[Tuple[float, float]]:
The resolution of the image in microns per pixel.
If the resolution is not available, this will be None.
"""
if self._tiff.is_svs:
return self._get_mpp_svs()
try:
return self._get_mpp_tiff_res_tag()
except KeyError:
return None
def _get_mpp_tiff_res_tag(self):
"""Get the microns per pixel for the image from the TIFF tags."""
tags = self._tiff_page.tags
y_resolution = tags["YResolution"].value[0] / tags["YResolution"].value[1]
x_resolution = tags["XResolution"].value[0] / tags["XResolution"].value[1]
resolution_units = tags["ResolutionUnit"].value
return ppu2mpp(x_resolution, resolution_units), ppu2mpp(
y_resolution, resolution_units
)
@staticmethod
def _parse_svs_key_values(description: str) -> Dict[str, str]:
"""Parse the key value pairs from the SVS description."""
parts = description.split("\n")
# Header in parts[0]
key_values_str = parts[-1]
# Conver to dict
return {
kv.split("=")[0].strip(): kv.split("=")[1].strip()
for kv in key_values_str.split("|")
}
def _get_mpp_svs(self):
"""Get the microns per pixel for the image from the SVS description."""
if self._tiff_page.description is None:
return None
svs_key_values = self._parse_svs_key_values(self._tiff_page.description)
mpp = svs_key_values.get("MPP")
return (float(mpp), float(mpp)) if mpp else None
[docs] def get_tile(self, index: Tuple[int, int], decode: bool = True) -> np.ndarray:
"""Get tile at index.
Args:
index (Tuple[int, int]):
The index of the tile to get.
decode (bool, optional):
Whether to decode the tile. Defaults to True.
Returns:
np.ndarray:
The tile at index.
"""
if self.tile_shape is None:
raise ValueError("Image is not tiled.")
flat_index = index[0] * self.tile_shape[1] + index[1]
fh = self._tiff.filehandle
_ = fh.seek(self.mosaic_byte_offsets[index])
data = fh.read(self.mosaic_byte_counts[index])
if not decode:
return data
tile, _, shape = self._tiff_page.decode(
data, flat_index, jpegtables=self._tiff_page.jpegtables
)
# tile may be None e.g. with NDPI blank tiles
if tile is None:
tile = np.zeros(shape, dtype=self.dtype)
return tile[0]
def __getitem__(self, index: Tuple[Union[slice, int]]) -> np.ndarray:
"""Get pixel data at index."""
index = index if isinstance(index, tuple) else (index,)
index = (self.default_t, self.default_z) + index
return self._tzyxc_dataset["0"][index].as_numpy().data
[docs] def thumbnail(self, shape: Tuple[int, ...], approx_ok: bool = False) -> np.ndarray:
"""Get a thumbnail of the image.
Uses xarray/dask block map to get the thumbnail.
Args:
shape (Tuple[int, ...]):
The shape of the thumbnail to get.
approx_ok (bool, optional):
Whether to use an approximate thumbnail. Defaults to False.
Returns:
np.ndarray:
The thumbnail of the image.
"""
yxc = self._tzyxc_dataset["0"][self.default_t, self.default_z]
(
_,
_,
downsample,
) = self._find_thumbnail_downsample(
shape,
yxc.shape[:2],
yxc.data.chunksize[:2],
)
thumbnail = ( # noqa: ECE001
yxc.coarsen(X=downsample, boundary="trim")
.mean()
.coarsen(Y=downsample, boundary="trim")
.mean()
.astype("u1")
.as_numpy()
)
return (
thumbnail.data
if approx_ok
else resize_array(thumbnail.data, shape, "bicubic")
)
[docs]class DICOMWSIReader(Reader):
"""Reader for DICOM Whole Slide Images (WSIs) using wsidicom.
DICOM Whole Slide Imaging: https://dicom.nema.org/Dicom/DICOMWSI/
"""
def __init__(self, path: Path) -> None:
"""Initialize reader.
Args:
path (Path):
Path to file.
"""
from pydicom import Dataset
from wsidicom import WsiDicom
super().__init__(path)
# Set up a timeout warning for slow sparse tiled files
sparse_tiled_warning = TimeoutWarning(
"Looks like this is taking a while..."
"if your DICOM file has a 'DimensionOrganizationType' "
"of 'TILED_SPARSE', this may be causing it to be slow.",
timeout=1,
)
context = sparse_tiled_warning if main_process() else nullcontext()
with context:
# Open the file, this will take a while if the file is sparse tiled
self.slide = WsiDicom.open(self.path)
channels = len(self.slide.read_tile(0, (0, 0)).getbands())
self.shape = (self.slide.size.height, self.slide.size.width, channels)
self.dtype = np.uint8
self.microns_per_pixel = (
self.slide.levels.base_level.mpp.height,
self.slide.levels.base_level.mpp.width,
)
dataset: Dataset = self.slide.levels.base_level.datasets[0]
self.tile_shape = (dataset.Rows, dataset.Columns)
self.mosaic_shape = mosaic_shape(
self.shape,
self.tile_shape,
)
# Sanity check
if np.prod(self.mosaic_shape[:2]) != int(dataset.NumberOfFrames):
raise ValueError(
f"Number of frames in DICOM dataset {dataset.NumberOfFrames}"
f" does not match mosaic shape {self.mosaic_shape}."
)
self.codec = Codec.NONE
if hasattr(dataset, "LossyImageCompressionMethod"):
self.codec: Codec = Codec.from_string(dataset.LossyImageCompressionMethod)
self.compression_level = (
None # Set if known: dataset.get(LossyImageCompressionRatio)?
)
self.color_space = ColorSpace.from_dicom(dataset.photometric_interpretation)
self.jpeg_tables = None
def _make_full_tiled_copy(self) -> None:
"""Make a copy of the file with TILED_FULL DimensionOrganizationType."""
print("Converting to TILED_FULL...")
from wsidicom import WsiDicom
if self.path.is_dir():
new_path = self.path.with_suffix(".tiled_full")
new_path.mkdir(parents=True, exist_ok=False)
else:
new_path = self.path.with_suffix(".tiled_full.dcm")
self.slide.save(new_path)
self.path = new_path
self.slide = WsiDicom.open(self.path)
print("Done.")
[docs] def get_tile(self, index: Tuple[int, int], decode: bool = True) -> np.ndarray:
"""Get tile at index.
Args:
index (Tuple[int, int]):
The index of the tile to get.
decode (bool, optional):
Whether to decode the tile. Defaults to True.
Returns:
np.ndarray:
The tile at index.
"""
if decode:
return np.array(self.slide.read_tile(level=0, tile=index[::-1], z=0))
return self.slide.read_encoded_tile(level=0, tile=index[::-1], z=0)
def __getitem__(self, index: Tuple[Union[slice, int]]) -> np.ndarray:
"""Get pixel data at index."""
if index is ...:
return np.array(self.slide.levels.base_level.get_default_full())
xs = index[1]
ys = index[0]
start_x = xs.start or 0
start_y = ys.start or 0
end_x = xs.stop or self.shape[1]
end_y = ys.stop or self.shape[0]
# Prevent reading past the edges of the image
end_x = min(end_x, self.shape[1])
end_y = min(end_y, self.shape[0])
# Read the image
img = self.slide.read_region(
location=(start_x, start_y),
level=0,
size=(end_x - start_x, end_y - start_y),
z=0,
)
return np.array(img.convert("RGB"))
[docs]class OpenSlideReader(Reader):
"""Reader for OpenSlide files using openslide-python."""
def __init__(self, path: Path) -> None:
import openslide
super().__init__(path)
self.os_slide = openslide.OpenSlide(str(path))
self.shape = self.os_slide.level_dimensions[0][::-1] + (3,)
self.dtype = np.uint8
self.axes = "YXS"
self.tile_shape = None # No easy way to get tile shape currently
self.microns_per_pixel = self._get_mpp()
self.mosaic_shape = None
[docs] def get_tile(self, index: Tuple[int, int], decode: bool = True) -> np.ndarray:
"""Get tile at index.
Args:
index (Tuple[int, int]):
The index of the tile to get.
decode (bool, optional):
Whether to decode the tile. Defaults to True.
Returns:
np.ndarray:
The tile at index.
"""
raise NotImplementedError("OpenSlideReader does not support reading tiles.")
def _get_mpp(self) -> Optional[Tuple[float, float]]:
"""Get the microns per pixel for the image.
Returns:
Optional[Tuple[float, float]]:
The microns per pixel as (x, y) tuple.
"""
try:
return (
float(self.os_slide.properties["openslide.mpp-x"]),
float(self.os_slide.properties["openslide.mpp-y"]),
)
except KeyError:
warnings.warn("OpenSlide could not find MPP.", stacklevel=2)
# Fall back to TIFF resolution tags
try:
resolution = (
float(self.os_slide.properties["tiff.XResolution"]),
float(self.os_slide.properties["tiff.YResolution"]),
)
units = self.os_slide.properties["tiff.ResolutionUnit"]
self._check_sensible_resolution(resolution, units)
return tuple(ppu2mpp(x, units) for x in resolution)
except KeyError:
warnings.warn("No resolution metadata found.", stacklevel=2)
return None
@staticmethod
def _check_sensible_resolution(
tiff_resolution: Tuple[float, float], tiff_units: int
) -> None:
"""Check whether the resolution is sensible.
It is common for TIFF files to have incorrect resolution tags.
This method checks whether the resolution is sensible and warns
if it is not.
Args:
tiff_resolution (Tuple[float, float]):
The TIFF resolution as an (x, y) tuple.
tiff_units (int):
The TIFF units of the resolution. A value of 2 indicates
inches and a value of 3 indicates centimeters.
"""
if tiff_units == 2 and 72 in tiff_resolution:
warnings.warn(
"TIFF resolution tags found."
" However, they have a common default value of 72 pixels per inch."
" This may from a misconfigured software library or tool"
" which is expecting to handle print documents.",
stacklevel=2,
)
if 0 in tiff_resolution:
warnings.warn(
"TIFF resolution tags found."
" However, one or more of the values is zero.",
stacklevel=2,
)
def __getitem__(self, index: Tuple[Union[int, slice], ...]) -> np.ndarray:
"""Get pixel data at index."""
if index is ...:
return np.array(self.os_slide.get_thumbnail(self.os_slide.dimensions))
xs = index[1]
ys = index[0]
start_x = xs.start or 0
start_y = ys.start or 0
end_x = xs.stop or self.shape[1]
end_y = ys.stop or self.shape[0]
# Prevent reading past the edges of the image
end_x = min(end_x, self.shape[1])
end_y = min(end_y, self.shape[0])
# Read the image
img = self.os_slide.read_region(
location=(start_x, start_y),
level=0,
size=(end_x - start_x, end_y - start_y),
)
return np.array(img.convert("RGB"))
[docs]class ZarrReader(Reader):
"""Reader for zarr files."""
def __init__(
self, path: Union[PathLike, zarr.storage.StoreLike], axes: Optional[str] = None
) -> None:
super().__init__(path)
register_codecs()
self.zarr = zarr.open(path, mode="r")
# Currently mpp not stored in zarr, could use xarray metadata
# for this or custom wsic metadata
self.microns_per_pixel = None
# If it is a zarr array, put it in a group
if isinstance(self.zarr, zarr.Array):
group = zarr.group()
group[0] = self.zarr
self.zarr = group
self.shape = self.zarr[0].shape
if len(self.shape) not in (2, 3, 4, 5):
raise ValueError(
"Only Zarrs with between 2 (e.g. YX) and 5 (e.g. TCZYX) "
"dimensions are supported."
)
self.dtype = self.zarr[0].dtype
# Assume the axes, this will be used if not given or specified in metadata
assumed_axes_mapping = {
2: "YX",
3: "YXC",
4: "CZYX",
5: "TCZYX",
}
self.axes = assumed_axes_mapping[len(self.shape)]
# Use the NGFF metadata NGFF if present
self.is_ngff = "omero" in self.zarr.attrs
self.zattrs = None
if self.is_ngff:
self.zattrs = self._load_zattrs()
self.axes = "".join( # noqa: ECE001
axis.name for axis in self.zattrs.multiscales[0].axes
).upper()
self.microns_per_pixel = self._get_mpp_ngff()
# Use the given axes if not None
self.axes = axes or self.axes
# Tile shape and mosaic attrs
self.tile_shape = self.zarr[0].chunks[:2]
self.mosaic_shape = mosaic_shape(self.shape, self.tile_shape)
def __getitem__(self, index: Tuple[Union[int, slice], ...]) -> np.ndarray:
return self.zarr[0][index]
def _load_zattrs(self) -> ngff.Zattrs:
"""Load the zarr attrs dictionary into dataclasses."""
zattrs = self.zarr.attrs
return ngff.Zattrs(
_creator=ngff.Creator(**zattrs.get("_creator")),
multiscales=[
ngff.Multiscale(
axes=[ngff.Axis(**axis) for axis in multiscale.get("axes", [])],
datasets=[
ngff.Dataset(
path=dataset.get("path"),
coordinateTransformations=[
ngff.CoordinateTransformation(
**coordinate_transformation
)
for coordinate_transformation in dataset.get(
"coordinateTransformations", []
)
],
)
for dataset in multiscale.get("datasets", [])
],
version=multiscale.get("version"),
)
for multiscale in self.zarr.attrs.get("multiscales", [])
],
_ARRAY_DIMENSIONS=self.zarr.attrs.get("_ARRAY_DIMENSIONS"),
omero=ngff.Omero(
name=self.zarr.attrs.get("omero", {}).get("name"),
channels=[
ngff.Channel(
coefficient=channel.get("coefficient"),
color=channel.get("color"),
family=channel.get("family"),
inverted=channel.get("inverted"),
label=channel.get("label"),
window=ngff.Window(**channel.get("window", {})),
)
for channel in zattrs.get("omero", {}).get("channels", [])
],
),
)
def _get_mpp_ngff(self) -> Optional[Tuple[float, float]]:
multiscale = self.zattrs.multiscales[0]
axes = multiscale.axes
units = [axis.unit for axis in axes]
space_axes = [axis.type == "space" for axis in axes]
dataset = multiscale.datasets[0]
transforms = dataset.coordinateTransformations
for transform in transforms:
if transform.type == "scale":
mpp = [
ppu2mpp(float(value), units)
for value, units, is_space in zip(
transform.scale, units, space_axes
)
if is_space
]
return tuple(mpp)
return None