Source code for straditize.label_selection

# -*- coding: utf-8 -*-
"""Module for the :class:`LabelSelection` class

This module defines the :class:`LabelSelection` class, a base class for the
:class:`straditize.straditizer.Straditizer`
:class:`straditize.binary.DataReader` classes. This class implements the
features to select parts of an image and deletes them. The
:class:`straditize.widgets.selection_toolbar.SelectionToolbar` interfaces
with instances of this class.

**Disclaimer**

Copyright (C) 2018-2019  Philipp S. Sommer

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>."""
import numpy as np
import matplotlib.colors as mcol
from straditize.common import docstrings


[docs]class LabelSelection(object): """Class to provide selection functionalities for an image This class provides functionalities to select features in an image. A new selection can be started through :meth:`enable_label_selection` method and selected parts can be removed through the :meth:`remove_selected_labels` method. A 2D boolean mask of the selected pixels can be accessed through the :attr:`selected_part` attribute. This class generally assumes that the array for the selection is a 2D integer array, e.g. obtained from the :func:`skimage.morphology.label` function. The selection of labels is handled through the colormap. The selection is displayed as a matplotlib image on the :attr:`ax` attribute of this instance. If the color for one label is equal to the :attr:`cselect` color, it is considered as selected. Additionally every cell that has a value greater than the original number of labels is considered to be selected. Cells with a value of -1 are not selected and cells with a value of 0 cannot be selected.""" _selection_arr = None #: matplotlib image of the selection _select_img = None #: The RGBA color for selected polygons cselect = [1., 0., 0., 1.] #: The RGBA color for unselected polygons cunselect = [0., 0., 0., 0.] #: List of attribute names of arrays that should be modified if the #: labels are about to be removed. The attributes might be callable and #: should then provide the array label_arrs = [] #: Functions that shall be called before the labels are removed. The keys #: must be the attributes in the :attr:`label_attrs` list, values must be #: list of function that accept too arguments, the array and the boolean #: mask highlighting the cells that will be set to 0 remove_callbacks = None _remove = True cid_select = None _magni_img = None _ellipses = [] @property def selected_labeled_part(self): """The selected part as a 2D boolean mask""" if self._selection_arr is None: return np.zeros_like(self.labels, dtype=bool) selection = self.selected_labels return np.isin(self._selection_arr, selection) @property def selected_part(self): """The selected part as a 2D boolean mask""" if self._selection_arr is None: return np.zeros_like(self.labels, dtype=bool) return self.selected_labeled_part | ( self._selection_arr > self._select_nlabels) @property def selected_labels(self): """A list of selected labels in the selection array""" bounds = self._select_img.norm.boundaries[1:] + 0.5 bounds = bounds.astype(int) cmap = self._select_img.get_cmap() colors = cmap(np.linspace(0, 1, cmap.N)) if np.allclose(colors[1], self.cselect): if len(colors) == 2: ret = np.unique(self._selection_arr) return ret[ret > 0] else: istart = 0 else: if len(colors) == 2: return np.array([], dtype=int) istart = 1 starts = bounds[istart::2] ends = bounds[istart + 1::2] return np.concatenate( list(np.arange(max(1, s), max(1, e)) for s, e in zip(starts, ends)))
[docs] def get_default_cmap(self, ncolors): """The default colormap for binary images""" colors = np.array([self.cunselect for i in range(ncolors)]) cmap = mcol.LinearSegmentedColormap.from_list('invisible', colors, ncolors) cmap.set_over(self.cselect) return cmap
[docs] @staticmethod def copy_cmap(cmap_src, colors): """Copy a colormap with replaced colors This function creates a method that has the same name and the same *under*, *over* and *bad* values as the given `cmap_src` but with replaced colors Parameters ---------- cmap_src: matplotlib.colors.Colormap The source colormap colors: np.ndarray The colors for the colormap Returns ------- matplotlib.colors.Colormap The new colormap""" cmap = mcol.LinearSegmentedColormap.from_list(cmap_src.name, colors, len(colors)) cmap.set_under(cmap_src(-0.1)) cmap.set_over(cmap_src(1.1)) cmap.set_bad(cmap(np.ma.masked_array([np.nan], [True]))[0]) return cmap
[docs] def pick_label(self, event): """Pick the label selected by the given mouseevent""" self.event = event artist = event.artist if artist is not self._select_img: return x, y = event.mouseevent.xdata, event.mouseevent.ydata x = int(np.round(x)) y = int(np.round(y)) extent = getattr(self, 'extent', None) if extent is not None: x -= extent[0] y -= min(extent[2:]) val = self._selection_arr[y, x] if val == -1 or val > self._select_nlabels: val = self._orig_selection_arr[y, x] if not np.isnan(val) and val != 0: self._selection_arr[self._orig_selection_arr == val] = val if val == 0 or np.isnan(val): return selected = self.selected_labels if val in selected: selected = np.delete(selected, selected.searchsorted(val)) else: selected = np.insert(selected, selected.searchsorted(val), val) self.select_labels(selected) self._select_img.axes.figure.canvas.draw()
[docs] def highlight_small_selections(self, n=20): """Highlight labels that cover only a small portion of cells This method uses the :func:`skimage.morphology.remove_small_objects` to detect and highlight small features in the diagram. Each feature will be highlighted through an ellipsis around it. See Also -------- remove_small_selection_ellipses""" from matplotlib.patches import Ellipse import skimage.morphology as skim if self._selection_arr is None: return arr = self.selected_part arr &= ~skim.remove_small_objects(arr, n) if not arr.any(): return labeled, num_labels = skim.label(arr, 8, return_num=True) min_height = np.ceil(0.05 * arr.shape[0]) min_width = np.ceil(0.05 * arr.shape[1]) self._ellipses = artists = [] extent = getattr(self, 'extent', None) if extent is not None: x0 = extent[0] y0 = min(extent[2:]) else: x0 = y0 = 0 for label in range(1, num_labels + 1): mask = labeled == label xmask = np.where(mask.any(axis=0))[0] width = xmask[-1] - xmask[0] xc = x0 + xmask[0] + width / 2. + 0.5 ymask = np.where(mask.any(axis=1))[0] height = ymask[-1] - ymask[0] yc = y0 + ymask[0] + height / 2. + 0.5 a = Ellipse((xc, yc), max(min_width, width + 2), max(min_height, height + 2), edgecolor='b', facecolor='b', alpha=0.3) artists.append(a) self._select_img.axes.add_patch(a)
[docs] def remove_small_selection_ellipses(self): """Remove the ellipes for small features Removes the ellipses plotted by the :meth:`highlight_small_selections` method""" for a in self._ellipses: a.remove() self._ellipses.clear()
[docs] def select_labels(self, selected): """Select a list of labels Parameters ---------- selected: np.ndarray The numpy array of labels that should be selected""" if len(selected) == 0: self._select_img.set_cmap(self._select_cmap) self._select_img.set_norm(self._select_norm) else: diffs = selected[1:] - selected[:-1] mask = diffs > 1 if mask.any(): starts = np.r_[[selected[0]], selected[1:][mask]] ends = np.r_[selected[:-1][mask], selected[-1]] + 1 else: starts = [selected[0]] ends = [selected[-1] + 1] notnull = self._selection_arr[self._selection_arr.astype(bool)] min_label = notnull.min() max_label = notnull.max() bounds = [0.1] if selected[0] == min_label else [0.1, 0.5] bounds = np.r_[bounds, np.array(list(zip(starts, ends))).ravel() - 0.5] if selected[-1] != max_label: bounds = np.r_[bounds, [self._select_nlabels + 0.5]] ncolors = len(bounds) - 1 cunselect = self.cunselect cselect = self.cselect if selected[0] != min_label: colors = [cunselect] + [ cselect if i % 2 else cunselect for i in range(ncolors - 1)] else: colors = [cunselect] + [ cunselect if i % 2 else cselect for i in range(ncolors - 1)] self._select_img.set_cmap(self.copy_cmap( self._select_img.get_cmap(), colors)) self._select_img.set_norm(mcol.BoundaryNorm(bounds, len(bounds)-1)) self._update_magni_img()
[docs] @docstrings.get_sectionsf('LabelSelection.enable_label_selection') def enable_label_selection(self, arr, ncolors, img=None, set_picker=False, **kwargs): """Start the selection of labels Parameters ---------- arr: 2D np.ndarray of dtype int The labeled array that contains the features to select. ncolors: int The maximum of the labels in `arr` img: matplotlib image The image for the selection. If not provided, a new image is created set_picker: bool If True, connect the matplotlib pick_event to the :meth:`pick_label` method See Also -------- disable_label_selection remove_selected_labels""" if img is None: cmap = self.get_default_cmap(2) cmap.set_under('none') kwargs.setdefault('cmap', cmap) norm = mcol.BoundaryNorm([0.1, 0.5, ncolors+0.5], 2) kwargs.setdefault('norm', norm) img = self.ax.imshow(arr, **kwargs) if getattr(self, 'magni', None) is not None: magni_img = self.magni.ax.imshow(arr, **kwargs) else: magni_img = None self._remove = True else: self._remove = False magni_img = None cmap = img.get_cmap() self._select_cmap = cmap self._select_norm = img.norm self._select_nlabels = ncolors self._selection_arr = arr self._orig_selection_arr = arr.copy() self._select_img = img self._magni_img = magni_img if set_picker: img.set_picker(True) self.cid_select = self.fig.canvas.mpl_connect( 'pick_event', self.pick_label)
[docs] def select_all_labels(self): """Select the entire array""" colors = [self.cunselect, self.cselect] self._selection_arr = self._orig_selection_arr.copy() self._select_img.set_cmap(self.copy_cmap(self._select_img.get_cmap(), colors)) self._select_img.set_norm(self._select_norm) self._select_img.set_array(self._selection_arr) self._update_magni_img()
def _update_magni_img(self): if self._magni_img is not None: img = self._select_img magni_img = self._magni_img magni_img.set_cmap(img.get_cmap()) magni_img.set_array(img.get_array()) magni_img.set_norm(img.norm) magni_img.set_alpha(img.get_alpha()) self.magni.ax.figure.canvas.draw_idle()
[docs] def unselect_all_labels(self): """Clear the selection""" self._select_img.set_cmap(self._select_cmap) self._select_img.set_norm(self._select_norm) self._update_magni_img()
[docs] def select_all_other_labels(self): """Invert the selection""" cmap = self._select_img.get_cmap() arr = np.linspace(0, 1., cmap.N) colors = cmap(arr) c1 = colors[1].copy() try: c2 = colors[2].copy() except IndexError: c2 = colors[0].copy() for i in range(1, len(colors), 2): colors[i, :] = c2 if i+1 < len(colors): colors[i+1, :] = c1 self._select_img.set_cmap(self.copy_cmap(cmap, colors)) self._update_magni_img()
[docs] def remove_selected_labels(self, disable=False): """Remove the selected parts of the diagram This method will call the callbackes in the :attr:`remove_callbacks` attribute for all the attributes in the :attr:`label_arrs` list. Parameters ---------- disable: bool If True, call the :meth:`disable_label_selection` method at the end See Also -------- enable_label_selection disable_label_selection """ selection = self.selected_labels to_big = self._selection_arr > self._select_nlabels if not len(selection) and not to_big.any(): if disable: self.disable_label_selection() return mask = np.isin(self._selection_arr, selection) | to_big plottet_arr_in_attrs = False for attr in self.label_arrs: arr = getattr(self, attr) if callable(arr): arr = arr() if arr is self._selection_arr: plottet_arr_in_attrs = True if arr.ndim == 3: amask = np.tile(mask[:, :, np.newaxis], (1, 1, arr.shape[-1])) amask[..., :-1] = False else: amask = mask for func in self.remove_callbacks.get(attr, []): func(arr, amask) try: arr[amask] = 0 except ValueError: # assignment destination is read-only pass if not plottet_arr_in_attrs: self._selection_arr[mask] = 0 self._select_img.set_array(self._selection_arr) self._select_img.set_cmap(self._select_cmap) self._select_img.set_norm(self._select_norm) self._update_magni_img() if disable: self.disable_label_selection()
[docs] def disable_label_selection(self, remove=None): """Disable the label selection This will disconnect the *pick_event* and remove the selection images Parameters ---------- remove: bool Whether to remove the selection image from the plot. If None, the :attr:`_remove` attribute is used See Also -------- enable_label_selection remove_selected_labels""" if remove is None: remove = self._remove if remove: try: self._select_img.remove() except (AttributeError, ValueError) as e: pass else: self._select_img.set_picker(False) try: self._magni_img.remove() except (AttributeError, ValueError) as e: pass if self.cid_select is not None: self.fig.canvas.mpl_disconnect(self.cid_select) for attr in ['_select_cmap', '_select_img', '_selection_arr', '_select_norm', 'cid_select', '_magni_img']: try: delattr(self, attr) except AttributeError: pass