# 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))