Skip to content
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
1 change: 1 addition & 0 deletions napari_wsi/backends/openslide.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def __init__(
Args:
path: The path to the input image file.
color_space: The target color space.

"""
if not isinstance(color_space, ColorSpace):
color_space = ColorSpace(color_space)
Expand Down
14 changes: 8 additions & 6 deletions napari_wsi/backends/rasterio.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def __init__(self, path: str | Path | UPath) -> None:

Args:
path: The path to the input image file.

"""
with catch_warnings(category=NotGeoreferencedWarning, action="ignore"):
path = UPath(path)
Expand Down Expand Up @@ -66,13 +67,14 @@ def __repr__(self) -> str:

@property
def spatial_transform(self) -> np.ndarray:
matrix = np.identity(3)
if self._handle.transform is None:
return matrix
transform = self._handle.transform
matrix[0] = (transform.e, transform.d, transform.f)
matrix[1] = (transform.b, transform.a, transform.c)
return matrix
return np.array(
[
[transform.e, transform.d, transform.f],
[transform.b, transform.a, transform.c],
[0.0, 0.0, 1.0],
]
)

@cached_property
def metadata(self) -> dict[str, JSON]:
Expand Down
36 changes: 20 additions & 16 deletions napari_wsi/backends/wsidicom.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import pandas as pd
from colorspacious import cspace_converter
from shapely import LineString as ShapelyPolyline
from shapely import Point as ShapelyPoint
from shapely import Polygon as ShapelyPolygon
from wsidicom import WsiDicom, WsiDicomWebClient
from wsidicom.errors import WsiDicomNotFoundError
Expand All @@ -41,6 +40,7 @@

if TYPE_CHECKING:
import napari
from shapely.geometry.base import BaseGeometry as ShapelyGeometry


@dataclass(frozen=True)
Expand Down Expand Up @@ -79,26 +79,26 @@ def _validate_annotation(
) -> AnnotationData | None:
coords = np.array(annotation.geometry.to_coords())[:, ::-1]

# We need check for invalid geometries to avoid errors on layer creation.
# We expect DICOM annotations to be valid geometries without
# holes, but let's check to avoid errors on layer creation.
if isinstance(annotation.geometry, Point):
assert len(coords) == 1
return AnnotationData(coords[0], shape_type="point")
if isinstance(annotation.geometry, Polygon):
shape = ShapelyPolygon(coords)
shape: ShapelyGeometry = ShapelyPolygon(coords)
shape_type: Literal["polygon", "path"] = "polygon"
elif isinstance(annotation.geometry, Polyline):
shape = ShapelyPolyline(coords)
elif isinstance(annotation.geometry, Point):
shape = ShapelyPoint(coords)
shape_type = "path"
else:
raise ValueError("Unsupported geometry type.")
raise TypeError("Unsupported geometry type.")
if not shape.is_valid:
return None
if len(getattr(shape, "interiors", [])) > 0:
return None
if tol > 0:
shape = shape.simplify(tol)

if isinstance(shape, ShapelyPolygon):
return AnnotationData(np.array(shape.exterior.coords), shape_type="polygon")
elif isinstance(shape, ShapelyPolyline):
return AnnotationData(np.array(shape.coords), shape_type="path")
assert isinstance(shape, ShapelyPoint)
return AnnotationData(np.array(shape.coords[0]), shape_type="point")
return AnnotationData(coords, shape_type=shape_type)


def _get_shape_annotations(
Expand Down Expand Up @@ -200,7 +200,6 @@ def __init__(
path: str | Path | UPath | None = None,
*,
client: WsiDicomWebClient | None = None,
name: str | None = None,
pyramid: int = 0,
optical_path: str | None = None,
color_space: str | ColorSpace = ColorSpace.RAW,
Expand All @@ -215,10 +214,12 @@ def __init__(
path: A path to the input image directory, or a URL.
client: A previously initialized DICOMWeb client. A `study_uid` and
`series_uids` must be provided as additional keyword arguments.
name: A name to identify the image.
pyramid: An index to select one of multiple image pyramids.
optical_path: An identifier to select one of multiple optical paths.
color_space: The target color space.
kwargs: All additional keyword arguments are passed to the `WsiDicom.open`
or `WsiDicom.open_web` method.

"""
if not isinstance(color_space, ColorSpace):
color_space = ColorSpace(color_space)
Expand Down Expand Up @@ -286,7 +287,7 @@ def spatial_transform(self) -> np.ndarray:
return matrix
scale_y, scale_x = self.resolution # mu/px
offset_y, offset_x = ics.origin.y * 1000, ics.origin.x * 1000 # mu
orientation = ics.orientation.values
orientation = ics.orientation.values # noqa: PD011
matrix[0] = (orientation[1] * scale_y, orientation[4] * scale_x, offset_y)
matrix[1] = (orientation[0] * scale_y, orientation[3] * scale_x, offset_x)
return matrix
Expand Down Expand Up @@ -370,9 +371,12 @@ def to_layer_data_tuples(
any subset of ("image", "shapes", "points").
tol: A tolerance value used to simplify all shape annotations. If this is
not greater zero, no simplification is performed.
kwargs: All additional keword arguments are passed to the
`to_layer_data_tuples` method of the base class.

Returns:
A list containing a napari layer data tuple of the given types.

"""
if isinstance(layer_type, str):
layer_type = (layer_type,)
Expand Down
5 changes: 3 additions & 2 deletions napari_wsi/color_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

class ColorSpace(StrEnum):
RAW = "RAW"
sRGB = "sRGB"
sRGB = "sRGB" # noqa: N815


class ColorTransform:
Expand All @@ -34,6 +34,7 @@ def __init__(
profile: The input ICC profile.
mode: The image mode, specifying the pixel format (usually 'RGB' or 'RGBA').
color_space: The target color space.

"""
self._transform = None
self._color_space = color_space
Expand All @@ -42,7 +43,7 @@ def __init__(
profile = getOpenProfile(BytesIO(profile))
self._transform = buildTransform(
profile,
createProfile(str(color_space)), # type: ignore
createProfile(str(color_space)), # type: ignore[arg-type]
mode,
mode,
Intent(getDefaultIntent(profile)),
Expand Down
22 changes: 15 additions & 7 deletions napari_wsi/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
import numpy as np
import zarr
from numpy.typing import DTypeLike
from typing_extensions import Self
from upath import UPath
from zarr.abc.metadata import Metadata
from zarr.core.array import Array
from zarr.core.common import JSON
from zarr.storage import MemoryStore

Expand All @@ -21,6 +21,7 @@

if TYPE_CHECKING:
import napari
from zarr.core.array import Array


@dataclass(frozen=True)
Expand Down Expand Up @@ -49,7 +50,7 @@ def dtype(self) -> DTypeLike:
def __iter__(self) -> Iterator[PyramidLevel]:
yield from self._levels

def __iadd__(self, level: PyramidLevel) -> "PyramidLevels":
def __iadd__(self, level: PyramidLevel) -> Self:
if len(self._levels) > 0:
top_level = self._levels[-1]
if level.factor <= top_level.factor:
Expand Down Expand Up @@ -110,9 +111,11 @@ def to_layer_data_tuples(
Args:
rgb: If `False`, the image data will be converted to channels-first format.
If `True`, 3- and 4-channel data will be left in channels-last format.
kwargs: All additional keyword arguments are used as layer parameters.

Returns:
A one-element list containing a napari layer data tuple of type `image`.

"""
pyramid_data: list[da.Array] = []
for _, array in self._pyramid:
Expand All @@ -133,14 +136,14 @@ def to_layer_data_tuples(
**kwargs,
},
"image",
)
),
]


class WSIStore(PyramidStore, ABC):
"""A base class for reading multi-scale whole-slide images."""

def __init__(self, path: UPath | None, levels: PyramidLevels):
def __init__(self, path: UPath | None, levels: PyramidLevels) -> None:
self._path = path
super().__init__(name=path.stem if path is not None else "Image", levels=levels)

Expand Down Expand Up @@ -191,19 +194,24 @@ def to_transformed_layer_data_tuples(
return items

def to_viewer(
self, viewer: "napari.viewer.Viewer", spatial_transform: bool = False, **kwargs
self,
viewer: "napari.viewer.Viewer",
*,
spatial_transform: bool = False,
**kwargs,
) -> list["napari.layers.Layer"]:
"""Add all available layer data to the napari viewer.

All additional keword arguments are passed to the `to_layer_data_tuples` method.

Args:
viewer: The napari viewer.
spatial_transform: If `True` and a spatial transform is available, all
layers are display in the corresponding transfored coordinate system.
kwargs: All additional keword arguments are passed to the
`to_layer_data_tuples` method.

Returns:
A list of layers added to the viewer.

"""
layers = []
for item in (
Expand Down
47 changes: 30 additions & 17 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "napari-wsi"
version = "1.2.0"
version = "1.2.1"
description = "A plugin to read whole-slide images within napari."
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -30,6 +30,7 @@ dependencies = [
"magicgui>=0.10",
"numpy>=1.26",
"pillow>=11.1",
"typing-extensions>=4.6.1",
"universal-pathlib>=0.2",
"zarr>=3.0",
]
Expand Down Expand Up @@ -64,6 +65,7 @@ dev = [
"pytest-qt>=4.4",
"ruff>=0.9",
"scikit-image>=0.25",
"types-shapely>=2.0.0",
]

[project.urls]
Expand All @@ -82,27 +84,38 @@ module = [
"colorspacious.*",
"napari.*",
"rasterio.*",
"shapely.*",
"wsidicom.*",
]

[tool.ruff]
target-version = "py310"
line-length = 88
lint.select = [
"E", "F", "W", #flake8
"UP", # pyupgrade
"I", # isort
"BLE", # flake8-blind-exception
"B", # flake8-bugbear
"A", # flake8-builtins
"C4", # flake8-comprehensions
"ISC", # flake8-implicit-str-concat
"G", # flake8-logging-format
"PIE", # flake8-pie
"SIM", # flake8-simplify

[tool.ruff.lint]
select = ["ALL"]
ignore = [
"ANN003", # Missing type annotation for `**kwargs`
"B028", # No explicit `stacklevel` keyword argument found
"COM812", # May cause conflicts when used with the formatter
"D100", # Missing docstring in public module
"D101", # Missing docstring in public class
"D102", # Missing docstring in public method
"D103", # Missing docstring in public function
"D104", # Missing docstring in public package
"D105", # Missing docstring in magic method
"D107", # Missing docstring in `__init__`
"D203", # D203 and D211 are incompatible
"D213", # D213 and D212 are incompatible
"EM101", # Exception must not use a string literal
"EM102", # Exception must not use an f-string literal
"FBT001", # Boolean-typed positional argument in function definition
"S101", # Use of `assert`
"TID252", # Prefer absolute imports over relative imports from parent modules
"TRY003", # Avoid specifying long messages outside the exception class
]
lint.ignore = [
"B028", # for warnings
"ISC001", # for ruff

[tool.ruff.lint.per-file-ignores]
"tests/test_*.py" = [
"PLR2004", # Magic value used in comparison
"SLF001", # Private member accessed
]
7 changes: 4 additions & 3 deletions tasks.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
from invoke import task
from invoke.context import Context


@task
def fix(ctx):
def fix(ctx: Context) -> None:
ctx.run("ruff format .")
ctx.run("ruff check . --fix")


@task
def check(ctx):
def check(ctx: Context) -> None:
ctx.run("ruff format . --check")
ctx.run("ruff check .")
ctx.run("mypy .")


@task
def test(ctx):
def test(ctx: Context) -> None:
ctx.run("pytest --verbose --cov=napari_wsi tests")
8 changes: 4 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import skimage.transform
import skimage.util
from attr import dataclass
from pytest import FixtureRequest, TempPathFactory
from rasterio.enums import Resampling
from rasterio.errors import NotGeoreferencedWarning

Expand Down Expand Up @@ -41,14 +40,15 @@ class Case:
file_path: Path | None = None
file_fixture: str | None = None

def path(self, request: FixtureRequest) -> Path:
def path(self, request: pytest.FixtureRequest) -> Path:
if self.file_path is not None:
path = self.file_path
elif self.file_fixture is not None:
path = request.getfixturevalue(self.file_fixture)
else:
raise ValueError("Need either 'file_path' or 'file_fixture'.")
assert isinstance(path, Path) and path.exists()
assert isinstance(path, Path)
assert path.exists()
return path.resolve()


Expand Down Expand Up @@ -92,7 +92,7 @@ def path(self, request: FixtureRequest) -> Path:


@pytest.fixture(scope="session")
def dummy_gtiff(tmp_path_factory: TempPathFactory) -> Path:
def dummy_gtiff(tmp_path_factory: pytest.TempPathFactory) -> Path:
path = tmp_path_factory.mktemp("data") / "image.tiff"

data = skimage.util.img_as_ubyte(skimage.data.binary_blobs(2048))
Expand Down
Loading