Source code for ClearMap.Visualization.Qt.widgets

import re

import cached_property
import numpy as np
import pandas as pd

from matplotlib.colors import to_hex
from PyQt5.QtGui import QColor
import pyqtgraph as pg

from ClearMap.gui.gui_utils_images import pseudo_random_rgb_array


[docs] def is_valid_hex_color(s): """ To check if the input is a valid hex color triplet despite the lack of # at the start """ return bool(re.fullmatch(r'(?:#[0-9a-fA-F]{6}|[0-9a-fA-F]{6})', s))
[docs] class Scatter3D: """ Scatter dataset for display in :class:`DataViewer`. Markers whose coordinate along the scroll axis matches the current slice are drawn at full size. When *z_radius* is set, markers from neighbouring slices are also drawn at reduced size to convey depth. Parameters ---------- coordinates : (N, 3) np.ndarray or pd.DataFrame Point positions, or a pre-built DataFrame with columns ``x``, ``y``, ``z``, ``colour``, ``symbol`` (and optionally ``pen`` / ``brush``). Passing a DataFrame skips the construction logic entirely. smarties : bool Assign a pseudo-random colour to every point (ignored when *colors* is provided). colors : array-like or None Per-point colours as hex strings or RGB(A) arrays. Converted to hex internally. hemispheres : array-like or None Integer label per point that controls the marker symbol. -1 is reserved for out-of-hemisphere points, drawn with ``out_of_bounds_symbol``. z_radius : int or None Half-width of the depth window in slices. Points within ±*z_radius* of the current slice are drawn at a size proportional to their proximity. ``None`` / 0 disables depth display. marker_size : int Base marker diameter in display pixels at the current slice (minimum 2). """ def __init__(self, coordinates, smarties=False, colors=None, hemispheres=None, z_radius=None, marker_size=5): self.__coordinates = None self.__has_hemispheres = hemispheres is not None # FIXME: this should be renamed to has_different_symbols self.z_radius = z_radius self.axis = 2 self.marker_size = max(2, marker_size) self.out_of_bounds_symbol = 'x' # Symbol to use for out of bounds markers if isinstance(coordinates, pd.DataFrame): self.data = coordinates self.symbols = self.data['symbol'].unique().tolist() self.__has_colours = self.data['colour'].nunique() > 1 self.__has_hemispheres = len(self.symbols) > 1 else: self.symbols = ['+', 'p'] if smarties and colors is None: n_samples = coordinates.shape[0] colors = pseudo_random_rgb_array(n_samples) if colors is not None and not is_valid_hex_color(str(colors[0])): # Convert to hex if not yet if not smarties: colors_dict = {tuple(col): to_hex(col) for col in np.unique(colors, axis=0)} colors_dict[None] = to_hex((1, 0, 0)) # default to red colors = [colors_dict[col] for col in colors] else: colors = [to_hex((1, 0, 0) if c is None else c) for c in colors] self.__has_colours = colors is not None if hemispheres is not None: hemispheres_values = np.unique(hemispheres) if -1 in hemispheres_values: # If there are values outside of the hemispheres symbols = [self.out_of_bounds_symbol] + self.symbols else: symbols = self.symbols self.symbol_map = {id_: symbols[i] for i, id_ in enumerate(hemispheres_values)} # colors = colors if colors is None else np.array([QColor( * col.astype(int)) for col in colors] self.data = pd.DataFrame({ 'x': coordinates[:, 0], 'y': coordinates[:, 1], 'z': coordinates[:, 2], 'hemisphere': hemispheres, 'colour': colors }) # TODO: could use id instead of colour self.data['symbol'] = self.data['hemisphere'].map(self.symbol_map) if self.__has_hemispheres else self.symbols[0] self.data['colour'] = self.data['colour'].astype(str) if self.__has_colours and not 'pen' in self.data.columns: # Finalise DF if colors is None: colors = self.data['colour'].values if colors is not None: unique_colors = np.unique(colors) self.point_map = pd.DataFrame({ 'colour': unique_colors, 'pen': [pg.mkPen(c) for c in unique_colors], 'brush': [pg.mkBrush(c) for c in unique_colors] }) self.data['pen'] = self.data['colour'].map(dict(self.point_map[['colour', 'pen']].values)) self.data['brush'] = self.data['colour'].map(dict(self.point_map[['colour', 'brush']].values)) @property def coordinates(self): if self.__coordinates is None: self.__coordinates = self.data[['x', 'y', 'z']].values return self.__coordinates @property def plane_axes(self): return [a for a in range(3) if a != self.axis]
[docs] def set_data(self, df): # print(self.data['colour'].values, df['colour']) if isinstance(df, dict): df = pd.DataFrame(df) if len(df) and 'colour' in df.columns: sample_colour = df['colour'][0] if isinstance(sample_colour, np.ndarray): # TODO: should be iterable df['colour'] = [to_hex(c) for c in df['colour']] # OPTIMISE: see map elif isinstance(sample_colour, QColor): df['colour'] = [c.name() for c in df['colour']] # OPTIMISE: see map unique_colors = np.unique(df['colour']) self.point_map = pd.DataFrame({ 'colour': unique_colors, 'pen': [pg.mkPen(c) for c in unique_colors], 'brush': [pg.mkBrush(c) for c in unique_colors] }) if set(self.data.columns) >= set(df.columns): for col in set(self.data.columns) - set(df.columns): # Add if missing df[col] = None self.data = df # print(self.data['colour'].values, df['colour'].values) self.__coordinates = None
@property def has_colours(self): return self.__has_colours @property def has_hemispheres(self): return self.__has_hemispheres
[docs] def get_3d_markers(self, main_slice_idx, z_radius=3, base_size=None): """ Collect markers from slices surrounding *main_slice_idx*. Marker size scales linearly with proximity: a point at distance *d* from the main slice gets size ``base_size * (z_radius - d) / z_radius``. Parameters ---------- main_slice_idx : int Index of the currently displayed slice. z_radius : int Half-width of the depth window. Overridden by ``self.z_radius`` when that attribute is set. base_size : int or None Reference size for a marker at distance 0. Pass the current UI spin-box value so surrounding markers scale consistently with the main-slice markers. Defaults to ``self.marker_size``. Returns ------- dict Keys: ``'pos'`` (N×2), ``'size'`` (N,), ``'symbol'`` (N,), and ``'pen'`` (N,) when :attr:`has_colours` is True. Returns empty arrays when *z_radius* is falsy or no points fall in range. """ empty = {'pos': np.empty(0), 'size': np.empty(0), 'symbol': np.empty(0)} if self.has_colours: empty['pen'] = np.empty(0) z_radius = self.z_radius if self.z_radius is not None else z_radius if not z_radius: return empty base_size = base_size if base_size is not None else self.marker_size z = self.coordinates[:, self.axis] mask = (z >= main_slice_idx - z_radius) & (z < main_slice_idx + z_radius) & (z >= 0) if not mask.any(): return empty pos = self.coordinates[mask][:, self.plane_axes] dist_to_main = np.abs(z[mask] - main_slice_idx) sizes = np.round(base_size * ((z_radius - dist_to_main) / z_radius)).astype(int) symbols = (self.data.loc[mask, 'symbol'].values if self.has_hemispheres else np.full(mask.sum(), self.symbols[0])) output = {'pos': pos, 'size': sizes, 'symbol': symbols} if self.has_colours: output['pen'] = self.data.loc[mask, 'pen'].values return output
[docs] def get_draw_params(self, current_slice): indices = self.current_slice_mask(current_slice) if indices is not None: draw_params = { 'pen': self.data.loc[indices, 'pen'].values, 'brush': self.data.loc[indices, 'brush'].values } return draw_params else: return {'pen': np.empty(0), 'brush': np.empty(0)}
[docs] def get_symbols(self, current_slice): if self.has_hemispheres: indices = self.current_slice_mask(current_slice) if indices is not None: return self.data.loc[indices, 'symbol'].values else: return np.array([]) else: return self.symbols[0]
[docs] def get_symbol_sizes(self, main_slice_idx, slice_idx, indices=None, half_size=3): marker_size = round(self.marker_size * ((half_size - abs(main_slice_idx - slice_idx)) / half_size)) n_markers = self.get_n_markers(indices=indices) return np.full(n_markers, marker_size)
[docs] def get_n_markers(self, slice_idx=None, indices=None): if indices is None: indices = self.current_slice_mask(slice_idx) if len(self.data): return np.count_nonzero(indices) else: return 0
[docs] def get_colours(self, current_slice=None, indices=None): if indices is None: indices = self.current_slice_mask(current_slice) if indices is not None: return self.data.loc[indices, 'colour'] else: return np.array([])
[docs] def current_slice_mask(self, current_slice): if len(self.data): return self.coordinates[:, self.axis] == current_slice return np.zeros(len(self.data), dtype=bool)
[docs] def get_pos(self, current_slice=None, indices=None): if indices is None: indices = self.current_slice_mask(current_slice) if indices is not None: return self.coordinates[np.ix_(indices, self.plane_axes)] else: return np.empty((0, 2))