diff --git a/.github/workflows/mypy-type-check.yml b/.github/workflows/mypy-type-check.yml index 32e05b217..987db45ad 100644 --- a/.github/workflows/mypy-type-check.yml +++ b/.github/workflows/mypy-type-check.yml @@ -50,4 +50,7 @@ jobs: tiatoolbox/models/__init__.py \ tiatoolbox/models/models_abc.py \ tiatoolbox/models/architecture/__init__.py \ - tiatoolbox/models/architecture/utils.py + tiatoolbox/models/architecture/utils.py \ + tiatoolbox/wsicore/__init__.py \ + tiatoolbox/wsicore/wsimeta.py \ + tiatoolbox/wsicore/metadata/ diff --git a/tests/test_meta_ngff_dataclasses.py b/tests/test_meta_ngff_dataclasses.py index 405c7c996..6dd53df50 100644 --- a/tests/test_meta_ngff_dataclasses.py +++ b/tests/test_meta_ngff_dataclasses.py @@ -29,6 +29,21 @@ def test_multiscales_defaults() -> None: """Test :class:`ngff.Multiscales` init with default args.""" ngff.Multiscales() + @staticmethod + def test_multiscales_iter() -> None: + """Test :class:`ngff.Multiscales` init.""" + multiscales = ngff.Multiscales() + iter_values = list(iter(multiscales)) + + # Check if all attributes are present in the yielded values + assert multiscales.axes in iter_values + assert multiscales.datasets in iter_values + assert multiscales.version in iter_values + + # Check the order of yielded values matches __dict__ order + expected = list(multiscales.__dict__.values()) + assert iter_values == expected + @staticmethod def test_omero_defaults() -> None: """Test :class:`ngff.Omero` init with default args.""" diff --git a/tests/test_wsireader.py b/tests/test_wsireader.py index 34cc8b8cd..4d4b1254e 100644 --- a/tests/test_wsireader.py +++ b/tests/test_wsireader.py @@ -16,6 +16,7 @@ import glymur import numpy as np import pytest +import tifffile import zarr from click.testing import CliRunner from packaging.version import Version @@ -770,6 +771,17 @@ def test_is_tiled_tiff(source_image: Path) -> None: source_image.with_suffix(".tiff").replace(source_image) +def test_is_not_tiled_tiff(tmp_samples_path: Path) -> None: + """Test if source_image is not a tiled tiff.""" + temp_tiff_path = tmp_samples_path / "not_tiled.tiff" + images = [np.zeros(shape=(4, 4)) for _ in range(3)] + # Write multi-page TIFF with all pages not tiled + with tifffile.TiffWriter(temp_tiff_path) as tif: + for image in images: + tif.write(image, compression=None, tile=None) + assert wsireader.is_tiled_tiff(temp_tiff_path) is False + + def test_read_rect_openslide_levels(sample_ndpi: Path) -> None: """Test openslide read rect with resolution in levels. diff --git a/tiatoolbox/wsicore/metadata/ngff.py b/tiatoolbox/wsicore/metadata/ngff.py index 464281e36..e17e09050 100644 --- a/tiatoolbox/wsicore/metadata/ngff.py +++ b/tiatoolbox/wsicore/metadata/ngff.py @@ -9,13 +9,14 @@ from __future__ import annotations -from dataclasses import dataclass, field from typing import TYPE_CHECKING, Literal -from tiatoolbox import __version__ as tiatoolbox_version - if TYPE_CHECKING: # pragma: no cover - from numbers import Number + from collections.abc import Iterator + +from dataclasses import dataclass, field + +from tiatoolbox import __version__ as tiatoolbox_version SpaceUnits = Literal[ "angstrom", @@ -169,6 +170,15 @@ class Multiscales: datasets: list[Dataset] = field(default_factory=lambda: [Dataset()]) version: str = "0.4" + def __iter__(self: Multiscales) -> Iterator: + """Iterate over the values of the attributes in the `Multiscales` object. + + Yields: + Iterator: An iterator + + """ + yield from self.__dict__.values() + @dataclass class Window: @@ -186,10 +196,10 @@ class Window: """ - end: Number = 255 - max: Number = 255 - min: Number = 0 - start: Number = 0 + end: int = 255 + max: int = 255 + min: int = 0 + start: int = 0 @dataclass diff --git a/tiatoolbox/wsicore/wsimeta.py b/tiatoolbox/wsicore/wsimeta.py index 97145e5d6..fcdf5d017 100644 --- a/tiatoolbox/wsicore/wsimeta.py +++ b/tiatoolbox/wsicore/wsimeta.py @@ -11,7 +11,7 @@ from numbers import Number from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import numpy as np @@ -121,7 +121,7 @@ def __init__( self.level_downsamples = ( [float(x) for x in level_downsamples] if level_downsamples is not None - else None + else [1.0] ) self.level_count = ( int(level_count) if level_count is not None else len(self.level_dimensions) @@ -212,7 +212,9 @@ def level_downsample( ceil = int(np.ceil(level)) floor_downsample = level_downsamples[floor] ceil_downsample = level_downsamples[ceil] - return np.interp(level, [floor, ceil], [floor_downsample, ceil_downsample]) + return float( + np.interp(level, [floor, ceil], [floor_downsample, ceil_downsample]) + ) def relative_level_scales( self: WSIMeta, @@ -260,13 +262,17 @@ def relative_level_scales( [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0] """ + base_scale: np.ndarray + resolution_array: np.ndarray + msg: str + if units not in ("mpp", "power", "level", "baseline"): msg = "Invalid units" raise ValueError(msg) level_downsamples = self.level_downsamples - def np_pair(x: Number | np.array) -> np.ndarray: + def np_pair(x: Resolution) -> np.ndarray: """Ensure input x is a numpy array of length 2.""" # If one number is given, the same value is used for x and y if isinstance(x, Number): @@ -274,6 +280,7 @@ def np_pair(x: Number | np.array) -> np.ndarray: return np.array(x) if units == "level": + resolution = cast("float", resolution) if resolution >= len(level_downsamples): msg = ( f"Target scale level {resolution} > " @@ -282,32 +289,37 @@ def np_pair(x: Number | np.array) -> np.ndarray: raise ValueError( msg, ) - base_scale, resolution = 1, self.level_downsample(resolution) - - resolution = np_pair(resolution) + resolution_array = np.array( + [self.level_downsample(resolution)] * 2, dtype=float + ) + base_scale = np.array([1.0, 1.0], dtype=float) - if units == "mpp": + elif units == "mpp": if self.mpp is None: msg = "MPP is None. Cannot determine scale in terms of MPP." raise ValueError(msg) base_scale = self.mpp + resolution_array = np_pair(resolution) - if units == "power": + elif units == "power": if self.objective_power is None: msg = ( "Objective power is None. " - "Cannot determine scale in terms of objective power.", + "Cannot determine scale in terms of objective power." ) raise ValueError( msg, ) - base_scale, resolution = 1 / self.objective_power, 1 / resolution + base_scale = np.array([1 / self.objective_power] * 2, dtype=float) + resolution_array = 1.0 / np_pair(resolution) - if units == "baseline": - base_scale, resolution = 1, 1 / resolution + else: # units == "baseline" + base_scale = np.array([1.0, 1.0], dtype=float) + resolution_array = 1.0 / np_pair(resolution) return [ - (base_scale * downsample) / resolution for downsample in level_downsamples + (base_scale * downsample) / resolution_array + for downsample in level_downsamples ] def as_dict(self: WSIMeta) -> dict: