"""CuCim backend for GPU-accelerated slide reading."""
from pathlib import Path
from typing import Protocol, runtime_checkable, cast
import numpy as np
import PIL.Image
import importlib.util
from glasscut.exceptions import BackendError
from .base import SlideBackend
from .openslide_backend import OpenSlideBackend
[docs]
@runtime_checkable
class CuPyArrayProtocol(Protocol):
"""Protocol for CuPy GPU arrays with NumPy-like interface."""
[docs]
def get(self) -> object:
"""Transfer array from GPU to CPU (NumPy array)."""
...
[docs]
@runtime_checkable
class CuImageProtocol(Protocol):
"""Protocol describing the interface of cucim.CuImage objects."""
@property
def shape(self) -> tuple[int, ...]:
"""Image shape as (height, width, channels)."""
...
[docs]
def read_region(
self, location: tuple[int, int], level: int, size: tuple[int, int]
) -> CuPyArrayProtocol | object:
"""Read a region from the image at specified level.
Returns a CuPy array or NumPy array.
"""
...
CUCIM_AVAILABLE = importlib.util.find_spec("cucim") is not None
if CUCIM_AVAILABLE:
import cucim
[docs]
class CuCimBackend(SlideBackend):
"""CuCim-based backend for GPU-accelerated slide reading.
This backend uses RAPIDS cuCim for high-performance GPU-accelerated
reading of whole slide images. Falls back to OpenSlide for metadata
and operations not supported by cuCim.
"""
[docs]
def __init__(self) -> None:
if not CUCIM_AVAILABLE:
raise BackendError(
"cuCim is not installed. Install it with: "
"pip install cupy-cuda12x cucim"
)
self._cucim_slide: CuImageProtocol | None = None
self._openslide_backend: OpenSlideBackend | None = None
self._path: str | None = None
[docs]
def open(self, path: str | Path) -> None:
"""Open a slide file using CuCim.
Parameters
----------
path : str | Path
Path to the slide file
Raises
------
FileNotFoundError
If the slide file does not exist
BackendError
If cuCim cannot open the file
"""
self._path = str(path) if isinstance(path, Path) else path
try:
# Open with cuCim for GPU acceleration
self._cucim_slide = cast(CuImageProtocol, cucim.CuImage(self._path)) # type: ignore
# Also open with OpenSlide for metadata (fallback)
self._openslide_backend = OpenSlideBackend()
self._openslide_backend.open(self._path)
except FileNotFoundError:
raise FileNotFoundError(f"Slide file not found: {self._path}")
except Exception as e:
raise BackendError(f"Failed to open slide with cuCim: {e}")
[docs]
def close(self) -> None:
"""Close the slide and free GPU memory."""
if self._cucim_slide is not None:
self._cucim_slide = None
if self._openslide_backend is not None:
self._openslide_backend.close()
self._openslide_backend = None
@property
def dimensions(self) -> tuple[int, int]:
"""Get slide dimensions at level 0.
Returns
-------
Tuple[int, int]
(width, height) at highest magnification
"""
if self._cucim_slide is None:
raise RuntimeError("Slide not opened")
# CuCim stores dimensions as (height, width)
shape = self._cucim_slide.shape
return (shape[1], shape[0])
@property
def properties(self) -> dict[str, str]:
"""Get slide metadata properties.
Falls back to OpenSlide for metadata retrieval.
Returns
-------
dict[str, str]
Dictionary of slide properties
"""
if self._openslide_backend is None:
raise RuntimeError("Slide not opened")
return self._openslide_backend.properties
@property
def num_levels(self) -> int:
"""Get number of pyramid levels.
Falls back to OpenSlide for level information.
Returns
-------
int
Number of pyramid levels
"""
if self._openslide_backend is None:
raise RuntimeError("Slide not opened")
return self._openslide_backend.num_levels
[docs]
def read_region(
self, location: tuple[int, int], level: int, size: tuple[int, int]
) -> PIL.Image.Image:
"""Read a region/tile from the slide using GPU acceleration.
Parameters
----------
location : tuple[int, int]
(x, y) coordinates at level 0
level : int
Pyramid level to read from
size : tuple[int, int]
(width, height) of the tile in pixels
Returns
-------
PIL.Image.Image
The tile image in RGB format
"""
if self._cucim_slide is None:
raise RuntimeError("Slide not opened")
try:
# Read region using cuCim's GPU acceleration
region_array = self._cucim_slide.read_region(
location=location, level=level, size=size
)
# Convert to PIL Image
# cuCim returns CuPy arrays, convert to NumPy then PIL
if isinstance(region_array, CuPyArrayProtocol):
# It's a CuPy array, move to CPU
region_array = cast(np.ndarray, region_array.get())
else:
region_array = cast(np.ndarray, region_array)
image = PIL.Image.fromarray(region_array)
return image.convert("RGB")
except Exception:
# Fallback to OpenSlide if cuCim fails
if self._openslide_backend is None:
raise RuntimeError("Slide not opened")
return self._openslide_backend.read_region(location, level, size)
[docs]
def get_thumbnail(self, size: tuple[int, int]) -> PIL.Image.Image:
"""Get thumbnail of the slide.
Falls back to OpenSlide if available.
Parameters
----------
size : tuple[int, int]
Maximum size of the thumbnail (width, height)
Returns
-------
PIL.Image.Image
Thumbnail image in RGB format
"""
if self._openslide_backend is None:
raise RuntimeError("Slide not opened")
return self._openslide_backend.get_thumbnail(size)
@property
def mpp(self) -> float:
"""Get microns per pixel at base magnification.
Falls back to OpenSlide for metadata retrieval.
Returns
-------
float
Microns per pixel (MPP)
Raises
------
SlidePropertyError
If MPP cannot be determined from metadata
"""
if self._openslide_backend is None:
raise RuntimeError("Slide not opened")
return self._openslide_backend.mpp
@property
def base_magnification(self) -> int | float:
"""Get the base magnification (objective power) of the slide.
Falls back to OpenSlide for metadata retrieval.
Returns
-------
int | float
Base magnification value
Raises
------
SlidePropertyError
If base magnification cannot be determined from metadata
"""
if self._openslide_backend is None:
raise RuntimeError("Slide not opened")
return self._openslide_backend.base_magnification