Source code for napari.components.dims

from collections.abc import Sequence
from numbers import Integral
from typing import (
    Any,
    Literal,
    NamedTuple,
    Optional,
    Union,
)

import numpy as np

from napari._pydantic_compat import root_validator, validator
from napari.utils.events import EventedModel
from napari.utils.misc import argsort, reorder_after_dim_reduction
from napari.utils.translations import trans


class RangeTuple(NamedTuple):
    start: float
    stop: float
    step: float


[docs] class Dims(EventedModel): """Dimensions object modeling slicing and displaying. Parameters ---------- ndim : int Number of dimensions. ndisplay : int Number of displayed dimensions. range : tuple of 3-tuple of float List of tuples (min, max, step), one for each dimension in world coordinates space. Lower and upper bounds are inclusive. point : tuple of floats Dims position in world coordinates for each dimension. margin_left : tuple of floats Left margin in world pixels of the slice for each dimension. margin_right : tuple of floats Right margin in world pixels of the slice for each dimension. order : tuple of int Tuple of ordering the dimensions, where the last dimensions are rendered. axis_labels : tuple of str Tuple of labels for each dimension. last_used : int Dimension which was last interacted with. Attributes ---------- ndim : int Number of dimensions. ndisplay : int Number of displayed dimensions. range : tuple of 3-tuple of float List of tuples (min, max, step), one for each dimension in world coordinates space. Lower and upper bounds are inclusive. point : tuple of floats Dims position in world coordinates for each dimension. margin_left : tuple of floats Left margin (=thickness) in world pixels of the slice for each dimension. margin_right : tuple of floats Right margin (=thickness) in world pixels of the slice for each dimension. order : tuple of int Tuple of ordering the dimensions, where the last dimensions are rendered. axis_labels : tuple of str Tuple of labels for each dimension. last_used : int Dimension which was last used. Tuple the slider position for each dims slider, in world coordinates. current_step : tuple of int Current step for each dimension (same as point, but in slider coordinates). nsteps : tuple of int Number of steps available to each slider. These are calculated from the ``range``. thickness : tuple of floats Thickness of the slice (sum of both margins) for each dimension in world coordinates. displayed : tuple of int List of dimensions that are displayed. These are calculated from the ``order`` and ``ndisplay``. not_displayed : tuple of int List of dimensions that are not displayed. These are calculated from the ``order`` and ``ndisplay``. displayed_order : tuple of int Order of only displayed dimensions. These are calculated from the ``displayed`` dimensions. rollable : tuple of bool Tuple of axis roll state. If True the axis is rollable. """ # fields ndim: int = 2 ndisplay: Literal[2, 3] = 2 order: tuple[int, ...] = () axis_labels: tuple[str, ...] = () rollable: tuple[bool, ...] = () range: tuple[RangeTuple, ...] = () margin_left: tuple[float, ...] = () margin_right: tuple[float, ...] = () point: tuple[float, ...] = () last_used: int = 0 # private vars _play_ready: bool = True # False if currently awaiting a draw event _scroll_progress: int = 0 # validators # check fields is false to allow private fields to work @validator( 'order', 'axis_labels', 'rollable', 'point', 'margin_left', 'margin_right', pre=True, allow_reuse=True, ) def _as_tuple(v): return tuple(v) @validator('range', pre=True) def _check_ranges(ranges): """ Ensure the range values are sane. - start < stop - step > 0 """ for axis, (start, stop, step) in enumerate(ranges): if start > stop: raise ValueError( trans._( 'start and stop must be strictly increasing, but got ({start}, {stop}) for axis {axis}', deferred=True, start=start, stop=stop, axis=axis, ) ) if step <= 0: raise ValueError( trans._( 'step must be strictly positive, but got {step} for axis {axis}.', deferred=True, step=step, axis=axis, ) ) return ranges @root_validator(skip_on_failure=True, allow_reuse=True) def _check_dims(cls, values): """Check the consistency of dimensionality for all attributes. Parameters ---------- values : dict Values dictionary to update dims model with. """ updated = {} ndim = values['ndim'] range_ = ensure_len(values['range'], ndim, pad_width=(0.0, 2.0, 1.0)) updated['range'] = tuple(RangeTuple(*rng) for rng in range_) point = ensure_len(values['point'], ndim, pad_width=0.0) # ensure point is limited to range updated['point'] = tuple( np.clip(pt, rng.start, rng.stop) for pt, rng in zip(point, updated['range']) ) updated['margin_left'] = ensure_len( values['margin_left'], ndim, pad_width=0.0 ) updated['margin_right'] = ensure_len( values['margin_right'], ndim, pad_width=0.0 ) # order and label default computation is too different to include in ensure_len() # Check the order tuple has same number of elements as ndim order = values['order'] if len(order) < ndim: order_ndim = len(order) # new dims are always prepended prepended_dims = tuple(range(ndim - order_ndim)) # maintain existing order, but shift accordingly existing_order = tuple(o + ndim - order_ndim for o in order) order = prepended_dims + existing_order elif len(order) > ndim: order = reorder_after_dim_reduction(order[-ndim:]) updated['order'] = order # Check the order is a permutation of 0, ..., ndim - 1 if set(updated['order']) != set(range(ndim)): raise ValueError( trans._( 'Invalid ordering {order} for {ndim} dimensions', deferred=True, order=updated['order'], ndim=ndim, ) ) # Check the axis labels tuple has same number of elements as ndim axis_labels = values['axis_labels'] labels_ndim = len(axis_labels) if labels_ndim < ndim: # Append new "default" labels to existing ones if axis_labels == tuple(map(str, range(labels_ndim))): updated['axis_labels'] = tuple(map(str, range(ndim))) else: updated['axis_labels'] = ( tuple(map(str, range(ndim - labels_ndim))) + axis_labels ) elif labels_ndim > ndim: updated['axis_labels'] = axis_labels[-ndim:] # Check the rollable axes tuple has same number of elements as ndim updated['rollable'] = ensure_len(values['rollable'], ndim, True) # If the last used slider is no longer visible, use the first. last_used = values['last_used'] ndisplay = values['ndisplay'] dims_range = updated['range'] nsteps = cls._nsteps_from_range(dims_range) not_displayed = [ d for d in order[:-ndisplay] if len(nsteps) > d and nsteps[d] > 1 ] if len(not_displayed) > 0 and last_used not in not_displayed: updated['last_used'] = not_displayed[0] return {**values, **updated} @staticmethod def _nsteps_from_range(dims_range) -> tuple[float, ...]: return tuple( # "or 1" ensures degenerate dimension works int((rng.stop - rng.start) / (rng.step or 1)) + 1 for rng in dims_range ) @property def nsteps(self) -> tuple[float, ...]: return self._nsteps_from_range(self.range) @nsteps.setter def nsteps(self, value): self.range = tuple( RangeTuple( rng.start, rng.stop, (rng.stop - rng.start) / (nsteps - 1) ) for rng, nsteps in zip(self.range, value) ) @property def current_step(self): return tuple( int(round((point - rng.start) / (rng.step or 1))) for point, rng in zip(self.point, self.range) ) @current_step.setter def current_step(self, value): self.point = tuple( rng.start + point * rng.step for point, rng in zip(value, self.range) ) @property def thickness(self) -> tuple[float, ...]: return tuple( left + right for left, right in zip(self.margin_left, self.margin_right) ) @thickness.setter def thickness(self, value): self.margin_left = self.margin_right = tuple(val / 2 for val in value) @property def displayed(self) -> tuple[int, ...]: """Tuple: Dimensions that are displayed.""" return self.order[-self.ndisplay :] @property def not_displayed(self) -> tuple[int, ...]: """Tuple: Dimensions that are not displayed.""" return self.order[: -self.ndisplay] @property def displayed_order(self) -> tuple[int, ...]: return tuple(argsort(self.displayed))
[docs] def set_range( self, axis: Union[int, Sequence[int]], _range: Union[ Sequence[Union[int, float]], Sequence[Sequence[Union[int, float]]] ], ): """Sets ranges (min, max, step) for the given dimensions. Parameters ---------- axis : int or sequence of int Dimension index or a sequence of axes whos range will be set. _range : tuple or sequence of tuple Range specified as (min, max, step) or a sequence of these range tuples. """ axis, value = self._sanitize_input( axis, _range, value_is_sequence=True ) full_range = list(self.range) for ax, val in zip(axis, value): full_range[ax] = val self.range = tuple(full_range)
[docs] def set_point( self, axis: Union[int, Sequence[int]], value: Union[float, Sequence[float]], ): """Sets point to slice dimension in world coordinates. Parameters ---------- axis : int or sequence of int Dimension index or a sequence of axes whos point will be set. value : scalar or sequence of scalars Value of the point for each axis. """ axis, value = self._sanitize_input( axis, value, value_is_sequence=False ) full_point = list(self.point) for ax, val in zip(axis, value): full_point[ax] = val self.point = tuple(full_point)
def set_current_step( self, axis: Union[int, Sequence[int]], value: Union[int, Sequence[int]], ): axis, value = self._sanitize_input( axis, value, value_is_sequence=False ) range_ = list(self.range) value_world = [] for ax, val in zip(axis, value): rng = range_[ax] value_world.append(rng.start + val * rng.step) self.set_point(axis, value_world)
[docs] def set_axis_label( self, axis: Union[int, Sequence[int]], label: Union[str, Sequence[str]], ): """Sets new axis labels for the given axes. Parameters ---------- axis : int or sequence of int Dimension index or a sequence of axes whos labels will be set. label : str or sequence of str Given labels for the specified axes. """ axis, label = self._sanitize_input( axis, label, value_is_sequence=False ) full_axis_labels = list(self.axis_labels) for ax, val in zip(axis, label): full_axis_labels[ax] = val self.axis_labels = tuple(full_axis_labels)
[docs] def reset(self): """Reset dims values to initial states.""" # Don't reset axis labels # TODO: could be optimized with self.update, but need to fix # event firing in EventedModel first self.range = ((0, 2, 1),) * self.ndim self.point = (0,) * self.ndim self.order = tuple(range(self.ndim)) self.margin_left = (0,) * self.ndim self.margin_right = (0,) * self.ndim self.rollable = (True,) * self.ndim
[docs] def transpose(self): """Transpose displayed dimensions. This swaps the order of the last two displayed dimensions. The order of the displayed is taken from Dims.order. """ order = list(self.order) order[-2], order[-1] = order[-1], order[-2] self.order = order
def _increment_dims_right(self, axis: Optional[int] = None): """Increment dimensions to the right along given axis, or last used axis if None Parameters ---------- axis : int, optional Axis along which to increment dims, by default None """ if axis is None: axis = self.last_used self.set_current_step(axis, self.current_step[axis] + 1) def _increment_dims_left(self, axis: Optional[int] = None): """Increment dimensions to the left along given axis, or last used axis if None Parameters ---------- axis : int, optional Axis along which to increment dims, by default None """ if axis is None: axis = self.last_used self.set_current_step(axis, self.current_step[axis] - 1) def _focus_up(self): """Shift focused dimension slider to be the next slider above.""" sliders = [d for d in self.not_displayed if self.nsteps[d] > 1] if len(sliders) == 0: return index = (sliders.index(self.last_used) + 1) % len(sliders) self.last_used = sliders[index] def _focus_down(self): """Shift focused dimension slider to be the next slider bellow.""" sliders = [d for d in self.not_displayed if self.nsteps[d] > 1] if len(sliders) == 0: return index = (sliders.index(self.last_used) - 1) % len(sliders) self.last_used = sliders[index]
[docs] def roll(self): """Roll order of dimensions for display.""" order = np.array(self.order) # we combine "rollable" and "nsteps" into a mask for rolling # this mask has to be aligned to "order" as "rollable" and # "nsteps" are static but order is dynamic, meaning "rollable" # and "nsteps" encode the axes by position, whereas "order" # encodes axis by number valid = np.logical_and(self.rollable, np.array(self.nsteps) > 1)[order] order[valid] = np.roll(order[valid], shift=1) self.order = order
def _go_to_center_step(self): self.current_step = [int((ns - 1) / 2) for ns in self.nsteps] def _sanitize_input( self, axis, value, value_is_sequence=False ) -> tuple[list[int], list]: """ Ensure that axis and value are the same length, that axes are not out of bounds, and coerces to lists for easier processing. """ if isinstance(axis, Integral): if ( isinstance(value, Sequence) and not isinstance(value, str) and not value_is_sequence ): raise ValueError( trans._('cannot set multiple values to a single axis') ) axis = [axis] value = [value] else: axis = list(axis) value = list(value) if len(axis) != len(value): raise ValueError( trans._('axis and value sequences must have equal length') ) for ax in axis: ensure_axis_in_bounds(ax, self.ndim) return axis, value
def ensure_len(value: tuple, length: int, pad_width: Any): """ Ensure that the value has the required number of elements. Right-crop if value is too long; left-pad with default if too short. Parameters ---------- value : Tuple A tuple of values to be resized. ndim : int Number of desired values. default : Tuple Default element for left-padding. """ if len(value) < length: # left pad value = (pad_width,) * (length - len(value)) + value elif len(value) > length: # right-crop value = value[-length:] return value def ensure_axis_in_bounds(axis: int, ndim: int) -> int: """Ensure a given value is inside the existing axes of the image. Returns ------- axis : int The axis which was checked for validity. ndim : int The dimensionality of the layer. Raises ------ ValueError The given axis index is out of bounds. """ if axis not in range(-ndim, ndim): msg = trans._( 'Axis {axis} not defined for dimensionality {ndim}. Must be in [{ndim_lower}, {ndim}).', deferred=True, axis=axis, ndim=ndim, ndim_lower=-ndim, ) raise ValueError(msg) return axis % ndim