import os
import json
from typing import Optional, Union, cast
import warnings
import numpy as np
import numpy.typing as npt
from .. import options
from .views import Dataview, Volume, Vertex, VolumeRGB, VertexRGB
from .viewRGB import warn_alpha_range
from .braindata import BrainData, VolumeData, VertexData
default_cmap2D = options.config.get("basic", "default_cmap2D")
class Dataview2D(Dataview):
"""Abstract base class for 2-dimensional data views.
"""
dim1: Dataview
dim2: Dataview
_cls: type[BrainData]
_alpha_brain_cache: Optional[Dataview]
def __init__(self, description: str="", cmap: Optional[str]=None,
vmin: Optional[float]=None, vmax: Optional[float]=None,
vmin2: Optional[float]=None, vmax2: Optional[float]=None, state=None,
alpha=None, **kwargs):
self.cmap = cmap or default_cmap2D
self.vmin = vmin
self.vmax = vmax
self.vmin2 = vmin if vmin2 is None else vmin2
self.vmax2 = vmax if vmax2 is None else vmax2
self.state = state
self.attrs = kwargs
if 'priority' not in self.attrs:
self.attrs['priority'] = 1
self.description = description
# Optional per-voxel/vertex alpha map. Kept as a Volume/Vertex (never
# inside ``attrs``, which is JSON-serialized for the WebGL viewer) so
# that both renderers can ship it like any other brain.
self.alpha = alpha
@property
def alpha(self) -> Optional[Dataview]:
"""Optional alpha map (Volume/Vertex in [vmin, vmax]) multiplied into
the colormap alpha. NaN anywhere (dim1, dim2 or alpha) renders as
alpha 0 regardless of this map."""
return self._alpha
@alpha.setter
def alpha(self, alpha: Optional[Union[npt.ArrayLike, Dataview]]) -> None:
if alpha is not None and not isinstance(alpha, self._cls):
alpha = np.asarray(alpha)
warn_alpha_range(alpha)
alpha = self._wrap_alpha(alpha)
if alpha is not None and alpha.subject != self.dim1.subject:
raise ValueError("alpha must belong to the same subject as dim1")
if alpha is not None and getattr(alpha, "xfmname", None) != getattr(self.dim1, "xfmname", None):
raise ValueError("alpha must use the same transform as dim1")
self._alpha = alpha
self._alpha_brain_cache = None
@property
def _alpha_brain(self) -> Optional[Dataview]:
"""The alpha map normalized to [0, 1] (vmin=0, vmax=1), as shipped to
the WebGL viewer and stored in HDF files. NaN is preserved."""
if self.alpha is None:
return None
if self._alpha_brain_cache is None:
self._alpha_brain_cache = self._wrap_alpha(
cast(npt.NDArray, self._normalized_alpha()))
return self._alpha_brain_cache
def _wrap_alpha(self, alpha: npt.NDArray) -> Dataview:
raise NotImplementedError
def _normalized_alpha(self, full_volume: bool=False) -> Optional[npt.NDArray[np.floating]]:
"""User alpha as float array in [0, 1] (NaN preserved), in the space of
``.data`` or, for volumes with ``full_volume=True``, of ``.volume``."""
alpha = self.alpha
if alpha is None:
return None
raw = alpha.volume if full_volume else alpha.data
arr = np.asarray(raw, dtype=float)
if np.asarray(raw).dtype == np.uint8:
return arr / 255.
vmin = 0. if alpha.vmin is None else float(alpha.vmin)
vmax = 1. if alpha.vmax is None else float(alpha.vmax)
if vmax == vmin:
return np.where(arr >= vmax, 1., 0.)
return (arr - vmin) / (vmax - vmin)
def uniques(self, collapse=False):
"""Yield the two underlying Dataview objects (dim1, dim2) that make
up this composite 2D view.
Parameters
----------
collapse : bool, optional
Unused here; accepted for interface compatibility with
`Dataview.uniques`.
Returns
-------
generator
Yields `self.dim1`, then `self.dim2`.
"""
yield self.dim1
yield self.dim2
if self.alpha is not None:
yield self._alpha_brain
def _write_hdf(self, h5, name="data"):
self._cls._write_hdf(self.dim1, h5)
self._cls._write_hdf(self.dim2, h5)
names = [self.dim1.name, self.dim2.name]
if self.alpha is not None:
# Stored normalized to [0, 1] so that it can be restored with
# vmin=0, vmax=1 (BrainData nodes carry no range).
self._cls._write_hdf(self._alpha_brain, h5)
names.append(self._alpha_brain.name)
viewnode = Dataview._write_hdf(self, h5, name=name)
viewnode[0] = json.dumps([names])
viewnode[3] = json.dumps([[self.vmin, self.vmin2]])
viewnode[4] = json.dumps([[self.vmax, self.vmax2]])
return viewnode
def to_json(self, simple=False):
"""Serialize this 2D dataview to a JSON-compatible dict, for the
webgl viewer / HDF5 export.
Parameters
----------
simple : bool, optional
Unused here; accepted for interface compatibility with
`Dataview.to_json`.
Returns
-------
dict
Serialized view data, including both dims' names, cmap,
vmin/vmax pairs, state, attrs, description, and (if the
underlying dims are Volumes) the shared xfm.
"""
sdict = dict(data=[[self.dim1.name, self.dim2.name]],
state=self.state,
attrs=self.attrs,
desc=self.description,
cmap=[self.cmap] )
d1js = self.dim1.to_json()
d2js = self.dim2.to_json()
# Use ``is None`` rather than `or` because vmin/vmax can be 0.
sdict.update(dict(
vmin = [[d1js['vmin'][0] if self.vmin is None else self.vmin,
d2js['vmin'][0] if self.vmin2 is None else self.vmin2]],
vmax = [[d1js['vmax'][0] if self.vmax is None else self.vmax,
d2js['vmax'][0] if self.vmax2 is None else self.vmax2]],
))
if "xfm" in d1js:
sdict['xfm'] = [[d1js['xfm'][0], d2js['xfm'][0]]]
if self.alpha is not None:
# The alpha map, normalized to [0, 1], ships as its own brain; this
# names it. Read by DataView in dataset.js (json.alpha -> alphaData).
sdict['alpha'] = [self._alpha_brain.name]
return sdict
def _to_raw(self, data1: npt.NDArray, data2: npt.NDArray,
alpha: Optional[npt.NDArray]=None) -> tuple[
npt.NDArray[np.uint8], npt.NDArray[np.uint8], npt.NDArray[np.uint8],
npt.NDArray[np.uint8], npt.NDArray[np.bool_]]:
"""Colormap (data1, data2) through the 2D colormap.
Returns ``(r, g, b, a, nan_mask)`` as uint8 channels. ``a`` is the
colormap alpha multiplied by the (normalized) user ``alpha`` map when
given; it is forced to 0 wherever data1, data2 or alpha is NaN.
"""
from matplotlib import pyplot as plt
from matplotlib.colors import Normalize
cmapdir = options.config.get("webgl", "colormaps")
cmap = plt.imread(os.path.join(cmapdir, "%s.png"%self.cmap))
_warn_non_perceptually_uniform_colormap(self.cmap)
norm1 = Normalize(self.vmin, self.vmax)
norm2 = Normalize(self.vmin2, self.vmax2)
d1 = np.clip(norm1(data1), 0, 1)
d2 = np.clip(1 - norm2(data2), 0, 1)
dim1 = np.round(d1 * (cmap.shape[1]-1))
# Nans in data seemed to cause weird interaction with conversion to uint32
dim1 = np.nan_to_num(dim1).astype(np.uint32)
dim2 = np.round(d2 * (cmap.shape[0]-1))
dim2 = np.nan_to_num(dim2).astype(np.uint32)
colored = cmap[dim2.ravel(), dim1.ravel()]
# map r, g, b, a values between 0 and 255 to avoid problems with
# VolumeRGB when plotting flatmaps with quickflat
colored = (colored * 255).astype(np.uint8)
r, g, b, a = colored.T
r.shape = dim1.shape
g.shape = dim1.shape
b.shape = dim1.shape
a.shape = dim1.shape
# NaN in either dimension (or in the alpha map) -> alpha = 0
aidx = np.logical_or(np.isnan(data1), np.isnan(data2))
if alpha is not None:
alpha = np.asarray(alpha, dtype=float)
aidx = np.logical_or(aidx, np.isnan(alpha))
user = np.clip(np.nan_to_num(alpha, nan=0.0), 0, 1)
a = np.round(a.astype(float) * user).astype(np.uint8)
aidx = np.broadcast_to(aidx, a.shape)
a[aidx] = 0
return r, g, b, a, aidx
@property
def subject(self):
return self.dim1.subject
[docs]
class Volume2D(Dataview2D):
"""
Contains two 3D volumes for simultaneous visualization. Includes information
on how the volumes should be jointly colormapped.
Parameters
----------
dim1 : ndarray or Volume
The first volume. Can be a 1D or 3D array (see Volume for details), or
a Volume.
dim2 : ndarray or Volume
The second volume. Can be a 1D or 3D array (see Volume for details), or
a Volume.
subject : str, optional
Subject identifier. Must exist in the pycortex database. If not given,
dim1 must be a Volume from which the subject can be extracted.
xfmname : str, optional
Transform name. Must exist in the pycortex database. If not given,
dim1 must be a Volume from which the subject can be extracted.
description : str, optional
String describing this dataset. Displayed in webgl viewer.
cmap : str, optional
Colormap (or colormap name) to use. If not given defaults to the
`default_cmap2d` in your pycortex options.cfg file.
vmin : float, optional
Minimum value in colormap for dim1. If not given, the ``vmin`` of dim1
is used (its own ``vmin`` for a Volume/Vertex object, the 1st percentile
of the data for an array).
vmax : float, optional
Maximum value in colormap for dim1. If not given, the ``vmax`` of dim1
is used (its own ``vmax`` for a Volume/Vertex object, the 99th
percentile of the data for an array).
vmin2 : float, optional
Minimum value in colormap for dim2. If not given, the ``vmin`` of dim2
is used (same rule as ``vmin``).
vmax2 : float, optional
Maximum value in colormap for dim2. If not given, the ``vmax`` of dim2
is used (same rule as ``vmax``).
alpha : ndarray or Volume/Vertex, optional
Per-voxel (per-vertex) opacity multiplied into the colormap alpha.
Arrays are taken in [0, 1]; Volume/Vertex objects are normalized by
their own ``vmin``/``vmax``. Honored identically by quickflat and the
WebGL viewer. Wherever dim1, dim2 or alpha is NaN the data is
rendered fully transparent, regardless of this map.
**kwargs
All additional arguments in kwargs are passed to the VolumeData and Dataview
state : untyped
role unclear
priority : int (default = 1)
controls the order in which datasets are viewed in webgl
stim : str
path to stimulus file
rate : numeric (default 1)
frame rate for movie/time series playback in webgl
delay : numeric (default 0)
delay for movie/time series playback in webgl
filter : str (default = "nearest")
interpolation filter for volumetric rendering in webgl (nearest/trilinear/nearlin/debug)
alpha : ndarray or Volume
overwrites the computed alpha channel from a cmap, but currently commented out in Dataview2D
"""
_cls = VolumeData
dim1: Volume
dim2: Volume
[docs]
def __init__(self, dim1: Union[npt.NDArray, Volume], dim2: Union[npt.NDArray, Volume], subject: Optional[str]=None, xfmname: Optional[str]=None, description: str="", cmap: Optional[str]=None,
vmin: Optional[float]=None, vmax: Optional[float]=None, vmin2: Optional[float]=None, vmax2: Optional[float]=None, **kwargs):
if isinstance(dim1, self._cls):
if subject is not None or xfmname is not None:
raise TypeError("Subject and xfmname cannot be specified with Volumes")
if not isinstance(dim2, self._cls) or dim2.subject != dim1.subject:
raise TypeError("Invalid data for second dimension")
self.dim1 = dim1
self.dim2 = dim2
else:
if isinstance(dim2, self._cls):
raise TypeError("If dim2 is a Volume, dim1 must be a Volume as well")
if subject is None or xfmname is None:
raise TypeError("Subject and xfmname must be specified with raw data")
self.dim1 = Volume(dim1, subject, xfmname, vmin=vmin, vmax=vmax)
self.dim2 = Volume(dim2, subject, xfmname, vmin=vmin2, vmax=vmax2)
vmin = self.dim1.vmin if vmin is None else vmin
vmin2 = self.dim2.vmin if vmin2 is None else vmin2
vmax = self.dim1.vmax if vmax is None else vmax
vmax2 = self.dim2.vmax if vmax2 is None else vmax2
super().__init__(description=description, cmap=cmap, vmin=vmin,
vmax=vmax, vmin2=vmin2, vmax2=vmax2, **kwargs)
def __repr__(self):
return "<2D volumetric data for (%s, %s)>"%(self.dim1.subject, self.dim1.xfmname)
def _write_hdf(self, h5, name="data"):
viewnode = super()._write_hdf(h5, name)
viewnode[7] = json.dumps([[self.dim1.xfmname, self.dim2.xfmname]])
return viewnode
@property
def raw(self) -> VolumeRGB:
"""VolumeRGB object containing the colormapped data from this object.
"""
if self.dim1.xfmname != self.dim2.xfmname:
raise ValueError("Both Volumes must have same xfmname to generate single raw volume")
def _same_mask(a: Volume, b: Volume) -> bool:
return bool(a.linear and b.linear and a.mask.shape == b.mask.shape
and np.all(a.mask == b.mask))
linear = _same_mask(self.dim1, self.dim2)
if linear and self.alpha is not None:
linear = _same_mask(self.dim1, cast(Volume, self.alpha))
if linear:
r, g, b, a, nan_mask = self._to_raw(
self.dim1.data, self.dim2.data, self._normalized_alpha())
else:
r, g, b, a, nan_mask = self._to_raw(
self.dim1.volume, self.dim2.volume,
self._normalized_alpha(full_volume=True))
result = VolumeRGB(r, g, b, subject=self.dim1.subject,
xfmname=self.dim1.xfmname, alpha=a,
state=self.state, description=self.description,
priority=self.priority)
result._nan_mask = nan_mask
return result
def _wrap_alpha(self, alpha: npt.NDArray) -> Volume:
return Volume(alpha, self.dim1.subject, self.dim1.xfmname, vmin=0, vmax=1)
@property
def xfmname(self):
return self.dim1.xfmname
[docs]
class Vertex2D(Dataview2D):
"""
Contains two vertex maps for simultaneous visualization. Includes information
on how the maps should be jointly colormapped.
Parameters
----------
dim1 : ndarray or Vertex
The first vertex map. Can be a 1D array (see Vertex for details), or
a Vertex.
dim2 : ndarray or Vertex
The second vertex map. Can be a 1D array (see Vertex for details), or
a Vertex.
subject : str, optional
Subject identifier. Must exist in the pycortex database. If not given,
dim1 must be a Vertex from which the subject can be extracted.
description : str, optional
String describing this dataset. Displayed in webgl viewer.
cmap : str, optional
Colormap (or colormap name) to use. If not given defaults to the
`default_cmap2d` in your pycortex options.cfg file.
vmin : float, optional
Minimum value in colormap for dim1. If not given, the ``vmin`` of dim1
is used (its own ``vmin`` for a Volume/Vertex object, the 1st percentile
of the data for an array).
vmax : float, optional
Maximum value in colormap for dim1. If not given, the ``vmax`` of dim1
is used (its own ``vmax`` for a Volume/Vertex object, the 99th
percentile of the data for an array).
vmin2 : float, optional
Minimum value in colormap for dim2. If not given, the ``vmin`` of dim2
is used (same rule as ``vmin``).
vmax2 : float, optional
Maximum value in colormap for dim2. If not given, the ``vmax`` of dim2
is used (same rule as ``vmax``).
alpha : ndarray or Volume/Vertex, optional
Per-voxel (per-vertex) opacity multiplied into the colormap alpha.
Arrays are taken in [0, 1]; Volume/Vertex objects are normalized by
their own ``vmin``/``vmax``. Honored identically by quickflat and the
WebGL viewer. Wherever dim1, dim2 or alpha is NaN the data is
rendered fully transparent, regardless of this map.
**kwargs
All additional arguments in kwargs are passed to the VolumeData and Dataview
state : untyped
role unclear
priority : int (default = 1)
controls the order in which datasets are viewed in webgl
stim : str
path to stimulus file
rate : numeric (default 1)
frame rate for movie/time series playback in webgl
delay : numeric (default 0)
delay for movie/time series playback in webgl
filter : str (default = "nearest")
interpolation filter for volumetric rendering in webgl (nearest/trilinear/nearlin/debug)
alpha : ndarray or Volume
overwrites the computed alpha channel from a cmap, but currently commented out in Dataview2D
"""
_cls = VertexData
blend_curvature = _cls.blend_curvature # hacky inheritance
dim1: Vertex
dim2: Vertex
[docs]
def __init__(self, dim1: Union[npt.NDArray, Vertex], dim2: Union[npt.NDArray, Vertex], subject: Optional[str]=None, description: str="", cmap: Optional[str]=None,
vmin: Optional[float]=None, vmax: Optional[float]=None, vmin2: Optional[float]=None, vmax2: Optional[float]=None, **kwargs):
if isinstance(dim1, VertexData):
if subject is not None:
raise TypeError("Subject cannot be specified with Vertex")
if not isinstance(dim2, VertexData) or dim2.subject != dim1.subject:
raise TypeError("Invalid data for second dimension")
self.dim1 = dim1
self.dim2 = dim2
else:
if isinstance(dim2, self._cls):
raise TypeError("If dim2 is a Vertex, dim1 must be a Vertex as well")
if subject is None:
raise TypeError("Subject must be specified with raw data")
self.dim1 = Vertex(dim1, subject, vmin=vmin, vmax=vmax)
self.dim2 = Vertex(dim2, subject, vmin=vmin2, vmax=vmax2)
vmin = self.dim1.vmin if vmin is None else vmin
vmin2 = self.dim2.vmin if vmin2 is None else vmin2
vmax = self.dim1.vmax if vmax is None else vmax
vmax2 = self.dim2.vmax if vmax2 is None else vmax2
super().__init__(description=description, cmap=cmap,
vmin=vmin, vmax=vmax, vmin2=vmin2,
vmax2=vmax2, **kwargs)
def __repr__(self):
return "<2D vertex data for (%s)>"%self.dim1.subject
@property
def raw(self) -> VertexRGB:
"""VertexRGB object containing the colormapped data from this object.
"""
r, g, b, a, nan_mask = self._to_raw(
self.dim1.data, self.dim2.data, self._normalized_alpha())
result = VertexRGB(r, g, b, subject=self.dim1.subject, alpha=a,
state=self.state, description=self.description,
priority=self.priority)
result._nan_mask = nan_mask
return result
def _wrap_alpha(self, alpha: npt.NDArray) -> Vertex:
return Vertex(alpha, self.dim1.subject, vmin=0, vmax=1)
@property
def vertices(self):
return self.raw.vertices
def _warn_non_perceptually_uniform_colormap(cmap):
mapping = {
"BuOr_2D": "PU_BuOr_covar",
"RdBu_covar": "PU_RdBu_covar",
"RdBu_covar2": "PU_BuOr_covar",
"RdBu_covar_alpha": "PU_RdBu_covar_alpha",
"RdGn_covar": "PU_RdGn_covar",
"hot_alpha": "fire_alpha",
}
if cmap in mapping:
warnings.warn("Colormap %r is not perceptually uniform. Consider using"
" %r instead." % (cmap, mapping[cmap]), UserWarning)