Skip to content

🔧 mypy type check wsicore #935

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jun 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/mypy-type-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
15 changes: 15 additions & 0 deletions tests/test_meta_ngff_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
12 changes: 12 additions & 0 deletions tests/test_wsireader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
26 changes: 18 additions & 8 deletions tiatoolbox/wsicore/metadata/ngff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
40 changes: 26 additions & 14 deletions tiatoolbox/wsicore/wsimeta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -260,20 +262,25 @@ 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):
return np.array([x] * 2)
return np.array(x)

if units == "level":
resolution = cast("float", resolution)
if resolution >= len(level_downsamples):
msg = (
f"Target scale level {resolution} > "
Expand All @@ -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:
Expand Down