Source code for magmap.plot.colormaps

# Colormaps for MagellanMapper
# Author: David Young, 2018, 2020
"""Custom colormaps for MagellanMapper.
"""

from enum import Enum, auto
from typing import Optional, Sequence, Tuple, Union

import numpy as np
from matplotlib import cm
from matplotlib import colors

from magmap.settings import config
from magmap.io import libmag

#: Dict[:class:`config.Cmaps`, :obj:`colors.LinearSegmentedColormap`]:
# Default colormaps.
CMAPS = {}

_logger = config.logger.getChild(__name__)


[docs] class DiscreteModes(Enum): """Discrete colormap generation modes.""" RANDOMN = auto() GRID = auto()
[docs] def make_dark_linear_cmap(name, color): """Make a linear colormap starting with black and ranging to ``color``. Args: name: Name to give to colormap. color: Colors will range from black to this color. Returns: A `LinearSegmentedColormap` object. """ return colors.LinearSegmentedColormap.from_list(name, ["black", color])
[docs] def setup_cmaps(): """Setup default colormaps, storing them in :const:``CMAPS``.""" CMAPS[config.Cmaps.CMAP_GRBK_NAME] = make_dark_linear_cmap( config.Cmaps.CMAP_GRBK_NAME.value, "green") CMAPS[config.Cmaps.CMAP_RDBK_NAME] = make_dark_linear_cmap( config.Cmaps.CMAP_RDBK_NAME.value, "red") CMAPS[config.Cmaps.CMAP_BUBK_NAME] = make_dark_linear_cmap( config.Cmaps.CMAP_BUBK_NAME.value, "blue") CMAPS[config.Cmaps.CMAP_CYBK_NAME] = make_dark_linear_cmap( config.Cmaps.CMAP_CYBK_NAME.value, "cyan") CMAPS[config.Cmaps.CMAP_MGBK_NAME] = make_dark_linear_cmap( config.Cmaps.CMAP_MGBK_NAME.value, "magenta") CMAPS[config.Cmaps.CMAP_YLBK_NAME] = make_dark_linear_cmap( config.Cmaps.CMAP_YLBK_NAME.value, "yellow")
[docs] class DiscreteColormap(colors.ListedColormap): """Extends :class:``matplotlib.colors.ListedColormap`` to generate a discrete colormap and associated normalization object. Extend ``ListedColormap`` rather than linear colormap since the number of colors should equal the number of possible vals, without requiring interpolation. Attributes: cmap_labels: Tuple of N lists of RGBA values, where N is equal to the number of colors, with a discrete color for each unique value in ``labels``. norm: Normalization object, which is of type :class:``matplotlib.colors.NoNorm`` if indexing directly or :class:``matplotlib.colors.BoundaryNorm`` if otherwise. img_labels (List[int]): Sorted sequence of unique labels. May have more values than in ``labels`` such as mirrored negative values. None if ``index_direct`` is False. """ def __init__( self, labels: Optional[Sequence[int]] = None, alpha: int = 150, index_direct: bool = True, max_val: int = 255, background: Optional[Tuple[int, Tuple[int, int, int, int]]] = None, dup_for_neg: bool = False, symmetric_colors: bool = False, cmap_labels: Optional[Sequence[str]] = None, **kwargs): """Generate discrete colormap for labels. Args: labels: Labels of integers for which a distinct color should be mapped to each unique label. Defults to None, in which case no colormap will be generated. alpha: Transparency leve; defaults to 150 for semi-transparent. index_direct: True if the colormap will be indexed directly, which assumes that the labels will serve as indexes to the colormap and should span sequentially from 0, 1, 2, ...; defaults to True. If False, a colormap will be generated for the full range of integers between the lowest and highest label values, inclusive, with a :obj:`colors.BoundaryNorm`, which may incur performance cost. max_val: Maximum value for random numbers; defaults to 255. background: Tuple of ``(background_label, (R, G, B, A))``, where ``background_label`` is the label value specifying the background, and ``RGBA`` will replace the color for that label. Defaults to None. dup_for_neg: True to duplicate positive labels as negative labels to recreate the same set of labels as for a mirrored labels map. Defaults to False. symmetric_colors: True to make symmetric colors, assuming symmetric labels centered on 0; defaults to False. cmap_labels: Sequence of colors as Matplotlib color strings or RGB(A) hex (eg "#0fab24ff") strings. """ self.norm = None self.cmap_labels = None self.img_labels = None self.symmetric_colors = symmetric_colors if labels is None: return labels_unique = np.unique(labels) if dup_for_neg and np.sum(labels_unique < 0) == 0: # for labels that are only >= 0, duplicate the pos portion # as neg so that images with or without negs use the same colors labels_unique = np.append( -1 * labels_unique[labels_unique > 0][::-1], labels_unique) num_colors = len(labels_unique) labels_offset = 0 if index_direct: # assume label vals increase by 1 from 0 until num_colors; store # sorted labels sequence to translate labels based on index self.norm = colors.NoNorm() self.img_labels = labels_unique else: # use labels as bounds for each color, including wide bounds # for large gaps between successive labels; offset bounds to # encompass each label and avoid off-by-one errors that appear # when viewing images with additional extreme labels; float32 # gives unsymmetric colors for large values in mirrored atlases # despite remaining within range for unclear reasons, fixed by # using float64 instead labels_offset = 0.5 bounds = labels_unique.astype(np.float64) bounds -= labels_offset # number of boundaries should be one more than number of labels to # avoid need for interpolation of boundary bin numbers and # potential merging of 2 extreme labels bounds = np.append(bounds, [bounds[-1] + 1]) # TODO: may have occasional colormap inaccuracies from this bug: # https://github.com/matplotlib/matplotlib/issues/9937; self.norm = colors.BoundaryNorm(bounds, num_colors) if cmap_labels is None: # auto-generate colors for the number of labels args = dict( alpha=alpha, prioritize_default=False, max_val=max_val, symmetric_colors=symmetric_colors) args.update(kwargs) self.cmap_labels = discrete_colormap(num_colors, **args) else: # generate RGBA colors from supplied color strings self.cmap_labels = colors.to_rgba_array(cmap_labels) * max_val if background is not None: # replace background label color with given color bkgdi = np.where(labels_unique == background[0] - labels_offset) if len(bkgdi) > 0 and bkgdi[0].size > 0: self.cmap_labels[bkgdi[0][0]] = background[1] #print(self.cmap_labels) self.make_cmap()
[docs] def make_cmap(self): """Initialize ``ListedColormap`` with stored labels rescaled to 0-1.""" super(DiscreteColormap, self).__init__( self.cmap_labels / 255.0, "discrete_cmap")
[docs] def modified_cmap(self, adjust: int) -> "DiscreteColormap": """Make a modified discrete colormap from itself. The resulting colormap is assumed to map to the same range of label image values, using the same :attr:`norm` and :attr:`img_labels`. Args: adjust: Value by which to adjust RGB (not A) values. Returns: New ``DiscreteColormap`` instance with :attr:`norm` pointing to first instance, :attr:`img_labels`, and :attr:`cmap_labels` incremented by ``adjust``. """ cmap = DiscreteColormap() # TODO: consider whether to copy instead cmap.norm = self.norm cmap.img_labels = self.img_labels # cast labels from uint8 (RBG) to int16 to accommodate adjustments # outside of 0-255 range but clip back to this range cmap.cmap_labels = np.copy(self.cmap_labels).astype(np.int16) cmap.cmap_labels[:, :3] += adjust cmap.cmap_labels = cmap.cmap_labels.clip(0, 255).astype(np.uint8) cmap.make_cmap() return cmap
[docs] def convert_img_labels(self, img): """Convert an image to the indices in :attr:`img_labels` to give a linearly scaled image. This image can be displayed using a colormap with :class:`matplotlib.colors.NoNorm` to index directly into the colormap. Args: img (:obj:`np.ndarray`): Image to convert. If :attr:`symmetric_colors` is True, the absolute value will be taken as a workaround for likely image display resampling errors. Returns: :class:`numpy.ndarray`: Array of same shape as ``img`` with values translated to their corresponding indices within :attr:`img_labels`, or ``img`` unchanged if :attr:`img_labels` is None. """ conv = img if self.img_labels is not None: if self.symmetric_colors: # WORKAROUND: corresponding pos/neg label vals may display # different colors despite mapping to the same colormap value, # perhaps because rounding or resampling issues related to: # https://github.com/matplotlib/matplotlib/issues/12071 # https://github.com/matplotlib/matplotlib/issues/16910 img = np.abs(img) conv = np.searchsorted(self.img_labels, img) # TESTING: show colormap correspondences with label IDs # img_un = np.unique(img) # img_conv = np.searchsorted(self.img_labels, img_un) # for im, cv in zip(img_un, img_conv): # print(im, cv, self(cv)) return conv
[docs] def discrete_colormap( num_colors: int, alpha: int = 255, prioritize_default: Union[bool, str, Sequence[Sequence[int]]] = True, seed: Optional[int] = None, min_val: Union[int, float] = 0, max_val: Union[int, float] = 255, min_any: Union[int, float] = 0, symmetric_colors: bool = False, dup_offset: int = 0, jitter: int = 0, mode: "DiscreteModes" = DiscreteModes.RANDOMN) -> np.ndarray: """Make a discrete colormap using randomly generated RGB values. Args: num_colors: Number of discrete colors to generate. alpha: Transparency level, from 0-255; defaults to 255. prioritize_default: If True (default), the default colors from :attr:``config.colors`` will replace the initial colormap elements. Can alternatively be a string for Matplotlib color specs, where `cn` = "CN" color spec, `css4` = CSS4/X11 spec, and "tableau" = Tableau spec. Can also be a sequence of colors, where each colors is in ``r, g, b, a``, ranging from 0-255. seed: Random number seed; defaults to None, in which case no seed will be set. min_val: Minimum value for random numbers; defaults to 0. max_val: Maximum value for random numbers; defaults to 255. For floating point ranges such as 0.0-1.0, set as a float. min_any: Minimum value above which at least one value must be in each set of RGB values; defaults to 0. If all values in an RGB set are below this value, the lowest RGB value will be scaled up by the ratio ``max_val:min_any``. Assumes a range of ``min_val < min_any < max_val``; defaults to 0 to ignore. symmetric_colors: True to create a symmetric set of colors, assuming the first half of ``num_colors`` mirror those of the second half; defaults to False. dup_offset: Amount by which to offset duplicate color values if ``dup_for_neg`` is enabled; defaults to 0. jitter: In :obj:`DiscreteModes.GRID` mode, coordinates are randomly shifted by half this value above or below their original value; defaults to 0. mode: Mode given as an enumeration; defaults to :obj:`DiscreteModes.RANDOMN` mode. Returns: 2D Numpy array in the format ``[[R, G, B, alpha], ...]`` on a scale of 0-255. This colormap will need to be converted into a Matplotlib colormap using ``LinearSegmentedColormap.from_list`` to generate a map that can be used directly in functions such as ``imshow``. """ if symmetric_colors: # make room for offset when duplicating colors max_val -= dup_offset # generate random combination of RGB values for each number of colors, # where each value ranges from min-max if mode is DiscreteModes.GRID: # discrete colors taken from an evenly spaced grid for min separation # between color values jitters = None if jitter > 0: if seed is not None: np.random.seed(seed) jitters = np.multiply( np.random.random((num_colors, 3)), jitter - jitter / 2).astype(int) max_val -= np.amax(jitters) min_val -= np.amin(jitters) # TODO: weight chls or scale non-linearly for better visual distinction space = (max_val - min_val) // np.cbrt(num_colors) sl = slice(min_val, max_val, space) grid = np.mgrid[sl, sl, sl] coords = np.c_[grid[0].ravel(), grid[1].ravel(), grid[2].ravel()] if min_any > 0: # remove all coords where all vals are below threshold # TODO: account for lost coords in initial space size determination coords = coords[~np.all(np.less(coords, min_any), axis=1)] if seed is not None: np.random.seed(seed) rand = np.random.choice(len(coords), num_colors, replace=False) rand_coords = coords[rand] if jitters is not None: rand_coords = np.add(rand_coords, jitters) rand_coords_shape = list(rand_coords.shape) rand_coords_shape[-1] += 1 cmap = np.zeros( rand_coords_shape, dtype=libmag.dtype_within_range(min_val, max_val)) cmap[:, :-1] = rand_coords else: # randomly generate each color value; 4th values only for simplicity # in generating array with shape for alpha channel if seed is not None: np.random.seed(seed) cmap = (np.random.random((num_colors, 4)) * (max_val - min_val) + min_val).astype( libmag.dtype_within_range(min_val, max_val)) if min_any > 0: # if all vals below threshold, scale up lowest value below_offset = np.all(np.less(cmap[:, :3], min_any), axis=1) axes = np.argmin(cmap[below_offset, :3], axis=1) cmap[below_offset, axes] = np.multiply( cmap[below_offset, axes], max_val / min_any) if prioritize_default is not False: # prioritize default colors by replacing first colors with default ones colors_default = config.colors colors_mplot = dict( base=colors.BASE_COLORS, xkcd=colors.XKCD_COLORS, css4=colors.CSS4_COLORS, tableau=colors.TABLEAU_COLORS, ) if prioritize_default == "cn": # "CN" color spec colors_default = np.multiply( [colors.to_rgb("C{}".format(i)) for i in range(10)], 255) elif prioritize_default in colors_mplot.keys(): # Matplotlib colors group colors_default = colors_mplot[prioritize_default].values() colors_default = np.multiply( colors.to_rgba_array(colors_default)[:, :3], 255) ncolors_default = len(colors_default) if num_colors < ncolors_default: # increase distinctiveness by picking evenly spaced colors, # assuming color list is sorted by similarity colors_default = colors_default[np.linspace( 0, ncolors_default - 1, num_colors).astype(int)] elif libmag.is_seq(prioritize_default): # use given color dictionary directly colors_default = prioritize_default # keep only required or available number of colors end = min((num_colors, len(colors_default))) cmap[:end, :3] = colors_default[:end] if symmetric_colors: # invert latter half onto former half, assuming that corresponding # labels are mirrored (eg -5, 3, 0, 3, 5), with background centered as 0 cmap_len = len(cmap) mid = cmap_len // 2 cmap[:mid] = cmap[:cmap_len-mid-1:-1] + dup_offset cmap[:, -1] = alpha # set transparency return cmap
[docs] def get_labels_discrete_colormap( labels_img: Optional[np.ndarray], alpha_bkgd: int = 255, use_orig_labels: bool = False, **kwargs) -> "DiscreteColormap": """Get a default discrete colormap for a labels image. Assumes that background is 0, and the seed is determined by :attr:``config.seed``. Args: labels_img: Labels image as a Numpy array. Can be None, in which case ``use_orig_labels`` should be True. alpha_bkgd: Background alpha level from 0 to 255; defaults to 255 to turn on background fully. use_orig_labels: True to use original labels from :attr:`config.labels_img_orig` if available, falling back to ``labels_img``. Defaults to False. Returns: A colormap with separate color for each unique value in ``labels_img``. """ lbls = labels_img if use_orig_labels: # use original labels image IDs if available for mapping consistency if (config.labels_metadata and config.labels_metadata.region_ids_orig is not None): # use saved label IDs lbls = config.labels_metadata.region_ids_orig elif config.labels_img_orig is not None: # fallback to use labels from original image if available lbls = config.labels_img_orig args = dict( seed=config.seed, alpha=255, min_any=160, min_val=10, background=(0, (0, 0, 0, alpha_bkgd))) args.update(kwargs) return DiscreteColormap(lbls, **args)
[docs] def get_borders_colormap(borders_img, labels_img, cmap_labels): """Get a colormap for borders, using corresponding labels with intensity change to distinguish the borders. If the number of labels differs from that of the original colormap, a new colormap will be generated instead. Args: borders_img: Borders image as a Numpy array, used to determine the number of labels required. If this image has multiple channels, a similar colormap with distinct intensity will be made for each channel. labels_img: Labels image as a Numpy array, used to compare the number of labels for each channel in ``borders_img``. cmap_labels: The original colormap on which the new colormaps will be based. Returns: List of borders colormaps corresponding to the number of channels, or None if ``borders_img`` is None """ cmap_borders = None if borders_img is not None: if np.unique(labels_img).size == np.unique(borders_img).size: # get matching colors by using labels colormap as template, # with brightest colormap for original (channel 0) borders channels = 1 if borders_img.ndim >= 4: channels = borders_img.shape[-1] cmap_borders = [ cmap_labels.modified_cmap(int(40 / (channel + 1))) for channel in range(channels)] else: # get a new colormap if borders image has different number # of labels while still ensuring a transparent background cmap_borders = [get_labels_discrete_colormap(borders_img, 0)] return cmap_borders
[docs] def make_binary_cmap(binary_colors): """Make a binary discrete colormap. Args: binary_colors (List[str]): Sequence of colors as ``[background, foreground]``. Returns: :obj:`DiscreteColormap`: Discrete colormap with labels of ``[0, 1]`` mapped to ``binary_colors``. """ return DiscreteColormap([0, 1], cmap_labels=binary_colors)
[docs] def setup_labels_cmap( labels_img: np.ndarray, binary_colors: Optional[Sequence[str]] = None, **kwargs) -> "DiscreteColormap": """Wrapper to set up a colormap for a labels image. Args: labels_img: Labels image. binary_colors: Sequence of colors as strings; defaults to None, in which case :attr:`config.atlas_labels[config.AtlasLabels.BINARY]` is used. 0 is assumed to be background, and 1 is foreground. Returns: Discrete colormap for the given labels. """ # TODO: consider merging with get_labels_discrete_colormap if binary_colors is None: binary_colors = config.atlas_labels[config.AtlasLabels.BINARY] if binary_colors: cmap_labels = make_binary_cmap(binary_colors) else: args = dict( dup_for_neg=True, use_orig_labels=True, symmetric_colors=config.atlas_labels[ config.AtlasLabels.SYMMETRIC_COLORS]) args.update(kwargs) cmap_labels = get_labels_discrete_colormap(labels_img, 0, **args) return cmap_labels
[docs] def get_cmap(cmap, n=None): """Get colormap from a list of colormaps, string, or enum. If ``n`` is given, ``cmap`` is assumed to be a list from which a colormap will be retrieved. Colormaps that are strings will be converted to the associated standard `Colormap` object, while enums in :class:``config.Cmaps`` will be used to retrieve a `Colormap` object from :const:``CMAPS``, which is assumed to have been initialized. Args: cmap: Colormap given as a string of Enum or list of colormaps. n: Index of `cmap` to retrieve a colormap, assuming that `cmap` is a sequence. Defaults to None to use `cmap` directly. Returns: The ``Colormap`` object, or None if no corresponding colormap is found. """ if n is not None: # assume that cmap is a list cmap = config.cmaps[n] if n < len(cmap) else None if isinstance(cmap, str): # cmap given as a standard Matplotlib colormap name cmap = cm.get_cmap(cmap) elif isinstance(cmap, config.Cmaps): # assume default colormaps have been initialized cmap = CMAPS[cmap] return cmap
[docs] def setup_colormaps(num_channels): """Set up colormaps based on the currently loaded main ROI profile. Args: num_channels (int): Number of channels in the main image; if the main ROI profile does not define this many colormaps, new colormaps will be randomly generated. """ config.cmaps = list(config.roi_profile["channel_colors"]) num_cmaps = len(config.cmaps) if num_cmaps < num_channels: # add colormap for each remaining channel, purposely inducing # int wraparound for greater color contrast chls_diff = num_channels - num_cmaps cmaps = discrete_colormap( chls_diff, alpha=255, prioritize_default=False, seed=config.seed, min_val=150) / 255.0 _logger.debug("Generating colormaps from RGBA colors:\n%s", cmaps) for cmap in cmaps: config.cmaps.append(make_dark_linear_cmap("", cmap))