import warnings
from collections import deque
from contextlib import contextmanager
from typing import Dict, List, Optional, Union
import numpy as np
import pandas as pd
from scipy import ndimage as ndi
from napari.utils.misc import _is_array_type
from ...utils import config
from ...utils._dtype import normalize_dtype
from ...utils.colormaps import (
color_dict_to_colormap,
label_colormap,
low_discrepancy_image,
)
from ...utils.events import Event
from ...utils.events.custom_types import Array
from ...utils.geometry import clamp_point_to_bounding_box
from ...utils.naming import magic_name
from ...utils.status_messages import generate_layer_status
from ...utils.translations import trans
from ..base import no_op
from ..image._image_utils import guess_multiscale
from ..image.image import _ImageBase
from ..utils.color_transformations import transform_color
from ..utils.layer_utils import _FeatureTable
from ._labels_constants import LabelColorMode, LabelsRendering, Mode
from ._labels_mouse_bindings import draw, pick
from ._labels_utils import (
indices_in_shape,
interpolate_coordinates,
sphere_indices,
)
_REV_SHAPE_HELP = {
trans._('enter paint or fill mode to edit labels'): {
Mode.PAN_ZOOM,
Mode.TRANSFORM,
},
trans._('hold <space> to pan/zoom, click to pick a label'): {
Mode.PICK,
Mode.FILL,
},
trans._(
'hold <space> to pan/zoom, hold <shift> to toggle preserve_labels, hold <control> to fill, hold <alt> to erase, drag to paint a label'
): {Mode.PAINT},
trans._('hold <space> to pan/zoom, drag to erase a label'): {Mode.ERASE},
}
# This avoid duplicating the trans._ help messages above
# as some modes have the same help.
# while most tooling will recognise identical messages,
# this can lead to human error.
_FWD_SHAPE_HELP = {}
for t, modes in _REV_SHAPE_HELP.items():
for m in modes:
_FWD_SHAPE_HELP[m] = t
class Labels(_ImageBase):
"""Labels (or segmentation) layer.
An image-like layer where every pixel contains an integer ID
corresponding to the region it belongs to.
Parameters
----------
data : array or list of array
Labels data as an array or multiscale. Must be integer type or bools.
Please note multiscale rendering is only supported in 2D. In 3D, only
the lowest resolution scale is displayed.
num_colors : int
Number of unique colors to use in colormap.
features : dict[str, array-like] or DataFrame
Features table where each row corresponds to a label and each column
is a feature. The first row corresponds to the background label.
properties : dict {str: array (N,)} or DataFrame
Properties for each label. Each property should be an array of length
N, where N is the number of labels, and the first property corresponds
to background.
color : dict of int to str or array
Custom label to color mapping. Values must be valid color names or RGBA
arrays.
seed : float
Seed for colormap random generator.
name : str
Name of the layer.
metadata : dict
Layer metadata.
scale : tuple of float
Scale factors for the layer.
translate : tuple of float
Translation values for the layer.
rotate : float, 3-tuple of float, or n-D array.
If a float convert into a 2D rotation matrix using that value as an
angle. If 3-tuple convert into a 3D rotation matrix, using a yaw,
pitch, roll convention. Otherwise assume an nD rotation. Angles are
assumed to be in degrees. They can be converted from radians with
np.degrees if needed.
shear : 1-D array or n-D array
Either a vector of upper triangular values, or an nD shear matrix with
ones along the main diagonal.
affine : n-D array or napari.utils.transforms.Affine
(N+1, N+1) affine transformation matrix in homogeneous coordinates.
The first (N, N) entries correspond to a linear transform and
the final column is a length N translation vector and a 1 or a napari
`Affine` transform object. Applied as an extra transform on top of the
provided scale, rotate, and shear values.
opacity : float
Opacity of the layer visual, between 0.0 and 1.0.
blending : str
One of a list of preset blending modes that determines how RGB and
alpha values of the layer visual get mixed. Allowed values are
{'opaque', 'translucent', and 'additive'}.
rendering : str
3D Rendering mode used by vispy. Must be one {'translucent', 'iso_categorical'}.
'translucent' renders without lighting. 'iso_categorical' uses isosurface
rendering to calculate lighting effects on labeled surfaces.
The default value is 'iso_categorical'.
depiction : str
3D Depiction mode. Must be one of {'volume', 'plane'}.
The default value is 'volume'.
visible : bool
Whether the layer visual is currently being displayed.
multiscale : bool
Whether the data is a multiscale image or not. Multiscale data is
represented by a list of array like image data. If not specified by
the user and if the data is a list of arrays that decrease in shape
then it will be taken to be multiscale. The first image in the list
should be the largest. Please note multiscale rendering is only
supported in 2D. In 3D, only the lowest resolution scale is
displayed.
cache : bool
Whether slices of out-of-core datasets should be cached upon retrieval.
Currently, this only applies to dask arrays.
plane : dict or SlicingPlane
Properties defining plane rendering in 3D. Properties are defined in
data coordinates. Valid dictionary keys are
{'position', 'normal', 'thickness', and 'enabled'}.
experimental_clipping_planes : list of dicts, list of ClippingPlane, or ClippingPlaneList
Each dict defines a clipping plane in 3D in data coordinates.
Valid dictionary keys are {'position', 'normal', and 'enabled'}.
Values on the negative side of the normal are discarded if the plane is enabled.
Attributes
----------
data : array or list of array
Integer label data as an array or multiscale. Can be N dimensional.
Every pixel contains an integer ID corresponding to the region it
belongs to. The label 0 is rendered as transparent. Please note
multiscale rendering is only supported in 2D. In 3D, only
the lowest resolution scale is displayed.
multiscale : bool
Whether the data is a multiscale image or not. Multiscale data is
represented by a list of array like image data. The first image in the
list should be the largest. Please note multiscale rendering is only
supported in 2D. In 3D, only the lowest resolution scale is
displayed.
metadata : dict
Labels metadata.
num_colors : int
Number of unique colors to use in colormap.
features : Dataframe-like
Features table where each row corresponds to a label and each column
is a feature. The first row corresponds to the background label.
properties : dict {str: array (N,)}, DataFrame
Properties for each label. Each property should be an array of length
N, where N is the number of labels, and the first property corresponds
to background.
color : dict of int to str or array
Custom label to color mapping. Values must be valid color names or RGBA
arrays.
seed : float
Seed for colormap random generator.
opacity : float
Opacity of the labels, must be between 0 and 1.
contiguous : bool
If `True`, the fill bucket changes only connected pixels of same label.
n_edit_dimensions : int
The number of dimensions across which labels will be edited.
contour : int
If greater than 0, displays contours of labels instead of shaded regions
with a thickness equal to its value.
brush_size : float
Size of the paint brush in data coordinates.
selected_label : int
Index of selected label. Can be greater than the current maximum label.
mode : str
Interactive mode. The normal, default mode is PAN_ZOOM, which
allows for normal interactivity with the canvas.
In PICK mode the cursor functions like a color picker, setting the
clicked on label to be the current label. If the background is picked it
will select the background label `0`.
In PAINT mode the cursor functions like a paint brush changing any
pixels it brushes over to the current label. If the background label
`0` is selected than any pixels will be changed to background and this
tool functions like an eraser. The size and shape of the cursor can be
adjusted in the properties widget.
In FILL mode the cursor functions like a fill bucket replacing pixels
of the label clicked on with the current label. It can either replace
all pixels of that label or just those that are contiguous with the
clicked on pixel. If the background label `0` is selected than any
pixels will be changed to background and this tool functions like an
eraser.
In ERASE mode the cursor functions similarly to PAINT mode, but to
paint with background label, which effectively removes the label.
plane : SlicingPlane
Properties defining plane rendering in 3D.
experimental_clipping_planes : ClippingPlaneList
Clipping planes defined in data coordinates, used to clip the volume.
Notes
-----
_selected_color : 4-tuple or None
RGBA tuple of the color of the selected label, or None if the
background label `0` is selected.
"""
_history_limit = 100
def __init__(
self,
data,
*,
num_colors=50,
features=None,
properties=None,
color=None,
seed=0.5,
name=None,
metadata=None,
scale=None,
translate=None,
rotate=None,
shear=None,
affine=None,
opacity=0.7,
blending='translucent',
rendering='iso_categorical',
depiction='volume',
visible=True,
multiscale=None,
cache=True,
plane=None,
experimental_clipping_planes=None,
):
if name is None and data is not None:
name = magic_name(data)
self._seed = seed
self._background_label = 0
self._num_colors = num_colors
self._random_colormap = label_colormap(self.num_colors)
self._all_vals = np.array([], dtype=float)
self._color_mode = LabelColorMode.AUTO
self._show_selected_label = False
self._contour = 0
data = self._ensure_int_labels(data)
self._color_lookup_func = None
super().__init__(
data,
rgb=False,
colormap=self._random_colormap,
contrast_limits=[0.0, 1.0],
interpolation2d='nearest',
interpolation3d='nearest',
rendering=rendering,
depiction=depiction,
iso_threshold=0,
name=name,
metadata=metadata,
scale=scale,
translate=translate,
rotate=rotate,
shear=shear,
affine=affine,
opacity=opacity,
blending=blending,
visible=visible,
multiscale=multiscale,
cache=cache,
plane=plane,
experimental_clipping_planes=experimental_clipping_planes,
)
self.events.add(
preserve_labels=Event,
properties=Event,
n_edit_dimensions=Event,
contiguous=Event,
brush_size=Event,
selected_label=Event,
color_mode=Event,
brush_shape=Event,
contour=Event,
features=Event,
paint=Event,
)
self._feature_table = _FeatureTable.from_layer(
features=features, properties=properties
)
self._label_index = self._make_label_index()
self._n_edit_dimensions = 2
self._contiguous = True
self._brush_size = 10
self._selected_label = 1
self._selected_color = self.get_color(self._selected_label)
self.color = color
self._mode = Mode.PAN_ZOOM
self._status = self.mode
self._preserve_labels = False
self._help = trans._('enter paint or fill mode to edit labels')
self._reset_history()
# Trigger generation of view slice and thumbnail
self._update_dims()
self._set_editable()
@property
def rendering(self):
"""Return current rendering mode.
Selects a preset rendering mode in vispy that determines how
lablels are displayed. Options include:
* ``translucent``: voxel colors are blended along the view ray until
the result is opaque.
* ``iso_categorical``: isosurface for categorical data.
Cast a ray until a non-background value is encountered. At that
location, lighning calculations are performed to give the visual
appearance of a surface.
Returns
-------
str
The current rendering mode
"""
return str(self._rendering)
@rendering.setter
def rendering(self, rendering):
self._rendering = LabelsRendering(rendering)
self.events.rendering()
@property
def contiguous(self):
"""bool: fill bucket changes only connected pixels of same label."""
return self._contiguous
@contiguous.setter
def contiguous(self, contiguous):
self._contiguous = contiguous
self.events.contiguous()
@property
def n_edit_dimensions(self):
return self._n_edit_dimensions
@n_edit_dimensions.setter
def n_edit_dimensions(self, n_edit_dimensions):
self._n_edit_dimensions = n_edit_dimensions
self.events.n_edit_dimensions()
@property
def contour(self):
"""int: displays contours of labels instead of shaded regions."""
return self._contour
@contour.setter
def contour(self, contour):
self._contour = contour
self.events.contour()
self.refresh()
@property
def brush_size(self):
"""float: Size of the paint in world coordinates."""
return self._brush_size
@brush_size.setter
def brush_size(self, brush_size):
self._brush_size = int(brush_size)
self.cursor_size = self._calculate_cursor_size()
self.events.brush_size()
def _calculate_cursor_size(self):
# Convert from brush size in data coordinates to
# cursor size in world coordinates
scale = self._data_to_world.scale
min_scale = np.min([abs(scale[d]) for d in self._dims_displayed])
return abs(self.brush_size * min_scale)
@property
def seed(self):
"""float: Seed for colormap random generator."""
return self._seed
@seed.setter
def seed(self, seed):
self._seed = seed
# invalidate _all_vals to trigger re-generation
# in _raw_to_displayed
self._all_vals = np.array([])
self._selected_color = self.get_color(self.selected_label)
self.refresh()
self.events.selected_label()
@_ImageBase.colormap.setter
def colormap(self, colormap):
super()._set_colormap(colormap)
self._selected_color = self.get_color(self.selected_label)
@property
def num_colors(self):
"""int: Number of unique colors to use in colormap."""
return self._num_colors
@num_colors.setter
def num_colors(self, num_colors):
self._num_colors = num_colors
self.colormap = label_colormap(num_colors)
self.refresh()
self._selected_color = self.get_color(self.selected_label)
self.events.selected_label()
@property
def data(self):
"""array: Image data."""
return self._data
@data.setter
def data(self, data):
data = self._ensure_int_labels(data)
self._data = data
self._update_dims()
self.events.data(value=self.data)
self._set_editable()
@property
def features(self):
"""Dataframe-like features table.
It is an implementation detail that this is a `pandas.DataFrame`. In the future,
we will target the currently-in-development Data API dataframe protocol [1].
This will enable us to use alternate libraries such as xarray or cuDF for
additional features without breaking existing usage of this.
If you need to specifically rely on the pandas API, please coerce this to a
`pandas.DataFrame` using `features_to_pandas_dataframe`.
References
----------
.. [1]: https://data-apis.org/dataframe-protocol/latest/API.html
"""
return self._feature_table.values
@features.setter
def features(
self,
features: Union[Dict[str, np.ndarray], pd.DataFrame],
) -> None:
self._feature_table.set_values(features)
self._label_index = self._make_label_index()
self.events.properties()
self.events.features()
@property
def properties(self) -> Dict[str, np.ndarray]:
"""dict {str: array (N,)}, DataFrame: Properties for each label."""
return self._feature_table.properties()
@properties.setter
def properties(self, properties: Dict[str, Array]):
self.features = properties
def _make_label_index(self) -> Dict[int, int]:
features = self._feature_table.values
label_index = {}
if 'index' in features:
label_index = {i: k for k, i in enumerate(features['index'])}
elif features.shape[1] > 0:
label_index = {i: i for i in range(features.shape[0])}
return label_index
@property
def color(self):
"""dict: custom color dict for label coloring"""
return self._color
@color.setter
def color(self, color):
if not color:
color = {}
if self._background_label not in color:
color[self._background_label] = 'transparent'
if None not in color:
color[None] = 'black'
colors = {
label: transform_color(color_str)[0]
for label, color_str in color.items()
}
self._color = colors
# `colors` may contain just the default None and background label
# colors, in which case we need to be in AUTO color mode. Otherwise,
# `colors` contains colors for all labels, and we should be in DIRECT
# mode.
# For more information
# - https://github.com/napari/napari/issues/2479
# - https://github.com/napari/napari/issues/2953
if self._is_default_colors(colors):
color_mode = LabelColorMode.AUTO
else:
color_mode = LabelColorMode.DIRECT
self.color_mode = color_mode
def _is_default_colors(self, color):
"""Returns True if color contains only default colors, otherwise False.
Default colors are black for `None` and transparent for
`self._background_label`.
Parameters
----------
color : Dict
Dictionary of label value to color array
Returns
-------
bool
True if color contains only default colors, otherwise False.
"""
if len(color) != 2:
return False
if not hasattr(self, '_color'):
return False
default_keys = [None, self._background_label]
if set(default_keys) != set(color.keys()):
return False
for key in default_keys:
if not np.allclose(self._color[key], color[key]):
return False
return True
def _ensure_int_labels(self, data):
"""Ensure data is integer by converting from bool if required, raising an error otherwise."""
looks_multiscale, data = guess_multiscale(data)
if not looks_multiscale:
data = [data]
int_data = []
for data_level in data:
# normalize_dtype turns e.g. tensorstore or torch dtypes into
# numpy dtypes
if np.issubdtype(normalize_dtype(data_level.dtype), np.floating):
raise TypeError(
trans._(
"Only integer types are supported for Labels layers, but data contains {data_level_type}.",
data_level_type=data_level.dtype,
)
)
if data_level.dtype == bool:
int_data.append(data_level.astype(np.int8))
else:
int_data.append(data_level)
data = int_data
if not looks_multiscale:
data = data[0]
return data
def _get_state(self):
"""Get dictionary of layer state.
Returns
-------
state : dict
Dictionary of layer state.
"""
state = self._get_base_state()
state.update(
{
'multiscale': self.multiscale,
'num_colors': self.num_colors,
'properties': self.properties,
'rendering': self.rendering,
'depiction': self.depiction,
'plane': self.plane.dict(),
'experimental_clipping_planes': [
plane.dict() for plane in self.experimental_clipping_planes
],
'seed': self.seed,
'data': self.data,
'color': self.color,
'features': self.features,
}
)
return state
@property
def selected_label(self):
"""int: Index of selected label."""
return self._selected_label
@selected_label.setter
def selected_label(self, selected_label):
if selected_label == self.selected_label:
return
self._selected_label = selected_label
self._selected_color = self.get_color(selected_label)
self.events.selected_label()
# note: self.color_mode returns a string and this comparison fails,
# so use self._color_mode
if self.show_selected_label:
self.refresh()
@property
def color_mode(self):
"""Color mode to change how color is represented.
AUTO (default) allows color to be set via a hash function with a seed.
DIRECT allows color of each label to be set directly by a color dict.
"""
return str(self._color_mode)
@color_mode.setter
def color_mode(self, color_mode: Union[str, LabelColorMode]):
color_mode = LabelColorMode(color_mode)
if color_mode == LabelColorMode.DIRECT:
custom_colormap, label_color_index = color_dict_to_colormap(
self.color
)
super()._set_colormap(custom_colormap)
self._label_color_index = label_color_index
elif color_mode == LabelColorMode.AUTO:
self._label_color_index = {}
super()._set_colormap(self._random_colormap)
else:
raise ValueError(trans._("Unsupported Color Mode"))
self._color_mode = color_mode
self._selected_color = self.get_color(self.selected_label)
self.events.color_mode()
self.events.colormap()
self.events.selected_label()
self.refresh()
@property
def show_selected_label(self):
"""Whether to filter displayed labels to only the selected label or not"""
return self._show_selected_label
@show_selected_label.setter
def show_selected_label(self, filter):
self._show_selected_label = filter
self.refresh()
@property
def mode(self):
"""MODE: Interactive mode. The normal, default mode is PAN_ZOOM, which
allows for normal interactivity with the canvas.
In PICK mode the cursor functions like a color picker, setting the
clicked on label to be the current label. If the background is picked it
will select the background label `0`.
In PAINT mode the cursor functions like a paint brush changing any
pixels it brushes over to the current label. If the background label
`0` is selected than any pixels will be changed to background and this
tool functions like an eraser. The size and shape of the cursor can be
adjusted in the properties widget.
In FILL mode the cursor functions like a fill bucket replacing pixels
of the label clicked on with the current label. It can either replace
all pixels of that label or just those that are contiguous with the
clicked on pixel. If the background label `0` is selected than any
pixels will be changed to background and this tool functions like an
eraser.
In ERASE mode the cursor functions similarly to PAINT mode, but to
paint with background label, which effectively removes the label.
"""
return str(self._mode)
_drag_modes = {
Mode.PAN_ZOOM: no_op,
Mode.TRANSFORM: no_op,
Mode.PICK: pick,
Mode.PAINT: draw,
Mode.FILL: draw,
Mode.ERASE: draw,
}
_move_modes = {
Mode.PAN_ZOOM: no_op,
Mode.TRANSFORM: no_op,
Mode.PICK: no_op,
Mode.PAINT: no_op,
Mode.FILL: no_op,
Mode.ERASE: no_op,
}
_cursor_modes = {
Mode.PAN_ZOOM: 'standard',
Mode.TRANSFORM: 'standard',
Mode.PICK: 'cross',
Mode.PAINT: 'circle',
Mode.FILL: 'cross',
Mode.ERASE: 'circle',
}
@mode.setter
def mode(self, mode: Union[str, Mode]):
mode, changed = self._mode_setter_helper(mode, Mode)
if not changed:
return
self.help = _FWD_SHAPE_HELP[mode]
if mode in (Mode.PAINT, Mode.ERASE):
self.cursor_size = self._calculate_cursor_size()
self.events.mode(mode=mode)
self.refresh()
@property
def preserve_labels(self):
"""Defines if painting should preserve existing labels.
Default to false to allow paint on existing labels. When
set to true, existing labels will be preserved during painting.
"""
return self._preserve_labels
@preserve_labels.setter
def preserve_labels(self, preserve_labels: bool):
self._preserve_labels = preserve_labels
self.events.preserve_labels(preserve_labels=preserve_labels)
@property
def contrast_limits(self):
return self._contrast_limits
@contrast_limits.setter
def contrast_limits(self, value):
# Setting contrast_limits of labels layers leads to wrong visualization of the layer
if tuple(value) != (0, 1):
raise AttributeError(
trans._(
"Setting contrast_limits on labels layers is not allowed.",
deferred=True,
)
)
self._contrast_limits = (0, 1)
def _set_editable(self, editable=None):
"""Set editable mode based on layer properties."""
if editable is None:
self.editable = not self.multiscale
if not self.editable:
self.mode = Mode.PAN_ZOOM
self._reset_history()
def _lookup_with_low_discrepancy_image(self, im, selected_label=None):
"""Returns display version of im using low_discrepancy_image.
Passes the image through low_discrepancy_image, only coloring
selected_label if it's not None.
Parameters
----------
im : array or int
Raw integer input image.
selected_label : int, optional
Value of selected label to color, by default None
"""
if selected_label:
image = np.where(
im == selected_label,
low_discrepancy_image(selected_label, self._seed),
0,
)
else:
image = np.where(im != 0, low_discrepancy_image(im, self._seed), 0)
return image
def _lookup_with_index(self, im, selected_label=None):
"""Returns display version of im using color lookup array by index
Parameters
----------
im : array or int
Raw integer input image.
selected_label : int, optional
Value of selected label to color, by default None
"""
if selected_label:
if selected_label > len(self._all_vals):
self._color_lookup_func = self._get_color_lookup_func(
im,
min(np.min(im), selected_label),
max(np.max(im), selected_label),
)
if (
self._color_lookup_func
== self._lookup_with_low_discrepancy_image
):
image = self._color_lookup_func(im, selected_label)
else:
colors = np.zeros_like(self._all_vals)
colors[selected_label] = low_discrepancy_image(
selected_label, self._seed
)
image = colors[im]
else:
try:
image = self._all_vals[im]
except IndexError:
self._color_lookup_func = self._get_color_lookup_func(
im, np.min(im), np.max(im)
)
if (
self._color_lookup_func
== self._lookup_with_low_discrepancy_image
):
# revert to "classic" mode converting all pixels since we
# encountered a large value in the raw labels image
image = self._color_lookup_func(im, selected_label)
else:
image = self._all_vals[im]
return image
def _get_color_lookup_func(self, data, min_label_val, max_label_val):
"""Returns function used for mapping label values to colors
If array of [0..max(data)] would be larger than data,
returns lookup_with_low_discrepancy_image, otherwise returns
lookup_with_index
Parameters
----------
data : array
labels data
min_label_val : int
minimum label value in data
max_label_val : int
maximum label value in data
Returns
-------
lookup_func : function
function to use for mapping label values to colors
"""
# low_discrepancy_image is slow for large images, but large labels can
# blow up memory usage of an index array of colors. If the index array
# would be larger than the image, we go back to computing the low
# discrepancy image on the whole input image. (Up to a minimum value of
# 1kB.)
min_label_val0 = min(min_label_val, 0)
# +1 to allow indexing with max_label_val
data_range = max_label_val - min_label_val0 + 1
nbytes_low_discrepancy = low_discrepancy_image(np.array([0])).nbytes
max_nbytes = max(data.nbytes, 1024)
if data_range * nbytes_low_discrepancy > max_nbytes:
return self._lookup_with_low_discrepancy_image
else:
if self._all_vals.size < data_range:
new_all_vals = low_discrepancy_image(
np.arange(min_label_val0, max_label_val + 1), self._seed
)
self._all_vals = np.roll(new_all_vals, min_label_val0)
self._all_vals[0] = 0
return self._lookup_with_index
def _raw_to_displayed(self, raw):
"""Determine displayed image from a saved raw image and a saved seed.
This function ensures that the 0 label gets mapped to the 0 displayed
pixel.
Parameters
----------
raw : array or int
Raw integer input image.
Returns
-------
image : array
Image mapped between 0 and 1 to be displayed.
"""
raw_modified = raw
if self.contour > 0:
if raw.ndim == 2:
raw_modified = np.zeros_like(raw)
struct_elem = ndi.generate_binary_structure(raw.ndim, 1)
thickness = self.contour
thick_struct_elem = ndi.iterate_structure(
struct_elem, thickness
).astype(bool)
boundaries = ndi.grey_dilation(
raw, footprint=struct_elem
) != ndi.grey_erosion(raw, footprint=thick_struct_elem)
raw_modified[boundaries] = raw[boundaries]
elif raw.ndim > 2:
warnings.warn(
trans._(
"Contours are not displayed during 3D rendering",
deferred=True,
)
)
if self._color_lookup_func is None:
self._color_lookup_func = self._get_color_lookup_func(
raw_modified, np.min(raw_modified), np.max(raw_modified)
)
if (
not self.show_selected_label
and self._color_mode == LabelColorMode.DIRECT
):
u, inv = np.unique(raw_modified, return_inverse=True)
image = np.array(
[
self._label_color_index[x]
if x in self._label_color_index
else self._label_color_index[None]
for x in u
]
)[inv].reshape(raw_modified.shape)
elif (
not self.show_selected_label
and self._color_mode == LabelColorMode.AUTO
):
image = self._color_lookup_func(raw_modified)
elif (
self.show_selected_label
and self._color_mode == LabelColorMode.AUTO
):
image = self._color_lookup_func(raw_modified, self._selected_label)
elif (
self.show_selected_label
and self._color_mode == LabelColorMode.DIRECT
):
selected = self._selected_label
if selected not in self._label_color_index:
selected = None
index = self._label_color_index
image = np.where(
raw_modified == selected,
index[selected],
np.where(
raw_modified != self._background_label,
index[None],
index[self._background_label],
),
)
else:
raise ValueError("Unsupported Color Mode")
return image
def new_colormap(self):
self.seed = np.random.rand()
[docs] def get_color(self, label):
"""Return the color corresponding to a specific label."""
if label == 0:
col = None
elif label is None:
col = self.colormap.map([0, 0, 0, 0])[0]
else:
val = self._raw_to_displayed(np.array([label]))
col = self.colormap.map(val)[0]
return col
def _get_value_ray(
self,
start_point: np.ndarray,
end_point: np.ndarray,
dims_displayed: List[int],
) -> Optional[int]:
"""Get the first non-background value encountered along a ray.
Parameters
----------
start_point : np.ndarray
(n,) array containing the start point of the ray in data coordinates.
end_point : np.ndarray
(n,) array containing the end point of the ray in data coordinates.
dims_displayed : List[int]
The indices of the dimensions currently displayed in the viewer.
Returns
-------
value : Optional[int]
The first non-zero value encountered along the ray. If none
was encountered or the viewer is in 2D mode, None is returned.
"""
if start_point is None or end_point is None:
return None
if len(dims_displayed) == 3:
# only use get_value_ray on 3D for now
# we use dims_displayed because the image slice
# has its dimensions in th same order as the vispy
# Volume
start_point = start_point[dims_displayed]
end_point = end_point[dims_displayed]
sample_ray = end_point - start_point
length_sample_vector = np.linalg.norm(sample_ray)
n_points = int(2 * length_sample_vector)
sample_points = np.linspace(
start_point, end_point, n_points, endpoint=True
)
im_slice = self._slice.image.raw
clamped = clamp_point_to_bounding_box(
sample_points, self._display_bounding_box(dims_displayed)
).astype(int)
values = im_slice[tuple(clamped.T)]
nonzero_indices = np.flatnonzero(values)
if len(nonzero_indices > 0):
# if a nonzer0 value was found, return the first one
return values[nonzero_indices[0]]
return None
def _get_value_3d(
self,
start_point: np.ndarray,
end_point: np.ndarray,
dims_displayed: List[int],
) -> Optional[int]:
"""Get the first non-background value encountered along a ray.
Parameters
----------
start_point : np.ndarray
(n,) array containing the start point of the ray in data coordinates.
end_point : np.ndarray
(n,) array containing the end point of the ray in data coordinates.
dims_displayed : List[int]
The indices of the dimensions currently displayed in the viewer.
Returns
-------
value : int
The first non-zero value encountered along the ray. If a
non-zero value is not encountered, returns 0 (the background value).
"""
return (
self._get_value_ray(
start_point=start_point,
end_point=end_point,
dims_displayed=dims_displayed,
)
or 0
)
def _reset_history(self, event=None):
self._undo_history = deque(maxlen=self._history_limit)
self._redo_history = deque(maxlen=self._history_limit)
self._staged_history = []
self._block_history = False
[docs] @contextmanager
def block_history(self):
"""Context manager to group history-editing operations together.
While in the context, history atoms are grouped together into a
"staged" history. When exiting the context, that staged history is
committed to the undo history queue, and an event is emitted
containing the change.
"""
prev = self._block_history
self._block_history = True
try:
yield
self._commit_staged_history()
finally:
self._block_history = prev
def _commit_staged_history(self):
"""Save staged history to undo history and clear it."""
if self._staged_history:
self._append_to_undo_history(self._staged_history)
self._staged_history = []
def _append_to_undo_history(self, item):
"""Append item to history and emit paint event.
Parameters
----------
item : List[Tuple[ndarray, ndarray, int]]
list of history atoms to append to undo history.
"""
self._undo_history.append(item)
self.events.paint(value=item)
def _save_history(self, value):
"""Save a history "atom" to the undo history.
A history "atom" is a single change operation to the array. A history
*item* is a collection of atoms that were applied together to make a
single change. For example, when dragging and painting, at each mouse
callback we create a history "atom", but we save all those atoms in
a single history item, since we would want to undo one drag in one
undo operation.
Parameters
----------
value : 3-tuple of arrays
The value is a 3-tuple containing:
- a numpy multi-index, pointing to the array elements that were
changed
- the values corresponding to those elements before the change
- the value(s) after the change
"""
self._redo_history.clear()
if self._block_history:
self._staged_history.append(value)
else:
self._append_to_undo_history([value])
def _load_history(self, before, after, undoing=True):
"""Load a history item and apply it to the array.
Parameters
----------
before : list of history items
The list of elements from which we want to load.
after : list of history items
The list of element to which to append the loaded element. In the
case of an undo operation, this is the redo queue, and vice versa.
undoing : bool
Whether we are undoing (default) or redoing. In the case of
redoing, we apply the "after change" element of a history element
(the third element of the history "atom").
See Also
--------
Labels._save_history
"""
if len(before) == 0:
return
history_item = before.pop()
after.append(list(reversed(history_item)))
for prev_indices, prev_values, next_values in reversed(history_item):
values = prev_values if undoing else next_values
self.data[prev_indices] = values
self.refresh()
def undo(self):
self._load_history(
self._undo_history, self._redo_history, undoing=True
)
def redo(self):
self._load_history(
self._redo_history, self._undo_history, undoing=False
)
[docs] def fill(self, coord, new_label, refresh=True):
"""Replace an existing label with a new label, either just at the
connected component if the `contiguous` flag is `True` or everywhere
if it is `False`, working in the number of dimensions specified by
the `n_edit_dimensions` flag.
Parameters
----------
coord : sequence of float
Position of mouse cursor in image coordinates.
new_label : int
Value of the new label to be filled in.
refresh : bool
Whether to refresh view slice or not. Set to False to batch paint
calls.
"""
int_coord = tuple(np.round(coord).astype(int))
# If requested fill location is outside data shape then return
if np.any(np.less(int_coord, 0)) or np.any(
np.greater_equal(int_coord, self.data.shape)
):
return
# If requested new label doesn't change old label then return
old_label = np.asarray(self.data[int_coord]).item()
if old_label == new_label or (
self.preserve_labels and old_label != self._background_label
):
return
dims_to_fill = sorted(self._dims_order[-self.n_edit_dimensions :])
data_slice_list = list(int_coord)
for dim in dims_to_fill:
data_slice_list[dim] = slice(None)
data_slice = tuple(data_slice_list)
labels = np.asarray(self.data[data_slice])
slice_coord = tuple(int_coord[d] for d in dims_to_fill)
matches = labels == old_label
if self.contiguous:
# if contiguous replace only selected connected component
labeled_matches, num_features = ndi.label(matches)
if num_features != 1:
match_label = labeled_matches[slice_coord]
matches = np.logical_and(
matches, labeled_matches == match_label
)
match_indices_local = np.nonzero(matches)
if self.ndim not in {2, self.n_edit_dimensions}:
n_idx = len(match_indices_local[0])
match_indices = []
j = 0
for d in data_slice:
if isinstance(d, slice):
match_indices.append(match_indices_local[j])
j += 1
else:
match_indices.append(np.full(n_idx, d, dtype=np.intp))
else:
match_indices = match_indices_local
match_indices = _coerce_indices_for_vectorization(
self.data, match_indices
)
self.data_setitem(match_indices, new_label, refresh)
def _draw(self, new_label, last_cursor_coord, coordinates):
"""Paint into coordinates, accounting for mode and cursor movement.
The draw operation depends on the current mode of the layer.
Parameters
----------
new_label : int
value of label to paint
last_cursor_coord : sequence
last painted cursor coordinates
coordinates : sequence
new cursor coordinates
"""
if coordinates is None:
return
ndisplay = len(self._dims_displayed)
interp_coord = interpolate_coordinates(
last_cursor_coord, coordinates, self.brush_size
)
for c in interp_coord:
if (
ndisplay == 3
and self.data[tuple(np.round(c).astype(int))] == 0
):
continue
if self._mode in [Mode.PAINT, Mode.ERASE]:
self.paint(c, new_label, refresh=False)
elif self._mode == Mode.FILL:
self.fill(c, new_label, refresh=False)
self.refresh()
[docs] def paint(self, coord, new_label, refresh=True):
"""Paint over existing labels with a new label, using the selected
brush shape and size, either only on the visible slice or in all
n dimensions.
Parameters
----------
coord : sequence of int
Position of mouse cursor in image coordinates.
new_label : int
Value of the new label to be filled in.
refresh : bool
Whether to refresh view slice or not. Set to False to batch paint
calls.
"""
shape = self.data.shape
dims_to_paint = sorted(self._dims_order[-self.n_edit_dimensions :])
dims_not_painted = sorted(self._dims_order[: -self.n_edit_dimensions])
paint_scale = np.array(
[self.scale[i] for i in dims_to_paint], dtype=float
)
slice_coord = [int(np.round(c)) for c in coord]
if self.n_edit_dimensions < self.ndim:
coord_paint = [coord[i] for i in dims_to_paint]
shape = [shape[i] for i in dims_to_paint]
else:
coord_paint = coord
# Ensure circle doesn't have spurious point
# on edge by keeping radius as ##.5
radius = np.floor(self.brush_size / 2) + 0.5
mask_indices = sphere_indices(radius, tuple(paint_scale))
mask_indices = mask_indices + np.round(np.array(coord_paint)).astype(
int
)
# discard candidate coordinates that are out of bounds
mask_indices = indices_in_shape(mask_indices, shape)
# Transfer valid coordinates to slice_coord,
# or expand coordinate if 3rd dim in 2D image
slice_coord_temp = [m for m in mask_indices.T]
if self.n_edit_dimensions < self.ndim:
for j, i in enumerate(dims_to_paint):
slice_coord[i] = slice_coord_temp[j]
for i in dims_not_painted:
slice_coord[i] = slice_coord[i] * np.ones(
mask_indices.shape[0], dtype=int
)
else:
slice_coord = slice_coord_temp
slice_coord = _coerce_indices_for_vectorization(self.data, slice_coord)
# slice coord is a tuple of coordinate arrays per dimension
# subset it if we want to only paint into background/only erase
# current label
if self.preserve_labels:
if new_label == self._background_label:
keep_coords = self.data[slice_coord] == self.selected_label
else:
keep_coords = self.data[slice_coord] == self._background_label
slice_coord = tuple(sc[keep_coords] for sc in slice_coord)
self.data_setitem(slice_coord, new_label, refresh)
[docs] def data_setitem(self, indices, value, refresh=True):
"""Set `indices` in `data` to `value`, while writing to edit history.
Parameters
----------
indices : tuple of int, slice, or sequence of int
Indices in data to overwrite. Can be any valid NumPy indexing
expression [1]_.
value : int or array of int
New label value(s). If more than one value, must match or
broadcast with the given indices.
refresh : bool, default True
whether to refresh the view, by default True
References
----------
..[1] https://numpy.org/doc/stable/user/basics.indexing.html
"""
self._save_history(
(
indices,
np.array(self.data[indices], copy=True),
value,
)
)
# update the labels image
self.data[indices] = value
if refresh is True:
self.refresh()
[docs] def get_status(
self,
position,
*,
view_direction: Optional[np.ndarray] = None,
dims_displayed: Optional[List[int]] = None,
world: bool = False,
) -> str:
"""Status message of the data at a coordinate position.
Parameters
----------
position : tuple
Position in either data or world coordinates.
view_direction : Optional[np.ndarray]
A unit vector giving the direction of the ray in nD world coordinates.
The default value is None.
dims_displayed : Optional[List[int]]
A list of the dimensions currently being displayed in the viewer.
The default value is None.
world : bool
If True the position is taken to be in world coordinates
and converted into data coordinates. False by default.
Returns
-------
msg : string
String containing a message that can be used as a status update.
"""
value = self.get_value(
position,
view_direction=view_direction,
dims_displayed=dims_displayed,
world=world,
)
msg = generate_layer_status(self.name, position, value)
# if this labels layer has properties
properties = self._get_properties(
position,
view_direction=view_direction,
dims_displayed=dims_displayed,
world=world,
)
if properties:
msg += "; " + ", ".join(properties)
return msg
def _get_tooltip_text(
self,
position,
*,
view_direction: Optional[np.ndarray] = None,
dims_displayed: Optional[List[int]] = None,
world: bool = False,
):
"""
tooltip message of the data at a coordinate position.
Parameters
----------
position : tuple
Position in either data or world coordinates.
view_direction : Optional[np.ndarray]
A unit vector giving the direction of the ray in nD world coordinates.
The default value is None.
dims_displayed : Optional[List[int]]
A list of the dimensions currently being displayed in the viewer.
The default value is None.
world : bool
If True the position is taken to be in world coordinates
and converted into data coordinates. False by default.
Returns
-------
msg : string
String containing a message that can be used as a tooltip.
"""
return "\n".join(
self._get_properties(
position,
view_direction=view_direction,
dims_displayed=dims_displayed,
world=world,
)
)
def _get_properties(
self,
position,
*,
view_direction: Optional[np.ndarray] = None,
dims_displayed: Optional[List[int]] = None,
world: bool = False,
) -> list:
if len(self._label_index) == 0 or self.features.shape[1] == 0:
return []
value = self.get_value(
position,
view_direction=view_direction,
dims_displayed=dims_displayed,
world=world,
)
# if the cursor is not outside the image or on the background
if value is None:
return []
label_value = value[1] if self.multiscale else value
if label_value not in self._label_index:
return [trans._('[No Properties]')]
idx = self._label_index[label_value]
return [
f'{k}: {v[idx]}'
for k, v in self.features.items()
if k != 'index'
and len(v) > idx
and v[idx] is not None
and not (isinstance(v[idx], float) and np.isnan(v[idx]))
]
if config.async_octree:
from ..image.experimental.octree_image import _OctreeImageBase
[docs] class Labels(Labels, _OctreeImageBase):
pass
def _coerce_indices_for_vectorization(array, indices: list) -> tuple:
"""Coerces indices so that they can be used for vectorized indexing in the given data array."""
if _is_array_type(array, 'xarray.DataArray'):
# Fix indexing for xarray if necessary
# See http://xarray.pydata.org/en/stable/indexing.html#vectorized-indexing
# for difference from indexing numpy
try:
import xarray as xr
return tuple(xr.DataArray(i) for i in indices)
except ModuleNotFoundError:
pass
return tuple(indices)