Source code for magmap.plot.colormaps

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

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

import numpy as np
try:
    from matplotlib import colormaps as mpl_cmaps
    cm = None
except ImportError:
    mpl_cmaps = None
    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: Dict["config.Cmaps", colors.LinearSegmentedColormap] = {}

_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 multiple labels whose values are only >= 0, make identical # neg vals so that images with or without negs use the same colors lbls = np.array(labels_unique[labels_unique > 0][::-1]) if lbls.size > 0: labels_unique = np.append( -1 * lbls.astype(libmag.dtype_within_range( min(lbls), max(lbls), signed=True)), 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: Optional[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: Union[str, "config.Cmaps"], n: Optional[int] = None ) -> Optional[colors.LinearSegmentedColormap]: """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 if mpl_cmaps: cmap = mpl_cmaps[cmap] else: # fallback to Matplotlib API for < v3.9 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))