diff --git a/src/napari_ndev/__init__.py b/src/napari_ndev/__init__.py index 144d4e7..9f747d1 100644 --- a/src/napari_ndev/__init__.py +++ b/src/napari_ndev/__init__.py @@ -6,6 +6,7 @@ from napari_ndev import helpers, measure, morphology from napari_ndev._plate_mapper import PlateMapper from napari_ndev.image_overview import ImageOverview, image_overview +from napari_ndev.nimage import nImage from napari_ndev.widgets import ( ApocContainer, ApocFeatureStack, @@ -15,6 +16,7 @@ ) __all__ = [ + 'nImage', 'WorkflowContainer', 'UtilitiesContainer', 'ApocContainer', diff --git a/src/napari_ndev/_napari_reader.py b/src/napari_ndev/_napari_reader.py index c3deb25..cf7d2a8 100644 --- a/src/napari_ndev/_napari_reader.py +++ b/src/napari_ndev/_napari_reader.py @@ -1,13 +1,11 @@ from __future__ import annotations -import contextlib import logging from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Callable from bioio import BioImage -from bioio_base.dimensions import DimensionNames from bioio_base.exceptions import UnsupportedFileFormatError from qtpy.QtWidgets import ( QCheckBox, @@ -17,8 +15,9 @@ QVBoxLayout, ) +from napari_ndev import nImage + if TYPE_CHECKING: - import xarray as xr from napari.types import LayerData, PathLike, ReaderFunction @@ -114,7 +113,7 @@ def napari_reader_function( in_memory = _determine_in_memory(path) if in_memory is None else in_memory logger.info('Bioio: Reading in-memory: %s', in_memory) - img = BioImage(path, reader=reader) + img = nImage(path, reader=reader) if len(img.scenes) > 1 and not open_first_scene_only: _get_scenes(path=path, img=img, in_memory=in_memory) @@ -122,8 +121,8 @@ def napari_reader_function( # TODO: why should I return the squeezed data and not the full data # is it because napari squeezes it anyway? - img_data = _get_image_data(img, in_memory=in_memory) - img_meta = _get_napari_metadata(path, img_data, img) + img_data = img.get_napari_image_data(in_memory=in_memory) + img_meta = img.get_napari_metadata(path) return [(img_data.data, img_meta, layer_type)] @@ -144,123 +143,6 @@ def _determine_in_memory( and filesize / available_mem <= max_mem_percent ) -def _get_image_data( - img: BioImage, - in_memory: bool | None = None -) -> xr.DataArray: - """ - Get the image data from the BioImage object. - - If the image has a mosaic, the data will be returned as a single - xarray DataArray with the mosaic dimensions removed. - - Parameters - ---------- - img : BioImage - BioImage object to get the data from - in_memory : bool, optional - Whether to read the data in memory, by default None - - Returns - ------- - xr.DataArray - The image data as an xarray DataArray - - """ - if DimensionNames.MosaicTile in img.reader.dims.order: - try: - if in_memory: - return img.reader.mosaic_xarray_data.squeeze() - - return img.reader.mosaic_xarray_dask_data.squeeze() - - except NotImplementedError: - logger.warning( - "Bioio: Mosaic tile switching not supported for this reader" - ) - return None - - if in_memory: - return img.reader.xarray_data.squeeze() - - return img.reader.xarray_dask_data.squeeze() - -def _get_napari_metadata( - path: PathLike, - img_data: xr.DataArray, - img: BioImage -) -> dict: - """ - Get the metadata for the image. - - Parameters - ---------- - path : PathLike - Path to the file - img_data : xr.DataArray - Image data as an xarray DataArray - img : BioImage - BioImage object - - Returns - ------- - dict - Dictionary containing the image metadata for napari - - """ - meta = {} - scene = img.current_scene - scene_index = img.current_scene_index - single_no_scene = len(img.scenes) == 1 and img.current_scene == "Image:0" - channel_dim = DimensionNames.Channel - - if channel_dim in img_data.dims: - # use filename if single scene and no scene name available - if single_no_scene: - channels_with_scene_index = [ - f'{Path(path).stem}{SCENE_LABEL_DELIMITER}{C}' - for C in img_data.coords[channel_dim].data.tolist() - ] - else: - channels_with_scene_index = [ - f'{scene_index}{SCENE_LABEL_DELIMITER}' - f'{scene}{SCENE_LABEL_DELIMITER}{C}' - for C in img_data.coords[channel_dim].data.tolist() - ] - meta['name'] = channels_with_scene_index - meta['channel_axis'] = img_data.dims.index(channel_dim) - - # not multi-chnanel, use current scene as image name - else: - if single_no_scene: - meta['name'] = Path(path).stem - else: - meta['name'] = img.reader.current_scene - - # Handle if RGB - if DimensionNames.Samples in img.reader.dims.order: - meta['rgb'] = True - - # Handle scales - scale = [ - getattr(img.physical_pixel_sizes, dim) - for dim in img_data.dims - if dim in {DimensionNames.SpatialX, DimensionNames.SpatialY, DimensionNames.SpatialZ} - and getattr(img.physical_pixel_sizes, dim) is not None - ] - - if scale: - meta['scale'] = tuple(scale) - - # get all other metadata - img_meta = {'bioimage': img, 'raw_image_metadata': img.metadata} - - with contextlib.suppress(NotImplementedError): - img_meta['metadata'] = img.ome_metadata - - meta['metadata'] = img_meta - return meta - def _widget_is_checked(widget_name: str) -> bool: import napari @@ -277,7 +159,7 @@ def _widget_is_checked(widget_name: str) -> bool: # Function to handle multi-scene files. -def _get_scenes(path: PathLike, img: BioImage, in_memory: bool) -> None: +def _get_scenes(path: PathLike, img: nImage, in_memory: bool) -> None: import napari # Get napari viewer from current process @@ -334,15 +216,13 @@ def open_scene(item: QListWidgetItem) -> None: # Update scene on image and get data img.set_scene(scene_index) # check whether to mosaic merge or not - if _widget_is_checked(DONT_MERGE_MOSAICS): - data = _get_image_data( - img=img, in_memory=in_memory, reconstruct_mosaic=False - ) - else: - data = _get_image_data(img=img, in_memory=in_memory) + # if _widget_is_checked(DONT_MERGE_MOSAICS): + # data = img.get_napari_image_data(in_memory=in_memory, reconstruct_mosaic=False) + # else: + data = img.get_napari_image_data(in_memory=in_memory) # Get metadata and add to image - meta = _get_napari_metadata("", data, img) + meta = img.get_napari_metadata("") # Optionally clear layers if _widget_is_checked(CLEAR_LAYERS_ON_SELECT): diff --git a/src/napari_ndev/_tests/test_nimage.py b/src/napari_ndev/_tests/test_nimage.py new file mode 100644 index 0000000..c60e6ad --- /dev/null +++ b/src/napari_ndev/_tests/test_nimage.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path + +from napari_ndev import nImage + +RGB_TIFF = "RGB.tiff" # has two scenes + +def test_nImage_init(resources_dir: Path): + img = nImage(resources_dir / RGB_TIFF) + assert img.path == resources_dir / RGB_TIFF + assert img.reader is not None + assert img.data.shape == (1, 1, 1, 1440, 1920, 3) + assert img.napari_data is None + +def test_determine_in_memory(resources_dir: Path): + img = nImage(resources_dir / RGB_TIFF) + assert img._determine_in_memory() is True + +def test_get_napari_image_data(resources_dir: Path): + img = nImage(resources_dir / RGB_TIFF) + img.get_napari_image_data() + assert img.napari_data.shape == (1440, 1920, 3) + assert img.napari_data.dims == ('Y', 'X', 'S') + +def test_get_napari_metadata(resources_dir: Path): + img = nImage(resources_dir / RGB_TIFF) + img.get_napari_metadata(path = img.path) + assert img.napari_metadata['name'] == 'Image:0' + assert img.napari_metadata['scale'] == (264.5833333333333, 264.5833333333333) + assert img.napari_metadata['rgb'] is True diff --git a/src/napari_ndev/nimage.py b/src/napari_ndev/nimage.py new file mode 100644 index 0000000..3c78594 --- /dev/null +++ b/src/napari_ndev/nimage.py @@ -0,0 +1,215 @@ +"""Additional functionality for BioImage objects to be used in napari-ndev.""" + +from __future__ import annotations + +import contextlib +import logging +from pathlib import Path + +import xarray as xr +from bioio import BioImage +from bioio_base.dimensions import DimensionNames +from bioio_base.reader import Reader +from bioio_base.types import ImageLike + +from napari.types import PathLike + +logger = logging.getLogger(__name__) + +LABEL_DELIMITER = " ||" + +class nImage(BioImage): + """ + An nImage is a BioImage with additional functionality for napari-ndev. + + Parameters + ---------- + image : ImageLike + Image to be loaded. + reader : Reader, optional + Reader to be used to load the image. If not provided, a reader will be + determined based on the image type. + + Attributes + ---------- + See BioImage for inherited attributes. + + Methods + ------- + get_napari_image_data(in_memory=None) + Get the image data as a xarray, optionally loading it into memory. + + + """ + + def __init__( + self, + image: ImageLike, + reader: Reader | None = None + ) -> None: + """Initialize an nImage with an image, and optionally a reader.""" + super().__init__(image, reader) + self.napari_data = None + self.napari_metadata = {} + self.path = image if isinstance(image, (str, Path)) else None + + def _determine_in_memory(self, path=None, max_in_mem_bytes: int = 4e9, max_in_mem_percent: int = 0.3) -> bool: + """ + Determine whether the image should be loaded into memory or not. + + If the image is smaller than the maximum filesize or percentage of the + available memory, this will determine to load image in memory. + Otherwise, suggest to load as a dask array. + + Parameters + ---------- + path : str or Path + Path to the image file. + max_in_mem_bytes : int + Maximum number of bytes that can be loaded into memory. + Default is 4 GB (4e9 bytes) + max_in_mem_percent : float + Maximum percentage of memory that can be loaded into memory. + Default is 30% of available memory (0.3) + + Returns + ------- + bool + True if image should be loaded in memory, False otherwise. + + """ + from bioio_base.io import pathlike_to_fs + from psutil import virtual_memory + + if path is None: + path = self.path + + fs, path = pathlike_to_fs(path) + filesize = fs.size(path) + available_mem = virtual_memory().available + return ( + filesize <= max_in_mem_bytes + and filesize < max_in_mem_percent * available_mem + ) + + def get_napari_image_data(self, in_memory: bool | None = None) -> xr.DataArray: + """ + Get the image data as a xarray DataArray. + + From BioImage documentation: + If you do not want the image pre-stitched together, you can use the base reader + by either instantiating the reader independently or using the `.reader` property. + + Parameters + ---------- + in_memory : bool, optional + Whether to load the image in memory or not. + If None, will determine whether to load in memory based on the image size. + + Returns + ------- + xr.DataArray + Image data as a xarray DataArray. + + """ + if in_memory is None: + in_memory = self._determine_in_memory() + + if DimensionNames.MosaicTile in self.reader.dims.order: + try: + if in_memory: + self.napari_data = self.reader.mosaic_xarray_data.squeeze() + else: + self.napari_data = self.reader.mosaic_xarray_dask_data.squeeze() + + except NotImplementedError: + logger.warning( + "Bioio: Mosaic tile switching not supported for this reader" + ) + return None + else: + if in_memory: + self.napari_data = self.reader.xarray_data.squeeze() + else: + self.napari_data = self.reader.xarray_dask_data.squeeze() + + return self.napari_data + + def get_napari_metadata( + self, + path: PathLike, + ) -> dict: + """ + Get the metadata for the image to be displayed in napari. + + Parameters + ---------- + path : PathLike + Path to the image file. + img_data : xr.DataArray + Image data as a xarray DataArray. + img : BioImage + BioImage object containing the image metadata + + Returns + ------- + dict + Metadata for the image to be displayed in napari. + + """ + if self.napari_data is None: + self.get_napari_image_data() + + meta = {} + scene = self.current_scene + scene_index = self.current_scene_index + single_no_scene = len(self.scenes) == 1 and self.current_scene == "Image:0" + channel_dim = DimensionNames.Channel + + if channel_dim in self.napari_data.dims: + # use filename if single scene and no scene name available + if single_no_scene: + channels_with_scene_index = [ + f'{Path(path).stem}{LABEL_DELIMITER}{C}' + for C in self.napari_data.coords[channel_dim].data.tolist() + ] + else: + channels_with_scene_index = [ + f'{scene_index}{LABEL_DELIMITER}' + f'{scene}{LABEL_DELIMITER}{C}' + for C in self.napari_data.coords[channel_dim].data.tolist() + ] + meta['name'] = channels_with_scene_index + meta['channel_axis'] = self.napari_data.dims.index(channel_dim) + + # not multi-chnanel, use current scene as image name + else: + if single_no_scene: + meta['name'] = Path(path).stem + else: + meta['name'] = self.reader.current_scene + + # Handle if RGB + if DimensionNames.Samples in self.reader.dims.order: + meta['rgb'] = True + + # Handle scales + scale = [ + getattr(self.physical_pixel_sizes, dim) + for dim in self.napari_data.dims + if dim in {DimensionNames.SpatialX, DimensionNames.SpatialY, DimensionNames.SpatialZ} + and getattr(self.physical_pixel_sizes, dim) is not None + ] + + if scale: + meta['scale'] = tuple(scale) + + # get all other metadata + img_meta = {'bioimage': self, 'raw_image_metadata': self.metadata} + + with contextlib.suppress(NotImplementedError): + img_meta['metadata'] = self.ome_metadata + + meta['metadata'] = img_meta + self.napari_metadata = meta + return self.napari_metadata