"""This module defines classes which can read image data from WSI formats."""

from __future__ import annotations

import itertools
import json
import logging
import math
import os
import re
from collections import defaultdict
from datetime import datetime, timezone
from numbers import Number
from pathlib import Path
from typing import TYPE_CHECKING

import cv2
import fsspec
import matplotlib.colors as mcolors
import numpy as np
import openslide
import pandas as pd
import SimpleITK as sitk  # noqa: N813
import tifffile
import zarr
from defusedxml import ElementTree
from imagecodecs.numcodecs import Delta, Jpeg, Jpeg2k, Lzw
from numcodecs import register_codec
from numpy.linalg import inv
from packaging.version import Version
from PIL import Image
from tifffile import TiffPages

from tiatoolbox import logger, utils
from tiatoolbox.annotation import AnnotationStore, SQLiteStore
from tiatoolbox.utils import postproc_defs
from tiatoolbox.utils.env_detection import pixman_warning
from tiatoolbox.utils.exceptions import FileNotSupportedError
from tiatoolbox.utils.magic import is_sqlite3
from tiatoolbox.utils.visualization import AnnotationRenderer
from tiatoolbox.wsicore.wsimeta import WSIMeta

if TYPE_CHECKING:  # pragma: no cover
    from collections.abc import Iterable

    import glymur

    from tiatoolbox.type_hints import (
        Bounds,
        IntBounds,
        IntPair,
        NumPair,
        Resolution,
        Units,
    )
    from tiatoolbox.wsicore.metadata.ngff import Multiscales

pixman_warning()

MIN_NGFF_VERSION = Version("0.4")
MAX_NGFF_VERSION = Version("0.4")


def is_dicom(path: Path) -> bool:
    """Check if the input is a DICOM file.

    Args:
        path (Path): Path to the file to check.

    Returns:
        bool: True if the file is a DICOM file.

    """
    path = Path(path)
    is_dcm = path.suffix.lower() == ".dcm"
    is_dcm_dir = path.is_dir() and any(
        p.suffix.lower() == ".dcm" for p in path.iterdir()
    )
    return is_dcm or is_dcm_dir


def is_tiled_tiff(path: Path) -> bool:
    """Check if the input is a tiled TIFF file.

    Args:
        path (Path):
            Path to the file to check.

    Returns:
        bool:
            True if the file is a tiled TIFF file.

    """
    path = Path(path)
    try:
        tif = tifffile.TiffFile(path)
    except tifffile.TiffFileError:
        return False
    return tif.pages[0].is_tiled


def is_zarr(path: Path) -> bool:
    """Check if the input is a Zarr file.

    Args:
        path (Path):
            Path to the file to check.

    Returns:
        bool:
            True if the file is a Zarr file.

    """
    path = Path(path)
    try:
        _ = zarr.open(str(path), mode="r")
    except Exception:  # skipcq: PYL-W0703  # noqa: BLE001
        return False

    return True


def is_ngff(  # noqa: PLR0911
    path: Path,
    min_version: Version = MIN_NGFF_VERSION,
    max_version: Version = MAX_NGFF_VERSION,
) -> bool:
    """Check if the input is an NGFF file.

    This should return True for a zarr groups stored in a directory, zip
    file, or SQLite database.

    Args:
        path (Path):
            Path to the file to check.
        min_version (Tuple[int, ...]):
            Minimum version of the NGFF file to be considered valid.
        max_version (Tuple[int, ...]):
            Maximum version of the NGFF file to be considered valid.

    Returns:
        bool:
            True if the file is an NGFF file.

    """
    path = Path(path)
    store = zarr.SQLiteStore(str(path)) if path.is_file() and is_sqlite3(path) else path
    try:
        zarr_group = zarr.open(store, mode="r")
    except (zarr.errors.FSPathExistNotDir, zarr.errors.PathNotFoundError):
        return False
    if not isinstance(zarr_group, zarr.hierarchy.Group):
        return False
    group_attrs = zarr_group.attrs.asdict()
    try:
        multiscales: Multiscales = group_attrs["multiscales"]
        omero = group_attrs["omero"]
        _ARRAY_DIMENSIONS = group_attrs["_ARRAY_DIMENSIONS"]  # noqa: N806
        if not all(
            [
                isinstance(multiscales, list),
                isinstance(_ARRAY_DIMENSIONS, list),
                isinstance(omero, dict),
                all(isinstance(m, dict) for m in multiscales),
            ],
        ):
            logger.warning(
                "The NGFF file is not valid. "
                "The multiscales, _ARRAY_DIMENSIONS and omero attributes "
                "must be present and of the correct type.",
            )
            return False
    except KeyError:
        return False
    multiscales_versions = {
        Version(scale["version"]) for scale in multiscales if "version" in scale
    }
    omero_version: str | None = omero.get("version")
    if omero_version:
        omero_version: Version = Version(omero_version)
        if omero_version < min_version:
            logger.warning(
                "The minimum supported version of the NGFF file is %s. "
                "But the versions of the multiscales in the file are %s.",
                min_version,
                multiscales_versions,
            )
            return False
        if omero_version > max_version:
            logger.warning(
                "The maximum supported version of the NGFF file is %s. "
                "But the versions of the multiscales in the file are %s.",
                max_version,
                multiscales_versions,
            )
            return True

    if len(multiscales_versions) > 1:
        logger.warning(
            "Found multiple versions for NGFF multiscales: %s",
            multiscales_versions,
        )

    if any(version < min_version for version in multiscales_versions):
        logger.warning(
            "The minimum supported version of the NGFF file is %s. "
            "But the versions of the multiscales in the file are %s.",
            min_version,
            multiscales_versions,
        )
        return False

    if any(version > max_version for version in multiscales_versions):
        logger.warning(
            "The maximum supported version of the NGFF file is %s. "
            "But the versions of the multiscales in the file are %s.",
            max_version,
            multiscales_versions,
        )
        return True

    return is_zarr(path)


def _handle_virtual_wsi(
    last_suffix: str,
    input_path: Path,
    mpp: tuple[Number, Number] | None,
    power: Number | None,
) -> VirtualWSIReader | None:
    """Handle virtual WSI cases.

    Args:
        last_suffix (str):
            Suffix of the file to read.
        input_path (Path):
             Input path to virtual WSI.
        mpp (:obj:`tuple` or :obj:`list` or :obj:`None`, optional):
            The MPP of the WSI. If not provided, the MPP is approximated
            from the objective power.
        power (:obj:`float` or :obj:`None`, optional):
            The objective power of the WSI. If not provided, the power
            is approximated from the MPP.

    Returns:
        VirtualWSIReader | None:
            :class:`VirtualWSIReader` if input_path is valid path to virtual WSI
            otherwise None.

    """

    # Handle homogeneous cases (based on final suffix)
    def np_virtual_wsi(
        input_path: np.ndarray,
        *args: Number | tuple | str | WSIMeta | None,
        **kwargs: dict,
    ) -> VirtualWSIReader:
        """Create a virtual WSI from a numpy array."""
        return VirtualWSIReader(input_path, *args, **kwargs)

    suffix_to_reader = {
        ".npy": np_virtual_wsi,
        ".jp2": JP2WSIReader,
        ".jpeg": VirtualWSIReader,
        ".jpg": VirtualWSIReader,
        ".png": VirtualWSIReader,
        ".tif": VirtualWSIReader,
        ".tiff": VirtualWSIReader,
    }

    if last_suffix in suffix_to_reader:
        return suffix_to_reader[last_suffix](input_path, mpp=mpp, power=power)

    return None


def _handle_tiff_wsi(
    input_path: Path,
    mpp: tuple[Number, Number] | None,
    power: Number | None,
    post_proc: str | callable | None,
) -> TIFFWSIReader | OpenSlideWSIReader | None:
    """Handle TIFF WSI cases.

    Args:
        input_path (Path):
             Input path to virtual WSI.
        mpp (:obj:`tuple` or :obj:`list` or :obj:`None`, optional):
            The MPP of the WSI. If not provided, the MPP is approximated
            from the objective power.
        power (:obj:`float` or :obj:`None`, optional):
            The objective power of the WSI. If not provided, the power
            is approximated from the MPP.
        post_proc (str | callable | None):
            Post-processing function to apply to the image.

    Returns:
        OpenSlideWSIReader | TIFFWSIReader | None:
            :class:`OpenSlideWSIReader` or :class:`TIFFWSIReader` if input_path is
            valid path to tiff WSI otherwise None.

    """
    if openslide.OpenSlide.detect_format(input_path) is not None:
        try:
            return OpenSlideWSIReader(
                input_path, mpp=mpp, power=power, post_proc=post_proc
            )
        except openslide.OpenSlideError:
            pass
    if is_tiled_tiff(input_path):
        return TIFFWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc)

    return None


class WSIReader:
    """Base whole slide image (WSI) reader class.

    This class defines functions for reading pixel data and metadata
    from whole slide image (WSI) files.

    Attributes:
        input_path (Path):
            Input path to WSI file.

    Args:
        input_img (str, :obj:`Path`, :obj:`ndarray` or :obj:`.WSIReader`):
            Input path to WSI.
        mpp (:obj:`tuple` or :obj:`list` or :obj:`None`, optional):
            The MPP of the WSI. If not provided, the MPP is approximated
            from the objective power.
        power (:obj:`float` or :obj:`None`, optional):
            The objective power of the WSI. If not provided, the power
            is approximated from the MPP.
        post_proc (str | callable | None):
            Post-processing function to apply to the image. If None,
            no post-processing is applied. If 'auto', the post-processing
            function is automatically selected based on the reader type.

    """

    @staticmethod
    def open(
        input_img: str | Path | np.ndarray | WSIReader,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        post_proc: str | callable | None = "auto",
        **kwargs: dict,
    ) -> WSIReader:
        """Return an appropriate :class:`.WSIReader` object.

        Args:
            input_img (str, Path, :obj:`numpy.ndarray` or :obj:`.WSIReader`):
                Input to create a WSI object from. Supported types of
                input are: `str` and :obj:`Path` which point to the
                location on the disk where image is stored,
                :class:`numpy.ndarray` in which the input image in the
                form of numpy array (HxWxC) is stored, or :obj:`.WSIReader`
                which is an already created tiatoolbox WSI handler. In
                the latter case, the function directly passes the
                input_imge to the output.
            mpp (tuple):
                (x, y) tuple of the MPP in the units of the input image.
            power (float):
                Objective power of the input image.
            post_proc (str | callable | None):
                Post-processing function to apply to the image. If None,
                no post-processing is applied. If 'auto', the post-processing
                function is automatically selected based on the reader type.
            kwargs (dict):
                Key-word arguments.

        Returns:
            WSIReader:
                An object with base :class:`.WSIReader` as base class.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> wsi = WSIReader.open(input_img="./sample.svs")

        When working with multi-channel images such as immunofluorescence,
        the default behaviour when post_proc is set to "auto" is to convert
        the output to RGB when reading from the slide. If you need the raw
        channel outputs, set post_proc to None:

            >>> wsi = WSIReader.open(input_img="./sample.ome.tiff", post_proc="auto")
            >>> region = wsi.read_rect((0, 0), (100, 100))
            >>> print(region.shape)
            (100, 100, 3)  # RGB output

            >>> wsi = WSIReader.open(input_img="./sample.ome.tiff", post_proc=None)
            >>> region = wsi.read_rect((0, 0), (100, 100))
            >>> print(region.shape)
            (100, 100, 5)  # raw channel outputs

        """
        WSIReader._validate_input(input_img)

        if isinstance(input_img, np.ndarray):
            return VirtualWSIReader(
                input_img, mpp=mpp, power=power, post_proc=post_proc
            )

        if isinstance(input_img, WSIReader):
            return input_img

        # Input is a string or Path, normalise to Path
        input_path = Path(input_img)
        WSIReader.verify_supported_wsi(input_path)

        # Handle special cases first (DICOM, Zarr/NGFF, OME-TIFF)
        special_reader = WSIReader._handle_special_cases(
            input_path, input_img, mpp, power, post_proc, **kwargs
        )
        if special_reader is not None:
            return special_reader

        # Try openslide last
        return OpenSlideWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc)

    @staticmethod
    def _validate_input(input_img: str | Path | np.ndarray) -> None:
        """Validate the input image type.

        Args:
            input_img (str | Path | np.ndarray): The input image, which
            must be a path, string, numpy array, or WSIReader.

        Raises:
            TypeError: If the input is not one of the accepted types.

        """
        if not isinstance(input_img, (WSIReader, np.ndarray, str, Path)):
            msg = "Invalid input: Must be a WSIReader, numpy array, string or Path"
            raise TypeError(msg)

    @staticmethod
    def verify_supported_wsi(input_path: Path) -> None:
        """Verify that an input image is supported.

        Args:
            input_path (:class:`Path`):
                Input path to WSI.

        Raises:
            FileNotSupportedError:
                If the input image is not supported.

        """
        if is_ngff(input_path) or is_dicom(input_path):
            return

        _, _, suffixes = utils.misc.split_path_name_ext(input_path)

        if suffixes and suffixes[-1] not in [
            ".svs",
            ".npy",
            ".ndpi",
            ".mrxs",
            ".tif",
            ".tiff",
            ".jp2",
            ".png",
            ".jpg",
            ".jpeg",
            ".zarr",
            ".db",
            ".qptiff",
            ".json",
        ]:
            msg = f"File {input_path} is not a supported file format."
            raise FileNotSupportedError(
                msg,
            )

    @staticmethod
    def _handle_special_cases(
        input_path: Path,
        input_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        post_proc: str | callable | None = "auto",
        **kwargs: dict,
    ) -> WSIReader | None:
        """Handle special cases for selecting the appropriate WSIReader.

        Args:
            input_path (Path): Path to the input image file.
            input_img (str | Path | np.ndarray): The input image or path.
            mpp (tuple[Number, Number] | None, optional): Microns per pixel resolution.
            power (Number | None, optional): Objective power.
            post_proc (str | callable | None, optional): Post-processing method
            or identifier.
            **kwargs (dict): Additional keyword arguments for specific reader types.

        Returns:
            WSIReader | None: An appropriate WSIReader instance if a match is found,
            otherwise None.

        Raises:
            FileNotSupportedError: If the file format is not supported for NGFF Zarr.

        """
        _, _, suffixes = utils.misc.split_path_name_ext(input_path)
        last_suffix = suffixes[-1]

        reader = (
            WSIReader.try_dicom(input_path, mpp, power, post_proc)
            or WSIReader.try_fsspec(input_img, mpp, power)
            or WSIReader.try_annotation_store(
                input_path, last_suffix, post_proc, kwargs
            )
            or WSIReader.try_ngff(input_path, last_suffix, mpp, power)
            or WSIReader.try_ome_tiff(
                input_path, suffixes, last_suffix, mpp, power, post_proc
            )
            or WSIReader.try_tiff(input_path, last_suffix, mpp, power, post_proc)
            or WSIReader.try_openslide(input_path, last_suffix, mpp, power)
        )

        if reader is None:
            reader = _handle_virtual_wsi(last_suffix, input_path, mpp, power)

        return reader

    @staticmethod
    def try_openslide(
        input_path: Path,
        last_suffix: str,
        mpp: tuple[Number, Number] | None,
        power: Number | None,
    ) -> OpenSlideWSIReader | None:
        """Try to create an OpenSlideWSIReader if the input is a TIFF file."""
        if last_suffix in (".tif", ".tiff"):
            try:
                return OpenSlideWSIReader(input_path, mpp=mpp, power=power)
            except (
                openslide.OpenSlideUnsupportedFormatError,
                openslide.OpenSlideError,
            ):
                return None
        return None

    @staticmethod
    def try_dicom(
        input_path: Path,
        mpp: tuple[Number, Number] | None,
        power: Number | None,
        post_proc: str | callable | None,
    ) -> DICOMWSIReader | None:
        """Try to create a DICOMWSIReader if the input is a DICOM file."""
        if is_dicom(input_path):
            return DICOMWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc)
        return None

    @staticmethod
    def try_fsspec(
        input_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None,
        power: Number | None,
    ) -> FsspecJsonWSIReader | None:
        """Try to create a FsspecJsonWSIReader if the input is a valid Zarr fsspec."""
        if FsspecJsonWSIReader.is_valid_zarr_fsspec(input_img):
            return FsspecJsonWSIReader(input_img, mpp=mpp, power=power)
        return None

    @staticmethod
    def try_annotation_store(
        input_path: Path,
        last_suffix: str,
        post_proc: str | callable | None,
        kwargs: dict,
    ) -> AnnotationStoreReader | None:
        """Try to create an AnnotationStoreReader if the file is a .db."""
        if last_suffix == ".db":
            kwargs["post_proc"] = post_proc
            return AnnotationStoreReader(input_path, **kwargs)
        return None

    @staticmethod
    def try_ngff(
        input_path: Path,
        last_suffix: str,
        mpp: tuple[Number, Number] | None,
        power: Number | None,
    ) -> NGFFWSIReader | None:
        """Try to create an NGFFWSIReader if the file is a valid NGFF Zarr."""
        if last_suffix == ".zarr":
            if not is_ngff(input_path):
                msg = f"File {input_path} does not appear to be a v0.4 NGFF zarr."
                raise FileNotSupportedError(msg)
            return NGFFWSIReader(input_path, mpp=mpp, power=power)
        return None

    @staticmethod
    def try_ome_tiff(
        input_path: Path,
        suffixes: list[str],
        last_suffix: str,
        mpp: tuple[Number, Number] | None,
        power: Number | None,
        post_proc: str | callable | None,
    ) -> TIFFWSIReader | None:
        """Try to create a TIFFWSIReader for OME-TIFF or QPTIFF formats."""
        if (
            suffixes[-2:] in ([".ome", ".tiff"], [".ome", ".tif"])
            or last_suffix == ".qptiff"
        ):
            return TIFFWSIReader(input_path, mpp=mpp, power=power, post_proc=post_proc)
        return None

    @staticmethod
    def try_tiff(
        input_path: Path,
        last_suffix: str,
        mpp: tuple[Number, Number] | None,
        power: Number | None,
        post_proc: str | callable | None,
    ) -> TIFFWSIReader | None:
        """Try to create a TIFFWSIReader.

        Try to create a TIFFWSIReader for standard TIFF formats,
        or fallback to virtual WSI.

        """
        if last_suffix in (".tif", ".tiff"):
            try:
                return TIFFWSIReader(
                    input_path, mpp=mpp, power=power, post_proc=post_proc
                )
            except ValueError as e:
                if "Unsupported TIFF WSI format" in str(e):
                    return _handle_virtual_wsi(last_suffix, input_path, mpp, power)
                raise
        return None

    def __init__(
        self: WSIReader,
        input_img: str | Path | np.ndarray | AnnotationStore,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        post_proc: callable | None = None,
    ) -> None:
        """Initialize :class:`WSIReader`."""
        if isinstance(input_img, (np.ndarray, AnnotationStore)):
            self.input_path = None
        else:
            self.input_path = Path(input_img)
            if not self.input_path.exists():
                msg = f"Input path does not exist: {self.input_path}"
                raise FileNotFoundError(msg)
        self._m_info = None

        # Set a manual mpp value
        if mpp is not None and isinstance(mpp, Number):
            mpp = (mpp, mpp)
        if mpp is not None and (not hasattr(mpp, "__len__") or len(mpp) != 2):  # noqa: PLR2004
            msg = "`mpp` must be a number or iterable of length 2."
            raise TypeError(msg)
        self._manual_mpp = tuple(mpp) if mpp is not None else None

        # Set a manual power value
        if power and not isinstance(power, Number):
            msg = "`power` must be a number."
            raise TypeError(msg)
        self._manual_power = power
        self.post_proc = self.get_post_proc(post_proc)

    @property
    def info(self: WSIReader) -> WSIMeta:
        """WSI metadata property.

        This property is cached and only generated on the first call.

        Returns:
            WSIMeta:
                An object containing normalized slide metadata.

        """
        if self._m_info is not None:
            return self._m_info
        self._m_info = self._info()
        if self._manual_mpp:
            self._m_info.mpp = np.array(self._manual_mpp)
        if self._manual_power:
            self._m_info.objective_power = self._manual_power
        return self._m_info

    @info.setter
    def info(self: WSIReader, meta: WSIMeta) -> None:
        """WSI metadata setter.

        Args:
            meta (WSIMeta): Metadata object.

        """
        self._m_info = meta

    def get_post_proc(self: WSIReader, post_proc: str | callable | None) -> callable:
        """Get the post-processing function.

        Args:
            post_proc (str | callable | None):
                Post-processing function to apply to the image. If auto,
                will use no post_proc unless reader is TIFF or Virtual Reader,
                in which case it will use MultichannelToRGB.

        Returns:
            callable:
                Post-processing function.

        """
        if callable(post_proc):
            return post_proc
        if post_proc is None:
            return None
        if post_proc == "auto":
            # if its TIFFWSIReader or VirtualWSIReader, return fn to
            # allow multichannel, else return None
            if isinstance(self, (TIFFWSIReader, VirtualWSIReader)):
                return postproc_defs.MultichannelToRGB()
            return None
        if isinstance(post_proc, str) and hasattr(postproc_defs, post_proc):
            return getattr(postproc_defs, post_proc)()
        msg = f"Invalid post-processing function: {post_proc}"
        raise ValueError(msg)

    def _info(self: WSIReader) -> WSIMeta:
        """WSI metadata internal getter used to update info property.

        Missing values for MPP and objective power are approximated and
        a warning raised. Objective power is calculated as the mean of
        the :func:utils.transforms.mpp2common_objective_power in x and
        y. MPP (x and y) is approximated using objective power via
        :func:utils.transforms.objective_power2mpp.

        Returns:
            WSIMeta:
                An object containing normalized slide metadata.

        """
        raise NotImplementedError

    def _find_optimal_level_and_downsample(
        self: WSIReader,
        resolution: Resolution,
        units: Units,
        precision: int = 3,
    ) -> tuple[int, np.ndarray]:
        """Find the optimal level to read at for a desired resolution and units.

        The optimal level is the most downscaled level of the image
        pyramid (or multi-resolution layer) which is larger than the
        desired target resolution. The returned scale is the downsample
        factor required, post read, to achieve the desired resolution.

        Args:
            resolution (Resolution):
                Resolution to find optimal read parameters for
            units (Units):
                Units of the scale.
            precision (int or optional):
                Decimal places to use when finding optimal scale. This
                can be adjusted to avoid errors when an unnecessary
                precision is used. E.g. 1.1e-10 > 1 is insignificant in
                most cases. Defaults to 3.

        Returns:
            tuple:
                Optimal read level and scale factor between the optimal
                level and the target scale (usually <= 1):
                - :py:obj:`int` - Optimal read level.
                - :class:`numpy.ndarray` - Scale factor in X and Y.

        """
        level_scales = self.info.relative_level_scales(resolution, units)
        level_resolution_sufficient = [
            all(np.round(x, decimals=precision) <= 1) for x in level_scales
        ]
        # Check if level 0 is lower resolution than required (scale > 1)
        if not any(level_resolution_sufficient):
            level = 0
        else:
            # Find the first level with relative scale >= 1.
            # Note: np.argmax finds the index of the first True element.
            # Here it is used on a reversed list to find the first
            # element <=1, which is the same element as the last <=1
            # element when counting forward in the regular list.
            reverse_index = np.argmax(level_resolution_sufficient[::-1])
            # Convert the index from the reversed list to the regular index (level)
            level = (len(level_scales) - 1) - reverse_index
        scale = level_scales[level]

        # Check for requested resolution > than baseline resolution
        if any(np.array(scale) > 1):
            logger.warning(
                "Read: Scale > 1."
                "This means that the desired resolution is higher"
                " than the WSI baseline (maximum encoded resolution)."
                " Interpolation of read regions may occur.",
            )
        return level, scale

    def find_read_rect_params(
        self: WSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution,
        units: Units,
        precision: int = 3,
    ) -> tuple[int, IntPair, IntPair, NumPair, IntPair]:
        """Find optimal parameters for reading a rect at a given resolution.

        Reading the image at full baseline resolution and re-sampling to
        the desired resolution would require a large amount of memory
        and be very slow. This function checks the other resolutions
        stored in the WSI's pyramid of resolutions to find the lowest
        resolution (the smallest level) which is higher resolution (a larger
        level) than the requested output resolution.

        In addition to finding this 'optimal level', the scale factor to
        apply after reading in order to obtain the desired resolution is
        found along with conversions of the location and size into level
        and baseline coordinates.

        Args:
            location (IntPair):
                Location in terms of the baseline image (level 0)
                resolution.
            size (IntPair):
                Desired output size in pixels (width, height) tuple.
            resolution (Resolution):
                Desired output resolution.
            units (Units):
                Units of scale, default = "level". Supported units are:
                - microns per pixel ('mpp')
                - objective power ('power')
                - pyramid / resolution level ('level')
                - pixels per baseline pixel ("baseline")
            precision (int, optional):
                Decimal places to use when finding optimal scale. See
                :func:`find_optimal_level_and_downsample` for more.

        Returns:
            tuple:
                Parameters for reading the requested region.

                - :py:obj:`int` - Optimal read level.

                - :py:obj:`tuple` - Read location in level coordinates.
                    - :py:obj:`int` - X location.
                    - :py:obj:`int` - Y location.

                - :py:obj:`tuple` - Region size in level coordinates.
                    - :py:obj:`int` - Width.
                    - :py:obj:`int` - Height.

                - :py:obj:`tuple` - Scaling to apply after level read.
                    - :py:obj:`float` - X scale factor.
                    - :py:obj:`float` - Y scale factor.

                - :py:obj:`tuple` - Region size in baseline coordinates.
                    - :py:obj:`int` - Width.
                    - :py:obj:`int` - Height.

        """
        read_level, post_read_scale_factor = self._find_optimal_level_and_downsample(
            resolution,
            units,
            precision,
        )
        info = self.info
        level_downsample = info.level_downsamples[read_level]
        baseline_read_size = np.round(
            np.array(size) * level_downsample / post_read_scale_factor,
        ).astype(int)
        level_read_size = np.round(np.array(size) / post_read_scale_factor).astype(int)
        level_location = np.round(np.array(location) / level_downsample).astype(int)
        return (
            read_level,
            level_location,
            level_read_size,
            post_read_scale_factor,
            baseline_read_size,
        )

    def _find_read_params_at_resolution(
        self: WSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution,
        units: Units,
    ) -> tuple[int, NumPair, IntPair, IntPair, IntPair, IntPair]:
        """Works similarly to `_find_read_rect_params`.

        Return the information necessary for scaling. While
        `_find_read_rect_params` assumes location to be at baseline.
        This function assumes location to be at requested resolution.

        Args:
            location (IntPair):
                Location in the requested resolution system.
            size (IntPair):
                Desired output size in pixels (width, height) tuple and
                in the requested resolution system.
            resolution (Resolution):
                Desired output resolution.
            units (Units):
                Units of scale, default = "level". Supported units are:
                - microns per pixel ('mpp') - objective power ('power')
                - pyramid / resolution level ('level') - pixels per
                baseline pixel ("baseline")

        Returns:
            tuple:
                Parameters for reading the requested region:
                - :py:obj:`int` - Optimal read level.
                - :py:obj:`tuple` - Scaling to apply after level read to
                  achieve desired output resolution.
                    - :py:obj:`float` - X scale factor.
                    - :py:obj:`float` - Y scale factor.
                - :py:obj:`tuple` - Region size in read level
                  coordinates.
                    - :py:obj:`int` - Width.
                    - :py:obj:`int` - Height.
                - :py:obj:`tuple` - Region location in read level
                  coordinates.
                    - :py:obj:`int` - X location.
                    - :py:obj:`int` - Y location.
                - :py:obj:`tuple` - Region size in level 0 coordinates.
                    - :py:obj:`int` - Width.
                    - :py:obj:`int` - Height.
                - :py:obj:`tuple` - Region location level 0 coordinates.
                    - :py:obj:`int` - X location.
                    - :py:obj:`int` - Y location.

        """
        (
            read_level,
            # read_level to requested resolution (full)
            read_level_to_resolution_scale_factor,
        ) = self._find_optimal_level_and_downsample(
            resolution,
            units,
        )
        info = self.info

        # Do we need sanity check for input form ?
        requested_location = np.array(location)
        requested_size = np.array(size)
        baseline_to_read_level_scale_factor = 1 / info.level_downsamples[read_level]

        baseline_to_resolution_scale_factor = (
            baseline_to_read_level_scale_factor * read_level_to_resolution_scale_factor
        )

        size_at_baseline = requested_size / baseline_to_resolution_scale_factor
        location_at_baseline = (
            requested_location.astype(np.float32) / baseline_to_resolution_scale_factor
        )
        size_at_read_level = requested_size / read_level_to_resolution_scale_factor
        location_at_read_level = (
            requested_location.astype(np.float32)
            / read_level_to_resolution_scale_factor
        )
        output = (
            size_at_read_level,
            location_at_read_level,
            size_at_baseline,
            location_at_baseline,
        )
        output = tuple(np.ceil(v).astype(np.int64) for v in output)
        return (read_level, read_level_to_resolution_scale_factor, *output)

    def bounds_at_resolution_to_baseline(
        self: WSIReader,
        bounds: Bounds,
        resolution: Resolution,
        units: Units,
    ) -> Bounds:
        """Find corresponding bounds in baseline.

        Find corresponding bounds in baseline given the input is at
        requested resolution.

        """
        bounds_at_resolution = np.array(bounds)
        tl_at_resolution = bounds_at_resolution[:2]  # is in XY
        br_at_resolution = bounds_at_resolution[2:]
        size_at_resolution = br_at_resolution - tl_at_resolution
        # Find parameters for optimal read
        (
            _,  # read_level,
            _,  # read_level_to_requested_scale_factor,
            _,  # size_at_read_level,
            _,  # location_at_read_level,
            size_at_baseline,
            location_at_baseline,
        ) = self._find_read_params_at_resolution(
            tl_at_resolution,
            size_at_resolution,
            resolution,
            units,
        )
        tl_at_baseline = location_at_baseline
        br_at_baseline = tl_at_baseline + size_at_baseline
        return np.concatenate([tl_at_baseline, br_at_baseline])  # bounds at baseline

    def slide_dimensions(
        self: WSIReader,
        resolution: Resolution,
        units: Units,
        precision: int = 3,
    ) -> IntPair:
        """Return the size of WSI at requested resolution.

        Args:
            resolution (Resolution):
                Resolution to read thumbnail at, default = 1.25
                (objective power).
            units (Units):
                resolution units, default="power".
            precision (int, optional):
                Decimal places to use when finding optimal scale. See
                :func:`find_optimal_level_and_downsample` for more.

        Returns:
            :py:obj:`tuple`:
                Size of the WSI in (width, height).

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> slide_shape = wsi.slide_dimensions(0.55, 'mpp')

        """
        wsi_shape_at_baseline = self.info.slide_dimensions
        # Find parameters for optimal read
        (
            _,
            _,
            wsi_shape_at_resolution,
            _,
        ) = self.find_read_bounds_params(
            [0, 0, *list(wsi_shape_at_baseline)],
            resolution,
            units,
            precision,
        )
        return wsi_shape_at_resolution

    def find_read_bounds_params(
        self: WSIReader,
        bounds: Bounds,
        resolution: Resolution,
        units: Units,
        precision: int = 3,
    ) -> tuple[int, IntBounds, IntPair, IntPair, np.ndarray]:
        """Find optimal parameters for reading bounds at a given resolution.

        Args:
            bounds (IntBounds):
                Tuple of (start_x, start_y, end_x, end_y) i.e. (left,
                top, right, bottom) of the region in baseline reference
                frame.
            resolution (Resolution):
                desired output resolution
            units (Units):
                units of scale, default = "level". Supported units are:
                microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            precision (int, optional):
                Decimal places to use when finding optimal scale. See
                :func:`find_optimal_level_and_downsample` for more.

        Returns:
            tuple:
                Parameters for reading the requested bounds area:
                - :py:obj:`int` - Optimal read level
                - :py:obj:`tuple` - Bounds of the region in level coordinates
                    - :py:obj:`int` - Left (start x value)
                    - :py:obj:`int` - Top (start y value)
                    - :py:obj:`int` - Right (end x value)
                    - :py:obj:`int` - Bottom (end y value)
                - :py:obj:`tuple` - Expected size of the output image
                    - :py:obj:`int` - Width
                    - :py:obj:`int` - Height
                - np.ndarray - Scale factor of re-sampling to apply after reading.

        """
        start_x, start_y, end_x, end_y = bounds
        read_level, post_read_scale_factor = self._find_optimal_level_and_downsample(
            resolution,
            units,
            precision,
        )
        info = self.info
        level_downsample = info.level_downsamples[read_level]
        location = np.array([start_x, start_y])
        size = np.array([end_x - start_x, end_y - start_y])
        level_size = np.round(np.array(size) / level_downsample).astype(int)
        level_location = np.round(location / level_downsample).astype(int)
        level_bounds = (*level_location, *(level_location + level_size))
        output_size = np.round(level_size * post_read_scale_factor).astype(int)
        return (read_level, level_bounds, output_size, post_read_scale_factor)

    @staticmethod
    def _check_unit_conversion_integrity(
        input_unit: str,
        output_unit: str,
        baseline_mpp: Resolution,
        baseline_power: Resolution,
    ) -> None:
        """Checks integrity of units before unit conversion.

        Args:
            input_unit (str):
                input units
            output_unit (str):
                output units
            baseline_mpp:
                baseline microns per pixel (mpp)
            baseline_power:
                baseline magnification level.

        Raises:
            ValueError:
                If the checks on unit conversion fails.

        """
        if input_unit not in {"mpp", "power", "level", "baseline"}:
            msg = (
                "Invalid input_unit: argument accepts only one of the "
                "following options: `'mpp'`, `'power'`, `'level'`, `'baseline'`."
            )
            raise ValueError(
                msg,
            )
        if output_unit not in {"mpp", "power", "baseline", None}:
            msg = (
                "Invalid output_unit: argument accepts only one of the "
                "following options: `'mpp'`, `'power'`, `'baseline'`, "
                "or None (to return all units)."
            )
            raise ValueError(
                msg,
            )
        if baseline_mpp is None and input_unit == "mpp":
            msg = (
                "Missing 'mpp': `input_unit` has been set to 'mpp' while "
                "there is no information about 'mpp' in WSI meta data."
            )
            raise ValueError(
                msg,
            )
        if baseline_power is None and input_unit == "power":
            msg = (
                "Missing 'objective_power': `input_unit` has been set to 'power' "
                "while there is no information about 'objective_power' "
                "in WSI meta data."
            )
            raise ValueError(
                msg,
            )

    def _prepare_output_dict(
        self: WSIReader,
        input_unit: Units,
        input_res: Resolution,
        baseline_mpp: Resolution,
        baseline_power: Resolution,
    ) -> dict:
        """Calculate output_res as dictionary based on input_unit and resolution."""
        output_dict = {
            "mpp": None,
            "power": None,
            "baseline": None,
        }
        if input_unit == "mpp":
            if isinstance(input_res, (list, tuple, np.ndarray)):
                output_dict["mpp"] = np.array(input_res)
            else:
                output_dict["mpp"] = np.array([input_res, input_res])
            output_dict["baseline"] = baseline_mpp[0] / output_dict["mpp"][0]
            if baseline_power is not None:
                output_dict["power"] = output_dict["baseline"] * baseline_power
            return output_dict
        if input_unit == "power":
            output_dict["baseline"] = input_res / baseline_power
            output_dict["power"] = input_res
        elif input_unit == "level":
            level_scales = self.info.relative_level_scales(input_res, input_unit)
            output_dict["baseline"] = level_scales[0]
            if baseline_power is not None:
                output_dict["power"] = output_dict["baseline"] * baseline_power
        else:  # input_unit == 'baseline'
            output_dict["baseline"] = input_res
            if baseline_power is not None:
                output_dict["power"] = baseline_power * output_dict["baseline"]

        if baseline_mpp is not None:
            output_dict["mpp"] = baseline_mpp / output_dict["baseline"]

        return output_dict

    def convert_resolution_units(
        self: WSIReader,
        input_res: Resolution,
        input_unit: Units,
        output_unit: Units | None = None,
    ) -> Resolution:
        """Converts resolution value between different units.

        This function accepts a resolution and its units in the input
        and converts it to all other units ('mpp', 'power', 'baseline').
        To achieve resolution in 'mpp' and 'power' units in the output,
        WSI metadata should contain `mpp` and `objective_power`
        information, respectively.

        Args:
            input_res (Resolution):
                the resolution which we want to convert to the other
                units.
            input_unit (Units):
                The unit of the input resolution (`input_res`).
                Acceptable input_units are 'mpp', 'power', 'baseline',
                and 'level'. output_unit (str): the desired unit to
                which we want to convert the `input_res`. Acceptable
                values for `output_unit` are: 'mpp', 'power', and
                'baseline'. If `output_unit` is not provided, all the
                conversions to all the mentioned units will be
                returned in a dictionary.
            output_unit (Units):
                Units of scale, Supported units are:
                - microns per pixel ('mpp')
                - objective power ('power')
                - pyramid / resolution level ('level')
                - pixels per baseline pixel ("baseline")


        Returns:
            output_res (Resolution):
                Either a float which is the converted `input_res` to the
                desired `output_unit` or a dictionary containing the
                converted `input_res` to all acceptable units (`'mpp'`,
                `'power'`, `'baseline'`). If there is not enough metadata
                to calculate a unit (like `mpp` or `power`), they
                will be set to None in the dictionary.

        """
        baseline_mpp = self.info.mpp
        baseline_power = self.info.objective_power

        self._check_unit_conversion_integrity(
            input_unit,
            output_unit,
            baseline_mpp,
            baseline_power,
        )

        output_dict = self._prepare_output_dict(
            input_unit,
            input_res,
            baseline_mpp,
            baseline_power,
        )
        out_res = output_dict[output_unit] if output_unit is not None else output_dict
        if out_res is None:
            logger.warning(
                "Although unit conversion from input_unit has been done, the requested "
                "output_unit is returned as None. Probably due to missing 'mpp' or "
                "'objective_power' in slide's meta data.",
            )
        return out_res

    def _find_tile_params(
        self: WSIReader,
        tile_objective_value: Number,
    ) -> tuple[int, IntPair, int, Number]:
        """Find the params for save tiles."""
        rescale = self.info.objective_power / tile_objective_value
        if not rescale.is_integer():
            msg = (
                "Tile objective value must be an integer multiple of the "
                "objective power of the slide.",
            )
            raise ValueError(
                msg,
            )
        scale_levels_available = [
            np.log2(np.round(x, 3)) for x in self.info.level_downsamples
        ]
        try:
            level_scale = np.log2(rescale)
            if not level_scale.is_integer():
                raise ValueError  # noqa: TRY301
            level_scale = np.int_(level_scale)
            if level_scale not in scale_levels_available:
                raise IndexError  # noqa: TRY301
            level = scale_levels_available.index(level_scale)
            slide_dimension = self.info.level_dimensions[level]
            rescale = 1
        # Raise index error if desired pyramid level not embedded
        # in level_dimensions
        except IndexError:
            level = 0
            slide_dimension = self.info.level_dimensions[level]
            rescale = np.int_(rescale)
            logger.warning(
                "Reading WSI at level 0. Desired tile_objective_value %s "
                "not available.",
                str(tile_objective_value),
            )
        except ValueError:
            level = 0
            slide_dimension = self.info.level_dimensions[level]
            rescale = 1
            logger.warning(
                "Reading WSI at level 0. Reading at tile_objective_value %s "
                "not allowed.",
                str(tile_objective_value),
            )
            tile_objective_value = self.info.objective_power

        return level, slide_dimension, rescale, tile_objective_value

    def read_rect_at_resolution(
        self: WSIReader,
        location: NumPair,
        size: NumPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: Number | Iterable[NumPair] = 0,
        **kwargs: dict,
    ) -> np.ndarray:
        """Helper to perform `read_rect` at resolution.

        In actuality, `read_rect` at resolution is synonymous with
        calling `read_bound` at resolution because `size` has always
        been within the resolution system.

        """
        tl = np.array(location)
        br = location + np.array(size)
        bounds = np.concatenate([tl, br])
        return self.read_bounds(
            bounds,
            resolution=resolution,
            units=units,
            interpolation=interpolation,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
            coord_space="resolution",
            **kwargs,
        )

    def read_rect(
        self: WSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | tuple[int, int] = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a region of the whole slide image at a location and size.

        Location is in terms of the baseline image (level 0  / maximum
        resolution), and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The field of view varies with resolution. For a fixed field of
        view see :func:`read_bounds`.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by VirtualWSIReader. See class
                docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=size[0], N=size[1]

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> img = wsi.read_rect(location, size)
            >>> # Read a region at 0.5 microns per pixel (mpp)
            >>> img = wsi.read_rect(location, size, 0.5, "mpp")
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.5),
            ...     units="mpp",
            ... )

        Note: The field of view varies with resolution when using
        :func:`read_rect`.

        .. figure:: ../images/read_rect_tissue.png
            :width: 512
            :alt: Diagram illustrating read_rect

        As the location is in the baseline reference frame but the size
        (width and height) is the output image size, the field of view
        therefore changes as resolution changes.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        .. figure:: ../images/read_rect-interpolated-reads.png
            :width: 512
            :alt: Diagram illustrating read_rect interpolting between levels

        When reading between the levels stored in the WSI, the
        coordinates of the requested region are projected to the next
        highest resolution. This resolution is then decoded and
        downsampled to produce the desired output. This is a major
        source of variability in the time take to perform a read
        operation. Reads which require reading a large region before
        downsampling will be significantly slower than reading at a
        fixed level.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # The resolution can be different in x and y, e.g.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.75),
            ...     units="mpp",
            ... )
            >>> # Several units can be used including: objective power,
            >>> # microns per pixel, pyramid/resolution level, and
            >>> # fraction of baseline.
            >>> # E.g. Read a region at an objective power of 10x
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=10,
            ...     units="power",
            ... )
            >>> # Read a region at pyramid / resolution level 1
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1,
            ...     units="level",
            ... )
            >>> # Read at a fractional level, this will linearly
            >>> # interpolate the downsampling factor between levels.
            >>> # E.g. if levels 0 and 1 have a downsampling of 1x and
            >>> # 2x of baseline, then level 0.5 will correspond to a
            >>> # downsampling factor 1.5x of baseline.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="level",
            ... )
            >>> # Read a region at half of the full / baseline
            >>> # resolution.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="baseline",
            ... )
            >>> # Read at a higher resolution than the baseline
            >>> # (interpolation applied to output)
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1.25,
            ...     units="baseline",
            ... )
            >>> # Assuming the image has a native mpp of 0.5,
            >>> # interpolation will be applied here.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.25,
            ...     units="mpp",
            ... )

        """
        raise NotImplementedError

    def read_bounds(
        self: WSIReader,
        bounds: Bounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: Number | Iterable[NumPair] = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a region of the whole slide image within given bounds.

        Bounds are in terms of the baseline image (level 0  / maximum
        resolution).

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                Units of resolution, default="level". Supported units
                are: microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=end_h-start_h, N=end_w-start_w

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> from matplotlib import pyplot as plt
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> bounds = [1000, 2000, 2000, 3000]
            >>> img = wsi.read_bounds(bounds)
            >>> plt.imshow(img)
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_bounds(
            ...     bounds,
            ...     resolution=0,
            ...     units="level",
            ... )
            >>> plt.imshow(img)

        Note: The field of view remains the same as resolution is varied
        when using :func:`read_bounds`.

        .. figure:: ../images/read_bounds_tissue.png
            :width: 512
            :alt: Diagram illustrating read_bounds

        This is because the bounds are in the baseline (level 0)
        reference frame. Therefore, varying the resolution does not
        change what is visible within the output image.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        """
        raise NotImplementedError

    def read_region(
        self: WSIReader,
        location: IntPair,
        level: int,
        size: IntPair,
    ) -> np.ndarray:
        """Read a region of the whole slide image (OpenSlide format args).

        This function is to help with writing code which is backwards
        compatible with OpenSlide. As such, it has the same arguments.

        This internally calls :func:`read_rect` which should be
        implemented by any :class:`WSIReader` subclass. Therefore, some
        WSI formats which are not supported by OpenSlide, such as Omnyx
        JP2 files, may also be readable with the same syntax.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the level 0
                reference frame.
            level (int):
                The level number.
            size (IntPair):
                (width, height) tuple giving the region size.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3.

        """
        return self.read_rect(
            location=location,
            size=size,
            resolution=level,
            units="level",
        )

    def slide_thumbnail(
        self: WSIReader,
        resolution: Resolution = 1.25,
        units: Units = "power",
    ) -> np.ndarray:
        """Read the whole slide image thumbnail (1.25x by default).

        For more information on resolution and units see
        :func:`read_rect`

        Args:
            resolution (Resolution):
                Resolution to read thumbnail at, default = 1.25
                (objective power)
            units (Units):
                Resolution units, default="power".

        Returns:
            :class:`numpy.ndarray`:
                Thumbnail image.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> slide_thumbnail = wsi.slide_thumbnail()

        """
        slide_dimensions = self.info.slide_dimensions
        bounds = (0, 0, *slide_dimensions)
        return self.read_bounds(bounds, resolution=resolution, units=units)

    def tissue_mask(
        self: WSIReader,
        method: str = "otsu",
        resolution: Resolution = 1.25,
        units: Units = "power",
        **masker_kwargs: dict,
    ) -> VirtualWSIReader:
        """Create a tissue mask and wrap it in a VirtualWSIReader.

        For the morphological method, mpp is used for calculating the
        scale of the morphological operations. If no mpp is available,
        objective power is used instead to estimate a good scale. This
        can be overridden with a custom size, via passing a
        `kernel_size` key-word argument in `masker_kwargs`, see
        :class:`tissuemask.MorphologicalMasker` for more.


        Args:
            method (str):
                Method to use for creating the mask. Defaults
                to 'otsu'. Methods are: otsu, morphological.
            resolution (float):
                Resolution to produce the mask at.
                Defaults to 1.25.
            units (Units):
                Units of resolution. Defaults to "power".
            **masker_kwargs:
                Extra kwargs passed to the masker class.

        """
        from tiatoolbox.tools import tissuemask  # noqa: PLC0415

        thumbnail = self.slide_thumbnail(resolution, units)
        if method not in ["otsu", "morphological"]:
            msg = f"Invalid tissue masking method: {method}."
            raise ValueError(msg)
        if method == "morphological":
            mpp = None
            power = None
            if units == "mpp":
                mpp = resolution
            elif units == "power":
                power = resolution
            masker = tissuemask.MorphologicalMasker(
                mpp=mpp,
                power=power,
                **masker_kwargs,
            )
        elif method == "otsu":
            masker = tissuemask.OtsuTissueMasker(**masker_kwargs)
        mask_img = masker.fit_transform([thumbnail])[0]
        return VirtualWSIReader(mask_img.astype(np.uint8), info=self.info, mode="bool")

    def save_tiles(
        self: WSIReader,
        output_dir: str | Path = "tiles",
        tile_objective_value: int = 20,
        tile_read_size: tuple[int, int] = (5000, 5000),
        tile_format: str = ".jpg",
        *,
        verbose: bool = False,
    ) -> None:
        """Generate image tiles from whole slide images.

        Args:
            output_dir(str or :obj:`Path`):
                Output directory to save the tiles.
            tile_objective_value (int):
                Objective value at which tile is generated, default = 20
            tile_read_size (tuple(int)):
                Tile (width, height), default = (5000, 5000).
            tile_format (str):
                File format to save image tiles, defaults = ".jpg".
            verbose (bool):
                Print output, default=False

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> wsi.save_tiles(output_dir='./dev_test',
            ...     tile_objective_value=10,
            ...     tile_read_size=(2000, 2000))

            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> slide_param = wsi.info

        """
        if verbose:
            logger.setLevel(logging.DEBUG)

        logger.debug("Processing %s.", self.input_path.name)

        output_dir = Path(output_dir, self.input_path.name)

        level, slide_dimension, rescale, tile_objective_value = self._find_tile_params(
            tile_objective_value,
        )

        tile_read_size = np.multiply(tile_read_size, rescale)
        slide_h = slide_dimension[1]
        slide_w = slide_dimension[0]
        tile_h = tile_read_size[1]
        tile_w = tile_read_size[0]

        output_dir = Path(output_dir)
        output_dir.mkdir(parents=True)
        data = []

        vertical_tiles = math.ceil((slide_h - tile_h) / tile_h + 1)
        horizontal_tiles = math.ceil((slide_w - tile_w) / tile_w + 1)
        for iter_tot, (h, w) in enumerate(np.ndindex(vertical_tiles, horizontal_tiles)):
            start_h = h * tile_h
            end_h = (h * tile_h) + tile_h
            start_w = w * tile_w
            end_w = (w * tile_w) + tile_w

            end_h = min(end_h, slide_h)
            end_w = min(end_w, slide_w)

            # convert to baseline reference frame
            bounds = start_w, start_h, end_w, end_h
            baseline_bounds = tuple(
                bound * int(np.round(self.info.level_downsamples[level], 3))
                for bound in bounds
            )
            # Read image region
            im = self.read_bounds(baseline_bounds, level)

            logger.debug(
                "Tile %d:  start_w: %d, end_w: %d, start_h: %d, end_h: %d, "
                "width: %d, height: %d",
                iter_tot,
                start_w,
                end_w,
                start_h,
                end_h,
                end_w - start_w,
                end_h - start_h,
            )

            # Rescale to the correct objective value
            if rescale != 1:
                im = utils.transforms.imresize(img=im, scale_factor=1 / rescale)

            img_save_name = (
                "_".join(
                    [
                        "Tile",
                        str(tile_objective_value),
                        str(int(start_w / rescale)),
                        str(int(start_h / rescale)),
                    ],
                )
                + tile_format
            )

            utils.imwrite(image_path=output_dir / img_save_name, img=im)

            data.append(
                [
                    iter_tot,
                    img_save_name,
                    int(start_w / rescale),
                    int(end_w / rescale),
                    int(start_h / rescale),
                    int(end_h / rescale),
                    im.shape[0],
                    im.shape[1],
                ],
            )

        # Save information on each slide to relate to the whole slide image
        save_tiles_df = pd.DataFrame(
            data,
            columns=[
                "iter",
                "Tile_Name",
                "start_w",
                "end_w",
                "start_h",
                "end_h",
                "size_w",
                "size_h",
            ],
        )
        save_tiles_df.to_csv(output_dir / "Output.csv", index=False)

        # Save slide thumbnail
        slide_thumb = self.slide_thumbnail()
        utils.imwrite(output_dir / f"slide_thumbnail{tile_format}", img=slide_thumb)

        if verbose:
            logger.setLevel(logging.INFO)

    @staticmethod
    def _estimate_mpp_objective_power(
        objective_power: float | None,
        mpp: float | tuple[float] | tuple[float, float] | None,
    ) -> tuple[
        float | None,
        float | tuple[float] | tuple[float, float] | np.ndarray | None,
    ]:
        """Estimate objective power or mpp if one of these is available."""
        if objective_power is not None and mpp is not None:
            return objective_power, mpp  # use slide metadata

        if objective_power is None and mpp is None:
            logger.warning(
                "Metadata: Unable to determine objective power "
                "or microns-per-pixel (MPP)."
            )
            return objective_power, mpp  # Unable to determine

        if objective_power is None:
            objective_power = utils.misc.mpp2common_objective_power(
                mpp=float(np.mean(mpp)),
            )
            logger.warning(
                "Metadata: Objective power inferred from microns-per-pixel (MPP).",
            )
            return objective_power, mpp  # estimate objective power from mpp

        # mpp is None
        mpp = utils.misc.objective_power2mpp(
            objective_power=np.asarray(objective_power),  # ensures ndarray output
        )
        # float ensures expected output type
        mpp = np.array([float(mpp), float(mpp)]) if mpp.ndim == 0 else mpp
        logger.warning(
            "Metadata: microns-per-pixel (MPP) inferred from Objective power.",
        )
        return objective_power, mpp  # estimate objective power from mpp


class OpenSlideWSIReader(WSIReader):
    """Reader for OpenSlide supported whole-slide images.

    Supported WSI formats:

    - Aperio (.svs, .tif)
    - Hamamatsu (.vms, .vmu, .ndpi)
    - Leica (.scn)
    - MIRAX (.mrxs)
    - Philips (.tiff)
    - Sakura (.svslide)
    - Trestle (.tif)
    - Ventana (.bif, .tif)
    - Generic tiled TIFF (.tif)


    Attributes:
        openslide_wsi (:obj:`openslide.OpenSlide`)

    """

    def __init__(
        self: OpenSlideWSIReader,
        input_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        post_proc: str | callable | None = "auto",
    ) -> None:
        """Initialize :class:`OpenSlideWSIReader`."""
        super().__init__(input_img=input_img, mpp=mpp, power=power, post_proc=post_proc)
        self.openslide_wsi = openslide.OpenSlide(filename=str(self.input_path))

    def read_rect(
        self: OpenSlideWSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a region of the whole slide image at a location and size.

        Location is in terms of the baseline image (level 0  / maximum
        resolution), and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The field of view varies with resolution. For a fixed field of
        view see :func:`read_bounds`.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by VirtualWSIReader. See class
                docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=size[0], N=size[1]

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> img = wsi.read_rect(location, size)
            >>> # Read a region at 0.5 microns per pixel (mpp)
            >>> img = wsi.read_rect(location, size, 0.5, "mpp")
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.5),
            ...     units="mpp",
            ... )

        Note: The field of view varies with resolution when using
        :func:`read_rect`.

        .. figure:: ../images/read_rect_tissue.png
            :width: 512
            :alt: Diagram illustrating read_rect

        As the location is in the baseline reference frame but the size
        (width and height) is the output image size, the field of view
        therefore changes as resolution changes.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        .. figure:: ../images/read_rect-interpolated-reads.png
            :width: 512
            :alt: Diagram illustrating read_rect interpolting between levels

        When reading between the levels stored in the WSI, the
        coordinates of the requested region are projected to the next
        highest resolution. This resolution is then decoded and
        downsampled to produce the desired output. This is a major
        source of variability in the time take to perform a read
        operation. Reads which require reading a large region before
        downsampling will be significantly slower than reading at a
        fixed level.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # The resolution can be different in x and y, e.g.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.75),
            ...     units="mpp",
            ... )
            >>> # Several units can be used including: objective power,
            >>> # microns per pixel, pyramid/resolution level, and
            >>> # fraction of baseline.
            >>> # E.g. Read a region at an objective power of 10x
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=10,
            ...     units="power",
            ... )
            >>> # Read a region at pyramid / resolution level 1
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1,
            ...     units="level",
            ... )
            >>> # Read at a fractional level, this will linearly
            >>> # interpolate the downsampling factor between levels.
            >>> # E.g. if levels 0 and 1 have a downsampling of 1x and
            >>> # 2x of baseline, then level 0.5 will correspond to a
            >>> # downsampling factor 1.5x of baseline.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="level",
            ... )
            >>> # Read a region at half of the full / baseline
            >>> # resolution.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="baseline",
            ... )
            >>> # Read at a higher resolution than the baseline
            >>> # (interpolation applied to output)
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1.25,
            ...     units="baseline",
            ... )
            >>> # Assuming the image has a native mpp of 0.5,
            >>> # interpolation will be applied here.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.25,
            ...     units="mpp",
            ... )

        """
        if coord_space == "resolution":
            return self.read_rect_at_resolution(
                location,
                size,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
            )

        # Find parameters for optimal read
        (
            read_level,
            level_location,
            level_size,
            post_read_scale,
            _,
        ) = self.find_read_rect_params(
            location=location,
            size=size,
            resolution=resolution,
            units=units,
        )

        wsi = self.openslide_wsi

        # Read at optimal level and corrected read size
        im_region = wsi.read_region(location, read_level, level_size)
        im_region = np.array(im_region)

        # Apply padding outside the slide area
        im_region = utils.image.crop_and_pad_edges(
            bounds=utils.transforms.locsize2bounds(level_location, level_size),
            max_dimensions=self.info.level_dimensions[read_level],
            region=im_region,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        # Resize to correct scale if required
        im_region = utils.transforms.imresize(
            img=im_region,
            scale_factor=post_read_scale,
            output_size=size,
            interpolation=interpolation,
        )

        if self.post_proc is not None:
            im_region = self.post_proc(im_region)
        return utils.transforms.background_composite(image=im_region, alpha=False)

    def read_bounds(
        self: OpenSlideWSIReader,
        bounds: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a region of the whole slide image within given bounds.

        Bounds are in terms of the baseline image (level 0  / maximum
        resolution).

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                Units of resolution, default="level". Supported units
                are: microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=end_h-start_h, N=end_w-start_w

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> from matplotlib import pyplot as plt
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> bounds = [1000, 2000, 2000, 3000]
            >>> img = wsi.read_bounds(bounds)
            >>> plt.imshow(img)
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_bounds(
            ...     bounds,
            ...     resolution=0,
            ...     units="level",
            ... )
            >>> plt.imshow(img)

        Note: The field of view remains the same as resolution is varied
        when using :func:`read_bounds`.

        .. figure:: ../images/read_bounds_tissue.png
            :width: 512
            :alt: Diagram illustrating read_bounds

        This is because the bounds are in the baseline (level 0)
        reference frame. Therefore, varying the resolution does not
        change what is visible within the output image.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        """
        # convert from requested to `baseline`
        bounds_at_baseline = bounds
        if coord_space == "resolution":
            bounds_at_baseline = self.bounds_at_resolution_to_baseline(
                bounds,
                resolution,
                units,
            )
            _, size_at_requested = utils.transforms.bounds2locsize(bounds)
            # don't use the `output_size` (`size_at_requested`) here
            # because the rounding error at `bounds_at_baseline` leads to
            # different `size_at_requested` (keeping same read resolution
            # but base image is of different scale)
            (
                read_level,
                bounds_at_read_level,
                _,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        else:  # duplicated portion with VirtualReader, factoring out ?
            # Find parameters for optimal read
            (
                read_level,
                bounds_at_read_level,
                size_at_requested,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )

        wsi = self.openslide_wsi

        # Read at optimal level and corrected read size
        location_at_baseline = bounds_at_baseline[:2]
        _, size_at_read_level = utils.transforms.bounds2locsize(bounds_at_read_level)
        im_region = wsi.read_region(
            location=location_at_baseline,
            level=read_level,
            size=size_at_read_level,
        )
        im_region = np.array(im_region)

        # Apply padding outside the slide area
        im_region = utils.image.crop_and_pad_edges(
            bounds=bounds_at_read_level,
            max_dimensions=self.info.level_dimensions[read_level],
            region=im_region,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        # Resize to correct scale if required
        if coord_space == "resolution":
            im_region = utils.transforms.imresize(
                img=im_region,
                output_size=size_at_requested,
                interpolation=interpolation,
            )
        else:
            im_region = utils.transforms.imresize(
                img=im_region,
                scale_factor=post_read_scale,
                output_size=size_at_requested,
                interpolation=interpolation,
            )

        if self.post_proc is not None:
            im_region = self.post_proc(im_region)
        return utils.transforms.background_composite(image=im_region, alpha=False)

    @staticmethod
    def _estimate_mpp(props: openslide.OpenSlide.properties) -> tuple:
        """Find microns per pixel (mpp).

        Args:
            props (:class:`OpenSlide.properties`):
                OpenSlide properties.

        Returns:
            tuple:
                Estimated microns per pixel (mpp).

        """
        # Check OpenSlide for mpp metadata first
        try:
            mpp_x = float(props[openslide.PROPERTY_NAME_MPP_X])
            mpp_y = float(props[openslide.PROPERTY_NAME_MPP_Y])
        # Fallback to TIFF resolution units and convert to mpp
        except KeyError:
            tiff_res_units = props.get("tiff.ResolutionUnit")
        else:
            return mpp_x, mpp_y

        try:
            x_res = float(props["tiff.XResolution"])
            y_res = float(props["tiff.YResolution"])
            mpp_x = utils.misc.ppu2mpp(x_res, tiff_res_units)
            mpp_y = utils.misc.ppu2mpp(y_res, tiff_res_units)

            logger.warning(
                "Metadata: Falling back to TIFF resolution tag"
                " for microns-per-pixel (MPP).",
            )
        except KeyError:
            logger.warning("Metadata: Unable to determine microns-per-pixel (MPP).")
        else:
            return mpp_x, mpp_y

        # Return None value if metadata cannot be determined.
        return None

    def _info(self: OpenSlideWSIReader) -> WSIMeta:
        """Openslide WSI meta data reader.

        Returns:
            WSIMeta:
                Metadata information.

        """
        props = self.openslide_wsi.properties
        if openslide.PROPERTY_NAME_OBJECTIVE_POWER in props:
            objective_power = float(props[openslide.PROPERTY_NAME_OBJECTIVE_POWER])
        else:
            objective_power = None

        slide_dimensions = self.openslide_wsi.level_dimensions[0]
        level_count = self.openslide_wsi.level_count
        level_dimensions = self.openslide_wsi.level_dimensions
        level_downsamples = self.openslide_wsi.level_downsamples
        vendor = props.get(openslide.PROPERTY_NAME_VENDOR)

        mpp = self._estimate_mpp(props)

        # Fallback to calculating objective power & mpp
        objective_power, mpp = self._estimate_mpp_objective_power(
            objective_power=objective_power,
            mpp=mpp,
        )

        return WSIMeta(
            file_path=self.input_path,
            axes="YXS",
            objective_power=objective_power,
            slide_dimensions=slide_dimensions,
            level_count=level_count,
            level_dimensions=level_dimensions,
            level_downsamples=level_downsamples,
            vendor=vendor,
            mpp=mpp,
            raw=dict(**props),
        )


class JP2WSIReader(WSIReader):
    """Class for reading Omnyx JP2 images.

    Supported WSI formats:

    - Omnyx JPEG-2000 (.jp2)

    Attributes:
        glymur_wsi (:obj:`glymur.Jp2k`)

    """

    def __init__(
        self: JP2WSIReader,
        input_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        post_proc: str | callable | None = "auto",
    ) -> None:
        """Initialize :class:`OmnyxJP2WSIReader`."""
        super().__init__(input_img=input_img, mpp=mpp, power=power, post_proc=post_proc)
        import glymur  # noqa: PLC0415

        glymur.set_option("lib.num_threads", os.cpu_count() or 1)
        self.glymur_jp2 = glymur.Jp2k(filename=str(self.input_path))

    def read_rect(
        self: JP2WSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a region of the whole slide image at a location and size.

        Location is in terms of the baseline image (level 0  / maximum
        resolution), and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The field of view varies with resolution. For a fixed field of
        view see :func:`read_bounds`.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by VirtualWSIReader. See class
                docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=size[0], N=size[1]

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> img = wsi.read_rect(location, size)
            >>> # Read a region at 0.5 microns per pixel (mpp)
            >>> img = wsi.read_rect(location, size, 0.5, "mpp")
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.5),
            ...     units="mpp",
            ... )

        Note: The field of view varies with resolution when using
        :func:`read_rect`.

        .. figure:: ../images/read_rect_tissue.png
            :width: 512
            :alt: Diagram illustrating read_rect

        As the location is in the baseline reference frame but the size
        (width and height) is the output image size, the field of view
        therefore changes as resolution changes.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        .. figure:: ../images/read_rect-interpolated-reads.png
            :width: 512
            :alt: Diagram illustrating read_rect interpolting between levels

        When reading between the levels stored in the WSI, the
        coordinates of the requested region are projected to the next
        highest resolution. This resolution is then decoded and
        downsampled to produce the desired output. This is a major
        source of variability in the time take to perform a read
        operation. Reads which require reading a large region before
        downsampling will be significantly slower than reading at a
        fixed level.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # The resolution can be different in x and y, e.g.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.75),
            ...     units="mpp",
            ... )
            >>> # Several units can be used including: objective power,
            >>> # microns per pixel, pyramid/resolution level, and
            >>> # fraction of baseline.
            >>> # E.g. Read a region at an objective power of 10x
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=10,
            ...     units="power",
            ... )
            >>> # Read a region at pyramid / resolution level 1
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1,
            ...     units="level",
            ... )
            >>> # Read at a fractional level, this will linearly
            >>> # interpolate the downsampling factor between levels.
            >>> # E.g. if levels 0 and 1 have a downsampling of 1x and
            >>> # 2x of baseline, then level 0.5 will correspond to a
            >>> # downsampling factor 1.5x of baseline.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="level",
            ... )
            >>> # Read a region at half of the full / baseline
            >>> # resolution.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="baseline",
            ... )
            >>> # Read at a higher resolution than the baseline
            >>> # (interpolation applied to output)
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1.25,
            ...     units="baseline",
            ... )
            >>> # Assuming the image has a native mpp of 0.5,
            >>> # interpolation will be applied here.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.25,
            ...     units="mpp",
            ... )

        """
        if coord_space == "resolution":
            return self.read_rect_at_resolution(
                location,
                size,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
            )

        # Find parameters for optimal read
        (
            read_level,
            _,
            _,
            post_read_scale,
            baseline_read_size,
        ) = self.find_read_rect_params(
            location=location,
            size=size,
            resolution=resolution,
            units=units,
        )

        stride = 2**read_level
        glymur_wsi = self.glymur_jp2
        bounds = utils.transforms.locsize2bounds(
            location=location,
            size=baseline_read_size,
        )
        im_region = utils.image.safe_padded_read(
            image=glymur_wsi,
            bounds=bounds,
            stride=stride,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        im_region = utils.transforms.imresize(
            img=im_region,
            scale_factor=post_read_scale,
            output_size=size,
            interpolation=interpolation,
        )

        if self.post_proc is not None:
            im_region = self.post_proc(im_region)
        return utils.transforms.background_composite(image=im_region, alpha=False)

    def read_bounds(
        self: JP2WSIReader,
        bounds: IntBounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a region of the whole slide image within given bounds.

        Bounds are in terms of the baseline image (level 0  / maximum
        resolution).

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                Units of resolution, default="level". Supported units
                are: microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=end_h-start_h, N=end_w-start_w

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> from matplotlib import pyplot as plt
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> bounds = [1000, 2000, 2000, 3000]
            >>> img = wsi.read_bounds(bounds)
            >>> plt.imshow(img)
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_bounds(
            ...     bounds,
            ...     resolution=0,
            ...     units="level",
            ... )
            >>> plt.imshow(img)

        Note: The field of view remains the same as resolution is varied
        when using :func:`read_bounds`.

        .. figure:: ../images/read_bounds_tissue.png
            :width: 512
            :alt: Diagram illustrating read_bounds

        This is because the bounds are in the baseline (level 0)
        reference frame. Therefore, varying the resolution does not
        change what is visible within the output image.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        """
        bounds_at_baseline = bounds
        if coord_space == "resolution":
            bounds_at_baseline = self.bounds_at_resolution_to_baseline(
                bounds,
                resolution,
                units,
            )
            _, size_at_requested = utils.transforms.bounds2locsize(bounds)
            # don't use the `output_size` (`size_at_requested`) here
            # because the rounding error at `bounds_at_baseline` leads to
            # different `size_at_requested` (keeping same read resolution
            # but base image is of different scale)
            (
                read_level,
                _,
                _,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        else:  # duplicated portion with VirtualReader, factoring out ?
            # Find parameters for optimal read
            (
                read_level,
                _,  # bounds_at_read_level,
                size_at_requested,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        glymur_wsi = self.glymur_jp2

        stride = 2**read_level

        im_region = utils.image.safe_padded_read(
            image=glymur_wsi,
            bounds=bounds_at_baseline,
            stride=stride,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        # Resize to correct scale if required
        if coord_space == "resolution":
            im_region = utils.transforms.imresize(
                img=im_region,
                output_size=size_at_requested,
                interpolation=interpolation,
            )
        else:
            im_region = utils.transforms.imresize(
                img=im_region,
                scale_factor=post_read_scale,
                output_size=size_at_requested,
                interpolation=interpolation,
            )

        if self.post_proc is not None:
            im_region = self.post_proc(im_region)
        return utils.transforms.background_composite(image=im_region, alpha=False)

    @staticmethod
    def _get_jp2_boxes(
        jp2: glymur.jp2.Jp2k,
    ) -> dict[str, glymur.jp2box.Jp2kBox]:
        """Get JP2 boxes.

        Args:
            jp2 (glymur.jp2.Jp2k):
                Glymur JP2 image object.

        Raises:
            ValueError:
                If the JP2 image header is missing.

        Returns:
            dict[str, glymur.jp2box.Jp2kBox]:
                Dictionary of JP2 boxes. Should contain the keys
                "xml " and "cres" for Omnyx JP2 images. For other JP2
                images this may contain only the "cres" key or neither.
                The image header "ihdr" box is always present.

        """

        def find_box(
            box: glymur.jp2box.Jp2kBox | None,
            box_id: str,
        ) -> glymur.jp2box.Jp2kBox | None:
            """Find a box by its ID.

            Args:
                box (glymur.jp2box.Jp2kBox):
                    A box to search within. If None, returns None.
                box_id (str):
                    Box ID to search for. Must be 4 characters.

            Returns:
                Optional[glymur.jp2box.Jp2kBox]:
                    JP2 box with the given ID. If no box is found, returns

            """
            expected_len_box_id = 4
            msg = f"Box ID must be {expected_len_box_id} characters."
            if not len(box_id) == expected_len_box_id:  # pragma: no cover
                raise ValueError(msg)
            if not box or not box.box:
                return None
            for sub_box in box.box:
                if sub_box.box_id == box_id:
                    return sub_box
            return None

        header_box = find_box(jp2, "jp2h")
        image_header = find_box(header_box, "ihdr")
        resolution_box = find_box(header_box, "res ")
        capture_resolution_box = find_box(resolution_box, "resc")
        xml_box = find_box(jp2, "xml ")
        if image_header is None:
            msg = "Metadata: JP2 image header missing!"
            raise ValueError(msg)
        result = {
            "ihdr": image_header,
        }
        if xml_box is not None:
            result["xml "] = xml_box
        if capture_resolution_box is not None:
            result["cres"] = capture_resolution_box
        return result

    def _info(self: JP2WSIReader) -> WSIMeta:
        """JP2 metadata reader.

        Returns:
            WSIMeta:
                Metadata information.

        """
        import glymur  # noqa: PLC0415

        jp2 = self.glymur_jp2
        boxes = self._get_jp2_boxes(jp2)
        objective_power = None
        vendor = None
        mpp = None
        # Check capture resolution box
        if "cres" in boxes:
            # Get the resolution in pixels per meter
            ppm_x = boxes.get("cres").horizontal_resolution
            ppm_y = boxes.get("cres").vertical_resolution
            mpp_x = utils.misc.ppu2mpp(ppm_x, "meter")
            mpp_y = utils.misc.ppu2mpp(ppm_y, "meter")
            mpp = [mpp_x, mpp_y]
        # Check for Aperio style/Omnyx XML (overwrites capture
        # resolution). This XML contains pipe seperated key values e.g.
        # "AppMag = 40 | ..."" in a <description> tag.
        if "xml " in boxes:
            description = boxes.get("xml ").xml.find("description")
            if description is not None and description.text:
                matches = re.search(
                    r"AppMag\s*=\s*(\d+)",
                    description.text,
                    flags=re.IGNORECASE,
                )
                if matches is not None:
                    objective_power = int(matches[1])
                if "Omnyx" in description.text:
                    vendor = "Omnyx"
                if "Aperio" in description.text:
                    vendor = "Aperio"
                matches = re.search(
                    r"MPP\s*=\s*(\d*\.\d+)",
                    description.text,
                    flags=re.IGNORECASE,
                )
                if matches is not None:
                    mpp_x = float(matches[1])
                    mpp_y = float(matches[1])
                    mpp = [mpp_x, mpp_y]

        # Fallback to calculating objective power & mpp
        objective_power, mpp = self._estimate_mpp_objective_power(
            objective_power=objective_power,
            mpp=mpp,
        )

        # Get image dimensions
        image_header = boxes["ihdr"]
        slide_dimensions = (image_header.width, image_header.height)

        # Determine level_count
        cod = None
        for segment in jp2.codestream.segment:
            if isinstance(segment, glymur.codestream.CODsegment):
                cod = segment
        if cod is None:
            logger.warning(
                "Metadata: JP2 codestream missing COD segment! "
                "Cannot determine number of decompositions (levels)",
            )
            level_count = 1
        else:
            level_count = cod.num_res

        level_downsamples = [2**n for n in range(level_count)]
        level_dimensions = [
            (int(slide_dimensions[0] / 2**n), int(slide_dimensions[1] / 2**n))
            for n in range(level_count)
        ]

        return WSIMeta(
            file_path=self.input_path,
            axes="YXS",
            objective_power=objective_power,
            slide_dimensions=slide_dimensions,
            level_count=level_count,
            level_dimensions=level_dimensions,
            level_downsamples=level_downsamples,
            vendor=vendor,
            mpp=mpp,
        )


class VirtualWSIReader(WSIReader):
    """Class for reading non-pyramidal images e.g., visual fields.

    Supported formats:

    - .jpg
    - .png
    - :class:`numpy.ndarray`

    This reader uses :func:`tiatoolbox.utils.image.sub_pixel_read` to
    allow reading low resolution images as if they are larger i.e. with
    'virtual' pyramid resolutions. This is useful for reading low
    resolution masks as if they were stretched to overlay a higher
    resolution WSI.

    Extra key-word arguments given to :func:`~WSIReader.read_region` and
    :func:`~WSIReader.read_bounds` will be passed to
    :func:`~tiatoolbox.utils.image.sub_pixel_read`.

    Attributes:
        img (:class:`numpy.ndarray`):
            Input image as :class:`numpy.ndarray`.
        mode (str):
            Mode of the input image. Default is 'rgb'. Allowed values
            are: rgb, bool, feature. "rgb" mode supports bright-field color images.
            "bool" mode supports binary masks,
            interpolation in this case will be "nearest" instead of "bicubic".
            "feature" mode allows multichannel features.

    Args:
        input_img (str, :obj:`Path`, :class:`numpy.ndarray`):
            Input path to WSI.
        info (WSIMeta):
            Metadata for the virtual wsi.
        mode (str):
            Mode of the input image. Default is 'rgb'. Allowed values
            are: rgb, bool, feature. "rgb" mode supports bright-field color images.
            "bool" mode supports binary masks,
            interpolation in this case will be "nearest" instead of "bicubic".
            "feature" mode allows multichannel features.
        post_proc (str, callable):
            Post-processing function to apply to the output image.

    """

    def __init__(
        self: VirtualWSIReader,
        input_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        info: WSIMeta | None = None,
        mode: str = "rgb",
        post_proc: str | callable | None = "auto",
    ) -> None:
        """Initialize :class:`VirtualWSIReader`."""
        super().__init__(
            input_img=input_img,
            mpp=mpp,
            power=power,
            post_proc=post_proc,
        )
        if mode.lower() not in ["rgb", "bool", "feature"]:
            msg = "Invalid mode."
            raise ValueError(msg)

        if isinstance(input_img, np.ndarray):
            self.img = input_img
        else:
            self.img = utils.imread(self.input_path)

        _min_image_ndims = (
            2  # Minimum number of dimensions required for an image (H, W)
        )
        # Reject 1D (or otherwise <2D) inputs early with a clear message.
        if self.img.ndim < _min_image_ndims:
            msg = "Input image must be 2D (H, W) or 3D (H, W, C). Got a 1D array."
            raise ValueError(msg)

        if mode != "bool" and (
            self.img.ndim == 2 or self.img.shape[2] not in [3, 4]  # noqa: PLR2004
        ):
            logger.warning(
                "The image mode is set to 'feature' as the input"
                " dimensions do not match with binary mask or RGB/RGBA.",
            )
            mode = "feature"

        self.mode = mode.lower()

        if info is not None:
            self._m_info = info

    def _info(self: VirtualWSIReader) -> WSIMeta:
        """Visual Field metadata getter.

        This generates a WSIMeta object for the slide if none exists.
        There is 1 level with dimensions equal to the image and no mpp,
        objective power, or vendor data.


        Returns:
            WSIMeta:
                Metadata information.

        """
        param = WSIMeta(
            file_path=self.input_path,
            axes="YSX",
            objective_power=None,
            # align to XY to match with OpenSlide
            slide_dimensions=self.img.shape[:2][::-1],
            level_count=1,
            level_dimensions=(self.img.shape[:2][::-1],),
            level_downsamples=[1.0],
            vendor=None,
            mpp=None,
            raw=None,
        )
        if self._m_info is None:
            self._m_info = param
        return self._m_info

    def _find_params_from_baseline(
        self: VirtualWSIReader,
        location: IntPair,
        baseline_read_size: IntPair,
    ) -> tuple[IntPair, IntPair]:
        """Convert read parameters from (virtual) baseline coordinates.

        Args:
            location (IntPair):
                Location of the location to read in (virtual) baseline
                coordinates.
            baseline_read_size (IntPair):
                Size of the region to read in (virtual) baseline
                coordinates.

        Returns:
            tuple(IntPair, IntPair):
                Baseline image location and read size.

        """
        baseline_size = np.array(self.info.slide_dimensions)
        image_size = np.array(self.img.shape[:2][::-1])
        size_ratio = image_size / baseline_size
        image_location = np.array(location, dtype=np.float32) * size_ratio
        read_size = np.array(baseline_read_size) * size_ratio
        return image_location, read_size

    def read_rect(
        self: VirtualWSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a region of the whole slide image at a location and size.

        Location is in terms of the baseline image (level 0  / maximum
        resolution), and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The field of view varies with resolution. For a fixed field of
        view see :func:`read_bounds`.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently, only used by VirtualWSIReader. See class
                docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=size[0], N=size[1]

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> img = wsi.read_rect(location, size)
            >>> # Read a region at 0.5 microns per pixel (mpp)
            >>> img = wsi.read_rect(location, size, 0.5, "mpp")
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.5),
            ...     units="mpp",
            ... )

        Note: The field of view varies with resolution when using
        :func:`read_rect`.

        .. figure:: ../images/read_rect_tissue.png
            :width: 512
            :alt: Diagram illustrating read_rect

        As the location is in the baseline reference frame but the size
        (width and height) is the output image size, the field of view
        therefore changes as resolution changes.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        .. figure:: ../images/read_rect-interpolated-reads.png
            :width: 512
            :alt: Diagram illustrating read_rect interpolting between levels

        When reading between the levels stored in the WSI, the
        coordinates of the requested region are projected to the next
        highest resolution. This resolution is then decoded and
        downsampled to produce the desired output. This is a major
        source of variability in the time take to perform a read
        operation. Reads which require reading a large region before
        downsampling will be significantly slower than reading at a
        fixed level.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # The resolution can be different in x and y, e.g.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.75),
            ...     units="mpp",
            ... )
            >>> # Several units can be used including: objective power,
            >>> # microns per pixel, pyramid/resolution level, and
            >>> # fraction of baseline.
            >>> # E.g. Read a region at an objective power of 10x
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=10,
            ...     units="power",
            ... )
            >>> # Read a region at pyramid / resolution level 1
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1,
            ...     units="level",
            ... )
            >>> # Read at a fractional level, this will linearly
            >>> # interpolate the downsampling factor between levels.
            >>> # E.g. if levels 0 and 1 have a downsampling of 1x and
            >>> # 2x of baseline, then level 0.5 will correspond to a
            >>> # downsampling factor 1.5x of baseline.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="level",
            ... )
            >>> # Read a region at half of the full / baseline
            >>> # resolution.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="baseline",
            ... )
            >>> # Read at a higher resolution than the baseline
            >>> # (interpolation applied to output)
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1.25,
            ...     units="baseline",
            ... )
            >>> # Assuming the image has a native mpp of 0.5,
            >>> # interpolation will be applied here.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.25,
            ...     units="mpp",
            ... )

        """
        if coord_space == "resolution":
            return self.read_rect_at_resolution(
                location,
                size,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
            )

        # Find parameters for optimal read
        (
            _,
            _,
            _,
            _,
            baseline_read_size,
        ) = self.find_read_rect_params(
            location=location,
            size=size,
            resolution=resolution,
            units=units,
        )

        image_location, image_read_size = self._find_params_from_baseline(
            location,
            baseline_read_size,
        )

        bounds = utils.transforms.locsize2bounds(
            location=image_location,
            size=image_read_size,
        )

        output_size = None if interpolation in [None, "none"] else size
        im_region = utils.image.sub_pixel_read(
            self.img,
            bounds,
            output_size=output_size,
            interpolation=interpolation,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
            read_kwargs=kwargs,
            pad_at_baseline=False,
        )

        if self.mode == "rgb":
            if self.post_proc is not None:
                im_region = self.post_proc(im_region)
            return utils.transforms.background_composite(image=im_region, alpha=False)
        return im_region

    def read_bounds(
        self: VirtualWSIReader,
        bounds: IntBounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a region of the whole slide image within given bounds.

        Bounds are in terms of the baseline image (level 0  / maximum
        resolution).

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                Units of resolution, default="level". Supported units
                are: microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=end_h-start_h, N=end_w-start_w

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> from matplotlib import pyplot as plt
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> bounds = [1000, 2000, 2000, 3000]
            >>> img = wsi.read_bounds(bounds)
            >>> plt.imshow(img)
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_bounds(
            ...     bounds,
            ...     resolution=0,
            ...     units="level",
            ... )
            >>> plt.imshow(img)

        Note: The field of view remains the same as resolution is varied
        when using :func:`read_bounds`.

        .. figure:: ../images/read_bounds_tissue.png
            :width: 512
            :alt: Diagram illustrating read_bounds

        This is because the bounds are in the baseline (level 0)
        reference frame. Therefore, varying the resolution does not
        change what is visible within the output image.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        """
        # convert from requested to `baseline`
        bounds_at_baseline = bounds
        if coord_space == "resolution":
            bounds_at_baseline = self.bounds_at_resolution_to_baseline(
                bounds,
                resolution,
                units,
            )
            _, size_at_requested = utils.transforms.bounds2locsize(bounds)
            # * Find parameters for optimal read
            # don't use the `output_size` (`size_at_requested`) here
            # because the rounding error at `bounds_at_baseline` leads to
            # different `size_at_requested` (keeping same read resolution
            # but base image is of different scale)
            _, _, _, post_read_scale = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        else:
            # * Find parameters for optimal read
            _, _, size_at_requested, post_read_scale = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )

        location_at_read, size_at_read = self._find_params_from_baseline(
            *utils.transforms.bounds2locsize(bounds_at_baseline),
        )
        bounds_at_read = utils.transforms.locsize2bounds(location_at_read, size_at_read)

        if interpolation in [None, "none"]:
            interpolation = None

        if interpolation == "optimise" and self.mode == "bool":
            interpolation = "nearest"

        im_region = utils.image.sub_pixel_read(
            self.img,
            bounds_at_read,
            output_size=size_at_requested,
            interpolation=interpolation,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
            read_kwargs=kwargs,
            pad_at_baseline=False,
        )

        if coord_space == "resolution":
            # do this to enforce output size is as defined by input bounds
            im_region = utils.transforms.imresize(
                img=im_region,
                output_size=size_at_requested,
            )
        else:
            im_region = utils.transforms.imresize(
                img=im_region,
                scale_factor=post_read_scale,
                output_size=size_at_requested,
            )

        if self.mode == "rgb":
            if self.post_proc is not None:
                im_region = self.post_proc(im_region)
            return utils.transforms.background_composite(image=im_region, alpha=False)
        return im_region


class ArrayView:
    """An object for viewing a zarr array with a different index ordering.

    Used to allow YXS index order reads for arrays with axes in other
    orders such as SYX. Currently supported axes are:
    - YXS
    - SYX

    """

    def __init__(self: ArrayView, array: zarr.Array, axes: str) -> None:
        """Initialise the view object.

        Args:
            array (zarr.Array):
                Zarr Array to read from.
            axes (str):
                Axes ordering string. Allowed values are YXS and SYX.

        """
        self.array = array
        self.axes = axes
        self._shape = dict(zip(self.axes, self.array.shape, strict=False))

    @property
    def shape(self: ArrayView) -> tuple:
        """Return array shape."""
        try:
            return tuple(self._shape[c] for c in "YXC")
        except KeyError:
            return tuple(self._shape[c] for c in "YXS")

    def __getitem__(self: ArrayView, index: int) -> np.ndarray:
        """Get an item from the dataset."""
        # Normalize to a tuple of length = len(self.axes)
        if not isinstance(index, tuple):
            index = (index,)
        while len(index) < len(self.axes):
            index = (*index, slice(None))

        if self.axes in ("YXS", "YXC"):
            return self.array[index]
        if self.axes in ("SYX", "CYX"):
            y, x, s = index
            index = (s, y, x)
            return np.rollaxis(self.array[index], 0, 3)
        msg = f"Unsupported axes `{self.axes}`."
        raise ValueError(msg)


class TIFFWSIReader(WSIReader):
    """Define Tiff WSI Reader."""

    def __init__(
        self: TIFFWSIReader,
        input_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        series: str = "auto",
        cache_size: int = 2**28,  # noqa: ARG002
        post_proc: str | callable | None = "auto",
    ) -> None:
        """Initialize :class:`TIFFWSIReader`."""
        super().__init__(input_img=input_img, mpp=mpp, power=power, post_proc=post_proc)
        self.tiff = tifffile.TiffFile(self.input_path)
        self._axes = self.tiff.series[0].axes
        # Flag which is True if the image is a simple single page tile TIFF
        is_single_page_tiled = all(
            [
                self.tiff.pages[0].is_tiled,
                # Not currently supporting multi-page images
                not self.tiff.is_multipage,
                # Currently only supporting single page generic tiled TIFF
                len(self.tiff.pages) == 1,
            ],
        )
        if not any(
            [
                self.tiff.is_svs,
                self.tiff.is_ome,
                is_single_page_tiled,
                self.tiff.is_bigtiff,
            ]
        ):
            msg = "Unsupported TIFF WSI format."
            raise ValueError(msg)

        self.series_n = series
        if self.tiff.series is None or len(self.tiff.series) == 0:  # pragma: no cover
            msg = "TIFF does not contain any valid series."
            raise FileNotSupportedError(msg)
        # Find the largest series if series="auto"
        if self.series_n == "auto":
            all_series = self.tiff.series or []

            def page_area(page: tifffile.TiffPage) -> float:
                """Calculate the area of a page."""
                return np.prod(
                    TIFFWSIReaderDelegate.canonical_shape(self._axes, page.shape)[:2],
                    dtype=float,
                )

            series_areas = [page_area(s.pages[0]) for s in all_series]  # skipcq
            self.series_n = np.argmax(series_areas)
        self._tiff_series = self.tiff.series[self.series_n]
        self._zarr_store = tifffile.imread(
            self.input_path,
            series=self.series_n,
            aszarr=True,
        )
        # remove LRU cache for now as seems to cause issues on windows
        self._zarr_group = zarr.open(self._zarr_store)
        if not isinstance(self._zarr_group, zarr.hierarchy.Group):  # pragma: no cover
            group = zarr.hierarchy.group()
            group[0] = self._zarr_group
            self._zarr_group = group
        self.level_arrays = {
            int(key): ArrayView(array, axes=self._axes)
            for key, array in self._zarr_group.items()
        }
        # ensure level arrays are sorted by descending area
        self.level_arrays = dict(
            sorted(
                self.level_arrays.items(),
                key=lambda x: (
                    -np.prod(
                        TIFFWSIReaderDelegate.canonical_shape(
                            self._axes, x[1].array.shape[:2]
                        ),
                        dtype=float,
                    )
                ),
            )
        )
        # maybe get colors if they exist in metadata
        self._get_colors_from_meta()

        self.tiff_reader_delegate = TIFFWSIReaderDelegate(self, self.level_arrays)

    def _get_colors_from_meta(self: TIFFWSIReader) -> None:
        """Get colors from metadata if they exist."""
        if not isinstance(self.post_proc, postproc_defs.MultichannelToRGB):
            return

        try:
            xml = self.info.raw["Description"]
            root = ElementTree.fromstring(xml)
        except ElementTree.ParseError:
            return

        # Try multiple formats
        for parser in (
            TIFFWSIReader._parse_scancolortable,
            TIFFWSIReader._parse_filtercolor_metadata,
            TIFFWSIReader._parse_ome_metadata_mapping,
        ):
            color_dict = parser(root)
            if color_dict:
                self.post_proc.color_dict = color_dict
                return

    @staticmethod
    def _parse_scancolortable(
        root: ElementTree,
    ) -> dict[str, tuple[float, float, float]] | None:
        """Parse ScanColorTable metadata from XML and convert color values to RGB.

        Args:
            root (ElementTree): The root of the parsed XML tree.

        Returns:
            dict[str, tuple[float, float, float]] | None: A mapping of channel
            names to RGB tuples, or None if not found.

        """
        color_info = root.find(".//ScanColorTable")
        if color_info is None:
            return None

        color_dict = {
            k.text.split("_")[0]: v.text
            for k, v in zip(
                color_info.iterfind("ScanColorTable-k"),
                color_info.iterfind("ScanColorTable-v"),
                strict=False,
            )
        }
        # values will be either a string of 3 ints e.g 155, 128, 0, or
        # a color name e.g Lime. Convert them all to RGB tuples.
        for key, value in color_dict.items():
            if value is None:
                continue
            if "," in value:
                color_dict[key] = tuple(int(x) / 255 for x in value.split(","))
            else:
                color_dict[key] = mcolors.to_rgb(value)

        return color_dict

    @staticmethod
    def _parse_filtercolor_metadata(
        root: ElementTree,
    ) -> dict[str, tuple[float, float, float]] | None:
        """Parse FilterColors metadata from XML and convert color values to RGB.

        Args:
            root (ElementTree): The root of the parsed XML tree.

        Returns:
            dict[str, tuple[float, float, float]] | None: A mapping of channel
            names to RGB tuples, or None if not found.

        """
        # try alternate metadata format
        # Build a map from filter pair string -> color label or RGB string
        # from the <FilterColors> section
        filter_colors = {}
        filter_colors_section = root.find(".//FilterColors")
        if filter_colors_section is None:
            return None

        keys = filter_colors_section.findall(".//FilterColors-k")
        vals = filter_colors_section.findall(".//FilterColors-v")
        for k, v in zip(keys, vals, strict=False):
            filter_colors[k.text] = v.text

        # Helper function to convert color strings like "Lime" or
        # "255, 128, 0" into (R,G,B)
        def color_string_to_rgb(s: str) -> tuple[float, float, float]:
            """Convert a color string (e.g., 'Lime' or '255, 128, 0') to an RGB tuple.

            Args:
                s (str): The color string.

            Returns:
                tuple[float, float, float]: RGB values normalized to [0, 1].
            """
            if "," in s:
                return tuple(int(x.strip()) / 255 for x in s.split(","))
            return mcolors.to_rgb(s)

        # 2) For each <ScanBands-i>, find the channel's name and figure out
        #    which filter pair it uses, then match that to a color.
        channel_dict = {}
        for scan_band in root.findall(".//ScanBands-i"):
            # Inside a <ScanBands-i> there is a <Bands-i> with a <Name> tag
            bands_i = scan_band.find(".//Bands-i")
            if bands_i is not None:
                band_name_element = bands_i.find("Name")
                if band_name_element is not None:
                    channel_name = band_name_element.text.strip()

                    # Grab the filter pair manufacturer info
                    filter_pair = scan_band.find(".//FilterPair")
                    if filter_pair is not None:
                        emission_part = filter_pair.find(
                            ".//EmissionFilter/FixedFilter/PartNumber"
                        )
                        excitation_part = filter_pair.find(
                            ".//ExcitationFilter/FixedFilter/PartNumber"
                        )
                        if emission_part is not None and excitation_part is not None:
                            matching_rgb = (1.0, 1.0, 1.0)  # default white
                            for fc_key, fc_val in filter_colors.items():
                                # if both part numbers appear in the FilterColors-k
                                # string, assume it's the match
                                if (
                                    emission_part.text in fc_key
                                    and excitation_part.text in fc_key
                                ):
                                    matching_rgb = color_string_to_rgb(fc_val)
                                    break

                            channel_dict[channel_name] = matching_rgb

        return channel_dict if channel_dict else None

    @staticmethod
    def _get_namespace(root: ElementTree) -> dict:
        """Extract the XML namespace from the root element.

        Args:
            root (ElementTree): Root of the parsed XML tree.

        Returns:
            dict: Dictionary containing the namespace prefix and URI.

        """
        if root.tag.startswith("{"):
            ns_uri = root.tag.split("}")[0].strip("{")
            return {"ns": ns_uri}

        return {}

    @staticmethod
    def _extract_dye_mapping(root: ElementTree, ns: dict) -> dict:
        """Extract dye mapping from OME-XML annotations.

        Args:
            root (ElementTree): Root of the parsed XML tree.
            ns (dict): XML namespace dictionary.

        Returns:
            dict: Mapping of channel IDs to dye names.

        """
        dye_mapping = {}
        for annotation in root.findall(
            ".//ns:StructuredAnnotations/ns:XMLAnnotation", ns
        ):
            value_elem = annotation.find("ns:Value", ns)
            if value_elem is not None:
                for chan_priv in value_elem.findall(".//ns:ChannelPriv", ns):
                    chan_id = chan_priv.attrib.get("ID")
                    dye = chan_priv.attrib.get("FluorescenceChannel")
                    if chan_id and dye:
                        dye_mapping[chan_id] = dye
        return dye_mapping

    @staticmethod
    def _int_to_rgb(color_int: int) -> tuple[float, float, float]:
        """Convert an integer color value to an RGB tuple.

        Args:
            color_int (int): Integer representation of a color.

        Returns:
            tuple[float, float, float]: RGB values normalized to [0, 1].

        """
        if color_int < 0:
            color_int += 1 << 32
        r = (color_int >> 16) & 0xFF
        g = (color_int >> 8) & 0xFF
        b = color_int & 0xFF

        return (r / 255, g / 255, b / 255)

    @staticmethod
    def _parse_channel_data(
        root: ElementTree, ns: dict, dye_mapping: dict
    ) -> list[dict]:
        """Parse channel metadata from OME-XML.

        Extract RGB color and dye information for each channel defined in the metadata.

        Args:
            root (ElementTree): Root of the parsed XML tree.
            ns (dict): XML namespace dictionary.
            dye_mapping (dict): Mapping of channel IDs to dye names.

        Returns:
            list[dict]: List of dictionaries containing channel metadata.

        """
        channel_data = []
        for pixels in root.findall(".//ns:Pixels", ns):
            for channel in pixels.findall("ns:Channel", ns):
                chan_id = channel.attrib.get("ID")
                name = channel.attrib.get("Name")
                color = channel.attrib.get("Color")
                if chan_id and name and color:
                    try:
                        color_int = int(color)
                        rgb = TIFFWSIReader._int_to_rgb(color_int)
                    except ValueError:
                        rgb = None
                    dye = dye_mapping.get(chan_id, "Unknown")
                    label = f"{chan_id}: {name} ({dye})"
                    channel_data.append(
                        {
                            "id": chan_id,
                            "name": name,
                            "dye": dye,
                            "rgb": rgb,
                            "label": label,
                        }
                    )
        return channel_data

    @staticmethod
    def _build_color_dict(
        channel_data: list[dict], dye_mapping: dict
    ) -> dict[str, tuple[float, float, float]]:
        """Build a dictionary mapping channel names to RGB color tuples.

        Args:
            channel_data (list[dict]): List of channel metadata dictionaries.
            dye_mapping (dict): Mapping of channel IDs to dye names.

        Returns:
            dict[str, tuple[float, float, float]]: Dictionary mapping channel labels to
            RGB values.

        """
        color_dict = {}
        key_counts = defaultdict(int)
        for c_data in channel_data:
            chan_id = c_data["id"]
            name = c_data["name"]
            dye = dye_mapping.get(chan_id)
            rgb = c_data["rgb"]
            base_key = f"{name} ({dye})" if dye else name
            count = key_counts[base_key]
            key = base_key if count == 0 else f"{base_key} [{count + 1}]"
            color_dict[key] = rgb
            key_counts[base_key] += 1

        return color_dict

    @staticmethod
    def _parse_ome_metadata_mapping(
        root: ElementTree,
    ) -> dict[str, tuple[float, float, float]] | None:
        """Parse OME metadata from the given XML root element.

        Args:
            root (ElementTree): The root of the parsed XML tree.

        Returns:
            dict[str, tuple[float, float, float]] | None: A mapping
            of channel names to RGB tuples, or None if not found.

        """
        # 3) Try OME/Lunaphore format e.g. for COMET
        ns = TIFFWSIReader._get_namespace(root)
        dye_mapping = TIFFWSIReader._extract_dye_mapping(root, ns)
        channel_data = TIFFWSIReader._parse_channel_data(root, ns, dye_mapping)
        color_dict = TIFFWSIReader._build_color_dict(channel_data, dye_mapping)

        return color_dict if color_dict else None

    def _get_ome_xml(self: TIFFWSIReader) -> ElementTree.Element:
        """Parse OME-XML from the description of the first IFD (page).

        Returns:
            ElementTree.Element:
                OME-XML root element.

        """
        description = self.tiff.pages[0].description
        return ElementTree.fromstring(description)

    def _parse_ome_metadata(self: TIFFWSIReader) -> dict:
        """Extract OME specific metadata.

        Returns:
            dict:
                Dictionary of kwargs for WSIMeta.

        """
        # The OME-XML should be in each IFD but is optional. It must be
        # present in the first IFD. We simply get the description from
        # the first IFD.
        xml = self._get_ome_xml()
        objective_power = self._get_ome_objective_power(xml)
        mpp = self._get_ome_mpp(xml)

        return {
            "objective_power": objective_power,
            "vendor": None,
            "mpp": mpp,
            "raw": {
                "Description": self.tiff.pages[0].description,
                "OME-XML": xml,
            },
        }

    def _get_ome_objective_power(
        self: TIFFWSIReader,
        xml: ElementTree.Element | None = None,
    ) -> float | None:
        """Get the objective power from the OME-XML.

        Args:
            xml (ElementTree.Element, optional):
                OME-XML root element. Defaults to None. If None, the
                OME-XML will be parsed from the first IFD.

        Returns:
            float:
                Objective power.

        """
        xml = xml or self._get_ome_xml()
        namespaces = {"ome": "http://www.openmicroscopy.org/Schemas/OME/2016-06"}

        try:
            xml_series = xml.findall("ome:Image", namespaces)[self.series_n]
            instrument_ref = xml_series.find("ome:InstrumentRef", namespaces)
            objective_settings = xml_series.find("ome:ObjectiveSettings", namespaces)
            if objective_settings is None:
                # try alternative tag
                objective_settings = xml_series.find("ome:Objective", namespaces)

            instrument_ref_id = instrument_ref.attrib.get("ID")
            objective_settings_id = (
                objective_settings.attrib.get("ID")
                if objective_settings is not None
                else "Objective:0"
            )

            instruments = {
                instrument.attrib.get("ID"): instrument
                for instrument in xml.findall("ome:Instrument", namespaces)
            }
            objectives = {
                (instrument_id, objective.attrib.get("ID")): objective
                for instrument_id, instrument in instruments.items()
                for objective in instrument.findall("ome:Objective", namespaces)
            }

            objective = objectives.get((instrument_ref_id, objective_settings_id))
            if objective is not None:
                return float(objective.attrib.get("NominalMagnification"))

        except (IndexError, AttributeError, ValueError, TypeError, KeyError) as e:
            logger.warning("OME objective power extraction failed: %s", e)

        # Fallback: try to infer from MPP
        mpp = self._get_ome_mpp(xml)
        if mpp is not None:
            try:
                return utils.misc.mpp2common_objective_power(float(np.mean(mpp)))
            except (TypeError, ValueError) as e:
                logger.warning("Failed to infer objective power from MPP: %s", e)

        logger.warning("Objective power could not be determined from OME-XML.")
        return None

    def _get_ome_mpp(
        self: TIFFWSIReader,
        xml: ElementTree.Element | None = None,
    ) -> list[float] | None:
        """Get the microns per pixel from the OME-XML.

        Args:
            xml (ElementTree.Element, optional):
                OME-XML root element. Defaults to None. If None, the
                OME-XML will be parsed from the first IFD.

        Returns:
            Optional[List[float]]:
                Microns per pixel.

        """
        xml = xml or self._get_ome_xml()
        namespaces = {"ome": "http://www.openmicroscopy.org/Schemas/OME/2016-06"}
        xml_series = xml.findall("ome:Image", namespaces)[self.series_n]
        xml_pixels = xml_series.find("ome:Pixels", namespaces)
        mppx = xml_pixels.attrib.get("PhysicalSizeX")
        mppy = xml_pixels.attrib.get("PhysicalSizeY")
        if mppx is not None and mppy is not None:
            return [mppx, mppy]
        if mppx is not None or mppy is not None:
            logger.warning("Only one MPP value found. Using it for both X  and Y.")
            return [mppx or mppy] * 2

        return None

    def _info(self: TIFFWSIReader) -> WSIMeta:
        """TIFF metadata constructor.

        Returns:
            WSIMeta:
                Containing metadata.

        """
        level_count = len(self.level_arrays)
        level_dimensions = [
            np.array(
                TIFFWSIReaderDelegate.canonical_shape(self._axes, p.array.shape)[:2][
                    ::-1
                ]
            )
            for p in self.level_arrays.values()
        ]
        slide_dimensions = level_dimensions[0]
        level_downsamples = [(level_dimensions[0] / x)[0] for x in level_dimensions]
        # The tags attribute object will not pickle or deepcopy,
        # so a copy with only python values or tifffile enums is made.
        tifffile_tags = self.tiff.pages[0].tags.items()
        tiff_tags = {
            code: {
                "code": code,
                "value": tag.value,
                "name": tag.name,
                "count": tag.count,
                "type": tag.dtype,
            }
            for code, tag in tifffile_tags
        }

        if self.tiff.is_svs:
            filetype_params = TIFFWSIReaderDelegate.parse_svs_metadata(self.tiff.pages)
        elif self.tiff.is_ome:
            filetype_params = self._parse_ome_metadata()
        else:
            filetype_params = TIFFWSIReaderDelegate.parse_generic_tiff_metadata(
                self.tiff.pages
            )
        filetype_params["raw"]["TIFF Tags"] = tiff_tags

        # Fallback to calculating objective power from mpp
        objective_power = filetype_params["objective_power"]
        mpp = (
            np.asarray(filetype_params["mpp"]).astype(float)
            if filetype_params["mpp"] is not None
            else None
        )
        # Fallback to calculating objective power & mpp
        objective_power, mpp = self._estimate_mpp_objective_power(
            objective_power=objective_power,
            mpp=mpp,
        )
        filetype_params["objective_power"] = objective_power
        filetype_params["mpp"] = mpp

        # Updating for mypy checks
        slide_dimensions = (slide_dimensions[0], slide_dimensions[1])
        level_dimensions = [
            (level_dimensions_[0], level_dimensions_[1])
            for level_dimensions_ in level_dimensions
        ]

        return WSIMeta(
            file_path=self.input_path,
            slide_dimensions=slide_dimensions,
            axes=self._axes,
            level_count=level_count,
            level_dimensions=level_dimensions,
            level_downsamples=level_downsamples,
            **filetype_params,
        )

    def read_rect(
        self: TIFFWSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """See TIFFWSIReaderDelegate.read_rect docs for details."""
        return self.tiff_reader_delegate.read_rect(
            location,
            size,
            resolution,
            units,
            interpolation,
            pad_mode,
            pad_constant_values,
            coord_space,
            **kwargs,
        )

    def read_bounds(
        self: TIFFWSIReader,
        bounds: IntBounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """See TIFFWSIReaderDelegate.read_bounds docs for details."""
        return self.tiff_reader_delegate.read_bounds(
            bounds,
            resolution,
            units,
            interpolation,
            pad_mode,
            pad_constant_values,
            coord_space,
            **kwargs,
        )


class FsspecJsonWSIReader(WSIReader):
    """Reader for fsspec zarr json generated by: tiatoolbox/utils/tiff_to_fsspec.py.

    The fsspec zarr json file represents a SVS or TIFF file
    that be accessed using byte range HTTP API.

    All the information on the chunk locations in the SVS or TIFF file
    is outlined as byte-ranges in the JSON,
    so the reader requests only chunks that are needed to display requested tiles,
    rather than the entire SVS or TIFF file.

    """

    def __init__(
        self: FsspecJsonWSIReader,
        input_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        cache_size: int = 2**28,
    ) -> None:
        """Initialize :class:`FsspecJsonWSIReader`."""
        super().__init__(input_img=input_img, mpp=mpp, power=power)
        jpeg_codec = Jpeg()
        register_codec(jpeg_codec, "imagecodecs_jpeg")

        jpeg2k_codec = Jpeg2k()
        register_codec(jpeg2k_codec, "imagecodecs_jpeg2k")

        lzw_codec = Lzw()
        register_codec(lzw_codec, "imagecodecs_lzw")

        delta_codec = Delta()
        register_codec(delta_codec, "imagecodecs_delta")

        mapper = fsspec.get_mapper(
            "reference://", fo=str(input_img), target_protocol="file"
        )

        self._zarr_array = zarr.open(mapper, mode="r")

        self.__set_axes()

        self._zarr_store = self._zarr_array.store

        self._zarr_lru_cache = zarr.LRUStoreCache(self._zarr_store, max_size=cache_size)
        self._zarr_group = zarr.open(self._zarr_lru_cache)
        if not isinstance(self._zarr_group, zarr.hierarchy.Group):  # pragma: no cover
            group = zarr.hierarchy.group()
            group[0] = self._zarr_group
            self._zarr_group = group
        self.level_arrays = {
            int(key): ArrayView(array, axes=self._axes)
            for key, array in self._zarr_group.items()
        }
        # ensure level arrays are sorted by descending area
        self.level_arrays = dict(
            sorted(
                self.level_arrays.items(),
                key=lambda x: (
                    -np.prod(
                        TIFFWSIReaderDelegate.canonical_shape(
                            self._axes, x[1].array.shape[:2]
                        )
                    )
                ),
            )
        )
        self.tiff_reader_delegate = TIFFWSIReaderDelegate(self, self.level_arrays)

    def __set_axes(self) -> None:  # pragma: no cover
        """Loads axes from the json file.

        In case zarr array has a group 0 at root,
        loads axes from the layer 0.

        In case the zarr array doesn't have a group 0 at
        root, loads axes from attrs at root.

        """
        if isinstance(self._zarr_array, zarr.hierarchy.Group):
            if "0" in self._zarr_array:
                zattrs = self._zarr_array["0"].attrs
                if "_ARRAY_DIMENSIONS" in zattrs:
                    self._axes = "".join(
                        zattrs["_ARRAY_DIMENSIONS"]
                    )  # Concatenate dimensions
                else:
                    msg = "'_ARRAY_DIMENSIONS' does not exist in the group '0'."
                    raise ValueError(msg)
            else:
                msg = "The group '0' does not exist in the zarr_array."
                raise ValueError(msg)
        else:
            zattrs_path = self._zarr_array.attrs
            if "_ARRAY_DIMENSIONS" in zattrs_path:
                self._axes = "".join(
                    zattrs_path["_ARRAY_DIMENSIONS"]
                )  # Concatenate dimensions
            else:
                msg = "'_ARRAY_DIMENSIONS' does not exist in the root .zattrs."
                raise ValueError(msg)

    @staticmethod
    def is_valid_zarr_fsspec(file_path: str) -> bool:
        """Check if the input path is a valid Zarr fsspec JSON file.

        Checks if the file_path is a valid Zarr fsspec JSON file generated by:
        tiatoolbox/utils/tiff_to_fsspec.py

        Args:
            file_path: str Path to the file to check.

        Returns:
            bool: True if the file is a valid Zarr fsspec JSON file
        """
        path = Path(file_path)

        if path.suffix.lower() != ".json":
            return False

        try:
            with fsspec.open(file_path, "r") as file:
                data = json.load(file)

            # Basic validation for fsspec Zarr JSON structure
            if ".zattrs" not in data:
                logger.error("Field .zattrs missing in '%s'.", file_path)
                return False

            return True  # noqa: TRY300

        except json.JSONDecodeError as e:
            logger.error("Invalid JSON file: %s", e)
            return False
        except (OSError, ValueError) as e:
            logger.error("An error occurred: %s", e)
            return False

    def _info(self: FsspecJsonWSIReader) -> WSIMeta:
        """TIFF metadata constructor.

        Returns:
            WSIMeta:
                Containing metadata.

        """
        level_count = len(self.level_arrays)
        level_dimensions = [
            np.array(
                TIFFWSIReaderDelegate.canonical_shape(self._axes, p.array.shape)[:2][
                    ::-1
                ]
            )
            for p in self.level_arrays.values()
        ]
        slide_dimensions = level_dimensions[0]
        level_downsamples = [(level_dimensions[0] / x)[0] for x in level_dimensions]

        zarr_attrs = self._zarr_array.attrs

        filetype_params = {}
        # Check for "multiscales" and extract metadata
        if "multiscales" in zarr_attrs:  # pragma: no cover
            multiscales = zarr_attrs[
                "multiscales"
            ]  # List of multiscale metadata entries
            for entry in multiscales:
                filetype_params = entry.get("metadata", {})

        # Fallback to calculating objective power & mpp
        objective_power, mpp = self._estimate_mpp_objective_power(
            objective_power=filetype_params["objective_power"],
            mpp=filetype_params["mpp"],
        )
        filetype_params["mpp"] = mpp
        filetype_params["objective_power"] = objective_power

        return WSIMeta(
            file_path=self.input_path,
            slide_dimensions=slide_dimensions,
            axes=self._axes,
            level_count=level_count,
            level_dimensions=level_dimensions,
            level_downsamples=level_downsamples,
            **filetype_params,
        )

    def read_rect(
        self: FsspecJsonWSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """See TIFFWSIReaderDelegate.read_rect docs for details."""
        return self.tiff_reader_delegate.read_rect(
            location,
            size,
            resolution,
            units,
            interpolation,
            pad_mode,
            pad_constant_values,
            coord_space,
            **kwargs,
        )

    def read_bounds(
        self: FsspecJsonWSIReader,
        bounds: IntBounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """See TIFFWSIReaderDelegate.read_bounds docs for details."""
        return self.tiff_reader_delegate.read_bounds(
            bounds,
            resolution,
            units,
            interpolation,
            pad_mode,
            pad_constant_values,
            coord_space,
            **kwargs,
        )


class TIFFWSIReaderDelegate:
    """Delegate class to handle image reading operations.

    Currently used in FsspecJsonWSIReader and TIFFWSIReader.
    """

    def __init__(self, reader: WSIReader, level_arrays: dict[int, ArrayView]) -> None:
        """Initialize the delegate with a reader and level arrays.

        Args:
            reader (WSIReader): An instance of FsspecJsonWSIReader or TIFFWSIReader.
            level_arrays (dict[int, ArrayView]): Dictionary of level arrays.
        """
        self.reader = reader
        self.level_arrays = level_arrays

    @staticmethod
    def parse_svs_metadata(pages: TiffPages) -> dict:
        """Extract SVS specific metadata.

        Returns:
            dict:
                Dictionary of kwargs for WSIMeta.

        """
        raw = {}
        mpp = None
        objective_power = None
        vendor = "Aperio"

        description = pages[0].description
        raw["Description"] = description
        parts = description.split("|")
        description_headers, key_value_pairs = parts[0], parts[1:]
        description_headers = description_headers.split(";")

        software, photometric_info = description_headers[0].splitlines()
        raw["Software"] = software
        raw["Photometric Info"] = photometric_info

        def parse_svs_tag(string: str) -> tuple[str, Number | str]:
            """Parse SVS key-value string.

            Infers type(s) of data by trial and error with a fallback to
            the original string type.

            Args:
                string (str):
                    Key-value string in SVS format: "key=value".

            Returns:
                tuple:
                    Key-value pair.

            """
            pair = string.split("=")
            if len(pair) != 2:  # noqa: PLR2004
                msg = "Invalid metadata. Expected string of the format 'key=value'."
                raise ValueError(
                    msg,
                )
            key, value_string = pair
            key = key.strip()
            value_string = value_string.strip()

            def us_date(string: str) -> datetime:
                """Return datetime parsed according to US date format (UTC-aware)."""
                # and we immediately attach UTC.
                dt = datetime.strptime(string, r"%m/%d/%y")  # noqa: DTZ007
                return dt.replace(tzinfo=timezone.utc)

            def time(string: str) -> datetime:
                """Return datetime parsed according to HMS format (UTC-aware)."""
                # parse to time first; although .time() is tz-agnostic
                # DTZ007 is triggered by strptime
                t = datetime.strptime(string, r"%H:%M:%S").time()  # noqa: DTZ007
                today_utc = datetime.now(timezone.utc)
                return today_utc.replace(
                    hour=t.hour, minute=t.minute, second=t.second, microsecond=0
                )

            casting_precedence = [us_date, time, int, float]
            value = value_string
            for cast in casting_precedence:
                try:
                    value = cast(value_string)
                except ValueError:  # noqa: PERF203
                    continue
                else:
                    return key, value

            return key, value

        svs_tags = dict(parse_svs_tag(string) for string in key_value_pairs)
        raw["SVS Tags"] = svs_tags
        mpp = svs_tags.get("MPP")
        if mpp is not None:  # pragma: no cover
            mpp = [mpp] * 2
        objective_power = svs_tags.get("AppMag")

        return {
            "objective_power": objective_power,
            "vendor": vendor,
            "mpp": mpp,
            "raw": raw,
        }

    @staticmethod
    def canonical_shape(axes: str, shape: tuple[int, int]) -> tuple[int, int]:
        """Make a level shape tuple in YXS order.

        Args:
            axes (str): The axes format.
            shape (tuple[int, int]): Input shape tuple.

        Returns:
            tuple[int, int]: Shape in YXS order.
        """
        if axes in ("YXS", "YXC"):
            return shape
        if axes in ("SYX", "CYX"):
            return np.roll(shape, -1)
        msg = f"Unsupported axes `{axes}`."
        raise ValueError(msg)

    def read_rect(
        self,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a region of the whole slide image at a location and size.

        Location is in terms of the baseline image (level 0  / maximum
        resolution), and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The field of view varies with resolution. For a fixed field of
        view see :func:`read_bounds`.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by VirtualWSIReader. See class
                docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=size[0], N=size[1]

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> img = wsi.read_rect(location, size)
            >>> # Read a region at 0.5 microns per pixel (mpp)
            >>> img = wsi.read_rect(location, size, 0.5, "mpp")
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.5),
            ...     units="mpp",
            ... )

        Note: The field of view varies with resolution when using
        :func:`read_rect`.

        .. figure:: ../images/read_rect_tissue.png
            :width: 512
            :alt: Diagram illustrating read_rect

        As the location is in the baseline reference frame but the size
        (width and height) is the output image size, the field of view
        therefore changes as resolution changes.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        .. figure:: ../images/read_rect-interpolated-reads.png
            :width: 512
            :alt: Diagram illustrating read_rect interpolting between levels

        When reading between the levels stored in the WSI, the
        coordinates of the requested region are projected to the next
        highest resolution. This resolution is then decoded and
        downsampled to produce the desired output. This is a major
        source of variability in the time take to perform a read
        operation. Reads which require reading a large region before
        downsampling will be significantly slower than reading at a
        fixed level.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # The resolution can be different in x and y, e.g.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.75),
            ...     units="mpp",
            ... )
            >>> # Several units can be used including: objective power,
            >>> # microns per pixel, pyramid/resolution level, and
            >>> # fraction of baseline.
            >>> # E.g. Read a region at an objective power of 10x
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=10,
            ...     units="power",
            ... )
            >>> # Read a region at pyramid / resolution level 1
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1,
            ...     units="level",
            ... )
            >>> # Read at a fractional level, this will linearly
            >>> # interpolate the downsampling factor between levels.
            >>> # E.g. if levels 0 and 1 have a downsampling of 1x and
            >>> # 2x of baseline, then level 0.5 will correspond to a
            >>> # downsampling factor 1.5x of baseline.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="level",
            ... )
            >>> # Read a region at half of the full / baseline
            >>> # resolution.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="baseline",
            ... )
            >>> # Read at a higher resolution than the baseline
            >>> # (interpolation applied to output)
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1.25,
            ...     units="baseline",
            ... )
            >>> # Assuming the image has a native mpp of 0.5,
            >>> # interpolation will be applied here.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.25,
            ...     units="mpp",
            ... )

        """
        if coord_space == "resolution":
            im_region = self.reader.read_rect_at_resolution(
                location,
                size,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
            )
            if self.reader.post_proc is not None:
                im_region = self.reader.post_proc(im_region)
            return im_region

        # Find parameters for optimal read
        (
            read_level,
            level_read_location,
            level_read_size,
            post_read_scale,
            _,
        ) = self.reader.find_read_rect_params(
            location=location,
            size=size,
            resolution=resolution,
            units=units,
        )

        bounds = utils.transforms.locsize2bounds(
            location=level_read_location,
            size=level_read_size,
        )
        im_region = utils.image.safe_padded_read(
            image=self.level_arrays[read_level],
            bounds=bounds,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )
        im_region = utils.transforms.imresize(
            img=im_region,
            scale_factor=post_read_scale,
            output_size=size,
            interpolation=interpolation,
        )

        if self.reader.post_proc is not None:
            im_region = self.reader.post_proc(im_region)
        return im_region

    def read_bounds(
        self: TIFFWSIReaderDelegate,
        bounds: IntBounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a region of the whole slide image within given bounds.

        Bounds are in terms of the baseline image (level 0  / maximum
        resolution).

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                Units of resolution, default="level". Supported units
                are: microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=end_h-start_h, N=end_w-start_w

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> from matplotlib import pyplot as plt
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> bounds = [1000, 2000, 2000, 3000]
            >>> img = wsi.read_bounds(bounds)
            >>> plt.imshow(img)
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_bounds(
            ...     bounds,
            ...     resolution=0,
            ...     units="level",
            ... )
            >>> plt.imshow(img)

        Note: The field of view remains the same as resolution is varied
        when using :func:`read_bounds`.

        .. figure:: ../images/read_bounds_tissue.png
            :width: 512
            :alt: Diagram illustrating read_bounds

        This is because the bounds are in the baseline (level 0)
        reference frame. Therefore, varying the resolution does not
        change what is visible within the output image.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        """
        bounds_at_baseline = bounds
        if coord_space == "resolution":
            bounds_at_baseline = self.reader.bounds_at_resolution_to_baseline(
                bounds,
                resolution,
                units,
            )
            _, size_at_requested = utils.transforms.bounds2locsize(bounds)
            # don't use the `output_size` (`size_at_requested`) here
            # because the rounding error at `bounds_at_baseline` leads to
            # different `size_at_requested` (keeping same read resolution
            # but base image is of different scale)
            (
                read_level,
                bounds_at_read_level,
                _,
                post_read_scale,
            ) = self.reader.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        else:  # duplicated portion with VirtualReader, factoring out ?
            # Find parameters for optimal read
            (
                read_level,
                bounds_at_read_level,
                size_at_requested,
                post_read_scale,
            ) = self.reader.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )

        im_region = utils.image.sub_pixel_read(
            image=self.level_arrays[read_level],
            bounds=bounds_at_read_level,
            output_size=size_at_requested,
            interpolation=interpolation,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
            read_kwargs=kwargs,
            pad_at_baseline=False,
        )

        if coord_space == "resolution":
            # do this to enforce output size is as defined by input bounds
            im_region = utils.transforms.imresize(
                img=im_region,
                output_size=size_at_requested,
            )
        else:
            im_region = utils.transforms.imresize(
                img=im_region,
                scale_factor=post_read_scale,
                output_size=size_at_requested,
            )

        if self.reader.post_proc is not None:
            return self.reader.post_proc(im_region)
        return im_region

    @staticmethod
    def parse_generic_tiff_metadata(pages: TiffPages) -> dict:
        """Extract generic tiled metadata.

        Returns:
            dict: Dictionary of kwargs for WSIMeta.

        """
        mpp = None
        objective_power = None
        vendor = "Generic"

        description = pages[0].description
        raw = {"Description": description}
        # Check for MPP in the tiff resolution tags
        # res_units: 1 = undefined, 2 = inch, 3 = centimeter
        res_units = pages[0].tags.get("ResolutionUnit")
        res_x = pages[0].tags.get("XResolution")
        res_y = pages[0].tags.get("YResolution")
        if (  # pragma: no cover
            all(x is not None for x in [res_units, res_x, res_y])
            and res_units.value != 1
        ):
            mpp = [
                utils.misc.ppu2mpp(res_x.value[0] / res_x.value[1], res_units.value),
                utils.misc.ppu2mpp(res_y.value[0] / res_y.value[1], res_units.value),
            ]

        return {
            "objective_power": objective_power,
            "vendor": vendor,
            "mpp": mpp,
            "raw": raw,
        }


class DICOMWSIReader(WSIReader):
    """Define DICOM WSI Reader."""

    wsidicom = None

    def __init__(
        self: DICOMWSIReader,
        input_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        post_proc: str | callable | None = "auto",
    ) -> None:
        """Initialize :class:`DICOMWSIReader`."""
        from wsidicom import WsiDicom  # noqa: PLC0415

        super().__init__(input_img, mpp, power, post_proc)
        self.wsi = WsiDicom.open(input_img)

    def _info(self: DICOMWSIReader) -> WSIMeta:
        """WSI metadata constructor.

        Returns:
            WSIMeta:
                Containing metadata.

        """
        level_dimensions = [
            (level.size.width, level.size.height) for level in self.wsi.levels
        ]
        level_downsamples = [
            np.mean(
                [
                    level_dimensions[0][0] / level.size.width,
                    level_dimensions[0][1] / level.size.height,
                ],
            )
            for level in self.wsi.levels
        ]
        dataset = self.wsi.levels.base_level.datasets[0]
        # Get pixel spacing in mm from DICOM file and convert to um/px (mpp)
        mm_per_pixel = dataset.pixel_spacing
        mpp = (mm_per_pixel.width * 1e3, mm_per_pixel.height * 1e3)

        objective_power = None
        ops_seq = getattr(dataset, "OpticalPathSequence", None)
        if ops_seq:
            ops = ops_seq[0]
            if hasattr(ops, "ObjectiveLensPower"):
                objective_power = ops.ObjectiveLensPower

        # Fallback to calculating objective power & mpp when metadata is missing.
        objective_power, mpp = self._estimate_mpp_objective_power(
            objective_power=objective_power,
            mpp=mpp,
        )

        return WSIMeta(
            slide_dimensions=level_dimensions[0],
            level_dimensions=level_dimensions,
            level_downsamples=level_downsamples,
            axes="YXS",
            mpp=mpp,
            objective_power=objective_power,
            level_count=len(level_dimensions),
            vendor=dataset.Manufacturer,
            file_path=self.input_path,
        )

    def read_rect(
        self: DICOMWSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a region of the whole slide image at a location and size.

        Location is in terms of the baseline image (level 0  / maximum
        resolution), and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The field of view varies with resolution. For a fixed field of
        view see :func:`read_bounds`.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by VirtualWSIReader. See class
                docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=size[0], N=size[1]

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> img = wsi.read_rect(location, size)
            >>> # Read a region at 0.5 microns per pixel (mpp)
            >>> img = wsi.read_rect(location, size, 0.5, "mpp")
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.5),
            ...     units="mpp",
            ... )

        Note: The field of view varies with resolution when using
        :func:`read_rect`.

        .. figure:: ../images/read_rect_tissue.png
            :width: 512
            :alt: Diagram illustrating read_rect

        As the location is in the baseline reference frame but the size
        (width and height) is the output image size, the field of view
        therefore changes as resolution changes.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        .. figure:: ../images/read_rect-interpolated-reads.png
            :width: 512
            :alt: Diagram illustrating read_rect interpolting between levels

        When reading between the levels stored in the WSI, the
        coordinates of the requested region are projected to the next
        highest resolution. This resolution is then decoded and
        downsampled to produce the desired output. This is a major
        source of variability in the time take to perform a read
        operation. Reads which require reading a large region before
        downsampling will be significantly slower than reading at a
        fixed level.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # The resolution can be different in x and y, e.g.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.75),
            ...     units="mpp",
            ... )
            >>> # Several units can be used including: objective power,
            >>> # microns per pixel, pyramid/resolution level, and
            >>> # fraction of baseline.
            >>> # E.g. Read a region at an objective power of 10x
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=10,
            ...     units="power",
            ... )
            >>> # Read a region at pyramid / resolution level 1
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1,
            ...     units="level",
            ... )
            >>> # Read at a fractional level, this will linearly
            >>> # interpolate the downsampling factor between levels.
            >>> # E.g. if levels 0 and 1 have a downsampling of 1x and
            >>> # 2x of baseline, then level 0.5 will correspond to a
            >>> # downsampling factor 1.5x of baseline.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="level",
            ... )
            >>> # Read a region at half of the full / baseline
            >>> # resolution.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="baseline",
            ... )
            >>> # Read at a higher resolution than the baseline
            >>> # (interpolation applied to output)
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1.25,
            ...     units="baseline",
            ... )
            >>> # Assuming the image has a native mpp of 0.5,
            >>> # interpolation will be applied here.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.25,
            ...     units="mpp",
            ... )

        """
        if coord_space == "resolution":
            return self.read_rect_at_resolution(
                location,
                size,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
            )

        # Find parameters for optimal read
        (
            read_level,
            level_location,
            level_read_size,
            post_read_scale,
            _,
        ) = self.find_read_rect_params(
            location=location,
            size=size,
            resolution=resolution,
            units=units,
        )

        wsi = self.wsi

        # Read at optimal level and corrected read size
        level_size = self.info.level_dimensions[read_level]
        constrained_read_bounds = utils.image.find_overlap(
            read_location=level_location,
            read_size=level_read_size,
            image_size=level_size,
        )
        _, constrained_read_size = utils.transforms.bounds2locsize(
            constrained_read_bounds,
        )

        # if out of bounds, return empty image consistent with openslide
        if np.any(np.array(constrained_read_size) <= 0):
            return (
                np.ones(
                    shape=(int(size[1]), int(size[0]), 3),
                    dtype=np.uint8,
                )
                * 255
            )

        dicom_level = wsi.levels[read_level].level
        im_region = wsi.read_region(level_location, dicom_level, constrained_read_size)
        im_region = np.array(im_region)

        # Apply padding outside the slide area
        level_read_bounds = utils.transforms.locsize2bounds(
            level_location,
            level_read_size,
        )
        im_region = utils.image.crop_and_pad_edges(
            bounds=level_read_bounds,
            max_dimensions=level_size,
            region=im_region,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        # Resize to correct scale if required
        im_region = utils.transforms.imresize(
            img=im_region,
            scale_factor=post_read_scale,
            output_size=tuple(np.array(size).astype(int)),
            interpolation=interpolation,
        )

        if self.post_proc is not None:
            im_region = self.post_proc(im_region)
        return utils.transforms.background_composite(image=im_region, alpha=False)

    def read_bounds(
        self: DICOMWSIReader,
        bounds: IntBounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a region of the whole slide image within given bounds.

        Bounds are in terms of the baseline image (level 0  / maximum
        resolution).

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                Units of resolution, default="level". Supported units
                are: microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=end_h-start_h, N=end_w-start_w

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> from matplotlib import pyplot as plt
            >>> wsi = WSIReader.open(input_img="./CMU-1.ndpi")
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> bounds = [1000, 2000, 2000, 3000]
            >>> img = wsi.read_bounds(bounds)
            >>> plt.imshow(img)
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_bounds(
            ...     bounds,
            ...     resolution=0,
            ...     units="level",
            ... )
            >>> plt.imshow(img)

        Note: The field of view remains the same as resolution is varied
        when using :func:`read_bounds`.

        .. figure:: ../images/read_bounds_tissue.png
            :width: 512
            :alt: Diagram illustrating read_bounds

        This is because the bounds are in the baseline (level 0)
        reference frame. Therefore, varying the resolution does not
        change what is visible within the output image.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        """
        # convert from requested to `baseline`
        bounds_at_baseline = bounds
        if coord_space == "resolution":
            bounds_at_baseline = self.bounds_at_resolution_to_baseline(
                bounds,
                resolution,
                units,
            )
            _, size_at_requested = utils.transforms.bounds2locsize(bounds)
            # don't use the `output_size` (`size_at_requested`) here
            # because the rounding error at `bounds_at_baseline` leads to
            # different `size_at_requested` (keeping same read resolution
            # but base image is of different scale)
            (
                read_level,
                bounds_at_read_level,
                _,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        else:  # duplicated portion with VirtualReader, factoring out ?
            # Find parameters for optimal read
            (
                read_level,
                bounds_at_read_level,
                size_at_requested,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )

        wsi = self.wsi

        # Read at optimal level and corrected read size
        level_location, size_at_read_level = utils.transforms.bounds2locsize(
            bounds_at_read_level,
        )
        level_size = self.info.level_dimensions[read_level]
        read_bounds = utils.image.find_overlap(
            level_location,
            size_at_read_level,
            level_size,
        )
        _, read_size = utils.transforms.bounds2locsize(read_bounds)
        dicom_level = wsi.levels[read_level].level
        im_region = wsi.read_region(
            location=level_location,
            level=dicom_level,
            size=read_size,
        )
        im_region = np.array(im_region)

        # Apply padding outside the slide area
        im_region = utils.image.crop_and_pad_edges(
            bounds=bounds_at_read_level,
            max_dimensions=self.info.level_dimensions[read_level],
            region=im_region,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        # Resize to correct scale if required
        if coord_space == "resolution":
            im_region = utils.transforms.imresize(
                img=im_region,
                output_size=size_at_requested,
                interpolation=interpolation,
            )
        else:
            im_region = utils.transforms.imresize(
                img=im_region,
                scale_factor=post_read_scale,
                output_size=size_at_requested,
                interpolation=interpolation,
            )

        if self.post_proc is not None:
            return self.post_proc(im_region)
        return utils.transforms.background_composite(image=im_region, alpha=False)


class NGFFWSIReader(WSIReader):
    """Reader for NGFF WSI zarr(s).

    Support is currently experimental. This supports reading from
    NGFF version 0.4.

    """

    def __init__(self: NGFFWSIReader, path: str | Path, **kwargs: dict) -> None:
        """Initialize :class:`NGFFWSIReader`."""
        super().__init__(path, **kwargs)
        from imagecodecs import numcodecs  # noqa: PLC0415

        from tiatoolbox.wsicore.metadata import ngff  # noqa: PLC0415

        numcodecs.register_codecs()
        store = zarr.SQLiteStore(path) if is_sqlite3(path) else path
        self._zarr_group: zarr.hierarchy.Group = zarr.open(store, mode="r")
        attrs = self._zarr_group.attrs
        multiscales = attrs["multiscales"][0]
        axes = multiscales["axes"]
        datasets = multiscales["datasets"]
        omero = attrs["omero"]
        self.zattrs = ngff.Zattrs(
            _creator=ngff.Creator(
                name=attrs.get("name"),
                version=attrs.get("version"),
            ),
            multiscales=ngff.Multiscales(
                version=multiscales.get("version"),
                axes=[ngff.Axis(**axis) for axis in axes],
                datasets=[
                    ngff.Dataset(
                        path=dataset["path"],
                        coordinateTransformations=dataset.get(
                            "coordinateTransformations",
                        ),
                    )
                    for dataset in datasets
                ],
            ),
            omero=ngff.Omero(
                name=omero.get("name"),
                id=omero.get("id"),
                channels=[ngff.Channel(**channel) for channel in omero["channels"]],
                rdefs=ngff.RDefs(**omero["rdefs"]),
                version=omero.get("version"),
            ),
            _ARRAY_DIMENSIONS=attrs["_ARRAY_DIMENSIONS"],
        )
        self.level_arrays = {
            int(key): ArrayView(array, axes=self.info.axes)
            for key, array in self._zarr_group.arrays()
        }

    def _info(self: NGFFWSIReader) -> WSIMeta:
        """WSI metadata constructor.

        Returns:
            WSIMeta:
                Containing metadata.

        """
        multiscales = self.zattrs.multiscales
        mpp = self._get_mpp()
        # This needs to be replaced once an appropriate image is available for test.
        objective_power = None

        # Fallback to calculating objective power & mpp
        objective_power, mpp = self._estimate_mpp_objective_power(
            objective_power=objective_power,
            mpp=mpp,
        )
        return WSIMeta(
            axes="".join(axis.name.upper() for axis in multiscales.axes),
            level_dimensions=[
                array.shape[:2][::-1]
                for _, array in sorted(self._zarr_group.arrays(), key=lambda x: x[0])
            ],
            slide_dimensions=self._zarr_group[0].shape[:2][::-1],
            vendor=self.zattrs._creator.name,  # skipcq: PYL-W0212  # noqa: SLF001
            raw=self._zarr_group.attrs,
            mpp=mpp,
            objective_power=objective_power,
        )

    def _get_mpp(self: NGFFWSIReader) -> tuple[float, float] | None:
        """Get the microns-per-pixel (MPP) of the slide.

        Returns:
            Tuple[float, float]:
                The mpp of the slide an x,y tuple. None if not available.

        """
        # Check that the required axes are present
        multiscales = self.zattrs.multiscales
        axes_dict = {a.name.lower(): a for a in multiscales.axes}
        if "x" not in axes_dict or "y" not in axes_dict:
            return None
        x = axes_dict["x"]
        y = axes_dict["y"]

        # Check the units,
        # Currently only handle micrometer units
        if x.unit != y.unit != "micrometer":
            logger.warning(
                "Expected units of micrometer, got %s and %s",
                x.unit,
                y.unit,
            )
            return None

        # Check that datasets is non-empty and has at least one coordinateTransformation
        if (
            not multiscales.datasets
            or not multiscales.datasets[0].coordinateTransformations
        ):
            return None

        # Currently simply using the first scale transform
        transforms = multiscales.datasets[0].coordinateTransformations
        for t in transforms:
            if "scale" in t and t.get("type") == "scale":
                x_index = multiscales.axes.index(x)
                y_index = multiscales.axes.index(y)
                return (t["scale"][x_index], t["scale"][y_index])
        return None

    def read_rect(
        self: NGFFWSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a region of the whole slide image at a location and size.

        Location is in terms of the baseline image (level 0  / maximum
        resolution), and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The field of view varies with resolution. For a fixed field of
        view see :func:`read_bounds`.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by VirtualWSIReader. See class
                docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=size[0], N=size[1]

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ome.zarr")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> img = wsi.read_rect(location, size)
            >>> # Read a region at 0.5 microns per pixel (mpp)
            >>> img = wsi.read_rect(location, size, 0.5, "mpp")
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.5),
            ...     units="mpp",
            ... )

        Note: The field of view varies with resolution when using
        :func:`read_rect`.

        .. figure:: ../images/read_rect_tissue.png
            :width: 512
            :alt: Diagram illustrating read_rect

        As the location is in the baseline reference frame but the size
        (width and height) is the output image size, the field of view
        therefore changes as resolution changes.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        .. figure:: ../images/read_rect-interpolated-reads.png
            :width: 512
            :alt: Diagram illustrating read_rect interpolting between levels

        When reading between the levels stored in the WSI, the
        coordinates of the requested region are projected to the next
        highest resolution. This resolution is then decoded and
        downsampled to produce the desired output. This is a major
        source of variability in the time take to perform a read
        operation. Reads which require reading a large region before
        downsampling will be significantly slower than reading at a
        fixed level.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load a WSI image
            >>> wsi = WSIReader.open(input_img="./CMU-1.ome.zarr")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # The resolution can be different in x and y, e.g.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.75),
            ...     units="mpp",
            ... )
            >>> # Several units can be used including: objective power,
            >>> # microns per pixel, pyramid/resolution level, and
            >>> # fraction of baseline.
            >>> # E.g. Read a region at an objective power of 10x
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=10,
            ...     units="power",
            ... )
            >>> # Read a region at pyramid / resolution level 1
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1,
            ...     units="level",
            ... )
            >>> # Read at a fractional level, this will linearly
            >>> # interpolate the downsampling factor between levels.
            >>> # E.g. if levels 0 and 1 have a downsampling of 1x and
            >>> # 2x of baseline, then level 0.5 will correspond to a
            >>> # downsampling factor 1.5x of baseline.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="level",
            ... )
            >>> # Read a region at half of the full / baseline
            >>> # resolution.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="baseline",
            ... )
            >>> # Read at a higher resolution than the baseline
            >>> # (interpolation applied to output)
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1.25,
            ...     units="baseline",
            ... )
            >>> # Assuming the image has a native mpp of 0.5,
            >>> # interpolation will be applied here.
            >>> img = wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.25,
            ...     units="mpp",
            ... )

        """
        if coord_space == "resolution":
            im_region = self.read_rect_at_resolution(
                location,
                size,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
            )
            return utils.transforms.background_composite(image=im_region, alpha=False)

        # Find parameters for optimal read
        (
            read_level,
            _,
            _,
            post_read_scale,
            baseline_read_size,
        ) = self.find_read_rect_params(
            location=location,
            size=size,
            resolution=resolution,
            units=units,
        )

        bounds = utils.transforms.locsize2bounds(
            location=location,
            size=baseline_read_size,
        )
        im_region = utils.image.safe_padded_read(
            image=self.level_arrays[read_level],
            bounds=bounds,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        im_region = utils.transforms.imresize(
            img=im_region,
            scale_factor=post_read_scale,
            output_size=size,
            interpolation=interpolation,
        )

        if self.post_proc is not None:
            im_region = self.post_proc(im_region)
        return utils.transforms.background_composite(image=im_region, alpha=False)

    def read_bounds(
        self: NGFFWSIReader,
        bounds: IntBounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a region of the whole slide image within given bounds.

        Bounds are in terms of the baseline image (level 0  / maximum
        resolution).

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                Units of resolution, default="level". Supported units
                are: microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, IntPair):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=end_h-start_h, N=end_w-start_w

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> from matplotlib import pyplot as plt
            >>> wsi = WSIReader.open(input_img="./CMU-1.ome.zarr")
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> bounds = [1000, 2000, 2000, 3000]
            >>> img = wsi.read_bounds(bounds)
            >>> plt.imshow(img)
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_bounds(
            ...     bounds,
            ...     resolution=0,
            ...     units="level",
            ... )
            >>> plt.imshow(img)

        Note: The field of view remains the same as resolution is varied
        when using :func:`read_bounds`.

        .. figure:: ../images/read_bounds_tissue.png
            :width: 512
            :alt: Diagram illustrating read_bounds

        This is because the bounds are in the baseline (level 0)
        reference frame. Therefore, varying the resolution does not
        change what is visible within the output image.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        """
        bounds_at_baseline = bounds
        if coord_space == "resolution":
            bounds_at_baseline = self.bounds_at_resolution_to_baseline(
                bounds,
                resolution,
                units,
            )
            _, size_at_requested = utils.transforms.bounds2locsize(bounds)
            # don't use the `output_size` (`size_at_requested`) here
            # because the rounding error at `bounds_at_baseline` leads to
            # different `size_at_requested` (keeping same read resolution
            # but base image is of different scale)
            (
                read_level,
                _,
                _,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        else:  # duplicated portion with VirtualReader, factoring out ?
            # Find parameters for optimal read
            (
                read_level,
                _,
                size_at_requested,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )

        im_region = utils.image.sub_pixel_read(
            image=self.level_arrays[read_level],
            bounds=bounds_at_baseline,
            output_size=size_at_requested,
            interpolation=interpolation,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
            read_kwargs=kwargs,
            pad_at_baseline=False,
        )

        if coord_space == "resolution":
            # do this to enforce output size is as defined by input bounds
            im_region = utils.transforms.imresize(
                img=im_region,
                output_size=size_at_requested,
            )
        else:
            im_region = utils.transforms.imresize(
                img=im_region,
                scale_factor=post_read_scale,
                output_size=size_at_requested,
            )
        if self.post_proc is not None:
            im_region = self.post_proc(im_region)

        return im_region


class AnnotationStoreReader(WSIReader):
    """Reader for Annotation stores.

    This reader is used to read annotation store data as if it were a WSI,
    rendering the annotations in the specified region to be read. Can be used
    either to render annotations as a stand-alone mask, or to render annotations
    on top of its parent WSI as a virtual 'annotated slide'.
    Note: Currently only supports annotations stored at the same resolution as
    the parent WSI base resolution. Support for annotations stored at arbitrary
    resolutions will be added in the future.

    Args:
        store (AnnotationStore | str | Path):
            An AnnotationStore or a path to an annotation store .db file.
        info (WSIMeta):
            Metadata of the base WSI for the annotations in the store.
            If this is not provided, will attempt to read it read from
            the store metadata, or the base_wsi if provided.
            If no source of metadata is found, will raise an error.
        renderer (AnnotationRenderer):
            Renderer to use for rendering annotations. Providing a renderer
            allows for customisation of the rendering process. If not provided,
            a sensible default will be created.
        base_wsi (WSIReader | str):
            Base WSI reader or path to use for reading the base WSI. Annotations
            will be rendered on top of the base WSI. If not provided,
            will render annotation masks without a base image.
        alpha (float):
            Opacity of the overlaid annotations. Must be between 0 and 1.
            Has no effect if base_wsi is not provided.

    """

    def __init__(
        self: AnnotationStoreReader,
        store: AnnotationStore | str | Path,
        info: WSIMeta | None = None,
        renderer: AnnotationRenderer | None = None,
        base_wsi: WSIReader | str | None = None,
        alpha: float = 1.0,
        **kwargs: dict,
    ) -> None:
        """Initialize :class:`AnnotationStoreReader`."""
        super().__init__(store, **kwargs)
        self.store = (
            SQLiteStore(Path(store)) if isinstance(store, (str, Path)) else store
        )
        self.base_wsi = base_wsi
        if isinstance(base_wsi, (str, Path)):
            self.base_wsi = WSIReader.open(base_wsi)
        if info is None:
            # try to get metadata from store
            try:
                info = WSIMeta(**json.loads(self.store.metadata["wsi_meta"]))
            except KeyError as exc:
                if self.base_wsi is not None:
                    # get the metadata from the base reader.
                    # assumes annotations saved at WSI baseline res
                    info = self.base_wsi.info
                else:
                    # we cant find any metadata
                    msg = (
                        "No metadata found in store. "
                        "Please provide either info or base slide."
                    )
                    raise ValueError(
                        msg,
                    ) from exc
        self.info = info
        if renderer is None:
            types = self.store.pquery("props['type']")
            if len(types) == 0:
                renderer = AnnotationRenderer(max_scale=1000)
            else:
                renderer = AnnotationRenderer("type", list(types), max_scale=1000)
        renderer.edge_thickness = 0
        self.renderer = renderer
        if self.base_wsi is not None:
            self.on_slide = True
        self.alpha = alpha

    def _info(self: AnnotationStoreReader) -> WSIMeta:
        """Get the metadata of the slide."""
        return self.info

    def read_rect(
        self: AnnotationStoreReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | tuple[int, int] = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a region using start location and size (width, height).

        Read a region of the annotation mask, or annotated whole slide
        image at a location and size.

        Location is in terms of the baseline image (level 0  / maximum
        resolution), and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The field of view varies with resolution. For a fixed field of
        view see :func:`read_bounds`.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by VirtualWSIReader. See class
                docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=size[0], N=size[1]

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load an annotation store and associated wsi to be
            >>> # overlaid upon.
            >>> annotated_wsi = WSIReader.open(input_img="./CMU-1.db",
            >>>                         base_wsi="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> img = annotated_wsi.read_rect(location, size)
            >>> # Read a region at 0.5 microns per pixel (mpp)
            >>> img = annotated_wsi.read_rect(location, size, 0.5, "mpp")
            >>> # This could also be written more verbosely as follows
            >>> img = annotated_wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.5),
            ...     units="mpp",
            ... )

        Note: The field of view varies with resolution when using
        :func:`read_rect`.

        .. figure:: ../images/read_rect_tissue.png
            :width: 512
            :alt: Diagram illustrating read_rect

        As the location is in the baseline reference frame but the size
        (width and height) is the output image size, the field of view
        therefore changes as resolution changes.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        .. figure:: ../images/read_rect-interpolated-reads.png
            :width: 512
            :alt: Diagram illustrating read_rect interpolting between levels

        When reading between the levels stored in the WSI, the
        coordinates of the requested region are projected to the next
        highest resolution. This resolution is then decoded and
        downsampled to produce the desired output. This is a major
        source of variability in the time take to perform a read
        operation. Reads which require reading a large region before
        downsampling will be significantly slower than reading at a
        fixed level.

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # Load an annotation store and associated wsi to be
            >>> # overlaid upon.
            >>> annotated_wsi = WSIReader.open(input_img="./CMU-1.db",
            >>>                     base_wsi="./CMU-1.ndpi")
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # The resolution can be different in x and y, e.g.
            >>> img = annotated_wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=(0.5, 0.75),
            ...     units="mpp",
            ... )
            >>> # Several units can be used including: objective power,
            >>> # microns per pixel, pyramid/resolution level, and
            >>> # fraction of baseline.
            >>> # E.g. Read a region at an objective power of 10x
            >>> img = annotated_wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=10,
            ...     units="power",
            ... )
            >>> # Read a region at pyramid / resolution level 1
            >>> img = annotated_wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1,
            ...     units="level",
            ... )
            >>> # Read at a fractional level, this will linearly
            >>> # interpolate the downsampling factor between levels.
            >>> # E.g. if levels 0 and 1 have a downsampling of 1x and
            >>> # 2x of baseline, then level 0.5 will correspond to a
            >>> # downsampling factor 1.5x of baseline.
            >>> img = annotated_wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="level",
            ... )
            >>> # Read a region at half of the full / baseline
            >>> # resolution.
            >>> img = annotated_wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.5,
            ...     units="baseline",
            ... )
            >>> # Read at a higher resolution than the baseline
            >>> # (interpolation applied to output)
            >>> img = annotated_wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=1.25,
            ...     units="baseline",
            ... )
            >>> # Assuming the image has a native mpp of 0.5,
            >>> # interpolation will be applied here.
            >>> img = annotated_wsi.read_rect(
            ...     location,
            ...     size,
            ...     resolution=0.25,
            ...     units="mpp",
            ... )

        Annotations can also be displayed as a stand-alone mask not
        overlaid on the WSI. In this case, the metadata of the store
        must contain the resolution at which the annotations were saved
        at, and the slide dimensions at that resolution.
        Alternatively, an instance of WSIMeta can be provided describing the
        slide the annotations are associated with (in which case annotations
        are assumed to be saved at the baseline resolution given in the metadata).

        Example:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> # get metadata from the slide (could also manually create a
            >>> # WSIMeta object if you know the slide info but do not have the
            >>> # slide itself)
            >>> metadata = WSIReader.open("CMU-1.ndpi").info
            >>> # Load associated annotations
            >>> annotation_mask = WSIReader.open(input_img="./CMU-1.db", info=wsi_meta)
            >>> location = (0, 0)
            >>> size = (256, 256)
            >>> # Read a region of the mask at level 0 (baseline / full resolution)
            >>> img = annotation_mask.read_rect(location, size)
            >>> # Read a region of the mask at 0.5 microns per pixel (mpp)
            >>> img = annotation_mask.read_rect(location, size, 0.5, "mpp")

        """
        if coord_space == "resolution":
            return self.read_rect_at_resolution(
                location,
                size,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
            )

        # Find parameters for optimal read
        (
            read_level,
            _,
            _,
            post_read_scale,
            baseline_read_size,
        ) = self.find_read_rect_params(
            location=location,
            size=size,
            resolution=resolution,
            units=units,
        )

        bounds = utils.transforms.locsize2bounds(
            location=location,
            size=baseline_read_size,
        )
        im_region = self.renderer.render_annotations(
            self.store,
            bounds,
            self.info.level_downsamples[read_level],
        )

        im_region = utils.transforms.imresize(
            img=im_region,
            scale_factor=post_read_scale,
            output_size=size,
            interpolation=interpolation,
        )

        if self.base_wsi is not None:
            # overlay image region on the base wsi
            base_region = self.base_wsi.read_rect(
                location,
                size,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
                coord_space=coord_space,
                **kwargs,
            )
            base_region = Image.fromarray(
                utils.transforms.background_composite(base_region, alpha=True),
            )
            im_region = Image.fromarray(im_region)
            if self.alpha < 1.0:
                im_region.putalpha(
                    im_region.getchannel("A").point(lambda i: i * self.alpha),
                )
            base_region = Image.alpha_composite(base_region, im_region)
            base_region = base_region.convert("RGB")
            base_region = np.array(base_region)
            if self.post_proc is not None:
                base_region = self.post_proc(base_region)
            return base_region
        if self.post_proc is not None:
            im_region = self.post_proc(im_region)
        return utils.transforms.background_composite(im_region, alpha=False)

    def read_bounds(
        self: AnnotationStoreReader,
        bounds: IntBounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | tuple[int, int] = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a region by defining boundary locations.

        Read a region of the annotation mask, or annotated whole slide
        image within given bounds.

        Bounds are in terms of the baseline image (level 0  / maximum
        resolution).

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                Units of resolution, default="level". Supported units
                are: microns per pixel (mpp), objective power (power),
                pyramid / resolution level (level), pixels per baseline
                pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs (dict):
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                Array of size MxNx3 M=end_h-start_h, N=end_w-start_w

        Examples:
            >>> from tiatoolbox.wsicore.wsireader import WSIReader
            >>> from matplotlib import pyplot as plt
            >>> annotated_wsi = WSIReader.open(input_img="./CMU-1.db",
            >>>                          base_wsi="./CMU-1.ndpi")
            >>> # Read a region at level 0 (baseline / full resolution)
            >>> bounds = [1000, 2000, 2000, 3000]
            >>> img = annotated_wsi.read_bounds(bounds)
            >>> plt.imshow(img)
            >>> # This could also be written more verbosely as follows
            >>> img = wsi.read_bounds(
            ...     bounds,
            ...     resolution=0,
            ...     units="level",
            ... )
            >>> plt.imshow(img)

        Note: The field of view remains the same as resolution is varied
        when using :func:`read_bounds`.

        .. figure:: ../images/read_bounds_tissue.png
            :width: 512
            :alt: Diagram illustrating read_bounds

        This is because the bounds are in the baseline (level 0)
        reference frame. Therefore, varying the resolution does not
        change what is visible within the output image.

        If the WSI does not have a resolution layer corresponding
        exactly to the requested resolution (shown above in white with a
        dashed outline), a larger resolution is downscaled to achieve
        the correct requested output resolution.

        If the requested resolution is higher than the baseline (maximum
        resultion of the image), then bicubic interpolation is applied
        to the output image.

        """
        bounds_at_baseline = bounds
        if coord_space == "resolution":
            bounds_at_baseline = self.bounds_at_resolution_to_baseline(
                bounds,
                resolution,
                units,
            )
            _, size_at_requested = utils.transforms.bounds2locsize(bounds)
            # don't use the `output_size` (`size_at_requested`) here
            # because the rounding error at `bounds_at_baseline` leads to
            # different `size_at_requested` (keeping same read resolution
            # but base image is of different scale)
            (
                read_level,
                _,
                _,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        else:  # duplicated portion with VirtualReader, factoring out ?
            # Find parameters for optimal read
            (
                read_level,
                _,
                size_at_requested,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )

        im_region = self.renderer.render_annotations(
            self.store,
            bounds_at_baseline,
            self.info.level_downsamples[read_level],
        )

        if coord_space == "resolution":
            # do this to enforce output size is as defined by input bounds
            im_region = utils.transforms.imresize(
                img=im_region,
                output_size=size_at_requested,
            )
        else:
            im_region = utils.transforms.imresize(
                img=im_region,
                scale_factor=post_read_scale,
                output_size=size_at_requested,
            )
        if self.base_wsi is not None:
            # overlay image region on the base wsi
            base_region = self.base_wsi.read_bounds(
                bounds,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
                coord_space=coord_space,
                **kwargs,
            )
            base_region = Image.fromarray(
                utils.transforms.background_composite(base_region, alpha=True),
            )
            im_region = Image.fromarray(im_region)
            if self.alpha < 1.0:
                im_region.putalpha(
                    im_region.getchannel("A").point(lambda i: i * self.alpha),
                )
            base_region = Image.alpha_composite(base_region, im_region)
            base_region = base_region.convert("RGB")
            base_region = np.array(base_region)
            if self.post_proc is not None:
                base_region = self.post_proc(base_region)
            return base_region
        if self.post_proc is not None:
            im_region = self.post_proc(im_region)
        return utils.transforms.background_composite(im_region, alpha=False)


class TransformedWSIReader(WSIReader):
    """Resampling regions from a whole slide image.

    This class is used to resample tiles/patches from a whole slide image
    using transformation.

    Example:
        >>> from tiatoolbox.wsicore.wsireader import TransformedWSIReader
        >>> transform_level0 = np.eye(3)
        >>> transformed_wsi = TransformedWSIReader(
        ...     input_img=sample_ome_tiff, target_img=sample_ome_tiff,
        ...     transform=transform_level0
        ... )
        >>> output = transformed_wsi.read_rect(
        ...     location,
        ...     size,
        ...     resolution,
        ...     units="level"
        ... )

    """

    def __init__(
        self: TransformedWSIReader,
        input_img: str | Path | np.ndarray,
        target_img: str | Path | np.ndarray,
        mpp: tuple[Number, Number] | None = None,
        power: Number | None = None,
        transform: np.ndarray | str | Path = None,  # Default to None
        fixed_info: WSIMeta = None,
    ) -> None:
        """Initialize :class:`TransformedWSIReader`.

        Args:
            input_img (str | Path | np.ndarray):
                Path to the input image or the image array.
            target_img (str | Path | np.ndarray):
                Path to the input target image or the image array.
            mpp (tuple(Number, Number)):
                Microns per pixel in x and y directions.
            power (Number):
                Objective power of the image.
            transform (str | Path | np.ndarray):
                Transformation matrix or path to a transformation file (.npy or .mha).

                - .npy: A file format for storing numpy arrays.
                - .mha: MetaImage Header, a file format used for storing medical imaging
                        data. It is part of the MetaIO library. For more information,
                        visit: https://public.kitware.com/Wiki/ITK/MetaIO/Documentation
            fixed_info (WSIMeta):
                Fixed metadata to use for the transformed image.

        """
        super().__init__(input_img=input_img, mpp=mpp, power=power)
        self.wsi_reader = WSIReader.open(input_img=input_img, mpp=mpp, power=power)
        self.target_wsi_reader = WSIReader.open(
            input_img=target_img, mpp=mpp, power=power
        )
        if transform is None:
            error_message = (
                "Transform cannot be None. "
                "Please provide a valid transformation matrix or file."
            )
            raise ValueError(error_message)
        # we need to set the info to be the fixed image info
        if fixed_info is not None:
            self.wsi_reader.info = fixed_info
        self.transformed_info = self.target_wsi_reader.info
        self.transform_type = "affine"
        if isinstance(transform, np.ndarray):
            self.transform_level0 = transform
        elif transform.suffix == ".npy":
            self.transform_level0 = np.load(transform)
        elif transform.suffix == ".mha":
            # .mha (MetaImage Header) is a file format used for storing medical imaging
            # data. It is part of the MetaIO library. For more information, visit:
            # https://public.kitware.com/Wiki/ITK/MetaIO/Documentation
            displacement_field = sitk.ReadImage(transform, sitk.sitkVectorFloat64)
            disp_array = sitk.GetArrayFromImage(displacement_field)  # (2, H, W)
            displacement_field_channels = 2
            if disp_array.shape[-1] != displacement_field_channels:
                # maybe in torch format with channel first
                disp_array = np.moveaxis(disp_array, 0, -1)
            self.df_dims = np.array((disp_array.shape[1], disp_array.shape[0]))
            # scale factors are actually in relation to the largest dimension
            # from source and target image (so add offset and then scale)
            self.level_scale_factors = [
                np.asarray([s_dims, t_dims]).max(axis=0) / np.array(self.df_dims)
                for s_dims, t_dims in zip(
                    self.wsi_reader.info.level_dimensions,
                    self.target_wsi_reader.info.level_dimensions,
                    strict=False,
                )
            ]
            self.level_pads = [
                (((t_dims[0] - s_dims[0]) // 2), ((t_dims[1] - s_dims[1]) // 2))
                for s_dims, t_dims in zip(
                    self.wsi_reader.info.level_dimensions,
                    self.target_wsi_reader.info.level_dimensions,
                    strict=False,
                )
            ]
            self.get_location_array(disp_array)
            self.transform_type = "displacement"
        else:
            error_message = "Unsupported transformation file format"
            raise ValueError(error_message)

        if self.transform_type == "affine":
            wsimeta = self.wsi_reader.info
            baseline_size = self.wsi_reader.slide_dimensions(
                resolution=0, units="level"
            )
            _, transformed_shape = self.get_transformed_location(
                location=(0, 0),
                size=baseline_size,
                level=0,
            )

            # Calculate the new shape at each level based on downsampling
            wsimeta.level_dimensions = tuple(
                tuple(
                    np.asarray(transformed_shape)
                    // (baseline_size[0] // s_dims[0], baseline_size[1] // s_dims[1])
                )
                for s_dims in self.wsi_reader.info.level_dimensions
            )

            wsimeta.slide_dimensions = wsimeta.level_dimensions[0]
            self.transformed_info = wsimeta

    def get_location_array(self, disp_array: np.ndarray) -> None:
        """Transform an array of locations using the displacement field.

        Gives an inverse showing, for a given pixel in a transformed image, where it
        would come from in the original image.

        Args:
            disp_array (np.ndarray): A numpy array representing the displacement values.

        Returns:
            None
        """
        location_array = np.mgrid[
            0 : disp_array.shape[0], 0 : disp_array.shape[1]
        ].transpose(1, 2, 0)
        location_array = np.flip(location_array, 2)
        transformed_image = self.transform_using_disp_array(location_array, disp_array)

        # make a reader for convenient reading at desired locations/resolutions
        wsimeta = self.wsi_reader.info
        wsimeta.level_dimensions = tuple(
            tuple(np.asarray([s_dims, t_dims]).max(axis=0))
            for s_dims, t_dims in zip(
                self.wsi_reader.info.level_dimensions,
                self.target_wsi_reader.info.level_dimensions,
                strict=False,
            )
        )
        wsimeta.slide_dimensions = wsimeta.level_dimensions[0]
        self.inverse_loc_reader = VirtualWSIReader(
            transformed_image, info=wsimeta, mode="feature"
        )
        self.transformed_info = wsimeta

    @staticmethod
    def transform_using_disp_array(
        input_array: np.ndarray, disp_array: np.ndarray
    ) -> np.ndarray:
        """Transform an array of locations using the displacement field.

        Args:
            input_array (np.ndarray): A numpy array representing the input locations.
            disp_array (np.ndarray): A numpy array representing the displacement field.

        Returns:
            np.ndarray: The transformed array of locations.

        """
        input_image = sitk.GetImageFromArray(input_array, isVector=True)

        # Convert displacement field numpy array to SimpleITK image
        displacement_field = sitk.GetImageFromArray(disp_array, isVector=True)

        # Create a displacement field transform
        transform = sitk.DisplacementFieldTransform(displacement_field)

        # Set the interpolator
        interpolator = sitk.sitkLinear  # maybe others better?

        # Apply the transform to the input image
        transformed_image = sitk.Resample(
            input_image,
            input_image,
            transform,
            interpolator,
            outputPixelType=sitk.sitkVectorFloat32,
        )
        return sitk.GetArrayFromImage(transformed_image)

    def _info(self: TransformedWSIReader) -> WSIMeta:
        """Get the WSI metadata."""
        return self.transformed_info

    @staticmethod
    def transform_points(points: np.ndarray, transform: np.ndarray) -> np.ndarray:
        """Transform points using the given transformation matrix.

        Args:
            points (:class:`numpy.ndarray`):
                A set of points of shape (N, 2).
            transform (:class:`numpy.ndarray`):
                Transformation matrix of shape (3, 3).

        Returns:
            :class:`numpy.ndarray`:
                Warped points  of shape (N, 2).

        """
        points = np.array(points)
        # Pad the data with ones, so that our transformation can do translations
        points_pad = np.hstack([points, np.ones((points.shape[0], 1))])
        points_warp = np.dot(points_pad, transform.T)
        return points_warp[:, :-1]

    def get_patch_dimensions(
        self: TransformedWSIReader,
        size: tuple[int, int],
        transform: np.ndarray,
    ) -> tuple[int, int]:
        """Compute patch size needed for transformation.

        Args:
            size (tuple(int)):
                (width, height) tuple giving the desired output image size.
            transform (:class:`numpy.ndarray`):
                Transformation matrix of shape (3, 3).

        Returns:
            :py:obj:`tuple` - Maximum size of the patch needed for transformation.
                - :py:obj:`int` - Width
                - :py:obj:`int` - Height

        """
        width, height = size[0], size[1]

        x_info = [
            np.linspace(1, width, width, endpoint=True),
            np.ones(height) * width,
            np.linspace(1, width, width, endpoint=True),
            np.ones(height),
        ]
        x = np.array(list(itertools.chain.from_iterable(x_info)))

        y_info = [
            np.ones(width),
            np.linspace(1, height, height, endpoint=True),
            np.ones(width) * height,
            np.linspace(1, height, height, endpoint=True),
        ]
        y = np.array(list(itertools.chain.from_iterable(y_info)))

        points = np.array([x, y]).transpose()
        transform = transform * [[1, 1, 0], [1, 1, 0], [1, 1, 1]]  # remove translation
        transform_points = self.transform_points(points, transform)

        width = (
            int(np.max(transform_points[:, 0]))
            - int(np.min(transform_points[:, 0]))
            + 1
        )
        height = (
            int(np.max(transform_points[:, 1]))
            - int(np.min(transform_points[:, 1]))
            + 1
        )

        return (width, height)

    def get_transformed_location(
        self: TransformedWSIReader,
        location: tuple[int, int],
        size: tuple[int, int],
        level: int,
    ) -> tuple[tuple[int, int], tuple[int, int]]:
        """Get corresponding location on unregistered image and the required patch size.

        This function applies inverse transformation to the centre point of the region.
        The transformed centre point is used to obtain the transformed top left pixel
        of the region.

        Args:
            location (tuple(int)):
                (x, y) tuple giving the top left pixel in the baseline (level 0)
                reference frame.
            size (tuple(int)):
                (width, height) tuple giving the desired output image size.
            level (int):
                Pyramid level/resolution layer.

        Returns:
            tuple:
                - :py:obj:`tuple` - Transformed location (top left pixel).
                    - :py:obj:`int` - X coordinate
                    - :py:obj:`int` - Y coordinate
                - :py:obj:`tuple` - Maximum size suitable for transformation.
                    - :py:obj:`int` - Width
                    - :py:obj:`int` - Height

        """
        inv_transform = inv(self.transform_level0)
        size_level0 = [x * (2**level) for x in size]
        center_level0 = [x + size_level0[i] / 2 for i, x in enumerate(location)]
        center_level0_arr = np.expand_dims(np.array(center_level0), axis=0)
        center_level0_arr = self.transform_points(center_level0_arr, inv_transform)[0]

        transformed_size = self.get_patch_dimensions(size, inv_transform)
        transformed_location = (
            int(center_level0_arr[0] - (transformed_size[0] * (2**level)) / 2),
            int(center_level0_arr[1] - (transformed_size[1] * (2**level)) / 2),
        )
        return transformed_location, transformed_size

    @staticmethod
    def sample_image_opencv(
        a: np.ndarray,
        b: np.ndarray,
    ) -> np.ndarray:
        """Samples image a at positions specified by b using OpenCV's remap function.

        Parameters:
        - a: numpy array of shape (H, W, 3), the source image.
        - b: numpy array of shape (M, N, 2), the array of x, y positions.

        Returns:
        - output: numpy array of shape (M, N, 3), the sampled image.
        """
        # Convert b to float32 and split into x and y maps
        b_float = b.astype(np.float32)
        map_x = b_float[..., 0]
        map_y = b_float[..., 1]

        # Use cv2.remap to sample the image
        return cv2.remap(
            a,
            map_x,
            map_y,
            interpolation=cv2.INTER_LANCZOS4,
            borderMode=cv2.BORDER_CONSTANT,
            borderValue=0,
        )

    def get_transformed_location_df(
        self: TransformedWSIReader,
        location: tuple[int, int],
        size: tuple[int, int],
        level: int,
    ) -> tuple[tuple[int, int], tuple[int, int]]:
        """Get corresponding location on unregistered image and the required patch size.

        This function applies inverse transformation to the points in the region,
        in the case of a displacement field transform.
        The transformed points are used to obtain the transformed bounding box
        of the region.

        Args:
            location (tuple(int)):
                (x, y) tuple giving the top left pixel in the read resolution
                reference frame.
            size (tuple(int)):
                (width, height) tuple giving the desired output image size.
            level (int):
                Pyramid level/resolution layer.

        Returns:
            tuple:
                - :py:obj:`tuple` - Transformed location (top left pixel).
                    - :py:obj:`int` - X coordinate
                    - :py:obj:`int` - Y coordinate
                - :py:obj:`tuple` - Maximum size suitable for transformation.
                    - :py:obj:`int` - Width
                    - :py:obj:`int` - Height

        """
        # read relevant bit of inverse displacement field
        inv_locs = self.inverse_loc_reader.read_rect(
            location, size, level, coord_space="resolution", interpolation="lanczos"
        )
        # scale the field according to the level
        transformed_grid = inv_locs * self.level_scale_factors[level]

        # Find bounding box of transformed grid + padding
        pad = 2
        min_x = max(np.min(transformed_grid[:, :, 0]) - pad, 0)
        max_x = np.max(transformed_grid[:, :, 0]) + pad
        min_y = max(np.min(transformed_grid[:, :, 1]) - pad, 0)
        max_y = np.max(transformed_grid[:, :, 1]) + pad
        # shift the grid into this coordinate space
        transformed_grid = transformed_grid - np.array([min_x, min_y]) + pad
        location = (int(min_x), int(min_y))
        # Unpad
        location = (
            location[0] - self.level_pads[level][0],
            location[1] - self.level_pads[level][1],
        )
        size = (
            int(max_x - min_x),
            int(max_y - min_y),
        )
        return location, size, transformed_grid

    def transform_patch(
        self: TransformedWSIReader,
        patch: np.ndarray,
        size: tuple[int, int],
    ) -> np.ndarray:
        """Apply transformation to the given patch.

        This function applies the transformation matrix after removing the translation.

        Args:
            patch (:class:`numpy.ndarray`):
                A region of whole slide image.
            size (tuple(int)):
                (width, height) tuple giving the desired output image size.

        Returns:
            :class:`numpy.ndarray`:
                A transformed region/patch.

        """
        transform = self.transform_level0 * [[1, 1, 0], [1, 1, 0], [1, 1, 1]]
        translation = (-size[0] / 2 + 0.5, -size[1] / 2 + 0.5)
        forward_translation = np.array(
            [[1, 0, translation[0]], [0, 1, translation[1]], [0, 0, 1]],
        )
        inverse_translation = np.linalg.inv(forward_translation)
        transform = inverse_translation @ transform @ forward_translation
        return cv2.warpAffine(patch, transform[0:-1][:], patch.shape[:2][::-1])

    def read_rect(
        self: TransformedWSIReader,
        location: IntPair,
        size: IntPair,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,
    ) -> np.ndarray:
        """Read a transformed region of the transformed whole slide image.

        Location is in terms of the baseline image (level 0 / maximum resolution),
        and size is the output image size.

        Args:
            location (IntPair):
                (x, y) tuple giving the top left pixel in the baseline
                (level 0) reference frame.
            size (IntPair):
                (width, height) tuple giving the desired output image
                size.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int, tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `location` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs:
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                A transformed region/patch.

        Example:
            >>> from tiatoolbox.wsicore.wsireader import TransformedWSIReader
            >>> transform_level0 = np.eye(3)
            >>> tfm = TransformedWSIReader(
            ...     input_img=sample_ome_tiff, target_img=sample_ome_tiff,
            ...     transform=transform_level0
            ... )
            >>> output = tfm.read_rect(
            ...     location, size, resolution=resolution, units="level"
            ... )

        """
        if coord_space == "resolution":
            # In actuality, `read_rect` at resolution is synonymous with
            # calling `read_bound` at resolution because `size` has always
            # been within the resolution system.

            tl = np.array(location)
            br = location + np.array(size)
            bounds = np.concatenate([tl, br])

            return self.read_bounds(
                bounds,
                resolution=resolution,
                units=units,
                interpolation=interpolation,
                pad_mode=pad_mode,
                pad_constant_values=pad_constant_values,
                coord_space="resolution",
                **kwargs,
            )

        pad = 2
        (
            read_level,
            _level_location,
            level_size,
            post_read_scale,
            _baseline_read_size,
        ) = self.wsi_reader.find_read_rect_params(
            location=location,
            size=size,
            resolution=resolution,
            units=units,
        )

        if self.transform_type == "displacement":
            transformed_location, max_size, transformed_grid = (
                self.get_transformed_location_df(
                    _level_location - pad,
                    level_size + pad * 2,
                    read_level,
                )
            )
            # Read at optimal level and corrected read size
            patch = self.wsi_reader.read_rect(
                transformed_location, max_size, read_level, coord_space="resolution"
            )
        else:
            transformed_location, max_size = self.get_transformed_location(
                location,
                size,
                read_level,
            )
            patch = self.wsi_reader.read_region(
                transformed_location, read_level, max_size
            )
            # convert location to read resolution
            transformed_location = (
                int(transformed_location[0] / (2**read_level)),
                int(transformed_location[1] / (2**read_level)),
            )
        patch = np.array(patch)

        # Apply padding outside the slide area
        patch = utils.image.crop_and_pad_edges(
            bounds=utils.transforms.locsize2bounds(transformed_location, max_size),
            max_dimensions=self.info.level_dimensions[read_level],
            region=patch,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        # Apply transformation
        if self.transform_type == "displacement":
            transformed_patch = self.sample_image_opencv(patch, transformed_grid)
            transformed_patch = transformed_patch[pad:-pad, pad:-pad, :]
        else:
            transformed_patch = self.transform_patch(patch, max_size)

        # Resize to desired size
        post_read_scale = float(post_read_scale[0]), float(post_read_scale[1])
        return utils.transforms.imresize(
            img=transformed_patch,
            scale_factor=post_read_scale,
            output_size=size,
            interpolation=interpolation,
        )

    def read_bounds(
        self: TransformedWSIReader,
        bounds: Bounds,
        resolution: Resolution = 0,
        units: Units = "level",
        interpolation: str = "optimise",
        pad_mode: str = "constant",
        pad_constant_values: int | IntPair = 0,
        coord_space: str = "baseline",
        **kwargs: dict,  # noqa: ARG002
    ) -> np.ndarray:
        """Read a transformed region of the transformed whole slide image within bounds.

        Bounds are in terms of the baseline image (level 0 / maximum resolution),
        and size is the output image size.

        Reads can be performed at different resolutions by supplying a
        pair of arguments for the resolution and the units of
        resolution. If metadata does not specify `mpp` or
        `objective_power` then `baseline` units should be selected with
        resolution 1.0

        The output image size may be different to the width and height
        of the bounds as the resolution will affect this. To read a
        region with a fixed output image size see :func:`read_rect`.

        Args:
            bounds (IntBounds):
                By default, this is a tuple of (start_x, start_y, end_x,
                end_y) i.e. (left, top, right, bottom) of the region in
                baseline reference frame. However, with
                `coord_space="resolution"`, the bound is expected to be
                at the requested resolution system.
            resolution (Resolution):
                Resolution at which to read the image, default = 0.
                Either a single number or a sequence of two numbers for
                x and y are valid. This value is in terms of the
                corresponding units. For example: resolution=0.5 and
                units="mpp" will read the slide at 0.5 microns
                per-pixel, and resolution=3, units="level" will read at
                level at pyramid level / resolution layer 3.
            units (Units):
                The units of resolution, default = "level". Supported
                units are: microns per pixel (mpp), objective power
                (power), pyramid / resolution level (level), pixels per
                baseline pixel (baseline).
            coord_space (str):
                Coordinate space of the bounds. By default, the bounds
                are in the baseline reference frame. If
                `coord_space="resolution"` then the bounds are expected
                to be at the requested resolution system.
            interpolation (str):
                Method to use when resampling the output image. Possible
                values are "linear", "cubic", "lanczos", "area", and
                "optimise". Defaults to 'optimise' which will use cubic
                interpolation for upscaling and area interpolation for
                downscaling to avoid moiré patterns.
            pad_mode (str):
                Method to use when padding at the edges of the image.
                Defaults to 'constant'. See :func:`numpy.pad` for
                available modes.
            pad_constant_values (int | tuple(int)):
                Constant values to use when padding with constant pad mode.
                Passed to the :func:`numpy.pad` `constant_values` argument.
                Default is 0.
            coord_space (str):
                Defaults to "baseline". This is a flag to indicate if
                the input `bounds` is in the baseline coordinate system
                ("baseline") or is in the requested resolution system
                ("resolution").
            **kwargs:
                Extra key-word arguments for reader specific parameters.
                Currently only used by :obj:`VirtualWSIReader`. See
                class docstrings for more information.

        Returns:
            :class:`numpy.ndarray`:
                A transformed region/patch.

        Example:
            >>> from tiatoolbox.wsicore.wsireader import TransformedWSIReader
            >>> wsi = TransformedWSIReader(
            ...    input_img="cmu-1.ndpi", target_img="cmu-1.ndpi",
            ...    transform="transform.mha"
            ... )
            >>> # read a region of size 1000x1000 at 1.25x scale
            >>> # from (10000, 10000) at level 0
            >>> img = wsi.read_bounds(25000,25000,27000,27000)

        """
        pad = 2
        # convert from requested to `baseline`
        bounds_at_baseline = bounds
        if coord_space == "resolution":
            bounds_at_baseline = self.bounds_at_resolution_to_baseline(
                bounds,
                resolution,
                units,
            )
            _, size_at_requested = utils.transforms.bounds2locsize(bounds)
            # don't use the `output_size` (`size_at_requested`) here
            # because the rounding error at `bounds_at_baseline` leads to
            # different `size_at_requested` (keeping same read resolution
            # but base image is of different scale)
            (
                read_level,
                bounds_at_read_level,
                _,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )
        else:  # duplicated portion with VirtualReader, factoring out ?
            # Find parameters for optimal read
            (
                read_level,
                bounds_at_read_level,
                size_at_requested,
                post_read_scale,
            ) = self.find_read_bounds_params(
                bounds_at_baseline,
                resolution=resolution,
                units=units,
            )

        # Read at optimal level and corrected read size
        location_at_baseline = np.array(bounds_at_baseline[:2])
        _, size_at_read_level = utils.transforms.bounds2locsize(bounds_at_read_level)

        # Transform bounds and read untransformed image
        if self.transform_type == "displacement":
            transformed_location, max_size, transformed_grid = (
                self.get_transformed_location_df(
                    location=np.array(bounds_at_read_level[:2]) - pad,
                    size=size_at_read_level + pad * 2,
                    level=read_level,
                )
            )
            # Read at optimal level and corrected read size
            patch = self.wsi_reader.read_rect(
                location=transformed_location,
                size=max_size,
                resolution=read_level,
                coord_space="resolution",
            )
        else:
            transformed_location, max_size = self.get_transformed_location(
                location=location_at_baseline,
                size=size_at_read_level,
                level=read_level,
            )
            patch = self.wsi_reader.read_region(
                location=transformed_location, level=read_level, size=max_size
            )
        patch = np.array(patch)

        # Apply padding outside the slide area.
        patch = utils.image.crop_and_pad_edges(
            bounds=bounds_at_read_level,
            max_dimensions=self.info.level_dimensions[read_level],
            region=patch,
            pad_mode=pad_mode,
            pad_constant_values=pad_constant_values,
        )

        # Apply transformation.
        if self.transform_type == "displacement":
            transformed_patch = self.sample_image_opencv(patch, transformed_grid)
            transformed_patch = transformed_patch[pad:-pad, pad:-pad, :]
        else:
            transformed_patch = self.transform_patch(patch, max_size)

        # Resize to desired size
        post_read_scale = float(post_read_scale[0]), float(post_read_scale[1])
        return utils.transforms.imresize(
            img=transformed_patch,
            scale_factor=post_read_scale,
            output_size=size_at_requested,
            interpolation=interpolation,
        )
