diff --git a/napari/_qt/_tests/test_qt_viewer.py b/napari/_qt/_tests/test_qt_viewer.py index c97372a08ab..a38bd991db6 100644 --- a/napari/_qt/_tests/test_qt_viewer.py +++ b/napari/_qt/_tests/test_qt_viewer.py @@ -814,6 +814,11 @@ def test_axes_labels(make_napari_viewer): assert tuple(axes_visual.node.text.text) == ('2', '1', '0') +def test_axes_visual_corresponds_to_axes_labels(): + """If we create an axes overlay visual, the text should be corresponding to the axes_labels of the particular layer of + which the axes are visualized""" + + @pytest.fixture() def qt_viewer(qtbot): qt_viewer = QtViewer(ViewerModel()) diff --git a/napari/components/_tests/test_viewer_model.py b/napari/components/_tests/test_viewer_model.py index 75c7790adfe..9973d76f7f2 100644 --- a/napari/components/_tests/test_viewer_model.py +++ b/napari/components/_tests/test_viewer_model.py @@ -974,3 +974,36 @@ def test_slice_order_with_mixed_dims(): assert image_2d._slice.image.view.shape == (4, 5) assert image_3d._slice.image.view.shape == (3, 5) assert image_4d._slice.image.view.shape == (2, 5) + + +def test_viewer_add_layer_with_axes_labels(): + "When we add a layer to the viewer model, the axis labels in the dims should be properly updated" + viewer = ViewerModel(ndisplay=2) + assert viewer.dims.axes_labels == ('-2', '-1') + with pytest.raises(ValueError): + viewer.add_image(np.zeros((4, 5)), axes_labels=('z', 'y', 'x')) + viewer.add_image(np.zeros((4, 5)), axes_labels=('x', 'y')) + assert viewer.dims.axes_labels == ('x', 'y') + + # Ensure axes labels stay the same when image with same axes labels are added + viewer.add_image(np.zeros((4, 5)), axes_labels=('x', 'y')) + assert viewer.dims.axes_labels == ('x', 'y') + + # Ensure axes labels are updated when layer with different axes labels than currently present are added. + viewer.add_image(np.zeros((4, 5, 5)), axes_labels=('a', 'b', 'c')) + assert viewer.dims.axes_labels == ('a', 'b', 'c', 'x', 'y') + + assert viewer.dims.displayed == ('a', 'b', 'c') + assert viewer.dims.not_displayed == ('x', 'y') + + +def test_viewer_multiple_layer_axes_labels(): + """When adding multiple layers to the viewer with the same axes labels the axes labels of the dims model should stay + the same. When layers do not share axes labels, the axes labels in the dims model should be updated. The attribute + not_displayed of the dims model should also be properly updated. + """ + + +def test_viewer_annotation_layer_axes_labels(): + """When adding a new layer to annotate (for example with shapes layer) an image layer, the axes labels of the parent + layer should be inherited.""" diff --git a/napari/layers/_scalar_field/scalar_field.py b/napari/layers/_scalar_field/scalar_field.py index 383aba52457..b276027649f 100644 --- a/napari/layers/_scalar_field/scalar_field.py +++ b/napari/layers/_scalar_field/scalar_field.py @@ -172,6 +172,7 @@ def __init__( custom_interpolation_kernel_2d=None, depiction='volume', experimental_clipping_planes=None, + layer_axis_labels=None, metadata=None, multiscale=None, name=None, @@ -219,6 +220,7 @@ def __init__( shear=shear, affine=affine, opacity=opacity, + layer_axis_labels=layer_axis_labels, blending=blending, visible=visible, multiscale=multiscale, diff --git a/napari/layers/_tests/test_utils.py b/napari/layers/_tests/test_utils.py deleted file mode 100644 index 902ca5df399..00000000000 --- a/napari/layers/_tests/test_utils.py +++ /dev/null @@ -1,58 +0,0 @@ -import numpy as np -import pytest -from numpy.testing import assert_array_equal -from skimage.util import img_as_ubyte - -from napari.layers.utils.layer_utils import convert_to_uint8 - - -@pytest.mark.filterwarnings('ignore:Downcasting uint:UserWarning:skimage') -@pytest.mark.parametrize('dtype', [np.uint8, np.uint16, np.uint32, np.uint64]) -def test_uint(dtype): - data = np.arange(50, dtype=dtype) - data_scaled = data * 256 ** (data.dtype.itemsize - 1) - assert convert_to_uint8(data_scaled).dtype == np.uint8 - assert_array_equal(data, convert_to_uint8(data_scaled)) - assert_array_equal(img_as_ubyte(data), convert_to_uint8(data)) - assert_array_equal( - img_as_ubyte(data_scaled), convert_to_uint8(data_scaled) - ) - - -@pytest.mark.filterwarnings('ignore:Downcasting int:UserWarning:skimage') -@pytest.mark.parametrize('dtype', [np.int8, np.int16, np.int32, np.int64]) -def test_int(dtype): - data = np.arange(50, dtype=dtype) - data_scaled = data * 256 ** (data.dtype.itemsize - 1) - assert convert_to_uint8(data).dtype == np.uint8 - assert convert_to_uint8(data_scaled).dtype == np.uint8 - assert_array_equal(img_as_ubyte(data), convert_to_uint8(data)) - assert_array_equal(2 * data, convert_to_uint8(data_scaled)) - assert_array_equal( - img_as_ubyte(data_scaled), convert_to_uint8(data_scaled) - ) - assert_array_equal(img_as_ubyte(data - 10), convert_to_uint8(data - 10)) - assert_array_equal( - img_as_ubyte(data_scaled - 10), convert_to_uint8(data_scaled - 10) - ) - - -@pytest.mark.parametrize('dtype', [np.float64, np.float32, float]) -def test_float(dtype): - data = np.linspace(0, 0.5, 128, dtype=dtype, endpoint=False) - res = np.arange(128, dtype=np.uint8) - assert convert_to_uint8(data).dtype == np.uint8 - assert_array_equal(convert_to_uint8(data), res) - data = np.linspace(0, 1, 256, dtype=dtype) - res = np.arange(256, dtype=np.uint8) - assert_array_equal(convert_to_uint8(data), res) - assert_array_equal(img_as_ubyte(data), convert_to_uint8(data)) - assert_array_equal(img_as_ubyte(data - 0.5), convert_to_uint8(data - 0.5)) - - -def test_bool(): - data = np.zeros((10, 10), dtype=bool) - data[2:-2, 2:-2] = 1 - converted = convert_to_uint8(data) - assert converted.dtype == np.uint8 - assert_array_equal(img_as_ubyte(data), converted) diff --git a/napari/layers/base/_tests/test_named_axes.py b/napari/layers/base/_tests/test_named_axes.py new file mode 100644 index 00000000000..91d0ad7d9a4 --- /dev/null +++ b/napari/layers/base/_tests/test_named_axes.py @@ -0,0 +1,52 @@ +import numpy as np +import pytest + +from napari.layers import Image + + +# These tests would be for testing the layers itself +def test_get_layer_axes_labels(): + "For a given layer we should be able to retrieve axes labels." + shape = (10, 15) + np.random.seed(0) + data = np.random.random(shape) + layer = Image(data, axes_labels=("y", "x")) + assert layer.axes_labels == ("y", "x") + + +def test_set_layer_axes_labels(): + "For a given layer we should be able to set the axes labels." + shape = (10, 15) + np.random.seed(0) + data = np.random.random(shape) + layer = Image(data, axes_label=("y", "x")) + with pytest.raises(ValueError): + layer.axes_labels = ("z", "y", "x") + + layer.axes_labels = ("z", "t") + assert layer.axes_labels == ("z", "t") + + # Note: should we give a warning if reversing axes labels for example y,x to x, y + + +def test_default_axes_labels(): + """If no axes labels are given, default names should be assigned equal to the number of dimensions of a particular + layer.""" + # Note that for broadcasting the default names should be based on negative integers, so ..., -2, -1. + shape = (10, 15) + data = np.random.random(shape) + layer = Image(data) + assert layer.axes_labels == (-2, -1) + + +def test_ndim_match_length_axes_labels(): + """The number of dimensions must match the length of axes labels when creating a layer and should throw an error + if not the case""" + shape = (10, 15) + data = np.random.random(shape) + layer = Image(data, axes_labels=("y", "x")) + assert layer.dim == 2 + + +def test_slice_using_axes_labels(): + "We should be able to slice based on current axes labels and an interval." diff --git a/napari/layers/base/base.py b/napari/layers/base/base.py index 1d7e3a4aff4..e195dc11e6d 100644 --- a/napari/layers/base/base.py +++ b/napari/layers/base/base.py @@ -8,6 +8,7 @@ import warnings from abc import ABC, ABCMeta, abstractmethod from collections import defaultdict +from collections.abc import Iterable from contextlib import contextmanager from functools import cached_property from typing import ( @@ -19,6 +20,7 @@ Generator, List, Optional, + Sequence, Tuple, Type, Union, @@ -315,6 +317,7 @@ def __init__( experimental_clipping_planes=None, mode='pan_zoom', projection_mode='none', + layer_axis_labels=None, ): super().__init__() @@ -357,6 +360,10 @@ def __init__( self._ndim = ndim + self.layer_axis_labels = self._validate_coerce_axis_labels( + layer_axis_labels + ) + self._slice_input = _SliceInput( ndisplay=2, world_slice=_ThickNDSlice.make_full(ndim=ndim), @@ -548,6 +555,40 @@ def _mode_setter_helper(self, mode_in: Union[Mode, str]) -> StringEnum: return mode + def _validate_coerce_axis_labels( + self, axis_labels: Sequence[str | int] | None + ) -> tuple[str]: + """Check proper input of axis labels and coerce it to a tuple of strings. + + Paramters + --------- + axis_labels : Sequence[str | int] | None + Axis labels of the layer + + Returns + ------- + tuple[str] + Validated axis labels coerced to a tuple of strings + """ + if axis_labels is None: + axis_labels = tuple(str(-i) for i in range(self._ndim, 0, -1)) + elif ( + isinstance(axis_labels, Iterable) + and len(axis_labels) == self._ndim + ): + axis_labels = tuple(str(i) for i in axis_labels) + + return axis_labels + + @property + def axis_labels(self) -> tuple[str]: + """The axis labels of the layer.""" + return self._axis_labels + + @axis_labels.setter + def axis_labels(self, axis_labels: Sequence[str | int] | None): + self._axis_labels = self._validate_coerce_axis_labels(axis_labels) + @property def mode(self) -> str: """str: Interactive mode diff --git a/napari/layers/image/image.py b/napari/layers/image/image.py index 0c1fffc3db6..d0a9a76a242 100644 --- a/napari/layers/image/image.py +++ b/napari/layers/image/image.py @@ -224,6 +224,7 @@ def __init__( self, data, *, + layer_axis_labels=None, affine=None, attenuation=0.05, blending='translucent', @@ -279,6 +280,7 @@ def __init__( opacity=opacity, plane=plane, projection_mode=projection_mode, + layer_axis_labels=layer_axis_labels, rendering=rendering, rotate=rotate, scale=scale, diff --git a/napari/layers/labels/labels.py b/napari/layers/labels/labels.py index c008e2343f3..fb92d274e72 100644 --- a/napari/layers/labels/labels.py +++ b/napari/layers/labels/labels.py @@ -279,6 +279,7 @@ def __init__( self, data, *, + layer_axis_labels=None, affine=None, blending='translucent', cache=True, @@ -318,10 +319,9 @@ def __init__( self._show_selected_label = False self._contour = 0 - data = self._ensure_int_labels(data) - super().__init__( data, + layer_axis_labels=layer_axis_labels, rendering=rendering, depiction=depiction, name=name, diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py index 5982f2cb303..e008285d3ef 100644 --- a/napari/layers/points/points.py +++ b/napari/layers/points/points.py @@ -373,6 +373,7 @@ def __init__( self, data=None, *, + layer_axis_labels=None, ndim=None, features=None, feature_defaults=None, @@ -450,6 +451,7 @@ def __init__( super().__init__( data, ndim, + layer_axis_labels=layer_axis_labels, name=name, metadata=metadata, scale=scale, diff --git a/napari/layers/shapes/shapes.py b/napari/layers/shapes/shapes.py index f3e808e5745..6f62e9cee94 100644 --- a/napari/layers/shapes/shapes.py +++ b/napari/layers/shapes/shapes.py @@ -416,6 +416,7 @@ class Shapes(Layer): def __init__( self, + layer_axis_labels=None, data=None, *, ndim=None, @@ -467,6 +468,7 @@ def __init__( super().__init__( data, + layer_axis_labels=layer_axis_labels, ndim=ndim, name=name, metadata=metadata, diff --git a/napari/layers/surface/surface.py b/napari/layers/surface/surface.py index 6f59ef59fec..d310bf50522 100644 --- a/napari/layers/surface/surface.py +++ b/napari/layers/surface/surface.py @@ -189,6 +189,7 @@ class Surface(IntensityVisualizationMixin, Layer): def __init__( self, data, + layer_axis_labels=None, *, features=None, feature_defaults=None, @@ -219,7 +220,8 @@ def __init__( super().__init__( data, - ndim, + layer_axis_labels=layer_axis_labels, + ndim=ndim, name=name, metadata=metadata, scale=scale, diff --git a/napari/layers/tracks/tracks.py b/napari/layers/tracks/tracks.py index f04dfbd003c..88e422717d7 100644 --- a/napari/layers/tracks/tracks.py +++ b/napari/layers/tracks/tracks.py @@ -97,6 +97,7 @@ class Tracks(Layer): def __init__( self, data, + layer_axis_labels=None, *, features=None, properties=None, @@ -131,6 +132,7 @@ def __init__( super().__init__( data, ndim, + layer_axis_labels=layer_axis_labels, name=name, metadata=metadata, scale=scale,