diff --git a/.github/workflows/build-main.yml b/.github/workflows/build-main.yml index ee812f1..7af7584 100644 --- a/.github/workflows/build-main.yml +++ b/.github/workflows/build-main.yml @@ -34,12 +34,17 @@ jobs: run: | python -m pip install --upgrade pip pip install .[test] + + # Install qt support libs + - uses: tlambert03/setup-qt-libs@v1 + - name: Download Test Resources run: | python scripts/download_test_resources.py - name: Test with pytest - run: | - pytest --cov-report xml --cov=napari_aicsimageio napari_aicsimageio/tests/ + uses: GabrielBB/xvfb-action@v1 + with: + run: pytest --cov-report xml --cov=napari_aicsimageio napari_aicsimageio/tests/ - name: Upload codecov uses: codecov/codecov-action@v1 diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index 9d1f9be..4d8d608 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -26,12 +26,17 @@ jobs: run: | python -m pip install --upgrade pip pip install .[test] + + # Install qt support libs + - uses: tlambert03/setup-qt-libs@v1 + - name: Download Test Resources run: | python scripts/download_test_resources.py - name: Test with pytest - run: | - pytest --cov-report xml --cov=napari_aicsimageio napari_aicsimageio/tests/ + uses: GabrielBB/xvfb-action@v1 + with: + run: pytest --cov-report xml --cov=napari_aicsimageio napari_aicsimageio/tests/ - name: Upload codecov uses: codecov/codecov-action@v1 diff --git a/README.md b/README.md index 866888a..f708caf 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,16 @@ AICSImageIO bindings for napari --- ## Features -* Supports reading metadata and imaging data for: - * `CZI` - * `OME-TIFF` - * `TIFF` - * Any formats supported by [aicsimageio](https://github.com/AllenCellModeling/aicsimageio) - * Any additional format supported by [imageio](https://github.com/imageio/imageio) + +- Supports reading metadata and imaging data for: + - `CZI` + - `OME-TIFF` + - `TIFF` + - Any formats supported by [aicsimageio](https://github.com/AllenCellModeling/aicsimageio) + - Any additional format supported by [imageio](https://github.com/imageio/imageio) ## Installation + **Stable Release:** `pip install napari-aicsimageio`
**Development Head:** `pip install git+https://github.com/AllenCellModeling/napari-aicsimageio.git` @@ -24,9 +26,10 @@ AICSImageIO bindings for napari ![screenshot of plugin sorter showing that napari-aicsimageio-in-memory should be placed above napari-aicsimageio-out-of-memory](https://raw.githubusercontent.com/AllenCellModeling/napari-aicsimageio/main/images/plugin-sorter.png) There are two variants of this plugin that are added during installation: -* `aicsimageio-in-memory`, which reads an image fully into memory -* `aicsimageio-out-of-memory`, which delays reading ZYX chunks until required. -This allows for incredible large files to be read and displayed. + +- `aicsimageio-in-memory`, which reads an image fully into memory +- `aicsimageio-out-of-memory`, which delays reading ZYX chunks until required. + This allows for incredible large files to be read and displayed. ## Examples of Features @@ -41,21 +44,25 @@ napari viewer thanks to `ome-types`. ![screenshot of an OME-TIFF image view, multi-channel, z-stack, with metadata viewer](https://raw.githubusercontent.com/AllenCellModeling/napari-aicsimageio/main/images/ome-tiff-with-metadata-viewer.png) -#### Mosaic CZI Reading +#### Mosaic Reading -When reading CZI images, if the image is a mosaic tiled image, `napari-aicsimageio` +When reading CZI or LIF images, if the image is a mosaic tiled image, `napari-aicsimageio` will return the reconstructed image: -![screenshot of a reconstructed / restitched mosaic tile CZI](https://raw.githubusercontent.com/AllenCellModeling/napari-aicsimageio/main/images/tiled-czi.png) +![screenshot of a reconstructed / restitched mosaic tile LIF](https://raw.githubusercontent.com/AllenCellModeling/napari-aicsimageio/main/images/tiled-lif.png) + +#### Multi-Scene Selection -#### Mosaic LIF Reading +**Experimental** -When reading LIF images, if the image is a mosaic tiled image, `napari-aicsimageio` -will return the reconstructed image: +When reading a multi-scene file, a widget will be added to the napari viewer to manage +scene selection (clearing the viewer each time you change scene or adding the +scene content to the viewer) and a list of all scenes in the file. -![screenshot of a reconstructed / restitched mosaic tile LIF](https://raw.githubusercontent.com/AllenCellModeling/napari-aicsimageio/main/images/tiled-lif.png) +![gif of drag and drop file to scene selection and management](https://raw.githubusercontent.com/AllenCellModeling/napari-aicsimageio/main/images/scene-selection.gif) ## Development + See [CONTRIBUTING.md](CONTRIBUTING.md) for information related to developing the code. For additional file format support, contributed directly to diff --git a/images/scene-selection.gif b/images/scene-selection.gif new file mode 100644 index 0000000..e4af23c Binary files /dev/null and b/images/scene-selection.gif differ diff --git a/napari_aicsimageio/core.py b/napari_aicsimageio/core.py index a32277a..73d6e64 100644 --- a/napari_aicsimageio/core.py +++ b/napari_aicsimageio/core.py @@ -2,28 +2,45 @@ # -*- coding: utf-8 -*- from functools import partial -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional +import napari import xarray as xr -from aicsimageio import AICSImage, exceptions, types +from aicsimageio import AICSImage, exceptions from aicsimageio.dimensions import DimensionNames +from qtpy.QtWidgets import ( + QCheckBox, + QGroupBox, + QListWidget, + QListWidgetItem, + QVBoxLayout, +) + +if TYPE_CHECKING: + from napari.types import LayerData, PathLike, ReaderFunction ############################################################################### -LayerData = Union[Tuple[types.ArrayLike, Dict[str, Any], str]] -PathLike = Union[str, List[str]] -ReaderFunction = Callable[[PathLike], List[LayerData]] +AICSIMAGEIO_CHOICES = "AICSImageIO Scene Management" +CLEAR_LAYERS_ON_SELECT = "Clear All Layers on New Scene Selection" +UNPACK_CHANNELS_TO_LAYERS = "Unpack Channels as Layers" + +SCENE_LABEL_DELIMITER = " :: " ############################################################################### -def _get_full_image_data(img: AICSImage, in_memory: bool) -> Optional[xr.DataArray]: +def _get_full_image_data( + img: AICSImage, + in_memory: bool, +) -> xr.DataArray: if DimensionNames.MosaicTile in img.reader.dims.order: try: if in_memory: return img.reader.mosaic_xarray_data - else: - return img.reader.mosaic_xarray_dask_data + + return img.reader.mosaic_xarray_dask_data # Catch reader does not support tile stitching except NotImplementedError: @@ -32,18 +49,139 @@ def _get_full_image_data(img: AICSImage, in_memory: bool) -> Optional[xr.DataArr "not yet supported for this file format reader." ) + if in_memory: + return img.reader.xarray_data + + return img.reader.xarray_dask_data + + +# Function to get Metadata to provide with data +def _get_meta(data: xr.DataArray, img: AICSImage) -> Dict[str, Any]: + meta: Dict[str, Any] = {} + if DimensionNames.Channel in data.dims: + # Construct basic metadata + channels_with_scene_index = [ + f"{img.current_scene_index}{SCENE_LABEL_DELIMITER}" + f"{img.current_scene}{SCENE_LABEL_DELIMITER}{channel_name}" + for channel_name in data.coords[DimensionNames.Channel].data.tolist() + ] + meta["name"] = channels_with_scene_index + meta["channel_axis"] = data.dims.index(DimensionNames.Channel) + + # Not multi-channel, use current scene as image name else: - if in_memory: - return img.reader.xarray_data - else: - return img.reader.xarray_dask_data + meta["name"] = img.reader.current_scene + + # Handle samples / RGB + if DimensionNames.Samples in img.reader.dims.order: + meta["rgb"] = True + + # Handle scales + scale: List[float] = [] + for dim in img.reader.dims.order: + if dim in [ + DimensionNames.SpatialX, + DimensionNames.SpatialY, + DimensionNames.SpatialZ, + ]: + scale_val = getattr(img.physical_pixel_sizes, dim) + if scale_val is not None: + scale.append(scale_val) + + # Apply scales + if len(scale) > 0: + meta["scale"] = tuple(scale) + + # Apply all other metadata + meta["metadata"] = {"ome_types": img.metadata} + + return meta + + +def _widget_is_checked(widget_name: str) -> bool: + # Get napari viewer from current process + viewer = napari.current_viewer() + + # Get scene management widget + scene_manager_choices_widget = viewer.window._dock_widgets[AICSIMAGEIO_CHOICES] + for child in scene_manager_choices_widget.widget().children(): + if isinstance(child, QCheckBox): + if child.text() == widget_name: + return child.isChecked() + + return False + + +# Function to handle multi-scene files. +def _get_scenes(path: "PathLike", img: AICSImage, in_memory: bool) -> None: + # Get napari viewer from current process + viewer = napari.current_viewer() + + # Add a checkbox widget if not present + if AICSIMAGEIO_CHOICES not in viewer.window._dock_widgets: + # Create a checkbox widget to set "Clear On Scene Select" or not + scene_clear_checkbox = QCheckBox(CLEAR_LAYERS_ON_SELECT) + scene_clear_checkbox.setChecked(False) + + # Create a checkbox widget to set "Unpack Channels" or not + channel_unpack_checkbox = QCheckBox(UNPACK_CHANNELS_TO_LAYERS) + channel_unpack_checkbox.setChecked(False) + + # Add all scene management state to a single box + scene_manager_group = QGroupBox() + scene_manager_group_layout = QVBoxLayout() + scene_manager_group_layout.addWidget(scene_clear_checkbox) + scene_manager_group_layout.addWidget(channel_unpack_checkbox) + scene_manager_group.setLayout(scene_manager_group_layout) + scene_manager_group.setFixedHeight(100) + + viewer.window.add_dock_widget( + scene_manager_group, + area="right", + name=AICSIMAGEIO_CHOICES, + ) + + # Create the list widget and populate with the ids & scenes in the file + list_widget = QListWidget() + for i, scene in enumerate(img.scenes): + list_widget.addItem(f"{i}{SCENE_LABEL_DELIMITER}{scene}") + + # Add this files scenes widget to viewer + viewer.window.add_dock_widget( + list_widget, + area="right", + name=f"{Path(path).name}{SCENE_LABEL_DELIMITER}Scenes", + ) + + # Function to create image layer from a scene selected in the list widget + def open_scene(item: QListWidgetItem) -> None: + scene_text = item.text() - return None + # Use scene indexes to cover for duplicate names + scene_index = int(scene_text.split(SCENE_LABEL_DELIMITER)[0]) + # Update scene on image and get data + img.set_scene(scene_index) + data = _get_full_image_data(img=img, in_memory=in_memory) -def reader_function( - path: PathLike, in_memory: bool, scene_name: Optional[str] = None -) -> Optional[List[LayerData]]: + # Get metadata and add to image + meta = _get_meta(data, img) + + # Optionally clear layers + if _widget_is_checked(CLEAR_LAYERS_ON_SELECT): + viewer.layers.clear() + + # Optionally remove channel axis + if not _widget_is_checked(UNPACK_CHANNELS_TO_LAYERS): + meta["name"] = scene_text + meta.pop("channel_axis", None) + + viewer.add_image(data, **meta) + + list_widget.currentItemChanged.connect(open_scene) + + +def reader_function(path: "PathLike", in_memory: bool) -> Optional[List["LayerData"]]: """ Given a single path return a list of LayerData tuples. """ @@ -57,56 +195,27 @@ def reader_function( # Open file and get data img = AICSImage(path) - print( - f"AICSImageIO: Image contains {len(img.scenes)} scenes. " - f"napari-aicsimageio currently only supports loading first scene, " - f"will load scene: '{img.current_scene}'." - ) - data = _get_full_image_data(img, in_memory=in_memory) + # Check for multiple scenes + if len(img.scenes) > 1: + print( + f"AICSImageIO: Image contains {len(img.scenes)} scenes. " + f"Supporting more than the first scene is experimental. " + f"Select a scene from the list widget. There may be dragons!" + ) + # Launch the list widget + _get_scenes(path=path, img=img, in_memory=in_memory) - # Catch None data - if data is None: - return None + # Return an empty LayerData list; ImgLayers will be handled via the widget. + # HT Jonas Windhager + return [(None,)] else: - # Metadata to provide with data - meta = {} - if DimensionNames.Channel in data.dims: - # Construct basic metadata - meta["name"] = data.coords[DimensionNames.Channel].data.tolist() - meta["channel_axis"] = data.dims.index(DimensionNames.Channel) - - # Not multi-channel, use current scene as image name - else: - meta["name"] = img.reader.current_scene - - # Handle samples / RGB - if DimensionNames.Samples in img.reader.dims.order: - meta["rgb"] = True - - # Handle scales - scale: List[float] = [] - for dim in img.reader.dims.order: - if dim in [ - DimensionNames.SpatialX, - DimensionNames.SpatialY, - DimensionNames.SpatialZ, - ]: - scale_val = getattr(img.physical_pixel_sizes, dim) - if scale_val is not None: - scale.append(scale_val) - - # Apply scales - if len(scale) > 0: - meta["scale"] = tuple(scale) - - # Apply all other metadata - meta["metadata"] = {"ome_types": img.metadata} - + data = _get_full_image_data(img, in_memory=in_memory) + meta = _get_meta(data, img) return [(data.data, meta, "image")] -def get_reader(path: PathLike, in_memory: bool) -> Optional[ReaderFunction]: +def get_reader(path: "PathLike", in_memory: bool) -> Optional["ReaderFunction"]: """ Given a single path or list of paths, return the appropriate aicsimageio reader. """ @@ -123,7 +232,7 @@ def get_reader(path: PathLike, in_memory: bool) -> Optional[ReaderFunction]: # The above line didn't error so we know we have a supported reader # Return a partial function with in_memory determined - return partial(reader_function, in_memory=in_memory) # type: ignore + return partial(reader_function, in_memory=in_memory) # No supported reader, return None except exceptions.UnsupportedFileFormatError: diff --git a/napari_aicsimageio/in_memory.py b/napari_aicsimageio/in_memory.py index e6331bd..77ca58d 100644 --- a/napari_aicsimageio/in_memory.py +++ b/napari_aicsimageio/in_memory.py @@ -3,6 +3,7 @@ from typing import Optional +from napari.types import PathLike, ReaderFunction from napari_plugin_engine import napari_hook_implementation from . import core @@ -11,5 +12,5 @@ @napari_hook_implementation -def napari_get_reader(path: core.PathLike) -> Optional[core.ReaderFunction]: +def napari_get_reader(path: PathLike) -> Optional[ReaderFunction]: return core.get_reader(path, in_memory=True) diff --git a/napari_aicsimageio/out_of_memory.py b/napari_aicsimageio/out_of_memory.py index b76efe9..4fc22fb 100644 --- a/napari_aicsimageio/out_of_memory.py +++ b/napari_aicsimageio/out_of_memory.py @@ -3,6 +3,7 @@ from typing import Optional +from napari.types import PathLike, ReaderFunction from napari_plugin_engine import napari_hook_implementation from . import core @@ -11,5 +12,5 @@ @napari_hook_implementation -def napari_get_reader(path: core.PathLike) -> Optional[core.ReaderFunction]: +def napari_get_reader(path: PathLike) -> Optional[ReaderFunction]: return core.get_reader(path, in_memory=False) diff --git a/napari_aicsimageio/tests/test_core.py b/napari_aicsimageio/tests/test_core.py index a6259a1..697e8ad 100644 --- a/napari_aicsimageio/tests/test_core.py +++ b/napari_aicsimageio/tests/test_core.py @@ -2,11 +2,13 @@ # -*- coding: utf-8 -*- from pathlib import Path -from typing import Any, Dict, Tuple +from typing import Any, Callable, Dict, Tuple, Type import dask.array as da +import napari import numpy as np import pytest +from napari.types import ArrayLike from napari_aicsimageio import core @@ -37,7 +39,11 @@ CZI_FILE, (3, 6183, 7705), { - "name": ["EGFP", "mCher", "PGC"], + "name": [ + "0 :: A1-A1 :: EGFP", + "0 :: A1-A1 :: mCher", + "0 :: A1-A1 :: PGC", + ], "channel_axis": 0, "scale": (9.08210704883533, 9.08210704883533), }, @@ -46,7 +52,12 @@ OME_TIFF, (1, 4, 65, 600, 900), { - "name": ["Bright_2", "EGFP", "CMDRP", "H3342"], + "name": [ + "0 :: Image:0 :: Bright_2", + "0 :: Image:0 :: EGFP", + "0 :: Image:0 :: CMDRP", + "0 :: Image:0 :: H3342", + ], "channel_axis": 1, "scale": (0.29, 0.10833333333333332, 0.10833333333333332), }, @@ -55,9 +66,14 @@ LIF_FILE, (1, 4, 1, 5622, 7666), { - "name": ["Gray", "Red", "Green", "Cyan"], + "name": [ + "0 :: TileScan_002 :: Gray", + "0 :: TileScan_002 :: Red", + "0 :: TileScan_002 :: Green", + "0 :: TileScan_002 :: Cyan", + ], "channel_axis": 1, - "scale": (4.984719055966396, 4.984719055966396), + "scale": (0.20061311154598827, 0.20061311154598827), }, ), ], @@ -94,3 +110,58 @@ def test_reader( # Check meta meta.pop("metadata", None) assert meta == expected_meta # type: ignore + + +SINGLESCENE_FILE = "s_1_t_1_c_1_z_1.czi" +MULTISCENE_FILE = "s_3_t_1_c_3_z_5.czi" + + +@pytest.mark.parametrize( + "in_memory, expected_dtype", + [ + (True, np.ndarray), + (False, da.core.Array), + ], +) +@pytest.mark.parametrize( + "filename, expected_shape", + [ + (SINGLESCENE_FILE, (1, 325, 475)), + (MULTISCENE_FILE, (3, 5, 325, 475)), + ], +) +def test_for_multiscene_widget( + make_napari_viewer: Callable[..., napari.Viewer], + resources_dir: Path, + filename: str, + in_memory: bool, + expected_dtype: Type[ArrayLike], + expected_shape: Tuple[int, ...], +) -> None: + # Make a viewer + viewer = make_napari_viewer() + assert len(viewer.layers) == 0 + assert len(viewer.window._dock_widgets) == 0 + + # Resolve filename to filepath + if isinstance(filename, str): + path = str(resources_dir / filename) + + # Get reader + reader = core.get_reader(path, in_memory) + + if reader is not None: + # Call reader on path + reader(path) + + if len(viewer.window._dock_widgets) != 0: + viewer.window._dock_widgets[f"{filename} :: Scenes"].widget().setCurrentRow( + 1 + ) + data = viewer.layers[0].data + assert isinstance(data.data, expected_dtype) + assert data.shape == expected_shape + else: + data, _, _ = reader(path)[0] + assert isinstance(data, expected_dtype) + assert data.shape == expected_shape diff --git a/setup.py b/setup.py index d853762..c560682 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,7 @@ "mypy>=0.800", "psutil>=5.7.0", "pytest>=5.4.3", + "pytest-qt", "pytest-cov>=2.9.0", "pytest-raises>=0.11", "quilt3~=3.4.0", @@ -40,9 +41,9 @@ ] requirements = [ - "aicsimageio[all]~=4.0.2", + "aicsimageio[all]~=4.1", "fsspec[http]", # no version pin, we pull from aicsimageio - "napari~=0.4.10", + "napari[all]~=0.4.11", "napari_plugin_engine~=0.1.4", ]