From b9c42b878d5847a7e86c03168ef1d41d81b2a768 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Thu, 5 Mar 2026 19:07:59 -0600 Subject: [PATCH 01/15] Add omezarr package requirements --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d064723b..3134b803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ huggingface_hub = "*" openslide-bin = "*" aicsimageio = "*" pyvips = "*" +ngff-zarr = ">=0.12" # Minimally support zarr version 3. +cf-units = ">=3.1" # Minimum for python 3.10 support. # Core model dependencies (installed with `pip install -e .`) transformers = ">=4.40,<5" From 208f30c93112c831a22e8ee856e9b8a0c052c635 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Fri, 6 Mar 2026 18:53:09 -0600 Subject: [PATCH 02/15] Init OMEZarr reader --- trident/wsi_objects/OMEZarrWSI.py | 177 ++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 trident/wsi_objects/OMEZarrWSI.py diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py new file mode 100644 index 00000000..9405fd5f --- /dev/null +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -0,0 +1,177 @@ +import ngff_zarr as nz +from typing import Tuple, Union, Optional, Any +from trident.wsi_objects.WSI import WSI, ReadMode +from cf_units import Unit as cf_Unit +from PIL import Image +import numpy as np + +class OMEZarrWSI(WSI): + """ + WSI implementation for reading zarrfiles following the OME specification. + """ + def __init__(self, slide_path: str, **kwargs: Any) -> None: + """ + Initialize a OMEZarr instance for OME-Zarr whole-slide images. + + Parameters + ---------- + slide_path : str + Path to an .ome.zarr multiscale file. + **kwargs : dict + Additional keyword arguments forwarded to the base `WSI` class. + - lazy_init (bool, default=True): Whether to defer loading WSI and metadata. + + Example + ------- + >>> wsi = OMEZarrWSI(slide_path="path/to/wsi", lazy_init=False) + >>> print(wsi) + + """ + super().__init__(slide_path, **kwargs) + + def _lazy_initialize(self) -> None: + """ + Lazily initialize the WSI using ngff-zarr. + + This method opens a whole-slide image using the ngff-zarr backend, extracting + key metadata including dimensions, magnification, and multiresolution pyramid + information. If a tissue segmentation mask is provided, it is also loaded. + + Raises + ------ + FileNotFoundError + If the WSI file or the tissue segmentation mask cannot be found. + RuntimeError + If an unexpected error occurs during WSI initialization. Including if there + are not 3 dimensions in an image, as read_region depends on this property. + + Notes + ----- + After initialization, the following attributes are set: + - `width` and `height`: spatial dimensions of the base level. + - `dimensions`: (width, height) tuple from the highest resolution. + - `level_count`: number of resolution levels in the image pyramid. + - `level_downsamples`: downsampling factors for each level. + - `level_dimensions`: image dimensions at each level. + - `properties`: metadata dictionary from OpenSlide. + - `mpp`: microns per pixel, inferred if not manually specified. + - `mag`: estimated magnification level (via WSI.py). + - `gdf_contours`: loaded from `tissue_seg_path` if provided (via WSI.py). + """ + + super()._lazy_initialize() + + _get_W_and_H = lambda ngffimg: (ngffimg.data.shape[-1], ngffimg.data.shape[-2]) + + if not self.lazy_init: + try: + self.img = nz.from_ngff_zarr(self.slide_path) # Multiscales dataclass from ngff-zarr + + toplevel_image = self.img.images[0] # a possibly cyx shape NgffImage object + assert len(toplevel_image.data.shape) == 3, "Err, read_region expects 3 dimensional image data" + + self.dimensions = _get_W_and_H(toplevel_image) # based on cyx array storage x -> width, y -> height + self.width, self.height = self.dimensions + self.level_count = len(self.img.images) + self.level_dimensions = tuple(map(_get_W_and_H, self.img.images)) + self.level_downsamples = self._fetch_downsamples() + if self.mpp is None: + self.mpp = self._fetch_mpp() + self.mag = self._fetch_magnification() + self.properties = self.img.metadata # Properties here are limited to OME rather than the whole zarrfile + + self.lazy_init = True + + except Exception as e: + raise RuntimeError(f"Failed to initialize WSI with ngff-zarr: {e}") from e + + def _fetch_mpp(self): + """ + Retrieve microns per pixel (MPP) from OME Zarr metadata. Conforming to the OME zarr + specification requires scale and UDUNITS-2, so custom_mpp_keys not requried. + + Returns + ------- + np.float64 + MPP value in microns per pixel. + """ + scale, scale_unit = self.img.images[0].scale['x'], self.img.images[0].axes_units['x'] + return cf_Unit(scale_unit).convert(scale, cf_Unit('micrometers')) # mpp for the x axis at the highest res image + + def _fetch_downsamples(self): + return tuple( [1.] + + [(self.img.images[0].data.shape[-1] / ngff_img.data.shape[-1]) for ngff_img in self.img.images[1:]] + ) + + def read_region( + self, + location: Tuple[int, int], + level: int, + size: Tuple[int, int], + read_as: ReadMode = 'pil', + ) -> Union[Image.Image, np.ndarray]: + """ + Extract a specific region from the whole-slide image (WSI). + + Parameters + ---------- + location : Tuple[int, int] + (x, y) coordinates of the top-left corner of the region to extract. + level : int + Pyramid level to read from. + size : Tuple[int, int] + (width, height) of the region to extract. + read_as : {'pil', 'numpy'}, optional + Output format for the region: + - 'pil': returns a PIL Image (default) + - 'numpy': returns a NumPy array (H, W, 3) + + Returns + ------- + Union[PIL.Image.Image, np.ndarray] + Extracted image region in the specified format. + + Raises + ------ + ValueError + If `read_as` is not one of 'pil' or 'numpy'. + + Examples + -------- + >>> region = wsi.read_region((0, 0), level=0, size=(512, 512), read_as='numpy') + >>> print(region.shape) + (512, 512, 3) + """ + # 'location' is relative to the level as calls are made to the data array + location_ = (int(location[0] / self.level_downsamples[level]), int(location[1] / self.level_downsamples[level])) + + x, y = location_ + width_size, height_size = size + + # WSI images are cyx, so [: -> c, y:y+height_size, x:x+width_size, ] + # also convert cyx to desired H,W,C + region = self.img.images[level].data[:, y:y+height_size, x:x+width_size].compute().transpose(1, 2, 0) + + if read_as == 'pil': + return Image.fromarray(region).convert('RGB') + elif read_as == 'numpy': + return region + else: + raise ValueError(f"Invalid `read_as` value: {read_as}. Must be 'pil', 'numpy'.") + + def get_thumbnail(self, size: tuple[int, int]) -> Image.Image: + """ + Generate a thumbnail of the WSI. + + Parameters + ---------- + size : tuple of int + Desired (width, height) of the thumbnail. + + Returns + ------- + PIL.Image.Image + RGB thumbnail as a PIL Image. + """ + bottomlevel_image = self.img.images[-1].data.compute().transpose(1, 2, 0) + return Image.fromarray(bottomlevel_image).convert('RGB').resize(size) \ No newline at end of file From 2e1fb21bea3b0afeccab2b56acfdadc34e5c2f45 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Mon, 9 Mar 2026 13:45:39 -0500 Subject: [PATCH 03/15] Add OMEZarr reader support based on changes in #163 --- run_batch_of_slides.py | 4 ++-- trident/Processor.py | 6 +++--- trident/__init__.py | 2 ++ trident/wsi_objects/WSIFactory.py | 23 ++++++++++++++++++----- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/run_batch_of_slides.py b/run_batch_of_slides.py index a9f70da0..3a098547 100644 --- a/run_batch_of_slides.py +++ b/run_batch_of_slides.py @@ -59,8 +59,8 @@ def build_parser() -> argparse.ArgumentParser: help='Custom keys used to store the resolution as MPP (micron per pixel) in your list of whole-slide image.') parser.add_argument('--custom_list_of_wsis', type=str, default=None, help='Custom list of WSIs specified in a csv file.') - parser.add_argument('--reader_type', type=str, choices=['openslide', 'image', 'cucim', 'sdpc'], default=None, - help='Force the use of a specific WSI image reader. Options are ["openslide", "image", "cucim", "sdpc"]. Defaults to None (auto-determine which reader to use).') + parser.add_argument('--reader_type', type=str, choices=['openslide', 'image', 'cucim', 'sdpc', 'omezarr'], default=None, + help='Force the use of a specific WSI image reader. Options are ["openslide", "image", "cucim", "sdpc", "omezarr"]. Defaults to None (auto-determine which reader to use).') parser.add_argument("--search_nested", action="store_true", help=("If set, recursively search for whole-slide images (WSIs) within all subdirectories of " "`wsi_source`. Uses `os.walk` to include slides from nested folders. " diff --git a/trident/Processor.py b/trident/Processor.py index f1f0e934..f61abc58 100644 --- a/trident/Processor.py +++ b/trident/Processor.py @@ -11,7 +11,7 @@ from trident import load_wsi, WSIReaderType from trident.IO import create_lock, remove_lock, is_locked, update_log, collect_valid_slides, splitext from trident.Maintenance import deprecated -from trident.wsi_objects.WSIFactory import OPENSLIDE_EXTENSIONS, PIL_EXTENSIONS, SDPC_EXTENSIONS +from trident.wsi_objects.WSIFactory import OPENSLIDE_EXTENSIONS, PIL_EXTENSIONS, SDPC_EXTENSIONS, OMEZARR_EXTENSIONS class Processor: @@ -74,7 +74,7 @@ def __init__( Maximum number of workers for data loading. If None, the default behavior will be used. Defaults to None. reader_type (WSIReaderType, optional): - Force the image reader engine to use. Options are are ["openslide", "image", "cucim"]. Defaults to None + Force the image reader engine to use. Options are are ["openslide", "image", "cucim", "sdpc", "omezarr"]. Defaults to None (auto-determine the right engine based on image extension). search_nested (bool, optional): If True, the processor will recursively search for WSIs within all subdirectories of `wsi_source`. @@ -108,7 +108,7 @@ def __init__( self.job_dir = job_dir self.wsi_source = wsi_source - self.wsi_ext = wsi_ext or (list(PIL_EXTENSIONS) + list(OPENSLIDE_EXTENSIONS) + list(SDPC_EXTENSIONS)) + self.wsi_ext = wsi_ext or (list(PIL_EXTENSIONS) + list(OPENSLIDE_EXTENSIONS) + list(SDPC_EXTENSIONS) + list(OMEZARR_EXTENSIONS)) self.skip_errors = skip_errors self.custom_mpp_keys = custom_mpp_keys self.max_workers = max_workers diff --git a/trident/__init__.py b/trident/__init__.py index 6cc936fd..6938995a 100644 --- a/trident/__init__.py +++ b/trident/__init__.py @@ -9,6 +9,7 @@ from trident.wsi_objects.CuCIMWSI import CuCIMWSI from trident.wsi_objects.ImageWSI import ImageWSI from trident.wsi_objects.SDPCWSI import SDPCWSI +from trident.wsi_objects.OMEZarrWSI import OMEZarrWSI from trident.wsi_objects.WSIFactory import load_wsi, WSIReaderType from trident.wsi_objects.WSIPatcher import OpenSlideWSIPatcher, WSIPatcher from trident.wsi_objects.WSIPatcherDataset import WSIPatcherDataset @@ -28,6 +29,7 @@ "ImageWSI", "CuCIMWSI", "SDPCWSI", + "OMEZarrWSI", "WSIPatcher", "OpenSlideWSIPatcher", "WSIPatcherDataset", diff --git a/trident/wsi_objects/WSIFactory.py b/trident/wsi_objects/WSIFactory.py index 7d5b5217..ffa0c1eb 100644 --- a/trident/wsi_objects/WSIFactory.py +++ b/trident/wsi_objects/WSIFactory.py @@ -6,11 +6,13 @@ from trident.wsi_objects.ImageWSI import ImageWSI from trident.wsi_objects.CuCIMWSI import CuCIMWSI from trident.wsi_objects.SDPCWSI import SDPCWSI -WSIReaderType = Literal['openslide', 'image', 'cucim', 'sdpc'] +from trident.wsi_objects.OMEZarrWSI import OMEZarrWSI +WSIReaderType = Literal['openslide', 'image', 'cucim', 'sdpc', 'omezarr'] OPENSLIDE_EXTENSIONS = {'.svs', '.tif', '.tiff', '.ndpi', '.vms', '.vmu', '.scn', '.mrxs'} CUCIM_EXTENSIONS = {'.svs', '.tif', '.tiff'} SDPC_EXTENSIONS = {'.sdpc'} PIL_EXTENSIONS = {'.png', '.jpg', '.jpeg'} +OMEZARR_EXTENSIONS = {'.ome.zarr'} def load_wsi( @@ -18,7 +20,7 @@ def load_wsi( reader_type: Optional[WSIReaderType] = None, lazy_init: bool = False, **kwargs -) -> Union[OpenSlideWSI, ImageWSI, CuCIMWSI, SDPCWSI]: +) -> Union[OpenSlideWSI, ImageWSI, CuCIMWSI, SDPCWSI, OMEZarrWSI]: """ Load a whole-slide image (WSI) using the appropriate backend. @@ -30,7 +32,7 @@ def load_wsi( ---------- slide_path : str Path to the whole-slide image. - reader_type : {'openslide', 'image', 'cucim', 'sdpc'}, optional + reader_type : {'openslide', 'image', 'cucim', 'sdpc', 'omezarr'}, optional Manually specify the WSI reader to use. If None (default), selection is automatic based on file extension. lazy_init : bool, optional @@ -41,7 +43,7 @@ def load_wsi( Returns ------- - Union[OpenSlideWSI, ImageWSI, CuCIMWSI, SDPCWSI] + Union[OpenSlideWSI, ImageWSI, CuCIMWSI, SDPCWSI, OMEZarrWSI] An instance of the appropriate WSI reader. Raises @@ -78,11 +80,22 @@ def load_wsi( f"Unsupported file format '{ext}' for CuCIM. " f"Supported whole-slide image formats are: {', '.join(CUCIM_EXTENSIONS)}." ) - + + elif reader_type == 'omezarr': + if ext in OMEZARR_EXTENSIONS: + return OMEZarrWSI(slide_path=slide_path, lazy_init=lazy_init, **kwargs) + else: + raise ValueError( + f"Unsupported file format '{ext}' for Ome-Zarr. " + f"Supported whole-slide image formats are: {', '.join(OMEZARR_EXTENSIONS)}." + ) + elif reader_type is None: if ext in OPENSLIDE_EXTENSIONS: return OpenSlideWSI(slide_path=slide_path, lazy_init=lazy_init, **kwargs) elif ext in SDPC_EXTENSIONS: return SDPCWSI(slide_path=slide_path, lazy_init=lazy_init, **kwargs) + elif ext in OMEZARR_EXTENSIONS: + return OMEZarrWSI(slide_path=slide_path, lazy_init=lazy_init, **kwargs) else: return ImageWSI(slide_path=slide_path, lazy_init=lazy_init, **kwargs) From 4157d3715e1660b4e4d2ceb8e3dd9c94cdbd3204 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Mon, 9 Mar 2026 16:10:18 -0500 Subject: [PATCH 04/15] Fix OMEZarr thumbnail algorithm and correct init flag to updated variable name --- trident/wsi_objects/OMEZarrWSI.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py index 9405fd5f..e36619ae 100644 --- a/trident/wsi_objects/OMEZarrWSI.py +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -63,7 +63,7 @@ def _lazy_initialize(self) -> None: _get_W_and_H = lambda ngffimg: (ngffimg.data.shape[-1], ngffimg.data.shape[-2]) - if not self.lazy_init: + if not self._initialized: try: self.img = nz.from_ngff_zarr(self.slide_path) # Multiscales dataclass from ngff-zarr @@ -80,7 +80,7 @@ def _lazy_initialize(self) -> None: self.mag = self._fetch_magnification() self.properties = self.img.metadata # Properties here are limited to OME rather than the whole zarrfile - self.lazy_init = True + self._initialized = True except Exception as e: raise RuntimeError(f"Failed to initialize WSI with ngff-zarr: {e}") from e @@ -148,7 +148,7 @@ def read_region( x, y = location_ width_size, height_size = size - # WSI images are cyx, so [: -> c, y:y+height_size, x:x+width_size, ] + # imgs are ordered cyx, so [: -> c, y:y+height_size, x:x+width_size, ] # also convert cyx to desired H,W,C region = self.img.images[level].data[:, y:y+height_size, x:x+width_size].compute().transpose(1, 2, 0) @@ -173,5 +173,12 @@ def get_thumbnail(self, size: tuple[int, int]) -> Image.Image: PIL.Image.Image RGB thumbnail as a PIL Image. """ - bottomlevel_image = self.img.images[-1].data.compute().transpose(1, 2, 0) - return Image.fromarray(bottomlevel_image).convert('RGB').resize(size) \ No newline at end of file + width, height = size + # takes the average ratio between the thumbsize and the object's (level dimension) size then applies abs(x - 1) so min finds + # the size ratio closest to 1 + get_dim_to_size_adjusted_ratio = lambda x: abs((((x[0]/width) + (x[1]/height)) / 2) - 1) + # get the min index rather than value + closest_level = min(range(self.level_count), key=lambda i: list(map(get_dim_to_size_adjusted_ratio, self.level_dimensions))[i]) + + thumbimg_data = self.img.images[closest_level].data.compute().transpose(1, 2, 0) + return Image.fromarray(thumbimg_data).convert('RGB').resize(size) \ No newline at end of file From fabd6c735c7a12a82eeeb26820626efd8201c873 Mon Sep 17 00:00:00 2001 From: Harvey South <73258129+HarveySouth@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:55:41 -0500 Subject: [PATCH 05/15] Update pyproject.toml Change the ome-zarr dependencies to hopefully work better with the strict package requirements of aicsimageio. --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3134b803..007c5a3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,10 @@ shapely = "*" requests = "*" huggingface_hub = "*" openslide-bin = "*" +ngff-zarr = ">=0.12" # Minimally support zarr version 3. +zarr = ">=3.0" # Further zarr3 enforcement as aicsimageio has strict reqs aicsimageio = "*" pyvips = "*" -ngff-zarr = ">=0.12" # Minimally support zarr version 3. cf-units = ">=3.1" # Minimum for python 3.10 support. # Core model dependencies (installed with `pip install -e .`) From 40b91270c6f7dbc4b1d977079389a879a620cbcf Mon Sep 17 00:00:00 2001 From: Harvey South Date: Tue, 10 Mar 2026 14:08:26 -0500 Subject: [PATCH 06/15] Update omezarr reader to successfully run through the same commands in test_openslidewsi.py --- pyproject.toml | 1 + trident/wsi_objects/OMEZarrWSI.py | 23 +++++++++++++++++++---- trident/wsi_objects/WSIFactory.py | 4 ++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 007c5a3c..6e44cb40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ huggingface_hub = "*" openslide-bin = "*" ngff-zarr = ">=0.12" # Minimally support zarr version 3. zarr = ">=3.0" # Further zarr3 enforcement as aicsimageio has strict reqs +dask = ">=2024.10" # Minimum for Zarr-Python 3 aicsimageio = "*" pyvips = "*" cf-units = ">=3.1" # Minimum for python 3.10 support. diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py index e36619ae..cadd0a00 100644 --- a/trident/wsi_objects/OMEZarrWSI.py +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -5,6 +5,8 @@ from PIL import Image import numpy as np +import dask + class OMEZarrWSI(WSI): """ WSI implementation for reading zarrfiles following the OME specification. @@ -148,9 +150,11 @@ def read_region( x, y = location_ width_size, height_size = size - # imgs are ordered cyx, so [: -> c, y:y+height_size, x:x+width_size, ] - # also convert cyx to desired H,W,C - region = self.img.images[level].data[:, y:y+height_size, x:x+width_size].compute().transpose(1, 2, 0) + # prevent deadlock that occurs when reading while nested in pytorch's distributed operations + with dask.config.set(scheduler='synchronous'): + # imgs are ordered cyx, so [: -> c, y:y+height_size, x:x+width_size, ] + # also convert cyx to desired H,W,C + region = self.img.images[level].data[:, y:y+height_size, x:x+width_size].compute().transpose(1, 2, 0) if read_as == 'pil': return Image.fromarray(region).convert('RGB') @@ -158,7 +162,18 @@ def read_region( return region else: raise ValueError(f"Invalid `read_as` value: {read_as}. Must be 'pil', 'numpy'.") - + + def get_dimensions(self) -> Tuple[int, int]: + """ + Return the dimensions (width, height) of the WSI. + + Returns + ------- + tuple of int + (width, height) in pixels. + """ + return self.dimensions + def get_thumbnail(self, size: tuple[int, int]) -> Image.Image: """ Generate a thumbnail of the WSI. diff --git a/trident/wsi_objects/WSIFactory.py b/trident/wsi_objects/WSIFactory.py index ffa0c1eb..ac778601 100644 --- a/trident/wsi_objects/WSIFactory.py +++ b/trident/wsi_objects/WSIFactory.py @@ -12,7 +12,7 @@ CUCIM_EXTENSIONS = {'.svs', '.tif', '.tiff'} SDPC_EXTENSIONS = {'.sdpc'} PIL_EXTENSIONS = {'.png', '.jpg', '.jpeg'} -OMEZARR_EXTENSIONS = {'.ome.zarr'} +OMEZARR_EXTENSIONS = {'.zarr'} def load_wsi( @@ -55,7 +55,7 @@ def load_wsi( """ ext = os.path.splitext(slide_path)[1].lower() - assert reader_type in ['openslide', 'image', 'cucim', 'sdpc', None], f"Unknown reader_type: {reader_type}. Choose from 'openslide', 'image', 'cucim', or 'sdpc'." + assert reader_type in ['openslide', 'image', 'cucim', 'sdpc', 'omezarr', None], f"Unknown reader_type: {reader_type}. Choose from 'openslide', 'image', 'cucim', or 'sdpc' 'omezarr'." if reader_type == 'openslide': return OpenSlideWSI(slide_path=slide_path, lazy_init=lazy_init, **kwargs) From 50d068e759335088f55cc8dc4fad65787d3f624d Mon Sep 17 00:00:00 2001 From: Harvey South Date: Tue, 10 Mar 2026 14:45:06 -0500 Subject: [PATCH 07/15] Add further OMEZarr reader references didn't change index.rst as the support is probably more narrow than any conception of a WSI in the zarr format. --- docs/quickstart.rst | 2 +- trident/wsi_objects/OMEZarrWSI.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 768423b9..42b44cc7 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -123,7 +123,7 @@ WSI discovery and reading - ``--search_nested``: recursively discover slides in nested subfolders. - ``--custom_list_of_wsis``: CSV subset list to process selected slides only. - ``--custom_mpp_keys``: metadata keys to read MPP from non-standard slide headers. -- ``--reader_type``: force backend reader (``openslide``, ``cucim``, ``image``, ``sdpc``). +- ``--reader_type``: force backend reader (``openslide``, ``cucim``, ``image``, ``sdpc``, ``omezarr``). When to change: diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py index cadd0a00..6f71b54f 100644 --- a/trident/wsi_objects/OMEZarrWSI.py +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -55,7 +55,7 @@ def _lazy_initialize(self) -> None: - `level_count`: number of resolution levels in the image pyramid. - `level_downsamples`: downsampling factors for each level. - `level_dimensions`: image dimensions at each level. - - `properties`: metadata dictionary from OpenSlide. + - `properties`: metadata object from ngff-zarr. - `mpp`: microns per pixel, inferred if not manually specified. - `mag`: estimated magnification level (via WSI.py). - `gdf_contours`: loaded from `tissue_seg_path` if provided (via WSI.py). From cf32d615062b165150dbdbf256c57ce61507320d Mon Sep 17 00:00:00 2001 From: Harvey South Date: Tue, 10 Mar 2026 14:52:42 -0500 Subject: [PATCH 08/15] Change OMEZarr WSI properties to dictionary --- trident/wsi_objects/OMEZarrWSI.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py index 6f71b54f..e2c0e1a4 100644 --- a/trident/wsi_objects/OMEZarrWSI.py +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -4,7 +4,7 @@ from cf_units import Unit as cf_Unit from PIL import Image import numpy as np - +import zarr import dask class OMEZarrWSI(WSI): @@ -80,7 +80,10 @@ def _lazy_initialize(self) -> None: if self.mpp is None: self.mpp = self._fetch_mpp() self.mag = self._fetch_magnification() - self.properties = self.img.metadata # Properties here are limited to OME rather than the whole zarrfile + try: + self.properties = dict(zarr.open(self.slide_path).attrs) # Properties here are limited to OME rather than the whole zarrfile + except: + self.properties = None self._initialized = True From 95e06fb5662771af7ea500fc443037b974731b40 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Tue, 10 Mar 2026 16:04:12 -0500 Subject: [PATCH 09/15] Change OMEZarrWSI documentation slightly --- trident/wsi_objects/OMEZarrWSI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py index e2c0e1a4..d00d9e27 100644 --- a/trident/wsi_objects/OMEZarrWSI.py +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -18,7 +18,7 @@ def __init__(self, slide_path: str, **kwargs: Any) -> None: Parameters ---------- slide_path : str - Path to an .ome.zarr multiscale file. + Path to an .zarr OME multiscale file. **kwargs : dict Additional keyword arguments forwarded to the base `WSI` class. - lazy_init (bool, default=True): Whether to defer loading WSI and metadata. From 4476e8cb4142e72b7ead8ecfbb48e99acbddf002 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Tue, 17 Mar 2026 10:22:45 -0500 Subject: [PATCH 10/15] Update comment for OMEZarrWSI property dictionary --- trident/wsi_objects/OMEZarrWSI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py index d00d9e27..156a230b 100644 --- a/trident/wsi_objects/OMEZarrWSI.py +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -81,7 +81,7 @@ def _lazy_initialize(self) -> None: self.mpp = self._fetch_mpp() self.mag = self._fetch_magnification() try: - self.properties = dict(zarr.open(self.slide_path).attrs) # Properties here are limited to OME rather than the whole zarrfile + self.properties = dict(zarr.open(self.slide_path).attrs) # get the whole zarr.json object except: self.properties = None From c4340ed289ba550771b4ac8411db96f6c6eb8f9d Mon Sep 17 00:00:00 2001 From: Harvey South Date: Wed, 18 Mar 2026 08:55:34 -0500 Subject: [PATCH 11/15] Fix py310 environment --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6e44cb40..48d22bae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ requests = "*" huggingface_hub = "*" openslide-bin = "*" ngff-zarr = ">=0.12" # Minimally support zarr version 3. -zarr = ">=3.0" # Further zarr3 enforcement as aicsimageio has strict reqs +zarr = ">=3.0.0a0" # Further zarr3 enforcement as aicsimageio has strict reqs dask = ">=2024.10" # Minimum for Zarr-Python 3 aicsimageio = "*" pyvips = "*" From 4e2af31bc3829f8658ccf3235fd18b758987a797 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Wed, 18 Mar 2026 08:59:12 -0500 Subject: [PATCH 12/15] Move OMEZarr support to be optional --- pyproject.toml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 48d22bae..34dd7036 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,12 +29,8 @@ shapely = "*" requests = "*" huggingface_hub = "*" openslide-bin = "*" -ngff-zarr = ">=0.12" # Minimally support zarr version 3. -zarr = ">=3.0.0a0" # Further zarr3 enforcement as aicsimageio has strict reqs -dask = ">=2024.10" # Minimum for Zarr-Python 3 aicsimageio = "*" pyvips = "*" -cf-units = ">=3.1" # Minimum for python 3.10 support. # Core model dependencies (installed with `pip install -e .`) transformers = ">=4.40,<5" @@ -55,6 +51,10 @@ musk = { git = "https://github.com/lilab-stanford/MUSK", optional = true } gigapath = { git = "https://github.com/prov-gigapath/prov-gigapath.git", optional = true } madeleine = { git = "https://github.com/mahmoodlab/MADELEINE.git", optional = true } pylibCZIrw = { version = "*", optional = true } +cf-units = { version = ">=3.1", optional = true } +ngff-zarr = { version = ">=0.12", optional = true } +zarr = { version = ">=3.0.0a0", optional = true } +dask = { version = ">=2024.10", optional = true } [tool.poetry.extras] patch-encoders = [ @@ -86,6 +86,12 @@ full = [ "madeleine", "pylibCZIrw", ] +omezarr = [ + "ngff-zarr", + "dask", + "cf-units", + "zarr" +] [tool.poetry.dev-dependencies] # Optional development dependencies From 9786c2a05e99961132ba9810cb5a09dedf278290 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Thu, 19 Mar 2026 10:29:08 -0500 Subject: [PATCH 13/15] Update OMEZarrWSI.py following PR review Fix assumption that ngff-multiscales have a strict dimension ordering Format code with black and autopep8 Move imports that are now optional to a try except block Add docstring to _fetch_downsamples Optimise read_region by adding some processing to lazy_initialize --- trident/wsi_objects/OMEZarrWSI.py | 160 +++++++++++++++++++++++------- 1 file changed, 123 insertions(+), 37 deletions(-) diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py index 156a230b..155920d2 100644 --- a/trident/wsi_objects/OMEZarrWSI.py +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -1,16 +1,24 @@ -import ngff_zarr as nz from typing import Tuple, Union, Optional, Any from trident.wsi_objects.WSI import WSI, ReadMode -from cf_units import Unit as cf_Unit from PIL import Image import numpy as np -import zarr -import dask + +try: + from zarr import open as zarr_open + from dask.config import set as dask_config_set + from ngff_zarr import from_ngff_zarr + from cf_units import Unit as cf_Unit + + _HAS_OME_ZARR = True +except ImportError: + _HAS_OME_ZARR = False + class OMEZarrWSI(WSI): """ WSI implementation for reading zarrfiles following the OME specification. """ + def __init__(self, slide_path: str, **kwargs: Any) -> None: """ Initialize a OMEZarr instance for OME-Zarr whole-slide images. @@ -44,7 +52,7 @@ def _lazy_initialize(self) -> None: FileNotFoundError If the WSI file or the tissue segmentation mask cannot be found. RuntimeError - If an unexpected error occurs during WSI initialization. Including if there + If an unexpected error occurs during WSI initialization. Including if there are not 3 dimensions in an image, as read_region depends on this property. Notes @@ -63,16 +71,41 @@ def _lazy_initialize(self) -> None: super()._lazy_initialize() - _get_W_and_H = lambda ngffimg: (ngffimg.data.shape[-1], ngffimg.data.shape[-2]) - if not self._initialized: + + if not _HAS_OME_ZARR: + raise ImportError("Please install the omezarr optionals") + try: - self.img = nz.from_ngff_zarr(self.slide_path) # Multiscales dataclass from ngff-zarr - - toplevel_image = self.img.images[0] # a possibly cyx shape NgffImage object - assert len(toplevel_image.data.shape) == 3, "Err, read_region expects 3 dimensional image data" + self.img = from_ngff_zarr( + self.slide_path + ) # Multiscales dataclass from ngff-zarr + + dimnames = self.img.metadata.dimension_names + assert (len(dimnames) == 3) and ( + set(dimnames) == {"x", "y", "c"} + ), "Err, read_region expects 3 dimensional image data with c,y,x dim names" + + _dimname_to_index = { + name: i for i, name in enumerate(self.img.metadata.dimension_names) + } + self._idx_x, self._idx_y, self._idx_c = ( + _dimname_to_index["x"], + _dimname_to_index["y"], + _dimname_to_index["c"], + ) + + self._transpose_order = (self._idx_y, self._idx_x, self._idx_c) + + # x -> width, y -> height + _get_W_and_H = lambda ngffimg: ( + ngffimg.data.shape[self._idx_x], + ngffimg.data.shape[self._idx_y], + ) + self.dimensions = _get_W_and_H( + self.img.images[0] + ) # use the top level image (largest resolution) - self.dimensions = _get_W_and_H(toplevel_image) # based on cyx array storage x -> width, y -> height self.width, self.height = self.dimensions self.level_count = len(self.img.images) self.level_dimensions = tuple(map(_get_W_and_H, self.img.images)) @@ -81,14 +114,18 @@ def _lazy_initialize(self) -> None: self.mpp = self._fetch_mpp() self.mag = self._fetch_magnification() try: - self.properties = dict(zarr.open(self.slide_path).attrs) # get the whole zarr.json object + self.properties = dict( + zarr_open(self.slide_path).attrs + ) # get the whole zarr.json object except: - self.properties = None + self.properties = None self._initialized = True except Exception as e: - raise RuntimeError(f"Failed to initialize WSI with ngff-zarr: {e}") from e + raise RuntimeError( + f"Failed to initialize WSI with ngff-zarr: {e}" + ) from e def _fetch_mpp(self): """ @@ -100,12 +137,35 @@ def _fetch_mpp(self): np.float64 MPP value in microns per pixel. """ - scale, scale_unit = self.img.images[0].scale['x'], self.img.images[0].axes_units['x'] - return cf_Unit(scale_unit).convert(scale, cf_Unit('micrometers')) # mpp for the x axis at the highest res image - + scale, scale_unit = ( + self.img.images[0].scale["x"], + self.img.images[0].axes_units["x"], + ) + return cf_Unit(scale_unit).convert( + scale, cf_Unit("micrometers") + ) # mpp for the x axis at the highest res image + def _fetch_downsamples(self): - return tuple( [1.] - + [(self.img.images[0].data.shape[-1] / ngff_img.data.shape[-1]) for ngff_img in self.img.images[1:]] + """ + Calculate the downsampling factors for each resolution level. + + Computes the ratio of the highest resolution level's x-axis dimension to + each subsequent level's x-axis dimension. The base level defaults to 1.0. + + Returns + ------- + Tuple[float] + Downsample factors for each level in the image pyramid. + """ + return tuple( + [1.0] + + [ + ( + self.img.images[0].data.shape[self._idx_x] + / ngff_img.data.shape[self._idx_x] + ) + for ngff_img in self.img.images[1:] + ] ) def read_region( @@ -113,7 +173,7 @@ def read_region( location: Tuple[int, int], level: int, size: Tuple[int, int], - read_as: ReadMode = 'pil', + read_as: ReadMode = "pil", ) -> Union[Image.Image, np.ndarray]: """ Extract a specific region from the whole-slide image (WSI). @@ -148,24 +208,39 @@ def read_region( (512, 512, 3) """ # 'location' is relative to the level as calls are made to the data array - location_ = (int(location[0] / self.level_downsamples[level]), int(location[1] / self.level_downsamples[level])) + downsample_factor = self.level_downsamples[level] + location_ = ( + int(location[0] / downsample_factor), + int(location[1] / downsample_factor), + ) x, y = location_ width_size, height_size = size + slice_init = [None, None, None] + slice_init[self._idx_y] = slice(y, y + height_size) + slice_init[self._idx_x] = slice(x, x + width_size) + slice_init[self._idx_c] = slice(None) + slice_init = tuple(slice_init) + # prevent deadlock that occurs when reading while nested in pytorch's distributed operations - with dask.config.set(scheduler='synchronous'): - # imgs are ordered cyx, so [: -> c, y:y+height_size, x:x+width_size, ] - # also convert cyx to desired H,W,C - region = self.img.images[level].data[:, y:y+height_size, x:x+width_size].compute().transpose(1, 2, 0) - - if read_as == 'pil': - return Image.fromarray(region).convert('RGB') - elif read_as == 'numpy': + with dask_config_set(scheduler="synchronous"): + region = ( + self.img.images[level] + .data[slice_init] + .compute() + .transpose(self._transpose_order) + ) + + if read_as == "pil": + return Image.fromarray(region).convert("RGB") + elif read_as == "numpy": return region else: - raise ValueError(f"Invalid `read_as` value: {read_as}. Must be 'pil', 'numpy'.") - + raise ValueError( + f"Invalid `read_as` value: {read_as}. Must be 'pil', 'numpy'." + ) + def get_dimensions(self) -> Tuple[int, int]: """ Return the dimensions (width, height) of the WSI. @@ -176,7 +251,7 @@ def get_dimensions(self) -> Tuple[int, int]: (width, height) in pixels. """ return self.dimensions - + def get_thumbnail(self, size: tuple[int, int]) -> Image.Image: """ Generate a thumbnail of the WSI. @@ -194,9 +269,20 @@ def get_thumbnail(self, size: tuple[int, int]) -> Image.Image: width, height = size # takes the average ratio between the thumbsize and the object's (level dimension) size then applies abs(x - 1) so min finds # the size ratio closest to 1 - get_dim_to_size_adjusted_ratio = lambda x: abs((((x[0]/width) + (x[1]/height)) / 2) - 1) + get_dim_to_size_adjusted_ratio = lambda x: abs( + (((x[0] / width) + (x[1] / height)) / 2) - 1 + ) # get the min index rather than value - closest_level = min(range(self.level_count), key=lambda i: list(map(get_dim_to_size_adjusted_ratio, self.level_dimensions))[i]) + closest_level = min( + range(self.level_count), + key=lambda i: list( + map(get_dim_to_size_adjusted_ratio, self.level_dimensions) + )[i], + ) - thumbimg_data = self.img.images[closest_level].data.compute().transpose(1, 2, 0) - return Image.fromarray(thumbimg_data).convert('RGB').resize(size) \ No newline at end of file + thumbimg_data = ( + self.img.images[closest_level] + .data.compute() + .transpose(self._transpose_order) + ) + return Image.fromarray(thumbimg_data).convert("RGB").resize(size) From f57023a5867cfa4ba3ed33e8489e247b86c167a0 Mon Sep 17 00:00:00 2001 From: Harvey South Date: Thu, 19 Mar 2026 21:09:48 -0500 Subject: [PATCH 14/15] Fix OMEZarr robustness issues Make imports more robust Expand possible dimnames Fix incorrect OME spec assumption in fetch_mpp Update slice varname for better readability Cache slice involved variables in init rather than read --- trident/wsi_objects/OMEZarrWSI.py | 120 ++++++++++++++++++++++-------- 1 file changed, 89 insertions(+), 31 deletions(-) diff --git a/trident/wsi_objects/OMEZarrWSI.py b/trident/wsi_objects/OMEZarrWSI.py index 155920d2..251376d8 100644 --- a/trident/wsi_objects/OMEZarrWSI.py +++ b/trident/wsi_objects/OMEZarrWSI.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union, Optional, Any +from typing import Tuple, Union, Any from trident.wsi_objects.WSI import WSI, ReadMode from PIL import Image import numpy as np @@ -10,8 +10,10 @@ from cf_units import Unit as cf_Unit _HAS_OME_ZARR = True -except ImportError: + _EXCEPT_MESSAGE = None +except ImportError as e: # ModuleNotFoundError is likely _HAS_OME_ZARR = False + _EXCEPT_MESSAGE = e class OMEZarrWSI(WSI): @@ -74,26 +76,20 @@ def _lazy_initialize(self) -> None: if not self._initialized: if not _HAS_OME_ZARR: - raise ImportError("Please install the omezarr optionals") + raise ImportError( + "ngff-zarr, zarr, dask, and cf_units are required for omezarr support. " + "Install them with pip, or pip install .[omezarr] when installing TRIDENT. " + f"When trying to import, got message {_EXCEPT_MESSAGE}" + ) try: self.img = from_ngff_zarr( self.slide_path ) # Multiscales dataclass from ngff-zarr - dimnames = self.img.metadata.dimension_names - assert (len(dimnames) == 3) and ( - set(dimnames) == {"x", "y", "c"} - ), "Err, read_region expects 3 dimensional image data with c,y,x dim names" - - _dimname_to_index = { - name: i for i, name in enumerate(self.img.metadata.dimension_names) - } - self._idx_x, self._idx_y, self._idx_c = ( - _dimname_to_index["x"], - _dimname_to_index["y"], - _dimname_to_index["c"], - ) + idx_tuple, dimname_tuple = self._fetch_dimension_metadata() + self._idx_x, self._idx_y, self._idx_c = idx_tuple + self._xname, self._yname, self._cname = dimname_tuple self._transpose_order = (self._idx_y, self._idx_x, self._idx_c) @@ -129,21 +125,33 @@ def _lazy_initialize(self) -> None: def _fetch_mpp(self): """ - Retrieve microns per pixel (MPP) from OME Zarr metadata. Conforming to the OME zarr - specification requires scale and UDUNITS-2, so custom_mpp_keys not requried. + Retrieve microns per pixel (MPP) from OME Zarr metadata. The OME spec + has a designated axes unit property in UDUNITS-2, so custom_mpp_keys not requried. Returns ------- np.float64 MPP value in microns per pixel. """ - scale, scale_unit = ( - self.img.images[0].scale["x"], - self.img.images[0].axes_units["x"], - ) - return cf_Unit(scale_unit).convert( - scale, cf_Unit("micrometers") - ) # mpp for the x axis at the highest res image + try: + scale, scale_unit = ( + self.img.images[0].scale[self._xname], + self.img.images[0].axes_units[self._xname], + ) + return cf_Unit(scale_unit).convert( + scale, cf_Unit("micrometers") + ) # mpp for the x axis at the highest res image + except: + raise ValueError( + f"Unable to extract MPP from slide metadata: '{self.slide_path}'.\n" + "Suggestions:\n" + "- Set the unit in the x/width axes metadata of the OME-Zarr Multiscales " + "(likely having to update the corresponding scale property).\n" + "- Set the MPP explicitly via the class constructor.\n" + "- If using the `run_batch_of_slides.py` script, pass the MPP via the " + "`--custom_list_of_wsis` argument in a CSV file. Refer to TRIDENT/README/Q&A." + ) + def _fetch_downsamples(self): """ @@ -168,6 +176,56 @@ def _fetch_downsamples(self): ] ) + def _fetch_dimension_metadata(self): + """ + Parse dimension metadata to identify spatial and channel axes. + + Extracts and maps the indices and original string names for the x-axis, + y-axis, and channel dimensions from the image metadata. + + Returns + ------- + Tuple[Tuple[int, int, int], Tuple[str, str, str]] + A pair of tuples containing the integer indices (idx_x, idx_y, idx_c) + and the matched string names (x_name, y_name, c_name), respectively. + + Raises + ------ + AssertionError + If the image does not have exactly 3 dimensions or contains unrecognized dimension names. + ValueError + If the dimensions do not consist of exactly one X-type, one Y-type, and one C-type axis. + """ + + dimnames = self.img.metadata.dimension_names + possible_dimnames_lowercase = {"x", "y", "c", "width", "height", 'channel'} + + strlower = lambda x: x.lower() + assert (len(dimnames) == 3) and ( + set(map(strlower, dimnames)).issubset(possible_dimnames_lowercase) + ), f"Err, read_region expects 3 dimensional image data with {possible_dimnames_lowercase} dim names, found {dimnames}" + + try: + _xname = next(d for d in dimnames if d.lower() in {"x", "width"}) + _yname = next(d for d in dimnames if d.lower() in {"y", "height"}) + _cname = next(d for d in dimnames if d.lower() in {"c", "channel"}) + except: + raise ValueError( + "Err, expecting one of each space/channel type dim in " + f"{possible_dimnames_lowercase}, found {dimnames}." + ) + + _dimname_to_index = { + name: i for i, name in enumerate(self.img.metadata.dimension_names) + } + _idx_x, _idx_y, _idx_c = ( + _dimname_to_index[_xname], + _dimname_to_index[_yname], + _dimname_to_index[_cname], + ) + + return (_idx_x, _idx_y, _idx_c), (_xname, _yname, _cname) + def read_region( self, location: Tuple[int, int], @@ -217,17 +275,17 @@ def read_region( x, y = location_ width_size, height_size = size - slice_init = [None, None, None] - slice_init[self._idx_y] = slice(y, y + height_size) - slice_init[self._idx_x] = slice(x, x + width_size) - slice_init[self._idx_c] = slice(None) - slice_init = tuple(slice_init) + region_as_slice = [None, None, None] + region_as_slice[self._idx_y] = slice(y, y + height_size) + region_as_slice[self._idx_x] = slice(x, x + width_size) + region_as_slice[self._idx_c] = slice(None) + region_as_slice = tuple(region_as_slice) # prevent deadlock that occurs when reading while nested in pytorch's distributed operations with dask_config_set(scheduler="synchronous"): region = ( self.img.images[level] - .data[slice_init] + .data[region_as_slice] .compute() .transpose(self._transpose_order) ) From ccb8aa5c8645b74b8179e799fd479ff21b5fc33a Mon Sep 17 00:00:00 2001 From: Harvey South Date: Thu, 19 Mar 2026 21:59:23 -0500 Subject: [PATCH 15/15] Update docs for optional install --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 50660cfa..195f8137 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ This project was developed by the [Mahmood Lab](https://faisal.ai/) at Harvard M Optional install profiles: - `pip install -e ".[patch-encoders]"` for CONCH/MUSK/CTransPath-related extras. - `pip install -e ".[slide-encoders]"` for PRISM/GigaPath/Madeleine-related extras. +- `pip install -e ".[omezarr]"` for OME Zarr WSI reader support - `pip install -e ".[convert]"` for slide conversion dependencies. - `pip install -e ".[full]"` to install all pip-installable optional dependencies. @@ -231,7 +232,7 @@ main() - **A**: Yes using the `--custom_list_of_wsis` argument. Provide a list of WSI names in a CSV (with slide extension, `wsi`). Optionally, provide the mpp (field `mpp`) - **Q**: Do I need to install any additional packages to use Trident? - - **A**: `pip install -e .` installs core dependencies. Some optional components still require extra installs. Use profiles (`.[patch-encoders]`, `.[slide-encoders]`, `.[convert]`, or `.[full]`) and run `trident-doctor` for preflight checks. + - **A**: `pip install -e .` installs core dependencies. Some optional components still require extra installs. Use profiles (`.[patch-encoders]`, `.[slide-encoders]`, `.[convert]`, `.[omezarr]` or `.[full]`) and run `trident-doctor` for preflight checks. ## License and Terms of Use