import warnings
from collections import deque
from typing import Dict, Union
import numpy as np
from scipy import ndimage as ndi
from ...utils.colormaps import (
color_dict_to_colormap,
label_colormap,
low_discrepancy_image,
)
from ...utils.events import Event
from ..image.image import _ImageBase
from ..utils.color_transformations import transform_color
from ..utils.layer_utils import dataframe_to_properties
from ._labels_constants import LabelBrushShape, LabelColorMode, Mode
from ._labels_mouse_bindings import draw, pick
from ._labels_utils import sphere_indices
[docs]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.
num_colors : int
Number of unique colors to use in colormap.
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.
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 lenght N translation vector and a 1 or a napari
AffineTransform object. If provided then translate, scale, rotate, and
shear values are ignored.
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'}.
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.
Attributes
----------
data : array
Integer valued label data. Can be N dimensional. Every pixel contains
an integer ID corresponding to the region it belongs to. The label 0 is
rendered as transparent.
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.
metadata : dict
Labels metadata.
num_colors : int
Number of unique colors to use in colormap.
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_dimensional : bool
If `True`, paint and fill edit labels across all dimensions.
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.
Extended Summary
----------
_data_raw : array (N, M)
2D labels data for the currently viewed slice.
_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,
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',
visible=True,
multiscale=None,
):
self._seed = seed
self._background_label = 0
self._num_colors = num_colors
self._random_colormap = label_colormap(self.num_colors)
self._color_mode = LabelColorMode.AUTO
self._brush_shape = LabelBrushShape.CIRCLE
self._show_selected_label = False
self._contour = 0
if properties is None:
self._properties = {}
label_index = {}
else:
properties = self._validate_properties(properties)
self._properties, label_index = dataframe_to_properties(properties)
if label_index is None:
props = self._properties
if len(props) > 0:
self._label_index = self._map_index(properties)
else:
self._label_index = {}
else:
self._label_index = label_index
super().__init__(
data,
rgb=False,
colormap=self._random_colormap,
contrast_limits=[0.0, 1.0],
interpolation='nearest',
rendering='translucent',
name=name,
metadata=metadata,
scale=scale,
translate=translate,
rotate=rotate,
shear=shear,
affine=affine,
opacity=opacity,
blending=blending,
visible=visible,
multiscale=multiscale,
)
self.events.add(
mode=Event,
preserve_labels=Event,
properties=Event,
n_dimensional=Event,
contiguous=Event,
brush_size=Event,
selected_label=Event,
color_mode=Event,
brush_shape=Event,
contour=Event,
)
self._n_dimensional = False
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._mode_history = self._mode
self._status = self.mode
self._preserve_labels = False
self._help = 'enter paint or fill mode to edit labels'
self._block_saving = False
self._reset_history()
# Trigger generation of view slice and thumbnail
self._update_dims()
self._set_editable()
@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_dimensional(self):
"""bool: paint and fill edits labels across all dimensions."""
return self._n_dimensional
@n_dimensional.setter
def n_dimensional(self, n_dimensional):
self._n_dimensional = n_dimensional
self.events.n_dimensional()
@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)
# Convert from brush size in data coordinates to
# cursor size in world coordinates
data2world_scale = np.mean(
[self.scale[d] for d in self._dims_displayed]
)
self.cursor_size = self.brush_size * data2world_scale
self.events.brush_size()
@property
def seed(self):
"""float: Seed for colormap random generator."""
return self._seed
@seed.setter
def seed(self, seed):
self._seed = seed
self._selected_color = self.get_color(self.selected_label)
self.refresh()
self.events.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 properties(self) -> Dict[str, np.ndarray]:
"""dict {str: array (N,)}, DataFrame: Properties for each label."""
return self._properties
@properties.setter
def properties(self, properties: Dict[str, np.ndarray]):
if not isinstance(properties, dict):
properties, label_index = dataframe_to_properties(properties)
if label_index is None:
label_index = self._map_index(properties)
self._properties = self._validate_properties(properties)
self._label_index = label_index
self.events.properties()
@property
def color(self):
"""dict: custom color dict for label coloring"""
return self._color
@color.setter
def color(self, color):
if not color:
color = {}
color_mode = LabelColorMode.AUTO
else:
color_mode = LabelColorMode.DIRECT
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
self.color_mode = color_mode
def _validate_properties(
self, properties: Dict[str, np.ndarray]
) -> Dict[str, np.ndarray]:
"""Validate the type and size of properties."""
lens = []
for k, v in properties.items():
lens.append(len(v))
if not isinstance(v, np.ndarray):
properties[k] = np.asarray(v)
if not all([v == lens[0] for v in lens]):
raise ValueError(
"the number of items must be equal for all properties"
)
return properties
def _map_index(self, properties: Dict[str, np.ndarray]) -> Dict[int, int]:
"""Map rows in given properties to label indices"""
arbitrary_key = list(properties.keys())[0]
label_index = {i: i for i in range(len(properties[arbitrary_key]))}
return label_index
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,
'seed': self.seed,
'data': self.data,
'color': self.color,
}
)
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 < 0:
raise ValueError('cannot reduce selected label below 0')
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)
self.colormap = custom_colormap
self._label_color_index = label_color_index
elif color_mode == LabelColorMode.AUTO:
self._label_color_index = {}
self.colormap = self._random_colormap
else:
raise ValueError("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 brush_shape(self):
"""str: Paintbrush shape"""
return str(self._brush_shape)
@brush_shape.setter
def brush_shape(self, brush_shape):
"""Set current brush shape."""
self._brush_shape = LabelBrushShape(brush_shape)
self.cursor = self.brush_shape
@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)
@mode.setter
def mode(self, mode: Union[str, Mode]):
mode = Mode(mode)
if not self.editable:
mode = Mode.PAN_ZOOM
if mode == self._mode:
return
if self._mode == Mode.PICK:
self.mouse_drag_callbacks.remove(pick)
elif self._mode in [Mode.PAINT, Mode.FILL, Mode.ERASE]:
self.mouse_drag_callbacks.remove(draw)
if mode == Mode.PAN_ZOOM:
self.cursor = 'standard'
self.interactive = True
self.help = 'enter paint or fill mode to edit labels'
elif mode == Mode.PICK:
self.cursor = 'cross'
self.interactive = False
self.help = 'hold <space> to pan/zoom, click to pick a label'
self.mouse_drag_callbacks.append(pick)
elif mode == Mode.PAINT:
self.cursor = self.brush_shape
# Convert from brush size in data coordinates to
# cursor size in world coordinates
data2world_scale = np.mean(
[self.scale[d] for d in self._dims_displayed]
)
self.cursor_size = self.brush_size * data2world_scale
self.interactive = False
self.help = (
'hold <space> to pan/zoom, '
'hold <shift> to toggle preserve_labels, '
'hold <control> to fill, '
'hold <alt> to erase, '
'drag to paint a label'
)
self.mouse_drag_callbacks.append(draw)
elif mode == Mode.FILL:
self.cursor = 'cross'
self.interactive = False
self.help = 'hold <space> to pan/zoom, click to fill a label'
self.mouse_drag_callbacks.append(draw)
elif mode == Mode.ERASE:
self.cursor = self.brush_shape
# Convert from brush size in data coordinates to
# cursor size in world coordinates
data2world_scale = np.mean(
[self.scale[d] for d in self._dims_displayed]
)
self.cursor_size = self.brush_size * data2world_scale
self.interactive = False
self.help = 'hold <space> to pan/zoom, drag to erase a label'
self.mouse_drag_callbacks.append(draw)
else:
raise ValueError("Mode not recognized")
self._mode = mode
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)
def _set_editable(self, editable=None):
"""Set editable mode based on layer properties."""
if editable is None:
if self.multiscale or self._ndisplay == 3:
self.editable = False
else:
self.editable = True
if not self.editable:
self.mode = Mode.PAN_ZOOM
self._reset_history()
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.
"""
if (
not self.show_selected_label
and self._color_mode == LabelColorMode.DIRECT
):
u, inv = np.unique(raw, 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.shape)
elif (
not self.show_selected_label
and self._color_mode == LabelColorMode.AUTO
):
image = np.where(
raw > 0, low_discrepancy_image(raw, self._seed), 0
)
elif (
self.show_selected_label
and self._color_mode == LabelColorMode.AUTO
):
selected = self._selected_label
image = np.where(
raw == selected,
low_discrepancy_image(selected, self._seed),
0,
)
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 == selected,
index[selected],
np.where(
raw != self._background_label,
index[None],
index[self._background_label],
),
)
else:
raise ValueError("Unsupported Color Mode")
if self.contour > 0 and raw.ndim == 2:
image = 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)
image[boundaries] = raw[boundaries]
image = np.where(
image > 0, low_discrepancy_image(image, self._seed), 0
)
elif self.contour > 0 and raw.ndim > 2:
warnings.warn("Contours are not displayed during 3D rendering")
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
else:
val = self._raw_to_displayed(np.array([label]))
col = self.colormap.map(val)[0]
return col
def _reset_history(self, event=None):
self._undo_history = deque()
self._redo_history = deque()
def _trim_history(self):
while (
len(self._undo_history) + len(self._redo_history)
> self._history_limit
):
self._undo_history.popleft()
def _save_history(self):
self._redo_history = deque()
if not self._block_saving:
self._undo_history.append(self.data[self._slice_indices].copy())
self._trim_history()
def _load_history(self, before, after):
if len(before) == 0:
return
prev = before.pop()
after.append(self.data[self._slice_indices].copy())
self.data[self._slice_indices] = prev
self.refresh()
def undo(self):
self._load_history(self._undo_history, self._redo_history)
def redo(self):
self._load_history(self._redo_history, self._undo_history)
[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 either just in the current slice if
the `n_dimensional` flag is `False` or on the entire data if it is
`True`.
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 = self.data[int_coord]
if old_label == new_label or (
self.preserve_labels and old_label != self._background_label
):
return
if refresh is True:
self._save_history()
if self.n_dimensional or self.ndim == 2:
# work with entire image
labels = self.data
slice_coord = tuple(int_coord)
else:
# work with just the sliced image
labels = self._data_raw
slice_coord = tuple(int_coord[d] for d in self._dims_displayed)
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
)
# Replace target pixels with new_label
labels[matches] = new_label
if not (self.n_dimensional or self.ndim == 2):
# if working with just the slice, update the rest of the raw data
self.data[tuple(self._slice_indices)] = labels
if refresh is True:
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.
"""
if refresh is True:
self._save_history()
if self.brush_shape == "square":
brush_size_dims = [self.brush_size] * self.ndim
if not self.n_dimensional and self.ndim > 2:
for i in self._dims_not_displayed:
brush_size_dims[i] = 1
slice_coord = tuple(
slice(
np.round(np.clip(c - brush_size / 2 + 0.5, 0, s)).astype(
int
),
np.round(np.clip(c + brush_size / 2 + 0.5, 0, s)).astype(
int
),
1,
)
for c, s, brush_size in zip(
coord, self.data.shape, brush_size_dims
)
)
elif self.brush_shape == "circle":
slice_coord = [int(np.round(c)) for c in coord]
shape = self.data.shape
if not self.n_dimensional and self.ndim > 2:
coord = [coord[i] for i in self._dims_displayed]
shape = [shape[i] for i in self._dims_displayed]
sphere_dims = len(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, sphere_dims)
mask_indices = mask_indices + np.round(np.array(coord)).astype(int)
# discard candidate coordinates that are out of bounds
discard_coords = np.logical_and(
~np.any(mask_indices < 0, axis=1),
~np.any(mask_indices >= np.array(shape), axis=1),
)
mask_indices = mask_indices[discard_coords]
# 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 not self.n_dimensional and self.ndim > 2:
for j, i in enumerate(self._dims_displayed):
slice_coord[i] = slice_coord_temp[j]
for i in self._dims_not_displayed:
slice_coord[i] = slice_coord[i] * np.ones(
mask_indices.shape[0], dtype=int
)
else:
slice_coord = slice_coord_temp
slice_coord = tuple(slice_coord)
# 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
if isinstance(self.data, xr.DataArray):
slice_coord = tuple(xr.DataArray(i) for i in slice_coord)
except ImportError:
pass
# slice_coord from square brush is tuple of slices per dimension
# slice_coord from circle brush is tuple of coord. arrays per dimension
# update the labels image
if not self.preserve_labels:
self.data[slice_coord] = new_label
else:
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
if self.brush_shape == "circle":
slice_coord = tuple(sc[keep_coords] for sc in slice_coord)
self.data[slice_coord] = new_label
else:
self.data[slice_coord][keep_coords] = new_label
if refresh is True:
self.refresh()
[docs] def get_status(self, position=None, world=False):
"""Status message of the data at a coordinate position.
Parameters
----------
position : tuple
Position in either data or world coordinates.
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.
"""
msg = super().get_status(position, world=world)
# if this labels layer has properties
if self._label_index and self._properties:
value = self.get_value(position, world=world)
# if the cursor is not outside the image or on the background
if value is not None:
if self.multiscale:
label_value = value[1]
else:
label_value = value
if label_value in self._label_index:
idx = self._label_index[label_value]
for k, v in self._properties.items():
if k != 'index':
msg += f', {k}: {v[idx]}'
else:
msg += ' [No Properties]'
return msg