Skip to content
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

Add base class for WSIs & openslide implementation #365

Merged
merged 4 commits into from
Apr 10, 2024
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: 5 additions & 0 deletions src/eva/vision/data/wsi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from eva.vision.data.wsi.backend import WsiBackend, get_wsi_class
from eva.vision.data.wsi.base import Wsi
from eva.vision.data.wsi.openslide import WsiOpenslide

__all__ = ["Wsi", "WsiOpenslide", "WsiBackend", "get_wsi_class"]
19 changes: 19 additions & 0 deletions src/eva/vision/data/wsi/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import enum

from eva.vision.data.wsi.base import Wsi
from eva.vision.data.wsi.openslide import WsiOpenslide


class WsiBackend(enum.Enum):
OPENSLIDE = 0
AUTO = 1


def get_wsi_class(backend: WsiBackend) -> Wsi:
match backend:
case WsiBackend.OPENSLIDE:
return WsiOpenslide
case WsiBackend.AUTO:
raise NotImplementedError
case _:
raise ValueError(f"Unknown WSI backend: {backend}")
53 changes: 53 additions & 0 deletions src/eva/vision/data/wsi/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import abc
from typing import Any, List, Tuple

import numpy as np


class Wsi(abc.ABC):
nkaenzig marked this conversation as resolved.
Show resolved Hide resolved
"""Base class for loading data from WSI (whole slide image) files."""

def __init__(self, file_path: str):
"""Initializes a new class instance.

Args:
file_path: The path to the whole slide image file.
"""
self._file_path = file_path
self._wsi = None

@property
@abc.abstractmethod
def level_dimensions(self) -> List[tuple[int, int]]:
"""A list of (width, height) tuples for each level, from highest to lowest resolution."""

@property
@abc.abstractmethod
def level_downsamples(self) -> List[float]:
"""A list of downsampling factors for each level, relative to the highest resolution."""

@property
@abc.abstractmethod
def mpp(self) -> float:
"""Microns per pixel at the highest resolution."""

@abc.abstractmethod
def read_region(
self, location: Tuple[int, int], size: Tuple[int, int], level: int
) -> np.ndarray:
"""Reads and returns image data for a specified region and zoom level.

Args:
location: Top-left corner (x, y) to start reading.
size: Region size as (width, height), relative to <location>.
level: Zoom level, with 0 being the highest resolution.
"""

@abc.abstractmethod
def open_slide(self) -> Any:
"""Opens the WSI file.

Note: This shouldn't be called in the constructor as wsi backends usually contain
C types or pointers, which the standard Python pickler cannot serialize, leading to
issues with torch.DataLoader in multiprocessing settings.
"""
46 changes: 46 additions & 0 deletions src/eva/vision/data/wsi/openslide.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import List, Tuple

import numpy as np
import openslide
from typing_extensions import override

from eva.vision.data.wsi import base


class WsiOpenslide(base.Wsi):
nkaenzig marked this conversation as resolved.
Show resolved Hide resolved
"""Class for loading data from WSI files using the OpenSlide library."""

_wsi: openslide.OpenSlide

@override
@property
def level_dimensions(self) -> List[Tuple[int, int]]:
return self._wsi.level_dimensions

@override
@property
def level_downsamples(self) -> List[float]:
return self._wsi.level_downsamples

@override
@property
def mpp(self) -> float:
try:
x_mpp = float(self._wsi.properties["openslide.mpp-x"])
y_mpp = float(self._wsi.properties["openslide.mpp-y"])
return (x_mpp + y_mpp) / 2.0
except KeyError:
# TODO: add overwrite_mpp class attribute to allow setting a default value
raise ValueError("Microns per pixel (mpp) value is not available for this slide.")

@override
def read_region(
self, location: Tuple[int, int], size: Tuple[int, int], level: int
) -> np.ndarray:
data = self._wsi.read_region(location, level, size)

return np.array(data.convert("RGB"))

@override
def open_slide(self) -> openslide.OpenSlide:
self._wsi = openslide.open_slide(self._file_path)