diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py index 6630379be61..9124a680404 100644 --- a/Orange/widgets/visualize/owheatmap.py +++ b/Orange/widgets/visualize/owheatmap.py @@ -1,34 +1,22 @@ import enum -import math -import itertools - +from itertools import islice from typing import ( - Iterable, Mapping, Any, Optional, Union, TypeVar, Type, NamedTuple, - Sequence, Tuple + Iterable, Mapping, Any, TypeVar, Type, NamedTuple, Sequence, Optional, + Union, Tuple, List, Callable ) import numpy as np import scipy.sparse as sp from AnyQt.QtWidgets import ( - QSizePolicy, QGraphicsScene, QGraphicsView, QGraphicsRectItem, - QGraphicsWidget, QGraphicsSimpleTextItem, QGraphicsPixmapItem, - QGraphicsGridLayout, QGraphicsLinearLayout, QGraphicsLayoutItem, - QFormLayout, QApplication, QComboBox, QWIDGETSIZE_MAX -) -from AnyQt.QtGui import ( - QFontMetrics, QPen, QPixmap, QTransform, - QStandardItemModel, QStandardItem, -) -from AnyQt.QtCore import ( - Qt, QSize, QPointF, QSizeF, QRectF, QObject, QEvent, - pyqtSignal as Signal, + QGraphicsScene, QGraphicsView, QFormLayout, QComboBox, QGroupBox, + QMenu, QAction ) -import pyqtgraph as pg +from AnyQt.QtGui import QStandardItemModel, QStandardItem +from AnyQt.QtCore import Qt, QSize, QRectF, QObject -from orangewidget.utils.combobox import ComboBox - -from Orange.data import Domain, Table +from orangewidget.utils.combobox import ComboBox, ComboBoxSearch +from Orange.data import Domain, Table, Variable, DiscreteVariable from Orange.data.sql.table import SqlTable import Orange.distance @@ -36,112 +24,25 @@ from Orange.widgets.utils import colorpalettes from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView -from Orange.widgets.utils.graphicstextlist import scaled, TextListWidget +from Orange.widgets.utils.graphicsview import GraphicsWidgetView +from Orange.widgets.utils.colorpalettes import DiscretePalette + from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets import widget, gui, settings -from Orange.widgets.unsupervised.owhierarchicalclustering import \ - DendrogramWidget -from Orange.widgets.unsupervised.owdistancemap import TextList as TextListWidget -from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Msg, Input, Output +from Orange.widgets.data.oweditdomain import table_column_data +from Orange.widgets.visualize.utils.heatmap import HeatmapGridWidget, ColorMap +from Orange.widgets.utils.widgetpreview import WidgetPreview -def kmeans_compress(X, k=50): - km = kmeans.KMeans(n_clusters=k, n_init=5, random_state=42) - return km.get_model(X) - - -def leaf_indices(tree): - return [leaf.value.index for leaf in hierarchical.leaves(tree)] - - -def levels_with_thresholds(low, high, threshold_low, threshold_high, center_palette): - lt = low + (high - low) * threshold_low - ht = low + (high - low) * threshold_high - if center_palette: - ht = max(abs(lt), abs(ht)) - lt = -max(abs(lt), abs(ht)) - return lt, ht - -# TODO: -# * Richer Tool Tips -# * Color map edit/manage -# * Restore saved row selection (?) -# * 'namespace' use cleanup - -# Heatmap grid description -# ######################## -# -# Heatmaps can be split vertically (by discrete var) and/or horizontaly -# (by suitable variable labels). -# Each vertical split has a title (split variable value) and can -# be sorted/clustred individually. Horizontal splits can also be -# clustered but will share the same cluster) - - -class RowPart(NamedTuple): - """ - A row group - - Attributes - ---------- - title: str - Group title - indices : (N, ) int ndarray | slice - Indexes the input data to retrieve the row subset for the group. - cluster : hierarchical.Tree optional - cluster_ordered : hierarchical.Tree optional - """ - title: str - indices: Sequence[int] - cluster: Optional[hierarchical.Tree] = None - cluster_ordered: Optional[hierarchical.Tree] = None - - @property - def can_cluster(self): - if isinstance(self.indices, slice): - return (self.indices.stop - self.indices.start) > 1 - else: - return len(self.indices) > 1 - - @property - def cluster_ord(self): - return self.cluster_ordered - - -class ColumnPart(NamedTuple): - """ - A column group - - Attributes - ---------- - title : str - Column group title - indices : (N, ) int ndarray | slice - Indexes the input data to retrieve the column subset for the group. - domain : List[Variable] - List of variables in the group. - cluster : hierarchical.Tree optional - cluster_ordered : hierarchical.Tree optional - """ - title: Optional[str] - indices: Sequence[int] - domain: Sequence[Orange.data.Variable] - cluster: Optional[hierarchical.Tree] = None - cluster_ordered: Optional[hierarchical.Tree] = None - - @property - def cluster_ord(self): - return self.cluster_ordered +__all__ = [] -class Parts(NamedTuple): - rows: Sequence[RowPart] #: A list of RowPart descriptors - columns: Sequence[ColumnPart] #: A list of ColumnPart descriptors - span: Tuple[float, float] #: (min, max) global data range - levels = property(lambda self: self.span) +def kmeans_compress(X, k=50): + km = kmeans.KMeans(n_clusters=k, n_init=5, random_state=42) + return km.get_model(X) def cbselect(cb: QComboBox, value, role: Qt.ItemDataRole = Qt.EditRole) -> None: @@ -186,6 +87,17 @@ class Clustering(enum.IntEnum): } ] +ColumnLabelsPosData = [ + {Qt.DisplayRole: name, Qt.UserRole: value} + for name, value in [ + ("None", HeatmapGridWidget.NoPosition), + ("Top", HeatmapGridWidget.PositionTop), + ("Bottom", HeatmapGridWidget.PositionBottom), + ("Top and Bottom", (HeatmapGridWidget.PositionTop | + HeatmapGridWidget.PositionBottom)), + ] +] + def create_list_model( items: Iterable[Mapping[Qt.ItemDataRole, Any]], @@ -215,9 +127,6 @@ def enum_get(etype: Type[E], name: str, default: E) -> E: return default -FLT_MAX = np.finfo(np.float32).max - - class OWHeatMap(widget.OWWidget): name = "Heat Map" description = "Plot a data matrix heatmap." @@ -236,8 +145,6 @@ class Outputs: settingsHandler = settings.DomainContextHandler() - NoPosition, PositionTop, PositionBottom = 0, 1, 2 - # Disable clustering for inputs bigger than this MaxClustering = 25000 # Disable cluster leaf ordering for inputs bigger than this @@ -249,22 +156,24 @@ class Outputs: merge_kmeans = settings.Setting(False) merge_kmeans_k = settings.Setting(50) - # Display stripe with averages - averages = settings.Setting(True) + # Display column with averages + averages: bool = settings.Setting(True) # Display legend - legend = settings.Setting(True) + legend: bool = settings.Setting(True) # Annotations + #: text row annotation (row names) annotation_var = settings.ContextSetting(None) + #: color row annotation + annotation_color_var = settings.ContextSetting(None) # Discrete variable used to split that data/heatmaps (vertically) split_by_var = settings.ContextSetting(None) - # Selected row/column clustering method (name) col_clustering_method: str = settings.Setting(Clustering.None_.name) row_clustering_method: str = settings.Setting(Clustering.None_.name) palette_name = settings.Setting(colorpalettes.DefaultContinuousPaletteName) - column_label_pos = settings.Setting(PositionTop) - selected_rows = settings.Setting(None, schema_only=True) + column_label_pos: int = settings.Setting(1) + selected_rows: List[int] = settings.Setting(None, schema_only=True) auto_commit = settings.Setting(True) @@ -305,13 +214,6 @@ def _(): self.col_clustering_method = self.col_clustering.name self.row_clustering_method = self.row_clustering.name - # set default settings - self.space_x = 3 - - self.colorSettings = None - self.selectedSchemaIndex = 0 - - self.palette = None self.keep_aspect = False #: The original data with all features (retained to @@ -327,7 +229,7 @@ def _(): #: a list (len==k) of int ndarray where the i-th item contains #: the indices which merge the input_data into the heatmap row i self.merge_indices = None - + self.parts: Optional[Parts] = None self.__rows_cache = {} self.__columns_cache = {} @@ -337,14 +239,11 @@ def _(): self.color_cb.currentIndexChanged.connect(self.update_color_schema) colorbox.layout().addWidget(self.color_cb) - # TODO: Add 'Manage/Add/Remove' action. - form = QFormLayout( formAlignment=Qt.AlignLeft, labelAlignment=Qt.AlignLeft, fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow ) - lowslider = gui.hSlider( colorbox, self, "threshold_low", minValue=0.0, maxValue=1.0, step=0.05, ticks=True, intOnly=False, @@ -433,22 +332,44 @@ def _(idx, cb=cb): gui.checkBox(box, self, 'averages', 'Stripes with averages', callback=self.update_averages_stripe) - - annotbox = gui.vBox(box, "Row Annotations", addSpace=False) - annotbox.setFlat(True) + annotbox = QGroupBox("Row Annotations", flat=True) + form = QFormLayout( + annotbox, + formAlignment=Qt.AlignLeft, + labelAlignment=Qt.AlignLeft, + fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow + ) self.annotation_model = DomainModel(placeholder="(None)") - gui.comboBox( - annotbox, self, "annotation_var", contentsLength=12, - model=self.annotation_model, callback=self.update_annotations) + self.annotation_text_cb = ComboBoxSearch( + minimumContentsLength=12, + sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLength + ) + self.annotation_text_cb.setModel(self.annotation_model) + self.annotation_text_cb.activated.connect(self.set_annotation_var) + self.connect_control("annotation_var", self.annotation_var_changed) + self.row_side_color_model = DomainModel( + placeholder="(None)", valid_types=(DiscreteVariable,), + flags=Qt.ItemIsSelectable | Qt.ItemIsEnabled, + parent=self, + ) + self.row_side_color_cb = ComboBoxSearch( + sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLength, + minimumContentsLength=12 + ) + self.row_side_color_cb.setModel(self.row_side_color_model) + self.row_side_color_cb.activated.connect(self.set_annotation_color_var) + self.connect_control("annotation_color_var", self.annotation_color_var_changed) + form.addRow("Text", self.annotation_text_cb) + form.addRow("Color", self.row_side_color_cb) + box.layout().addWidget(annotbox) posbox = gui.vBox(box, "Column Labels Position", addSpace=False) posbox.setFlat(True) - - gui.comboBox( + cb = gui.comboBox( posbox, self, "column_label_pos", - items=["None", "Top", "Bottom", "Top and Bottom"], callback=self.update_column_annotations) - + cb.setModel(create_list_model(ColumnLabelsPosData, parent=self)) + cb.setCurrentIndex(self.column_label_pos) gui.checkBox(self.controlArea, self, "keep_aspect", "Keep aspect ratio", box="Resize", callback=self.__aspect_mode_changed) @@ -457,38 +378,22 @@ def _(idx, cb=cb): gui.auto_send(self.controlArea, self, "auto_commit") # Scene with heatmap - self.heatmap_scene = self.scene = HeatmapScene(parent=self) - self.selection_manager = HeatmapSelectionManager(self) - self.selection_manager.selection_changed.connect( - self.__update_selection_geometry) - self.selection_manager.selection_finished.connect( - self.on_selection_finished) - self.heatmap_scene.set_selection_manager(self.selection_manager) - - item = QGraphicsRectItem(0, 0, 10, 10, None) - self.heatmap_scene.addItem(item) - self.heatmap_scene.itemsBoundingRect() - self.heatmap_scene.removeItem(item) - - self.sceneView = StickyGraphicsView( + class HeatmapScene(QGraphicsScene): + widget: Optional[HeatmapGridWidget] = None + + self.scene = self.scene = HeatmapScene(parent=self) + self.view = GraphicsView( self.scene, verticalScrollBarPolicy=Qt.ScrollBarAlwaysOn, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOn, viewportUpdateMode=QGraphicsView.FullViewportUpdate, + widgetResizable=True, ) - - self.sceneView.viewport().installEventFilter(self) - - self.mainArea.layout().addWidget(self.sceneView) - self.heatmap_scene.widget = None - - self.heatmap_widget_grid = [[]] - self.attr_annotation_widgets = [] - self.attr_dendrogram_widgets = [] - self.gene_annotation_widgets = [] - self.gene_dendrogram_widgets = [] - - self.selection_rects = [] + self.view.setContextMenuPolicy(Qt.CustomContextMenu) + self.view.customContextMenuRequested.connect( + self._on_view_context_menu + ) + self.mainArea.layout().addWidget(self.view) self.selected_rows = [] @property @@ -496,6 +401,16 @@ def center_palette(self): palette = self.color_cb.currentData() return bool(palette.flags & palette.Diverging) + @property + def _column_label_pos(self) -> HeatmapGridWidget.Position: + return ColumnLabelsPosData[self.column_label_pos][Qt.UserRole] + + def annotation_color_var_changed(self, value): + cbselect(self.row_side_color_cb, value, Qt.EditRole) + + def annotation_var_changed(self, value): + cbselect(self.annotation_text_cb, value, Qt.EditRole) + def set_row_clustering(self, method: Clustering) -> None: assert isinstance(method, Clustering) if self.row_clustering != method: @@ -510,12 +425,18 @@ def set_col_clustering(self, method: Clustering) -> None: cbselect(self.col_cluster_cb, method, ClusteringRole) self.__update_column_clustering() - def sizeHint(self): - return QSize(800, 400) + def sizeHint(self) -> QSize: + return super().sizeHint().expandedTo(QSize(900, 700)) def color_palette(self): return self.color_cb.currentData().lookup_table() + def color_map(self) -> ColorMap: + return ColorMap( + self.color_palette(), (self.threshold_low, self.threshold_high), + 0 if self.center_palette else None + ) + def clear(self): self.data = None self.input_data = None @@ -524,8 +445,11 @@ def clear(self): self.merge_indices = None self.annotation_model.set_domain(None) self.annotation_var = None + self.row_side_color_model.set_domain(None) + self.annotation_color_var = None self.row_split_model.set_domain(None) self.split_by_var = None + self.parts = None self.clear_scene() self.selected_rows = [] self.__columns_cache.clear() @@ -533,20 +457,19 @@ def clear(self): self.__update_clustering_enable_state(None) def clear_scene(self): - self.selection_manager.set_heatmap_widgets([[]]) - self.heatmap_scene.clear() - self.heatmap_scene.widget = None - self.heatmap_widget_grid = [[]] - self.col_annotation_widgets = [] - self.col_annotation_widgets_bottom = [] - self.col_annotation_widgets_top = [] - self.row_annotation_widgets = [] - self.col_dendrograms = [] - self.row_dendrograms = [] - self.selection_rects = [] - self.sceneView.setSceneRect(QRectF()) - self.sceneView.setHeaderSceneRect(QRectF()) - self.sceneView.setFooterSceneRect(QRectF()) + if self.scene.widget is not None: + self.scene.widget.layoutDidActivate.disconnect( + self.__on_layout_activate + ) + self.scene.widget.selectionFinished.disconnect( + self.on_selection_finished + ) + self.scene.widget = None + self.scene.clear() + + self.view.setSceneRect(QRectF()) + self.view.setHeaderSceneRect(QRectF()) + self.view.setFooterSceneRect(QRectF()) @Inputs.data def set_dataset(self, data=None): @@ -604,7 +527,9 @@ def set_dataset(self, data=None): if data is not None: self.annotation_model.set_domain(self.input_data.domain) + self.row_side_color_model.set_domain(self.input_data.domain) self.annotation_var = None + self.annotation_color_var = None self.row_split_model.set_domain(data.domain) if data.domain.has_discrete_class: self.split_by_var = data.domain.class_var @@ -616,7 +541,8 @@ def set_dataset(self, data=None): self.update_heatmaps() if data is not None and self.__pending_selection is not None: - self.selection_manager.select_rows(self.__pending_selection) + assert self.scene.widget is not None + self.scene.widget.selectRows(self.__pending_selection) self.selected_rows = self.__pending_selection self.__pending_selection = None @@ -644,11 +570,8 @@ def update_heatmaps(self): elif self.merge_kmeans and len(self.data) < 3: self.Error.not_enough_instances_k_means() else: - self.heatmapparts = self.construct_heatmaps( - self.data, self.split_by_var - ) - self.construct_heatmaps_scene( - self.heatmapparts, self.effective_data) + parts = self.construct_heatmaps(self.data, self.split_by_var) + self.construct_heatmaps_scene(parts, self.effective_data) self.selected_rows = [] else: self.clear() @@ -666,26 +589,26 @@ def _make_parts(self, data, group_var=None): """ if group_var is not None: assert group_var.is_discrete - _col_data, _ = data.get_column_view(group_var) + _col_data = table_column_data(data, group_var) row_indices = [np.flatnonzero(_col_data == i) for i in range(len(group_var.values))] row_groups = [RowPart(title=name, indices=ind, cluster=None, cluster_ordered=None) for name, ind in zip(group_var.values, row_indices)] else: - row_groups = [RowPart(title=None, indices=slice(0, len(data)), + row_groups = [RowPart(title=None, indices=range(0, len(data)), cluster=None, cluster_ordered=None)] col_groups = [ ColumnPart( - title=None, indices=slice(0, len(data.domain.attributes)), + title=None, indices=range(0, len(data.domain.attributes)), domain=data.domain, cluster=None, cluster_ordered=None) ] minv, maxv = np.nanmin(data.X), np.nanmax(data.X) return Parts(row_groups, col_groups, span=(minv, maxv)) - def cluster_rows(self, data: Table, parts: Parts, ordered=False) -> Parts: + def cluster_rows(self, data: Table, parts: 'Parts', ordered=False) -> 'Parts': row_groups = [] for row in parts.rows: if row.cluster is not None: @@ -727,16 +650,15 @@ def cluster_columns(self, data, parts, ordered=False): cluster = col0.cluster else: cluster = None - if col0.cluster_ord is not None: - cluster_ord = col0.cluster_ord + if col0.cluster_ordered is not None: + cluster_ord = col0.cluster_ordered else: cluster_ord = None need_dist = cluster is None or (ordered and cluster_ord is None) - matrix = None if need_dist: data = Orange.distance._preprocess(data) - matrix = Orange.distance.PearsonR(data, axis=0) + matrix = np.asarray(Orange.distance.PearsonR(data, axis=0)) # nan values break clustering below matrix = np.nan_to_num(matrix) @@ -814,7 +736,7 @@ def construct_heatmaps(self, data, group_var=None) -> 'Parts': self.__rows_cache[rows_cache_key] = parts return parts - def construct_heatmaps_scene(self, parts: Parts, data: Table) -> None: + def construct_heatmaps_scene(self, parts: 'Parts', data: Table) -> None: _T = TypeVar("_T", bound=Union[RowPart, ColumnPart]) def select_cluster(clustering: Clustering, item: _T) -> _T: @@ -831,359 +753,69 @@ def select_cluster(clustering: Clustering, item: _T) -> _T: for rowitem in parts.rows] cols = [select_cluster(self.col_clustering, colitem) for colitem in parts.columns] - parts = Parts(columns=cols, rows=rows, span=parts.levels) + parts = Parts(columns=cols, rows=rows, span=parts.span) self.setup_scene(parts, data) def setup_scene(self, parts, data): - # parts = * a list of row descriptors (title, indices, cluster,) - # * a list of col descriptors (title, indices, cluster, domain) - self.heatmap_scene.clear() - # The top level container widget - widget = GraphicsWidget() - widget.layoutDidActivate.connect(self.__on_layout_activate) - - grid = QGraphicsGridLayout() - grid.setSpacing(self.space_x) - self.heatmap_scene.addItem(widget) - - N, M = len(parts.rows), len(parts.columns) - - # Start row/column where the heatmap items are inserted - # (after the titles/legends/dendrograms) - Row0 = 3 - Col0 = 3 - LegendRow = 0 - # The column for the vertical dendrogram - DendrogramColumn = 1 - # The row for the horizontal dendrograms - DendrogramRow = 1 - RightLabelColumn = Col0 + M - TopLabelsRow = 2 - BottomLabelsRow = Row0 + N - GroupTitleColumn = 0 - - widget.setLayout(grid) - - palette = self.color_palette() - - sort_i = [] - sort_j = [] - - column_dendrograms = [None] * M - row_dendrograms = [None] * N - - for i, rowitem in enumerate(parts.rows): - if rowitem.title: - title = QGraphicsSimpleTextItem(rowitem.title, widget) - item = GraphicsSimpleTextLayoutItem(title, orientation=Qt.Vertical, parent=grid) - item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Maximum) - grid.addItem(item, Row0 + i, GroupTitleColumn, alignment=Qt.AlignCenter) - - if rowitem.cluster: - dendrogram = DendrogramWidget( - parent=widget, - selectionMode=DendrogramWidget.NoSelection, - hoverHighlightEnabled=True) - dendrogram.set_root(rowitem.cluster) - dendrogram.setMaximumWidth(100) - dendrogram.setMinimumWidth(100) - # Ignore dendrogram vertical size hint (heatmap's size - # should define the row's vertical size). - dendrogram.setSizePolicy( - QSizePolicy.Expanding, QSizePolicy.Ignored) - dendrogram.itemClicked.connect( - lambda item, partindex=i: - self.__select_by_cluster(item, partindex) - ) - - grid.addItem(dendrogram, Row0 + i, DendrogramColumn) - sort_i.append(np.array(leaf_indices(rowitem.cluster))) - row_dendrograms[i] = dendrogram - else: - sort_i.append(None) - - for j, colitem in enumerate(parts.columns): - if colitem.title: - title = QGraphicsSimpleTextItem(colitem.title, widget) - item = GraphicsSimpleTextLayoutItem(title, parent=grid) - grid.addItem(item, 1, Col0 + j) - - if colitem.cluster: - dendrogram = DendrogramWidget( - parent=widget, - orientation=DendrogramWidget.Top, - selectionMode=DendrogramWidget.NoSelection, - hoverHighlightEnabled=False) - - dendrogram.set_root(colitem.cluster) - dendrogram.setMaximumHeight(100) - dendrogram.setMinimumHeight(100) - # Ignore dendrogram horizontal size hint (heatmap's width - # should define the column width). - dendrogram.setSizePolicy( - QSizePolicy.Ignored, QSizePolicy.Expanding) - grid.addItem(dendrogram, DendrogramRow, Col0 + j) - sort_j.append(np.array(leaf_indices(colitem.cluster))) - column_dendrograms[j] = dendrogram - else: - sort_j.append(None) - - heatmap_widgets = [] - for i in range(N): - heatmap_row = [] - for j in range(M): - row_ix = parts.rows[i].indices - col_ix = parts.columns[j].indices - hw = GraphicsHeatmapWidget(parent=widget) - X_part = data[row_ix, col_ix].X - - if sort_i[i] is not None: - X_part = X_part[sort_i[i]] - if sort_j[j] is not None: - X_part = X_part[:, sort_j[j]] - - hw.set_levels(parts.levels) - hw.set_thresholds(self.threshold_low, self.threshold_high) - hw.set_color_table(palette, self.center_palette) - hw.set_show_averages(self.averages) - hw.set_heatmap_data(X_part) - - grid.addItem(hw, Row0 + i, Col0 + j) - grid.setRowStretchFactor(Row0 + i, X_part.shape[0] * 100) - heatmap_row.append(hw) - heatmap_widgets.append(heatmap_row) - - row_annotation_widgets = [] - col_annotation_widgets = [] - col_annotation_widgets_top = [] - col_annotation_widgets_bottom = [] - - for i, rowitem in enumerate(parts.rows): - if isinstance(rowitem.indices, slice): - indices = np.array( - range(*rowitem.indices.indices(data.X.shape[0]))) - else: - indices = rowitem.indices - if sort_i[i] is not None: - indices = indices[sort_i[i]] - - labels = [str(i) for i in indices] - - labelslist = TextListWidget( - items=labels, parent=widget, orientation=Qt.Vertical) - - labelslist._indices = indices - labelslist.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - labelslist.setContentsMargins(0.0, 0.0, 0.0, 0.0) - labelslist.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - - grid.addItem(labelslist, Row0 + i, RightLabelColumn) - grid.setAlignment(labelslist, Qt.AlignLeft) - row_annotation_widgets.append(labelslist) - - for j, colitem in enumerate(parts.columns): - # Top attr annotations - if isinstance(colitem.indices, slice): - indices = np.array( - range(*colitem.indices.indices(data.X.shape[1]))) - else: - indices = colitem.indices - if sort_j[j] is not None: - indices = indices[sort_j[j]] - - labels = [data.domain[i].name for i in indices] - - labelslist = TextListWidget( - items=labels, parent=widget, orientation=Qt.Horizontal) - labelslist.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - labelslist._indices = indices - - labelslist.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - grid.addItem(labelslist, TopLabelsRow, Col0 + j, - Qt.AlignBottom | Qt.AlignLeft) - col_annotation_widgets.append(labelslist) - col_annotation_widgets_top.append(labelslist) - - # Bottom attr annotations - labelslist = TextListWidget( - items=labels, parent=widget, orientation=Qt.Horizontal) - labelslist.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - labelslist.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - grid.addItem(labelslist, BottomLabelsRow, Col0 + j) - col_annotation_widgets.append(labelslist) - col_annotation_widgets_bottom.append(labelslist) - - legend = GradientLegendWidget( - parts.levels[0], parts.levels[1], self.threshold_low, self.threshold_high, - parent=widget) - - legend.set_color_table(palette, self.center_palette) - legend.setMinimumSize(QSizeF(100, 20)) - legend.setVisible(self.legend) - - grid.addItem(legend, LegendRow, Col0) - - self.heatmap_scene.widget = widget - self.heatmap_widget_grid = heatmap_widgets - self.selection_manager.set_heatmap_widgets(heatmap_widgets) + # type: (Parts, Table) -> None + widget = HeatmapGridWidget() + widget.setColorMap(self.color_map()) + self.scene.addItem(widget) + self.scene.widget = widget + columns = [v.name for v in data.domain.attributes] + parts = HeatmapGridWidget.Parts( + rows=[ + HeatmapGridWidget.RowItem(r.title, r.indices, r.cluster) + for r in parts.rows + ], + columns=[ + HeatmapGridWidget.ColumnItem(c.title, c.indices, c.cluster) + for c in parts.columns + ], + data=data.X, + span=parts.span, + row_names=None, + col_names=columns, + ) + widget.setHeatmaps(parts) + side = self.row_side_colors() + if side is not None: + widget.setRowSideColorAnnotations(side[0], name=side[1].name) + widget.setColumnLabelsPosition(self._column_label_pos) + widget.setAspectRatioMode( + Qt.KeepAspectRatio if self.keep_aspect else Qt.IgnoreAspectRatio + ) + widget.setShowAverages(self.averages) + widget.setLegendVisible(self.legend) - self.row_annotation_widgets = row_annotation_widgets - self.col_annotation_widgets = col_annotation_widgets - self.col_annotation_widgets_top = col_annotation_widgets_top - self.col_annotation_widgets_bottom = col_annotation_widgets_bottom - self.col_dendrograms = column_dendrograms - self.row_dendrograms = row_dendrograms + widget.layoutDidActivate.connect(self.__on_layout_activate) + widget.selectionFinished.connect(self.on_selection_finished) self.update_annotations() - self.update_column_annotations() - - self.__update_size_constraints() - - def __update_size_constraints(self): - if self.heatmap_scene.widget is not None: - mode = Qt.KeepAspectRatio if self.keep_aspect \ - else Qt.IgnoreAspectRatio - # get the preferred size from the view (view size - space for - # scrollbars) - view = self.sceneView - size = view.size() - fw = view.frameWidth() - vsb_extent = view.verticalScrollBar().sizeHint().width() - hsb_extent = view.horizontalScrollBar().sizeHint().height() - size = QSizeF(max(size.width() - 2 * fw - vsb_extent, 0), - max(size.height() - 2 * fw - hsb_extent, 0)) - widget = self.heatmap_scene.widget - layout = widget.layout() - if mode == Qt.IgnoreAspectRatio: - # Reset the row height constraints ... - for i, hm_row in enumerate(self.heatmap_widget_grid): - layout.setRowMaximumHeight(3 + i, FLT_MAX) - # ... and resize to match the viewport, taking the minimum size - # into account - minsize = widget.minimumSize() - size = size.expandedTo(minsize) - preferred = widget.effectiveSizeHint(Qt.PreferredSize) - widget.resize(preferred.boundedTo(size)) - else: - # First set/update the widget's width (the layout will - # distribute the available width to heatmap widgets in - # the grid) - minsize = widget.minimumSize() - preferred = widget.effectiveSizeHint(Qt.PreferredSize) - - if preferred.width() < size.expandedTo(minsize).width(): - size = preferred - - widget.resize(size.expandedTo(minsize).width(), - widget.size().height()) - # calculate and set the heatmap row's heights based on - # the width - for i, hm_row in enumerate(self.heatmap_widget_grid): - heights = [] - for hm in hm_row: - hm_size = QSizeF(hm.heatmap_item.pixmap().size()) - hm_size = scaled( - hm_size, QSizeF(hm.size().width(), -1), - Qt.KeepAspectRatioByExpanding) - - heights.append(hm_size.height()) - layout.setRowMaximumHeight(3 + i, max(heights)) - layout.setRowPreferredHeight(3 + i, max(heights)) - - # set/update the widget's height - constraint = QSizeF(size.width(), -1) - sh = widget.effectiveSizeHint(Qt.PreferredSize, constraint) - minsize = widget.effectiveSizeHint(Qt.MinimumSize, constraint) - sh = sh.expandedTo(minsize).expandedTo(widget.minimumSize()) - -# print("Resize 2", sh) -# print(" old:", widget.size().width(), widget.size().height()) -# print(" new:", widget.size().width(), sh.height()) - - widget.resize(sh) -# print("Did resize") - self.__fixup_grid_layout() - - def __fixup_grid_layout(self): - self.__update_margins() - self.__update_scene_rects() - self.__update_selection_geometry() + self.view.setCentralWidget(widget) + self.parts = parts def __update_scene_rects(self): - rect = self.scene.widget.geometry() - self.heatmap_scene.setSceneRect(rect) - - spacing = self.scene.widget.layout().rowSpacing(2) - headerrect = QRectF(rect) - headerrect.setBottom( - max((w.geometry().bottom() - for w in (self.col_annotation_widgets_top + - self.col_dendrograms) - if w is not None and w.isVisible()), - default=rect.top()) - ) - - if not headerrect.isEmpty(): - headerrect = headerrect.adjusted(0, 0, 0, spacing / 2) - - footerrect = QRectF(rect) - footerrect.setTop( - min((w.geometry().top() for w in self.col_annotation_widgets_bottom - if w is not None and w.isVisible()), - default=rect.bottom()) - ) - if not footerrect.isEmpty(): - footerrect = footerrect.adjusted(0, - spacing / 2, 0, 0) - - self.sceneView.setSceneRect(rect) - self.sceneView.setHeaderSceneRect(headerrect) - self.sceneView.setFooterSceneRect(footerrect) + widget = self.scene.widget + if widget is None: + return + rect = widget.geometry() + self.scene.setSceneRect(rect) + self.view.setSceneRect(rect) + self.view.setHeaderSceneRect(widget.headerGeometry()) + self.view.setFooterSceneRect(widget.footerGeometry()) def __on_layout_activate(self): self.__update_scene_rects() - self.__update_selection_geometry() def __aspect_mode_changed(self): - self.__update_size_constraints() - - def eventFilter(self, reciever, event): - if reciever is self.sceneView.viewport() and \ - event.type() == QEvent.Resize: - self.__update_size_constraints() - - return super().eventFilter(reciever, event) - - def __update_margins(self): - """ - Update horizontal dendrogram and text list widgets margins to - include the space for average stripe. - """ - def offset(hm): - if hm.show_averages: - return hm.averages_item.size().width() - else: - return 0 - - hm_row = self.heatmap_widget_grid[0] - dendrogram_col = self.col_dendrograms - - col_annot = zip(self.col_annotation_widgets_top, - self.col_annotation_widgets_bottom) - - for hm, annot, dendrogram in zip(hm_row, col_annot, dendrogram_col): - left_offset = offset(hm) - if dendrogram is not None: - _, top, right, bottom = dendrogram.getContentsMargins() - dendrogram.setContentsMargins( - left_offset, top, right, bottom) - - _, top, right, bottom = annot[0].getContentsMargins() - annot[0].setContentsMargins(left_offset, top, right, bottom) - _, top, right, bottom = annot[1].getContentsMargins() - annot[1].setContentsMargins(left_offset, top, right, bottom) + widget = self.scene.widget + if widget is None: + return + widget.setAspectRatioMode( + Qt.KeepAspectRatio if self.keep_aspect else Qt.IgnoreAspectRatio + ) def __update_clustering_enable_state(self, data): if data is not None: @@ -1239,50 +871,12 @@ def setenabled(cb: QComboBox, clu: bool, clu_op: bool): setenabled(self.row_cluster_cb, rc_enabled, rco_enabled) setenabled(self.col_cluster_cb, cc_enabled, cco_enabled) - def heatmap_widgets(self): - """Iterate over heatmap widgets. - """ - for item in self.heatmap_scene.items(): - if isinstance(item, GraphicsHeatmapWidget): - yield item - - def label_widgets(self): - """Iterate over GraphicsSimpleTextList widgets. - """ - for item in self.heatmap_scene.items(): - if isinstance(item, TextListWidget): - yield item - - def dendrogram_widgets(self): - """Iterate over dendrogram widgets - """ - for item in self.heatmap_scene.items(): - if isinstance(item, DendrogramWidget): - yield item - - def legend_widgets(self): - for item in self.heatmap_scene.items(): - if isinstance(item, GradientLegendWidget): - yield item - def update_averages_stripe(self): """Update the visibility of the averages stripe. """ - if self.effective_data is not None: - for widget in self.heatmap_widgets(): - widget.set_show_averages(self.averages) - widget.layout().activate() - - self.scene.widget.layout().activate() - self.__fixup_grid_layout() - - def update_grid_spacing(self): - """Update layout spacing. - """ - if self.scene.widget: - layout = self.scene.widget.layout() - layout.setSpacing(self.space_x) - self.__fixup_grid_layout() + widget = self.scene.widget + if widget is not None: + widget.setShowAverages(self.averages) def update_lowslider(self): low, high = self.controls.threshold_low, self.controls.threshold_high @@ -1298,14 +892,9 @@ def update_highslider(self): def update_color_schema(self): self.palette_name = self.color_cb.currentData().name - palette = self.color_palette() - for heatmap in self.heatmap_widgets(): - heatmap.set_thresholds(self.threshold_low, self.threshold_high) - heatmap.set_color_table(palette, self.center_palette) - - for legend in self.legend_widgets(): - legend.set_thresholds(self.threshold_low, self.threshold_high) - legend.set_color_table(palette, self.center_palette) + w = self.scene.widget + if w is not None: + w.setColorMap(self.color_map()) def __update_column_clustering(self): self.update_heatmaps() @@ -1316,89 +905,117 @@ def __update_row_clustering(self): self.commit() def update_legend(self): - for item in self.heatmap_scene.items(): - if isinstance(item, GradientLegendWidget): - item.setVisible(self.legend) + widget = self.scene.widget + if widget is not None: + widget.setLegendVisible(self.legend) + + def row_annotation_var(self): + return self.annotation_var + + def row_annotation_data(self): + var = self.row_annotation_var() + if var is None: + return None + return column_str_from_table(self.input_data, var) + + def _merge_row_indices(self): + if self.merge_kmeans and self.kmeans_model is not None: + return self.merge_indices + else: + return None + + def set_annotation_var(self, var: Union[None, Variable, int]): + if isinstance(var, int): + var = self.annotation_model[var] + if self.annotation_var != var: + self.annotation_var = var + self.update_annotations() def update_annotations(self): - if self.input_data is not None: - var = self.annotation_var - show = var is not None - if show: - annot_col, _ = self.input_data.get_column_view(var) + widget = self.scene.widget + if widget is not None: + annot_col = self.row_annotation_data() + merge_indices = self._merge_row_indices() + if merge_indices is not None and annot_col is not None: + join = lambda _1: join_elided(", ", 42, _1, " ({} more)") + annot_col = aggregate_apply(join, annot_col, merge_indices) + if annot_col is not None: + widget.setRowLabels(annot_col) + widget.setRowLabelsVisible(True) else: - annot_col = None + widget.setRowLabelsVisible(False) + widget.setRowLabels(None) - if self.merge_kmeans and self.kmeans_model is not None: - merge_indices = self.merge_indices - else: - merge_indices = None - - for labelslist in self.row_annotation_widgets: - labelslist.setVisible(bool(show)) - if show: - indices = labelslist._indices - if merge_indices is not None: - join = lambda values: ( - join_ellided(", ", 42, values, " ({} more)") - ) - # collect all original labels for every merged row - values = [annot_col[merge_indices[i]] for i in indices] - labels = [join(list(map(var.str_val, vals))) - for vals in values] - else: - data = annot_col[indices] - labels = [var.str_val(val) for val in data] - - labelslist.setItems(labels) + def row_side_colors(self): + var = self.annotation_color_var + if var is None: + return None + assert var.is_discrete + column_data = column_data_from_table(self.input_data, var) + merges = self._merge_row_indices() + if merges is not None: + column_data = aggregate(var, column_data, merges) + colors = self._colorize(var, column_data) + return colors, var + + def set_annotation_color_var(self, var: Union[None, Variable, int]): + """Set the current side color annotation variable.""" + if isinstance(var, int): + var = self.row_side_color_model[var] + if self.annotation_color_var != var: + self.annotation_color_var = var + self.update_row_side_colors() + + def update_row_side_colors(self): + widget = self.scene.widget + if widget is None: + return + colors = self.row_side_colors() + if colors is None: + widget.setRowSideColorAnnotations(None) + else: + widget.setRowSideColorAnnotations(colors[0], colors[1].name) + + def _colorize(self, var: DiscreteVariable, data: np.ndarray) -> np.ndarray: + palette = var.palette # type: DiscretePalette + colors = np.array( + [[c.red(), c.green(), c.blue()] + for c in palette.qcolors_w_nan], + dtype=np.uint8, + ) + mask = np.isnan(data) + data[mask] = -1 + return colors[data.astype(int)] def update_column_annotations(self): - if self.data is not None: - show_top = self.column_label_pos & OWHeatMap.PositionTop - show_bottom = self.column_label_pos & OWHeatMap.PositionBottom - - for labelslist in self.col_annotation_widgets_top: - labelslist.setVisible(show_top) - for labelslist in self.col_annotation_widgets_bottom: - labelslist.setVisible(show_bottom) - self.__fixup_grid_layout() - - def __select_by_cluster(self, item, dendrogramindex): - # User clicked on a dendrogram node. - # Select all rows corresponding to the cluster item. - node = item.node - try: - hm = self.heatmap_widget_grid[dendrogramindex][0] - except IndexError: - pass - else: - key = QApplication.keyboardModifiers() - clear = not (key & ((Qt.ControlModifier | Qt.ShiftModifier | - Qt.AltModifier))) - remove = (key & (Qt.ControlModifier | Qt.AltModifier)) - append = (key & Qt.ControlModifier) - self.selection_manager.selection_add( - node.value.first, node.value.last - 1, hm, - clear=clear, remove=remove, append=append) - - def __update_selection_geometry(self): - for item in self.selection_rects: - item.setParentItem(None) - self.heatmap_scene.removeItem(item) - - self.selection_rects = [] - self.selection_manager.update_selection_rects() - rects = self.selection_manager.selection_rects - for rect in rects: - item = QGraphicsRectItem(rect, None) - pen = QPen(Qt.black, 2) - pen.setCosmetic(True) - item.setPen(pen) - self.heatmap_scene.addItem(item) - self.selection_rects.append(item) + widget = self.scene.widget + if self.data is not None and widget is not None: + widget.setColumnLabelsPosition(self._column_label_pos) + + def _on_view_context_menu(self, pos): + widget = self.scene.widget + if widget is None: + return + assert isinstance(widget, HeatmapGridWidget) + menu = QMenu(self.view.viewport()) + menu.setAttribute(Qt.WA_DeleteOnClose) + menu.addActions(self.view.actions()) + menu.addSeparator() + a = QAction("Keep aspect ratio", menu, checkable=True) + a.setChecked(self.keep_aspect) + + @a.toggled.connect + def ontoggled(state): + self.keep_aspect = state + self.__aspect_mode_changed() + menu.addAction(a) + menu.popup(self.view.viewport().mapToGlobal(pos)) def on_selection_finished(self): - self.selected_rows = self.selection_manager.selections + if self.scene.widget is not None: + self.selected_rows = list(self.scene.widget.selectedRows()) + else: + self.selected_rows = [] self.commit() def commit(self): @@ -1410,10 +1027,7 @@ def commit(self): merge_indices = None if self.input_data is not None and self.selected_rows: - sortind = np.hstack([labels._indices - for labels in self.row_annotation_widgets]) - indices = sortind[self.selected_rows] - + indices = self.selected_rows if merge_indices is not None: # expand merged indices indices = np.hstack([merge_indices[i] for i in indices]) @@ -1443,765 +1057,157 @@ def migrate_settings(cls, settings, version): if version is not None and version < 3: def st2cl(state: bool) -> Clustering: return Clustering.OrderedClustering if state else \ - Clustering.None_ + Clustering.None_ + rc = settings.pop("row_clustering", False) cc = settings.pop("col_clustering", False) settings["row_clustering_method"] = st2cl(rc).name settings["col_clustering_method"] = st2cl(cc).name -class GraphicsWidget(QGraphicsWidget): - """A graphics widget which can notify on relayout events. - """ - #: The widget's layout has activated (i.e. did a relayout - #: of the widget's contents) - layoutDidActivate = Signal() - - def event(self, event): - rval = super().event(event) - if event.type() == QEvent.LayoutRequest and self.layout() is not None: - self.layoutDidActivate.emit() - return rval - - -class GraphicsPixmapWidget(QGraphicsWidget): - def __init__(self, parent=None, pixmap=None, scaleContents=False, - aspectMode=Qt.KeepAspectRatio, **kwargs): - super().__init__(parent) - self.setContentsMargins(0, 0, 0, 0) - self.__scaleContents = scaleContents - self.__aspectMode = aspectMode - - self.__pixmap = pixmap or QPixmap() - self.__item = QGraphicsPixmapItem(self.__pixmap, self) - self.__updateScale() - - def setPixmap(self, pixmap): - self.prepareGeometryChange() - self.__pixmap = pixmap or QPixmap() - self.__item.setPixmap(self.__pixmap) - self.updateGeometry() - - def pixmap(self): - return self.__pixmap - - def setAspectRatioMode(self, mode): - if self.__aspectMode != mode: - self.__aspectMode = mode - - def aspectRatioMode(self): - return self.__aspectMode - - def setScaleContents(self, scale): - if self.__scaleContents != scale: - self.__scaleContents = bool(scale) - self.updateGeometry() - self.__updateScale() - - def scaleContents(self): - return self.__scaleContents - - def sizeHint(self, which, constraint=QSizeF()): - if which == Qt.PreferredSize: - sh = QSizeF(self.__pixmap.size()) - if self.__scaleContents: - sh = scaled(sh, constraint, self.__aspectMode) - return sh - elif which == Qt.MinimumSize: - if self.__scaleContents: - return QSizeF(0, 0) - else: - return QSizeF(self.__pixmap.size()) - elif which == Qt.MaximumSize: - if self.__scaleContents: - return QSizeF() - else: - return QSizeF(self.__pixmap.size()) - else: - # Qt.MinimumDescent - return QSizeF() - - def setGeometry(self, rect): - super().setGeometry(rect) - crect = self.contentsRect() - self.__item.setPos(crect.topLeft()) - self.__updateScale() - - def __updateScale(self): - if self.__pixmap.isNull(): - return - pxsize = QSizeF(self.__pixmap.size()) - crect = self.contentsRect() - self.__item.setPos(crect.topLeft()) - - if self.__scaleContents: - csize = scaled(pxsize, crect.size(), self.__aspectMode) - else: - csize = pxsize - - xscale = csize.width() / pxsize.width() - yscale = csize.height() / pxsize.height() - - t = QTransform().scale(xscale, yscale) - self.__item.setTransform(t) - - def pixmapTransform(self): - return QTransform(self.__item.transform()) - - -class GraphicsHeatmapWidget(QGraphicsWidget): - def __init__(self, parent=None, data=None, **kwargs): - super().__init__(parent, **kwargs) - self.setAcceptHoverEvents(True) - - self.__levels = None - self.__threshold_low, self.__threshold_high = 0., 1. - self.__center_palette = False - self.__colortable = None - self.__data = data - - self.__pixmap = QPixmap() - self.__avgpixmap = QPixmap() - - layout = QGraphicsLinearLayout(Qt.Horizontal) - layout.setContentsMargins(0, 0, 0, 0) - self.heatmap_item = GraphicsPixmapWidget( - self, scaleContents=True, aspectMode=Qt.IgnoreAspectRatio) - - self.averages_item = GraphicsPixmapWidget( - self, scaleContents=True, aspectMode=Qt.IgnoreAspectRatio) - - layout.addItem(self.averages_item) - layout.addItem(self.heatmap_item) - layout.setItemSpacing(0, 2) - - self.setLayout(layout) - - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - - self.show_averages = True - - self.set_heatmap_data(data) - - def clear(self): - """Clear/reset the widget.""" - self.__data = None - self.__pixmap = None - self.__avgpixmap = None - - self.heatmap_item.setPixmap(QPixmap()) - self.averages_item.setPixmap(QPixmap()) - self.show_averages = True - self.updateGeometry() - self.layout().invalidate() - - def set_heatmap(self, heatmap): - """Set the heatmap data for display. - """ - self.clear() - - self.set_heatmap_data(heatmap) - self.update() - - def set_heatmap_data(self, data): - """Set the heatmap data for display.""" - if self.__data is not data: - self.clear() - self.__data = data - self._update_pixmap() - self.update() - - def heatmap_data(self): - if self.__data is not None: - v = self.__data.view() - v.flags.writeable = False - return v - else: - return None - - def set_levels(self, levels): - if levels != self.__levels: - self.__levels = levels - self._update_pixmap() - self.update() - - def set_show_averages(self, show): - if self.show_averages != show: - self.show_averages = show - self.averages_item.setVisible(show) - self.averages_item.setMaximumWidth(-1 if show else 0) - self.layout().invalidate() - self.update() - - def set_color_table(self, table, center): - self.__colortable = table - self.__center_palette = center - self._update_pixmap() - self.update() - - def set_thresholds(self, threshold_low, threshold_high): - self.__threshold_low = threshold_low - self.__threshold_high = threshold_high - self._update_pixmap() - self.update() - - def _update_pixmap(self): - """ - Update the pixmap if its construction arguments changed. - """ - if self.__data is not None: - if self.__colortable is not None: - lut = self.__colortable - else: - lut = None - - ll, lh = self.__levels - ll, lh = levels_with_thresholds(ll, lh, self.__threshold_low, self.__threshold_high, - self.__center_palette) - - argb, _ = pg.makeARGB( - self.__data, lut=lut, levels=(ll, lh)) - argb[np.isnan(self.__data)] = (100, 100, 100, 255) - - qimage = pg.makeQImage(argb, transpose=False) - self.__pixmap = QPixmap.fromImage(qimage) - avg = np.nanmean(self.__data, axis=1, keepdims=True) - argb, _ = pg.makeARGB( - avg, lut=lut, levels=(ll, lh)) - qimage = pg.makeQImage(argb, transpose=False) - self.__avgpixmap = QPixmap.fromImage(qimage) - else: - self.__pixmap = QPixmap() - self.__avgpixmap = QPixmap() - - self.heatmap_item.setPixmap(self.__pixmap) - self.averages_item.setPixmap(self.__avgpixmap) - hmsize = QSizeF(self.__pixmap.size()) - avsize = QSizeF(self.__avgpixmap.size()) - - self.heatmap_item.setMinimumSize(hmsize) - self.averages_item.setMinimumSize(avsize) - size = QFontMetrics(self.font()).lineSpacing() - self.heatmap_item.setPreferredSize(hmsize * size) - self.averages_item.setPreferredSize(avsize * size) - self.layout().invalidate() - - def cell_at(self, pos): - """Return the cell row, column from `pos` in local coordinates. - """ - if self.__pixmap.isNull() or not ( - self.heatmap_item.geometry().contains(pos) or - self.averages_item.geometry().contains(pos)): - return (-1, -1) - - if self.heatmap_item.geometry().contains(pos): - item_clicked = self.heatmap_item - elif self.averages_item.geometry().contains(pos): - item_clicked = self.averages_item - pos = self.mapToItem(item_clicked, pos) - size = self.heatmap_item.size() - - x, y = pos.x(), pos.y() - - N, M = self.__data.shape - fx = x / size.width() - fy = y / size.height() - i = min(int(math.floor(fy * N)), N - 1) - j = min(int(math.floor(fx * M)), M - 1) - return i, j - - def cell_rect(self, row, column): - """Return a rectangle in local coordinates containing the cell - at `row` and `column`. - """ - size = self.__pixmap.size() - if not (0 <= column < size.width() or 0 <= row < size.height()): - return QRectF() - - topleft = QPointF(column, row) - bottomright = QPointF(column + 1, row + 1) - t = self.heatmap_item.pixmapTransform() - rect = t.mapRect(QRectF(topleft, bottomright)) - rect.translated(self.heatmap_item.pos()) - return rect - - def row_rect(self, row): - """ - Return a QRectF in local coordinates containing the entire row. - """ - rect = self.cell_rect(row, 0) - rect.setLeft(0) - rect.setRight(self.size().width()) - return rect - - def cell_tool_tip(self, row, column): - return "{}, {}: {:g}".format(row, column, self.__data[row, column]) - - def hoverMoveEvent(self, event): - pos = event.pos() - row, column = self.cell_at(pos) - if row != -1: - tooltip = self.cell_tool_tip(row, column) - # TODO: Move/delegate to (Scene) helpEvent - self.setToolTip(tooltip) - return super().hoverMoveEvent(event) - - -class HeatmapScene(QGraphicsScene): - """A Graphics Scene with heatmap widgets.""" - def __init__(self, parent=None): - QGraphicsScene.__init__(self, parent) - self.selection_manager = HeatmapSelectionManager() - self.__selecting = False - - def set_selection_manager(self, manager): - self.selection_manager = manager - - def _items(self, pos=None, cls=object): - if pos is not None: - items = self.items(QRectF(pos, QSizeF(3, 3)).translated(-1.5, -1.5)) - else: - items = self.items() - - for item in items: - if isinstance(item, cls): - yield item - - def heatmap_at_pos(self, pos): - items = list(self._items(pos, GraphicsHeatmapWidget)) - if items: - return items[0] - else: - return None +# If StickyGraphicsView ever defines qt signals/slots/properties this will +# break +class GraphicsView(GraphicsWidgetView, StickyGraphicsView): + pass - def heatmap_widgets(self): - return self._items(None, GraphicsHeatmapWidget) - - def select_from_dendrogram(self, dendrogram, key): - """Select all heatmap rows which belong to the dendrogram. - """ - dendrogram_widget = dendrogram.parentWidget() - anchors = list(dendrogram_widget.leaf_anchors()) - cluster = dendrogram.cluster - start, end = anchors[cluster.first], anchors[cluster.last - 1] - start, end = dendrogram_widget.mapToScene(start), dendrogram_widget.mapToScene(end) - # Find a heatmap widget containing start and end y coordinates. - - heatmap = None - for hm in self.heatmap_widgets(): - b_rect = hm.sceneBoundingRect() - if b_rect.contains(QPointF(b_rect.center().x(), start.y())): - heatmap = hm - break - - if dendrogram: - b_rect = heatmap.boundingRect() - start, end = heatmap.mapFromScene(start), heatmap.mapFromScene(end) - start, _ = heatmap.cell_at(QPointF(b_rect.center().x(), start.y())) - end, _ = heatmap.cell_at(QPointF(b_rect.center().x(), end.y())) - clear = not (key & ((Qt.ControlModifier | Qt.ShiftModifier | - Qt.AltModifier))) - remove = (key & (Qt.ControlModifier | Qt.AltModifier)) - append = (key & Qt.ControlModifier) - self.selection_manager.selection_add( - start, end, heatmap, clear=clear, remove=remove, append=append) - - def mousePressEvent(self, event): - pos = event.scenePos() - heatmap = self.heatmap_at_pos(pos) - if heatmap and event.button() & Qt.LeftButton: - row, _ = heatmap.cell_at(heatmap.mapFromScene(pos)) - if row != -1: - self.selection_manager.selection_start(heatmap, event) - self.__selecting = True - return QGraphicsScene.mousePressEvent(self, event) - - def mouseMoveEvent(self, event): - pos = event.scenePos() - heatmap = self.heatmap_at_pos(pos) - if heatmap and event.buttons() & Qt.LeftButton and self.__selecting: - row, _ = heatmap.cell_at(heatmap.mapFromScene(pos)) - if row != -1: - self.selection_manager.selection_update(heatmap, event) - return QGraphicsScene.mouseMoveEvent(self, event) - - def mouseReleaseEvent(self, event): - pos = event.scenePos() - heatmap = self.heatmap_at_pos(pos) - if heatmap and event.button() == Qt.LeftButton and self.__selecting: - self.selection_manager.selection_finish(heatmap, event) - - if event.button() == Qt.LeftButton and self.__selecting: - self.__selecting = False - - return QGraphicsScene.mouseReleaseEvent(self, event) - - def mouseDoubleClickEvent(self, event): - return QGraphicsScene.mouseDoubleClickEvent(self, event) - - -class GraphicsSimpleTextLayoutItem(QGraphicsLayoutItem): - """ A Graphics layout item wrapping a QGraphicsSimpleTextItem alowing it - to be managed by a layout. +class RowPart(NamedTuple): """ - def __init__(self, text_item, orientation=Qt.Horizontal, parent=None): - super().__init__(parent) - self.orientation = orientation - self.text_item = text_item - if orientation == Qt.Vertical: - self.text_item.rotate(-90) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - else: - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - - def setGeometry(self, rect): - super().setGeometry(rect) - if self.orientation == Qt.Horizontal: - self.text_item.setPos(rect.topLeft()) - else: - self.text_item.setPos(rect.bottomLeft()) + A row group - def sizeHint(self, which, constraint=QSizeF()): - if which in [Qt.PreferredSize]: - size = self.text_item.boundingRect().size() - if self.orientation == Qt.Horizontal: - return size - else: - return QSizeF(size.height(), size.width()) - else: - return QSizeF() - - def updateGeometry(self): - super().updateGeometry() - parent = self.parentLayoutItem() - if parent.isLayout(): - parent.updateGeometry() - - def setFont(self, font): - self.text_item.setFont(font) - self.updateGeometry() - - def setText(self, text): - self.text_item.setText(text) - self.updateGeometry() - - -class GradientLegendWidget(QGraphicsWidget): - def __init__(self, low, high, threshold_low, threshold_high, parent=None): - super().__init__(parent) - self.low = low - self.high = high - self.threshold_low = threshold_low - self.threshold_high = threshold_high - self.center_palette = False - self.color_table = None - - layout = QGraphicsLinearLayout(Qt.Vertical) - self.setLayout(layout) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(1) - - layout_labels = QGraphicsLinearLayout(Qt.Horizontal) - layout.addItem(layout_labels) - layout_labels.setContentsMargins(0, 0, 0, 0) - label_lo = QGraphicsSimpleTextItem("%.2f" % low, self) - label_hi = QGraphicsSimpleTextItem("%.2f" % high, self) - self.item_low = GraphicsSimpleTextLayoutItem(label_lo, parent=self) - self.item_high = GraphicsSimpleTextLayoutItem(label_hi, parent=self) - - layout_labels.addItem(self.item_low) - layout_labels.addStretch(10) - layout_labels.addItem(self.item_high) - - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.__pixitem = GraphicsPixmapWidget(parent=self, scaleContents=True, - aspectMode=Qt.IgnoreAspectRatio) - self.__pixitem.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) - self.__pixitem.setMinimumHeight(12) - layout.addItem(self.__pixitem) - self.__update() - - def set_color_table(self, color_table, center): - self.color_table = color_table - self.center_palette = center - self.__update() - - def set_thresholds(self, threshold_low, threshold_high): - self.threshold_low = threshold_low - self.threshold_high = threshold_high - self.__update() - - def __update(self): - data = np.linspace(self.low, self.high, num=1000) - data = data.reshape((1, -1)) - ll, lh = levels_with_thresholds(self.low, self.high, - self.threshold_low, self.threshold_high, - self.center_palette) - argb, _ = pg.makeARGB(data, lut=self.color_table, - levels=(ll, lh)) - qimg = pg.makeQImage(argb, transpose=False) - self.__pixitem.setPixmap(QPixmap.fromImage(qimg)) - - self.item_low.setText("%.2f" % self.low) - self.item_high.setText("%.2f" % self.high) - self.layout().invalidate() - - -class HeatmapSelectionManager(QObject): - """Selection manager for heatmap rows + Attributes + ---------- + title: str + Group title + indices : (N, ) Sequence[int] + Indices in the input data to retrieve the row subset for the group. + cluster : hierarchical.Tree optional + cluster_ordered : hierarchical.Tree optional """ - selection_changed = Signal() - selection_finished = Signal() - - def __init__(self, parent=None): - QObject.__init__(self, parent) - self.selections = [] - self.selection_ranges = [] - self.selection_ranges_temp = [] - self.heatmap_widgets = [] - self.selection_rects = [] - self.heatmaps = [] - self._heatmap_ranges = {} - self._start_row = 0 - - def clear(self): - self.remove_rows(self.selection) - - def set_heatmap_widgets(self, widgets): - self.remove_rows(self.selections) - self.heatmaps = list(zip(*widgets)) - - # Compute row ranges for all heatmaps - self._heatmap_ranges = {} - start = end = 0 - - for group in zip(*widgets): - start = end = 0 - for heatmap in group: - end += heatmap.heatmap_data().shape[0] - self._heatmap_ranges[heatmap] = (start, end) - start = end - - def select_rows(self, rows, heatmap=None, clear=True): - """Add `rows` to selection. If `heatmap` is provided the rows - are mapped from the local indices to global heatmap indices. If `clear` - then remove previous rows. - """ - if heatmap is not None: - start, _ = self._heatmap_ranges[heatmap] - rows = [start + r for r in rows] + title: str + indices: Sequence[int] + cluster: Optional[hierarchical.Tree] = None + cluster_ordered: Optional[hierarchical.Tree] = None - old_selection = list(self.selections) - if clear: - self.selections = rows + @property + def can_cluster(self) -> bool: + if isinstance(self.indices, slice): + return (self.indices.stop - self.indices.start) > 1 else: - self.selections = sorted(set(self.selections + rows)) + return len(self.indices) > 1 - if self.selections != old_selection: - self.update_selection_rects() - self.selection_changed.emit() - def remove_rows(self, rows): - """Remove `rows` from the selection. - """ - old_selection = list(self.selections) - self.selections = sorted(set(self.selections) - set(rows)) - if old_selection != self.selections: - self.update_selection_rects() - self.selection_changed.emit() - - def combined_ranges(self, ranges): - combined_ranges = set() - for start, end in ranges: - if start <= end: - rng = range(start, end + 1) - else: - rng = range(start, end - 1, -1) - combined_ranges.update(rng) - return sorted(combined_ranges) +class ColumnPart(NamedTuple): + """ + A column group - def selection_start(self, heatmap_widget, event): - """ Selection started by `heatmap_widget` due to `event`. - """ - pos = heatmap_widget.mapFromScene(event.scenePos()) - row, _ = heatmap_widget.cell_at(pos) - - start, _ = self._heatmap_ranges[heatmap_widget] - row = start + row - self._start_row = row - range = (row, row) - self.selection_ranges_temp = [] - if event.modifiers() & Qt.ControlModifier: - self.selection_ranges_temp = self.selection_ranges - self.selection_ranges = self.remove_range( - self.selection_ranges, row, row, append=True) - elif event.modifiers() & Qt.ShiftModifier: - self.selection_ranges.append(range) - elif event.modifiers() & Qt.AltModifier: - self.selection_ranges = self.remove_range( - self.selection_ranges, row, row, append=False) - else: - self.selection_ranges = [range] - self.select_rows(self.combined_ranges(self.selection_ranges)) + Attributes + ---------- + title : str + Column group title + indices : (N, ) int ndarray + Indexes the input data to retrieve the column subset for the group. + domain : List[Variable] + List of variables in the group. + cluster : hierarchical.Tree optional + cluster_ordered : hierarchical.Tree optional + """ + title: str + indices: Sequence[int] + domain: Sequence[int] + cluster: Optional[hierarchical.Tree] = None + cluster_ordered: Optional[hierarchical.Tree] = None - def selection_update(self, heatmap_widget, event): - """ Selection updated by `heatmap_widget due to `event` (mouse drag). - """ - pos = heatmap_widget.mapFromScene(event.scenePos()) - row, _ = heatmap_widget.cell_at(pos) - if row < 0: - return - start, _ = self._heatmap_ranges[heatmap_widget] - row = start + row - if event.modifiers() & Qt.ControlModifier: - self.selection_ranges = self.remove_range( - self.selection_ranges_temp, self._start_row, row, append=True) - elif event.modifiers() & Qt.AltModifier: - self.selection_ranges = self.remove_range( - self.selection_ranges, self._start_row, row, append=False) - else: - if self.selection_ranges: - self.selection_ranges[-1] = (self._start_row, row) - else: - self.selection_ranges = [(row, row)] +class Parts(NamedTuple): + rows: Sequence[RowPart] + columns: Sequence[ColumnPart] + span: Tuple[float, float] - self.select_rows(self.combined_ranges(self.selection_ranges)) - def selection_finish(self, heatmap_widget, event): - """ Selection finished by `heatmap_widget due to `event`. - """ - pos = heatmap_widget.mapFromScene(event.scenePos()) - row, _ = heatmap_widget.cell_at(pos) - start, _ = self._heatmap_ranges[heatmap_widget] - row = start + row - if event.modifiers() & Qt.ControlModifier: - pass - elif event.modifiers() & Qt.AltModifier: - self.selection_ranges = self.remove_range( - self.selection_ranges, self._start_row, row, append=False) - else: - if len(self.selection_ranges) > 0: - self.selection_ranges[-1] = (self._start_row, row) - self.select_rows(self.combined_ranges(self.selection_ranges)) - self.selection_finished.emit() - - def selection_add(self, start, end, heatmap=None, clear=True, - remove=False, append=False): - """ Add/remove a selection range from `start` to `end`. - """ - if heatmap is not None: - _start, _ = self._heatmap_ranges[heatmap] - start = _start + start - end = _start + end - - if clear: - self.selection_ranges = [] - if remove: - self.selection_ranges = self.remove_range( - self.selection_ranges, start, end, append=append) - else: - self.selection_ranges.append((start, end)) - self.select_rows(self.combined_ranges(self.selection_ranges)) - self.selection_finished.emit() - - def remove_range(self, ranges, start, end, append=False): - if start > end: - start, end = end, start - comb_ranges = [i for i in self.combined_ranges(ranges) - if i > end or i < start] - if append: - comb_ranges += [i for i in range(start, end + 1) - if i not in self.combined_ranges(ranges)] - comb_ranges = sorted(comb_ranges) - return self.combined_to_ranges(comb_ranges) - - def combined_to_ranges(self, comb_ranges): - ranges = [] - if len(comb_ranges) > 0: - i, start, end = 0, comb_ranges[0], comb_ranges[0] - for val in comb_ranges[1:]: - i += 1 - if start + i < val: - ranges.append((start, end)) - i, start = 0, val - end = val - ranges.append((start, end)) - return ranges - - def update_selection_rects(self): - """ Update the selection rects. - """ - def group_selections(selections): - """Group selections along with heatmaps. - """ - rows2hm = self.rows_to_heatmaps() - selections = iter(selections) - try: - start = end = next(selections) - except StopIteration: - return - end_heatmaps = rows2hm[end] - try: - while True: - new_end = next(selections) - new_end_heatmaps = rows2hm[new_end] - if new_end > end + 1 or new_end_heatmaps != end_heatmaps: - yield start, end, end_heatmaps - start = end = new_end - end_heatmaps = new_end_heatmaps - else: - end = new_end - - except StopIteration: - yield start, end, end_heatmaps - - def selection_rect(start, end, heatmaps): - rect = QRectF() - for heatmap in heatmaps: - h_start, _ = self._heatmap_ranges[heatmap] - rect |= heatmap.mapToScene(heatmap.row_rect(start - h_start)).boundingRect() - rect |= heatmap.mapToScene(heatmap.row_rect(end - h_start)).boundingRect() - return rect - - self.selection_rects = [] - for start, end, heatmaps in group_selections(self.selections): - rect = selection_rect(start, end, heatmaps) - self.selection_rects.append(rect) - - def rows_to_heatmaps(self): - heatmap_groups = zip(*self.heatmaps) - rows2hm = {} - for heatmaps in heatmap_groups: - hm = heatmaps[0] - start, end = self._heatmap_ranges[hm] - rows2hm.update(dict.fromkeys(range(start, end), heatmaps)) - return rows2hm - - -def join_ellided(sep, maxlen, values, ellidetemplate="..."): +def join_elided(sep, maxlen, values, elidetemplate="..."): def generate(sep, ellidetemplate, values): count = len(values) length = 0 parts = [] for i, val in enumerate(values): - ellide = ellidetemplate.format(count - i) if count - i > 1 else "" + elide = ellidetemplate.format(count - i) if count - i > 1 else "" parts.append(val) length += len(val) + (len(sep) if parts else 0) - yield i, itertools.islice(parts, i + 1), length, ellide + yield i, islice(parts, i + 1), length, elide best = None - for _, parts, length, ellide in generate(sep, ellidetemplate, values): + for _, parts, length, elide in generate(sep, elidetemplate, values): if length > maxlen: if best is None: - best = sep.join(parts) + ellide + best = sep.join(parts) + elide return best - fulllen = length + len(ellide) + fulllen = length + len(elide) if fulllen < maxlen or best is None: - best = sep.join(parts) + ellide + best = sep.join(parts) + elide return best +def column_str_from_table( + table: Orange.data.Table, + column: Union[int, Orange.data.Variable], +) -> np.ndarray: + var = table.domain[column] + data, _ = table.get_column_view(column) + return np.asarray([var.str_val(v) for v in data], dtype=object) + + +def column_data_from_table( + table: Orange.data.Table, + column: Union[int, Orange.data.Variable], +) -> np.ndarray: + var = table.domain[column] + data, _ = table.get_column_view(column) + if var.is_primitive() and data.dtype.kind != "f": + data = data.astype(float) + return data + + +def aggregate( + var: Variable, data: np.ndarray, groupindices: Sequence[Sequence[int]], +) -> np.ndarray: + if var.is_string: + join = lambda values: (join_elided(", ", 42, values, " ({} more)")) + # collect all original labels for every merged row + values = [data[indices] for indices in groupindices] + data = [join(list(map(var.str_val, vals))) for vals in values] + return np.array(data, dtype=object) + elif var.is_continuous: + data = [np.nanmean(data[indices]) if len(indices) else np.nan + for indices in groupindices] + return np.array(data, dtype=float) + elif var.is_discrete: + from Orange.statistics.util import nanmode + data = [nanmode(data[indices])[0] if len(indices) else np.nan + for indices in groupindices] + return np.asarray(data, dtype=float) + else: + raise TypeError(type(var)) + + +def agg_join_str(var, data, groupindices, maxlen=50, elidetemplate=" ({} more)"): + join_s = lambda values: ( + join_elided(", ", maxlen, values, elidetemplate=elidetemplate) + ) + join = lambda values: join_s(map(var.str_val, values)) + return aggregate_apply(join, data, groupindices) + + +_T = TypeVar("_T") + + +def aggregate_apply( + f: Callable[[Sequence], _T], + data: np.ndarray, + groupindices: Sequence[Sequence[int]] +) -> Sequence[_T]: + return [f(data[indices]) for indices in groupindices] + + if __name__ == "__main__": # pragma: no cover WidgetPreview(OWHeatMap).run(Table("brown-selected.tab")) diff --git a/Orange/widgets/visualize/tests/test_owheatmap.py b/Orange/widgets/visualize/tests/test_owheatmap.py index 75ebe91e6ed..19d9167e1e4 100644 --- a/Orange/widgets/visualize/tests/test_owheatmap.py +++ b/Orange/widgets/visualize/tests/test_owheatmap.py @@ -62,6 +62,7 @@ def test_information_message(self): self.widget.set_row_clustering(Clustering.OrderedClustering) continuizer = Continuize() cont_titanic = continuizer(self.titanic) + self.widget.MaxClustering = 1000 self.send_signal(self.widget.Inputs.data, cont_titanic) self.assertTrue(self.widget.Information.active) self.send_signal(self.widget.Inputs.data, self.data) @@ -87,7 +88,7 @@ def test_settings_changed(self): def _select_data(self): selected_indices = list(range(10, 31)) - self.widget.selection_manager.select_rows(selected_indices) + self.widget.scene.widget.selectRows(selected_indices) self.widget.on_selection_finished() return selected_indices @@ -175,8 +176,8 @@ def test_use_enough_colors(self): self.send_signal(self.widget.Inputs.data, table) self.widget.threshold_high = 0.05 self.widget.update_color_schema() - heatmap_widget = self.widget.heatmap_widget_grid[0][0] - image = heatmap_widget.heatmap_item.pixmap().toImage() + heatmap_widget = self.widget.scene.widget.heatmap_widget_grid[0][0] + image = heatmap_widget.pixmap().toImage() colors = image_row_colors(image) unique_colors = len(np.unique(colors, axis=0)) self.assertLessEqual(len(data)*self.widget.threshold_low, unique_colors) @@ -200,7 +201,7 @@ def test_saved_selection(self): self.send_signal(self.widget.Inputs.data, iris) selected_indices = list(range(10, 31)) - self.widget.selection_manager.select_rows(selected_indices) + self.widget.scene.widget.selectRows(selected_indices) self.widget.on_selection_finished() settings = self.widget.settingsHandler.pack_data(self.widget) @@ -213,11 +214,11 @@ def test_set_split_var(self): w = self.widget self.send_signal(self.widget.Inputs.data, data, widget=w) self.assertIs(w.split_by_var, data.domain.class_var) - self.assertEqual(len(w.heatmapparts.rows), + self.assertEqual(len(w.parts.rows), len(data.domain.class_var.values)) w.set_split_variable(None) self.assertIs(w.split_by_var, None) - self.assertEqual(len(w.heatmapparts.rows), 1) + self.assertEqual(len(w.parts.rows), 1) def test_palette_centering(self): data = np.arange(2).reshape(-1, 1) @@ -237,8 +238,8 @@ def test_palette_centering(self): for center, desired in [(False, desired_uncentered), (True, desired_centered)]: with patch.object(OWHeatMap, "center_palette", center): self.widget.update_color_schema() - heatmap_widget = self.widget.heatmap_widget_grid[0][0] - image = heatmap_widget.heatmap_item.pixmap().toImage() + heatmap_widget = self.widget.scene.widget.heatmap_widget_grid[0][0] + image = heatmap_widget.pixmap().toImage() colors = image_row_colors(image) np.testing.assert_almost_equal(colors, desired) @@ -263,6 +264,14 @@ def test_migrate_settings_v3(self): self.assertEqual(w.row_clustering, Clustering.None_) self.assertEqual(w.col_clustering, Clustering.OrderedClustering) + def test_row_color_annotations(self): + widget = self.widget + data = Table("brown-selected")[::5] + self.send_signal(widget.Inputs.data, data, widget=widget) + widget.set_annotation_color_var(data.domain["function"]) + self.assertTrue(widget.scene.widget.right_side_colors[0].isVisible()) + widget.set_annotation_color_var(None) + self.assertFalse(widget.scene.widget.right_side_colors[0].isVisible()) if __name__ == "__main__": diff --git a/Orange/widgets/visualize/utils/heatmap.py b/Orange/widgets/visualize/utils/heatmap.py new file mode 100644 index 00000000000..fe16ae33a02 --- /dev/null +++ b/Orange/widgets/visualize/utils/heatmap.py @@ -0,0 +1,1280 @@ +import math +import enum +from collections import deque +from itertools import chain, zip_longest + +from typing import ( + Optional, List, NamedTuple, Iterable, Sequence, Tuple, Dict, TypeVar, + Callable, Any, Deque, Union, +) + +import numpy as np + +from AnyQt.QtCore import ( + Signal, Property, Qt, QRectF, QSizeF, QEvent, QPointF, QObject +) +from AnyQt.QtGui import QPixmap, QPalette, QPen, QColor, QFontMetrics +from AnyQt.QtWidgets import ( + QGraphicsWidget, QSizePolicy, QGraphicsGridLayout, QGraphicsRectItem, + QApplication, QGraphicsSceneMouseEvent, QGraphicsLinearLayout, + QGraphicsItem, QGraphicsSimpleTextItem +) + +import pyqtgraph as pg + +from Orange.clustering import hierarchical +from Orange.clustering.hierarchical import Tree +from Orange.widgets.utils.colorpalettes import DefaultContinuousPalette +from Orange.widgets.utils.graphicslayoutitem import SimpleLayoutItem, scaled +from Orange.widgets.utils.graphicspixmapwidget import GraphicsPixmapWidget +from Orange.widgets.utils.image import qimage_from_array + +from Orange.widgets.unsupervised.owdistancemap import TextList as TextListWidget +from Orange.widgets.unsupervised.owhierarchicalclustering import \ + DendrogramWidget + + +_T1 = TypeVar("_T1") + + +def apply_all(seq, op): + # type: (Iterable[_T1], Callable[[_T1], Any]) -> None + """Apply `op` on all elements of `seq`.""" + d = deque(maxlen=0) # type: Deque[_T1] + d.extend(map(op, seq)) + + +def leaf_indices(tree: Tree) -> Sequence[int]: + return [leaf.value.index for leaf in hierarchical.leaves(tree)] + + +class ColorMap: + """Color map for the heatmap.""" + #: A color table. A (N, 3) uint8 ndarray + colortable: np.ndarray + #: Lower ad upper thresholding operator parameters. Expressed as relative + #: to the data span (range) so (0, 1) applies no thresholding + thresholds: Tuple[float, float] = (0., 1.) + #: Should the color map be center and if so around which value. + center: Optional[float] = None + + def __init__(self, colors, thresholds=thresholds, center=None): + self.colortable = colors + self.thresholds = thresholds + assert thresholds[0] < thresholds[1] + self.center = center + + def adjust_levels(self, low: float, high: float) -> Tuple[float, float]: + """ + Adjust the data low, high levels by applying the thresholding and + centering. + """ + assert low <= high + threshold_low, threshold_high = self.thresholds + lt = low + (high - low) * threshold_low + ht = low + (high - low) * threshold_high + if self.center is not None: + center = self.center + maxoff = max(abs(center - lt), abs(center - ht)) + lt = center - maxoff + ht = center + maxoff + return lt, ht + + +def normalized_indices(item: Union['RowItem', 'ColumnItem']) -> np.ndarray: + if item.cluster is None: + return np.asarray(item.indices, dtype=int) + else: + reorder = np.array(leaf_indices(item.cluster), dtype=int) + indices = np.asarray(item.indices, dtype=int) + return indices[reorder] + + +class GridLayout(QGraphicsGridLayout): + def setGeometry(self, rect: QRectF) -> None: + super().setGeometry(rect) + parent = self.parentLayoutItem() + if isinstance(parent, HeatmapGridWidget): + parent.layoutDidActivate.emit() + + +def grid_layout_row_geometry(layout: QGraphicsGridLayout, row: int) -> QRectF: + """ + Return the geometry of the `row` in the grid layout. + + If the row is empty return an empty geometry + """ + if not 0 <= row < layout.rowCount(): + return QRectF() + + columns = layout.columnCount() + geometries: List[QRectF] = [] + for item in (layout.itemAt(row, column) for column in range(columns)): + if item is not None: + itemgeom = item.geometry() + if itemgeom.isValid(): + geometries.append(itemgeom) + if geometries: + rect = layout.geometry() + rect.setTop(min(g.top() for g in geometries)) + rect.setBottom(max(g.bottom() for g in geometries)) + return rect + else: + return QRectF() + + +# Positions +class Position(enum.IntFlag): + NoPosition = 0 + Left, Top, Right, Bottom = 1, 2, 4, 8 + + +Left, Right = Position.Left, Position.Right +Top, Bottom = Position.Top, Position.Bottom + + +class HeatmapGridWidget(QGraphicsWidget): + """ + A graphics widget with a annotated 2D grid of heatmaps. + """ + class RowItem(NamedTuple): + """ + A row group item + + Attributes + ---------- + title: str + Group title + indices : (N, ) Sequence[int] + Indices in the input data to retrieve the row subset for the group. + cluster : Optional[Tree] + + """ + title: str + indices: Sequence[int] + cluster: Optional[Tree] = None + + @property + def size(self): + return len(self.indices) + + @property + def normalized_indices(self): + return normalized_indices(self) + + class ColumnItem(NamedTuple): + """ + A column group + + Attributes + ---------- + title: str + Column group title + indices: (N, ) Sequence[int] + Indexes the input data to retrieve the column subset for the group. + cluster: Optional[Tree] + """ + title: str + indices: Sequence[int] + cluster: Optional[Tree] = None + + @property + def size(self): + return len(self.indices) + + @property + def normalized_indices(self): + return normalized_indices(self) + + class Parts(NamedTuple): + #: define the splits of data over rows, and define dendrogram and/or row + #: reordering + rows: Sequence['RowItem'] + #: define the splits of data over columns, and define dendrogram and/or + #: column reordering + columns: Sequence['ColumnItem'] + #: span (min, max) of the values in `data` + span: Tuple[float, float] + #: the complete data array (shape (N, M)) + data: np.ndarray + #: Row names (len N) + row_names: Optional[Sequence[str]] = None + #: Column names (len M) + col_names: Optional[Sequence[str]] = None + + # Positions + class Position(enum.IntFlag): + NoPosition = 0 + Left, Top, Right, Bottom = 1, 2, 4, 8 + + Left, Right = Position.Left, Position.Right + Top, Bottom = Position.Top, Position.Bottom + + #: The widget's layout has activated (i.e. did a relayout + #: of the widget's contents) + layoutDidActivate = Signal() + + #: Signal emitted when the user finished a selection operation + selectionFinished = Signal() + #: Signal emitted on any change in selection + selectionChanged = Signal() + + NoPosition, PositionTop, PositionBottom = 0, Top, Bottom + + # Start row/column where the heatmap items are inserted + # (after the titles/legends/dendrograms) + Row0 = 3 + Col0 = 3 + # The (color) legend row and column + LegendRow, LegendCol = 0, 4 + # The column for the vertical dendrogram + DendrogramColumn = 1 + # The row for the horizontal dendrograms + DendrogramRow = 1 + # The row for top column annotation labels + TopLabelsRow = 2 + # Vertical split title column + GroupTitleColumn = 0 + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self.__spacing = 3 + self.__colormap = ColorMap( + DefaultContinuousPalette.lookup_table() + ) + self.parts = None # type: Optional[Parts] + self.__averagesVisible = False + self.__legendVisible = True + self.__aspectRatioMode = Qt.IgnoreAspectRatio + self.__columnLabelPosition = Top + self.heatmap_widget_grid = [] # type: List[List[GraphicsHeatmapWidget]] + self.row_annotation_widgets = [] # type: List[TextListWidget] + self.col_annotation_widgets = [] # type: List[TextListWidget] + self.col_annotation_widgets_top = [] # type: List[TextListWidget] + self.col_annotation_widgets_bottom = [] # type: List[TextListWidget] + self.col_dendrograms = [] # type: List[Optional[DendrogramWidget]] + self.row_dendrograms = [] # type: List[Optional[DendrogramWidget]] + self.right_side_colors = [] # type: List[Optional[GraphicsPixmapWidget]] + self.__layout = GridLayout() + self.__layout.setSpacing(self.__spacing) + self.setLayout(self.__layout) + self.__selection_manager = SelectionManager(self) + self.__selection_manager.selection_changed.connect( + self.__update_selection_geometry + ) + self.__selection_manager.selection_finished.connect( + self.selectionFinished + ) + self.__selection_manager.selection_changed.connect( + self.selectionChanged + ) + self.selection_rects = [] + + def clear(self): + """Clear the widget.""" + for i in reversed(range(self.__layout.count())): + item = self.__layout.itemAt(i) + self.__layout.removeAt(i) + if item is not None and item.graphicsItem() is not None: + remove_item(item.graphicsItem()) + + self.heatmap_widget_grid = [] + self.row_annotation_widgets = [] + self.col_annotation_widgets = [] + self.col_dendrograms = [] + self.row_dendrograms = [] + self.right_side_colors = [] + self.parts = None + self.updateGeometry() + + def setHeatmaps(self, parts: 'Parts') -> None: + """Set the heatmap parts for display""" + self.clear() + grid = self.__layout + N, M = len(parts.rows), len(parts.columns) + + # Start row/column where the heatmap items are inserted + # (after the titles/legends/dendrograms) + Row0 = self.Row0 + Col0 = self.Col0 + # The column for the vertical dendrograms + DendrogramColumn = self.DendrogramColumn + # The row for the horizontal dendrograms + DendrogramRow = self.DendrogramRow + RightLabelColumn = Col0 + 2 * M + 1 + TopLabelsRow = self.TopLabelsRow + BottomLabelsRow = Row0 + N + colormap = self.__colormap + column_dendrograms: List[Optional[DendrogramWidget]] = [None] * M + row_dendrograms: List[Optional[DendrogramWidget]] = [None] * N + right_side_colors: List[Optional[GraphicsPixmapWidget]] = [None] * N + + ncols = sum(c.size for c in parts.columns) + nrows = sum(r.size for r in parts.rows) + data = parts.data + if parts.col_names is None: + col_names = np.full(ncols, "", dtype=object) + else: + col_names = np.asarray(parts.col_names, dtype=object) + if parts.row_names is None: + row_names = np.full(nrows, "", dtype=object) + else: + row_names = np.asarray(parts.row_names, dtype=object) + + assert data.shape == (nrows, ncols) + assert len(col_names) == ncols + assert len(row_names) == nrows + + for i, rowitem in enumerate(parts.rows): + if rowitem.title: + item = QGraphicsSimpleTextItem(rowitem.title, parent=self) + item.rotate(-90) + item = SimpleLayoutItem(item, parent=grid, anchor=(0, 1), + anchorItem=(0, 0)) + item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Maximum) + grid.addItem(item, Row0 + i, self.GroupTitleColumn, + alignment=Qt.AlignCenter) + if rowitem.cluster: + dendrogram = DendrogramWidget( + parent=self, + selectionMode=DendrogramWidget.NoSelection, + hoverHighlightEnabled=True, + ) + dendrogram.set_root(rowitem.cluster) + dendrogram.setMaximumWidth(100) + dendrogram.setMinimumWidth(100) + # Ignore dendrogram vertical size hint (heatmap's size + # should define the row's vertical size). + dendrogram.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Ignored) + dendrogram.itemClicked.connect( + lambda item, partindex=i: + self.__select_by_cluster(item, partindex) + ) + grid.addItem(dendrogram, Row0 + i, DendrogramColumn) + row_dendrograms[i] = dendrogram + + for j, colitem in enumerate(parts.columns): + if colitem.title: + item = SimpleLayoutItem( + QGraphicsSimpleTextItem(colitem.title, parent=self), + parent=grid + ) + grid.addItem(item, 1, Col0 + 2 * j + 1) + + if colitem.cluster: + dendrogram = DendrogramWidget( + parent=self, + orientation=DendrogramWidget.Top, + selectionMode=DendrogramWidget.NoSelection, + hoverHighlightEnabled=False + ) + dendrogram.set_root(colitem.cluster) + dendrogram.setMaximumHeight(100) + dendrogram.setMinimumHeight(100) + # Ignore dendrogram horizontal size hint (heatmap's width + # should define the column width). + dendrogram.setSizePolicy( + QSizePolicy.Ignored, QSizePolicy.Expanding) + grid.addItem(dendrogram, DendrogramRow, Col0 + 2 * j + 1) + column_dendrograms[j] = dendrogram + + heatmap_widgets = [] + for i in range(N): + heatmap_row = [] + for j in range(M): + row_ix = parts.rows[i].normalized_indices + col_ix = parts.columns[j].normalized_indices + X_part = data[np.ix_(row_ix, col_ix)] + hw = GraphicsHeatmapWidget( + aspectRatioMode=self.__aspectRatioMode, + data=X_part, span=parts.span, colormap=colormap, + ) + sp = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + sp.setHeightForWidth(True) + hw.setSizePolicy(sp) + + avgimg = GraphicsHeatmapWidget( + data=np.nanmean(X_part, axis=1, keepdims=True), + span=parts.span, colormap=colormap, + visible=self.__averagesVisible, + ) + avgimg.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Ignored) + grid.addItem(avgimg, Row0 + i, Col0 + 2 * j) + grid.addItem(hw, Row0 + i, Col0 + 2 * j + 1) + + heatmap_row.append(hw) + heatmap_widgets.append(heatmap_row) + + for j in range(M): + grid.setColumnStretchFactor(Col0 + 2 * j, 1) + grid.setColumnStretchFactor( + Col0 + 2 * j + 1, parts.columns[j].size) + grid.setColumnStretchFactor(RightLabelColumn - 1, 1) + + for i in range(N): + grid.setRowStretchFactor(Row0 + i, parts.rows[i].size) + + row_annotation_widgets = [] + col_annotation_widgets = [] + col_annotation_widgets_top = [] + col_annotation_widgets_bottom = [] + + for i, rowitem in enumerate(parts.rows): + # Right row annotations + indices = np.asarray(rowitem.normalized_indices, dtype=np.intp) + labels = row_names[indices] + labelslist = TextListWidget( + items=labels, parent=self, orientation=Qt.Vertical, + alignment=Qt.AlignLeft | Qt.AlignVCenter, + sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Ignored) + ) + pm = QPixmap(5, rowitem.size) + pm.fill(Qt.darkMagenta) + rowauxsidecolor = GraphicsPixmapWidget( + parent=self, visible=False, + scaleContents=True, aspectMode=Qt.IgnoreAspectRatio, + ) + rowauxsidecolor.setVisible(False) + grid.addItem(rowauxsidecolor, Row0 + i, RightLabelColumn - 1) + + grid.addItem(labelslist, Row0 + i, RightLabelColumn, alignment=Qt.AlignLeft) + row_annotation_widgets.append(labelslist) + right_side_colors[i] = rowauxsidecolor + + for j, colitem in enumerate(parts.columns): + # Top attr annotations + indices = np.asarray(colitem.normalized_indices, dtype=np.intp) + labels = col_names[indices] + labelslist = TextListWidget( + items=labels, parent=self, + alignment=Qt.AlignLeft | Qt.AlignVCenter, + orientation=Qt.Horizontal, + visible=self.__columnLabelPosition & Position.Top + ) + labelslist.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + labelslist.setSizePolicy(QSizePolicy.Expanding, + QSizePolicy.Fixed) + + grid.addItem(labelslist, TopLabelsRow, Col0 + 2 * j + 1, + Qt.AlignBottom | Qt.AlignLeft) + col_annotation_widgets.append(labelslist) + col_annotation_widgets_top.append(labelslist) + + # Bottom attr annotations + labelslist = TextListWidget( + items=labels, parent=self, + alignment=Qt.AlignRight | Qt.AlignVCenter, + orientation=Qt.Horizontal, + visible=self.__columnLabelPosition & Position.Bottom + ) + labelslist.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + grid.addItem(labelslist, BottomLabelsRow, Col0 + 2 * j + 1) + col_annotation_widgets.append(labelslist) + col_annotation_widgets_bottom.append(labelslist) + + legend = GradientLegendWidget( + parts.span[0], parts.span[1], + colormap, + parent=self, + minimumSize=QSizeF(100, 20), + visible=self.__legendVisible, + sizePolicy=QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) + ) + legend.setMaximumWidth(300) + grid.addItem(legend, self.LegendRow, self.LegendCol) + + self.heatmap_widget_grid = heatmap_widgets + self.row_annotation_widgets = row_annotation_widgets + self.col_annotation_widgets = col_annotation_widgets + self.col_annotation_widgets_top = col_annotation_widgets_top + self.col_annotation_widgets_bottom = col_annotation_widgets_bottom + self.col_dendrograms = column_dendrograms + self.row_dendrograms = row_dendrograms + self.right_side_colors = right_side_colors + self.parts = parts + self.__selection_manager.set_heatmap_widgets(heatmap_widgets) + + def legendVisible(self) -> bool: + """Is the colormap legend visible.""" + return self.__legendVisible + + def setLegendVisible(self, visible: bool) -> None: + """Set colormap legend visible state.""" + self.__legendVisible = visible + item = self.__layout.itemAt(self.LegendRow, self.LegendCol) + if isinstance(item, GradientLegendWidget): + item.setVisible(visible) + + legendVisible_ = Property(bool, legendVisible, setLegendVisible) + + def setAspectRatioMode(self, mode: Qt.AspectRatioMode) -> None: + """ + Set the scale aspect mode. + + The widget will try to keep (hint) the scale ratio via the sizeHint + reimplementation. + """ + if self.__aspectRatioMode != mode: + self.__aspectRatioMode = mode + for hm in chain.from_iterable(self.heatmap_widget_grid): + hm.setAspectMode(mode) + + def aspectRatioMode(self) -> Qt.AspectRatioMode: + return self.__aspectRatioMode + + aspectRatioMode_ = Property( + Qt.AspectRatioMode, aspectRatioMode, setAspectRatioMode + ) + + def setColumnLabelsPosition(self, position: Position) -> None: + self.__columnLabelPosition = position + top = bool(position & HeatmapGridWidget.PositionTop) + bottom = bool(position & HeatmapGridWidget.PositionBottom) + for w in self.col_annotation_widgets_top: + w.setVisible(top) + for w in self.col_annotation_widgets_bottom: + w.setVisible(bottom) + + def columnLabelPosition(self) -> Position: + return self.__columnLabelPosition + + def setColumnLabels(self, data: Optional[Sequence[str]]) -> None: + """Set the column labels to display. If None clear the row names.""" + if data is not None: + data = np.asarray(data, dtype=object) + for top, bottom, part in zip(self.col_annotation_widgets_top, + self.col_annotation_widgets_bottom, + self.parts.columns): + if data is not None: + top.setItems(data[part.normalized_indices]) + bottom.setItems(data[part.normalized_indices]) + else: + top.clear() + bottom.clear() + + def setRowLabels(self, data: Optional[Sequence[str]]): + """ + Set the row labels to display. If None clear the row names. + """ + if data is not None: + data = np.asarray(data, dtype=object) + for widget, part in zip(self.row_annotation_widgets, self.parts.rows): + if data is not None: + widget.setItems(data[part.normalized_indices]) + else: + widget.clear() + + def setRowLabelsVisible(self, visible: bool): + """Set row labels visibility""" + for widget in self.row_annotation_widgets: + widget.setVisible(visible) + + def setRowSideColorAnnotations(self, colors: Optional[np.ndarray], name=""): + """ + Set an optional row side color annotations. + + Parameters + ---------- + colors: An (N, 3) uint8 array, optional + An array specifying the rgb color components for every row. + If None then the side color annotations are cleared. + name: str + Name/title for the annotation stripe. + """ + items = self.right_side_colors + col = self.Col0 + 2 * len(self.parts.columns) + nameitem = self.__layout.itemAt(self.TopLabelsRow, col) + if colors is None: + apply_all(filter(None, items), lambda a: a.setVisible(False)) + apply_all(filter(None, items), lambda a: a.setPreferredWidth(-1)) + if nameitem is not None: + nameitem.setPreferredWidth(0) + nameitem.item.setVisible(False) + return + fm = QFontMetrics(self.font()) + width = fm.lineSpacing() + parts = self.parts.rows + nrows = sum(p.size for p in parts) + assert len(colors) == nrows + for p, item in zip(parts, items): + if item is not None: + subset = colors[p.normalized_indices] + img = qimage_from_array(subset.reshape((-1, 1, subset.shape[-1]))) + item.setPixmap(img) + item.setVisible(True) + item.setPreferredWidth(width) + + if nameitem is None: + item = QGraphicsSimpleTextItem(name, self) + item.rotate(-90) + nameitem = SimpleLayoutItem( + item, anchor=(0, 1), resizeContents=True, + aspectMode=Qt.KeepAspectRatio, + ) + nameitem.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred) + # nameitem spans all header rows + self.__layout.addItem(nameitem, 0, col, self.TopLabelsRow + 1, 1) + + nameitem.item.setText(name) + nameitem.item.setVisible(True) + nameitem.setPreferredWidth(width) + nameitem.updateGeometry() + + def headerGeometry(self) -> QRectF: + """Return the 'header' geometry. + + This is the top part of the widget spanning the top dendrogram, + column labels... (can be empty). + """ + layout = self.__layout + geom1 = grid_layout_row_geometry(layout, self.DendrogramRow) + geom2 = grid_layout_row_geometry(layout, self.TopLabelsRow) + first = grid_layout_row_geometry(layout, self.TopLabelsRow + 1) + geom = geom1.united(geom2) + if geom.isValid(): + if first.isValid(): + geom.setBottom(geom.bottom() / 2.0 + first.top() / 2.0) + return QRectF(self.geometry().topLeft(), geom.bottomRight()) + else: + return QRectF() + + def footerGeometry(self) -> QRectF: + """Return the 'footer' geometry. + + This is the bottom part of the widget spanning the bottom column labels + when applicable (can be empty). + """ + layout = self.__layout + row = self.Row0 + len(self.heatmap_widget_grid) + geom = grid_layout_row_geometry(layout, row) + nextolast = grid_layout_row_geometry(layout, row - 1) + if geom.isValid(): + if nextolast.isValid(): + geom.setTop(geom.top() / 2 + nextolast.bottom() / 2) + return QRectF(geom.topLeft(), self.geometry().bottomRight()) + else: + return QRectF() + + def setColorMap(self, colormap: ColorMap) -> None: + self.__colormap = colormap + for hm in chain.from_iterable(self.heatmap_widget_grid): + hm.setColorMap(colormap) + for item in self.__avgitems(): + item.setColorMap(colormap) + for ch in self.childItems(): + if isinstance(ch, GradientLegendWidget): + ch.setColorMap(colormap) + + def colorMap(self) -> ColorMap: + return self.__colormap + + def __avgitems(self): + if self.parts is None: + return + N = len(self.parts.rows) + M = len(self.parts.columns) + layout = self.__layout + for i in range(N): + for j in range(M): + item = layout.itemAt(self.Row0 + i, self.Col0 + 2 * j) + if isinstance(item, GraphicsHeatmapWidget): + yield item + + def setShowAverages(self, visible): + self.__averagesVisible = visible + for item in self.__avgitems(): + item.setVisible(visible) + item.setPreferredWidth(0 if not visible else 10) + + def event(self, event): + # type: (QEvent) -> bool + rval = super().event(event) + if event.type() == QEvent.LayoutRequest and self.layout() is not None: + self.__update_selection_geometry() + return rval + + def setGeometry(self, rect: QRectF) -> None: + super().setGeometry(rect) + self.__update_selection_geometry() + + def __update_selection_geometry(self): + scene = self.scene() + self.__selection_manager.update_selection_rects() + rects = self.__selection_manager.selection_rects + palette = self.palette() + pen = QPen(palette.color(QPalette.Foreground), 2) + pen.setCosmetic(True) + brushcolor = QColor(palette.color(QPalette.Highlight)) + brushcolor.setAlpha(50) + selection_rects = [] + for rect, item in zip_longest(rects, self.selection_rects): + assert rect is not None or item is not None + if item is None: + item = QGraphicsRectItem(rect, None) + item.setPen(pen) + item.setBrush(brushcolor) + scene.addItem(item) + selection_rects.append(item) + elif rect is not None: + item.setRect(rect) + item.setPen(pen) + item.setBrush(brushcolor) + selection_rects.append(item) + else: + scene.removeItem(item) + self.selection_rects = selection_rects + + def __select_by_cluster(self, item, dendrogramindex): + # User clicked on a dendrogram node. + # Select all rows corresponding to the cluster item. + node = item.node + try: + hm = self.heatmap_widget_grid[dendrogramindex][0] + except IndexError: + pass + else: + key = QApplication.keyboardModifiers() + clear = not (key & ((Qt.ControlModifier | Qt.ShiftModifier | + Qt.AltModifier))) + remove = (key & (Qt.ControlModifier | Qt.AltModifier)) + append = (key & Qt.ControlModifier) + self.__selection_manager.selection_add( + node.value.first, node.value.last - 1, hm, + clear=clear, remove=remove, append=append) + + def heatmapAtPos(self, pos: QPointF) -> Optional['GraphicsHeatmapWidget']: + for hw in chain.from_iterable(self.heatmap_widget_grid): + if hw.contains(hw.mapFromItem(self, pos)): + return hw + return None + + __selecting = False + + def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: + pos = event.pos() + heatmap = self.heatmapAtPos(pos) + if heatmap and event.button() & Qt.LeftButton: + row, _ = heatmap.heatmapCellAt(heatmap.mapFromScene(event.scenePos())) + if row != -1: + self.__selection_manager.selection_start(heatmap, event) + self.__selecting = True + event.setAccepted(True) + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent) -> None: + pos = event.pos() + heatmap = self.heatmapAtPos(pos) + if heatmap and event.buttons() & Qt.LeftButton and self.__selecting: + row, _ = heatmap.heatmapCellAt(heatmap.mapFromScene(pos)) + if row != -1: + self.__selection_manager.selection_update(heatmap, event) + event.setAccepted(True) + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent) -> None: + pos = event.pos() + if event.button() == Qt.LeftButton and self.__selecting: + self.__selection_manager.selection_finish( + self.heatmapAtPos(pos), event) + self.__selecting = False + super().mouseReleaseEvent(event) + + def selectedRows(self) -> Sequence[int]: + """Return the current selected rows.""" + if self.parts is None: + return [] + visual_indices = self.__selection_manager.selections + indices = np.hstack([r.normalized_indices for r in self.parts.rows]) + return indices[visual_indices].tolist() + + def selectRows(self, selection: Sequence[int]): + """Select the specified rows. Previous selection is cleared.""" + if self.parts is not None: + indices = np.hstack([r.normalized_indices for r in self.parts.rows]) + else: + indices = [] + condition = np.in1d(indices, selection) + visual_indices = np.flatnonzero(condition) + self.__selection_manager.select_rows(visual_indices.tolist()) + + +class GraphicsHeatmapWidget(QGraphicsWidget): + __aspectMode = Qt.KeepAspectRatio + + def __init__( + self, parent=None, + data: Optional[np.ndarray] = None, + span: Tuple[float, float] = (0., 1.), + colormap: Optional[ColorMap] = None, + aspectRatioMode=Qt.KeepAspectRatio, + **kwargs + ) -> None: + super().__init__(None, **kwargs) + self.setAcceptHoverEvents(True) + self.__levels = span + if colormap is None: + colormap = ColorMap(DefaultContinuousPalette.lookup_table()) + self.__colormap = colormap + self.__data: Optional[np.ndarray] = None + self.__pixmap = QPixmap() + self.__aspectMode = aspectRatioMode + + layout = QGraphicsLinearLayout(Qt.Horizontal) + layout.setContentsMargins(0, 0, 0, 0) + self.__pixmapItem = GraphicsPixmapWidget( + self, scaleContents=True, aspectMode=Qt.IgnoreAspectRatio + ) + sp = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + sp.setHeightForWidth(True) + self.__pixmapItem.setSizePolicy(sp) + layout.addItem(self.__pixmapItem) + self.setLayout(layout) + sp = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + sp.setHeightForWidth(True) + self.setSizePolicy(sp) + self.setHeatmapData(data) + + if parent is not None: + self.setParentItem(parent) + + def setAspectMode(self, mode: Qt.AspectRatioMode) -> None: + if self.__aspectMode != mode: + self.__aspectMode = mode + self.updateGeometry() + + def aspectMode(self) -> Qt.AspectRatioMode: + return self.__aspectMode + + def sizeHint(self, which: Qt.SizeHint, constraint=QSizeF(-1, -1)) -> QSizeF: + if which == Qt.PreferredSize and constraint.width() >= 0: + sh = super().sizeHint(which) + return scaled(sh, QSizeF(constraint.width(), -1), self.__aspectMode) + return super().sizeHint(which, constraint) + + def clear(self): + """Clear/reset the widget.""" + self.__data = None + self.__pixmap = QPixmap() + self.__pixmapItem.setPixmap(self.__pixmap) + self.updateGeometry() + + def setHeatmapData(self, data): + """Set the heatmap data for display.""" + if self.__data is not data: + self.clear() + self.__data = data + self.__updatePixmap() + self.update() + + def heatmapData(self) -> Optional[np.ndarray]: + if self.__data is not None: + v = self.__data.view() + v.flags.writeable = False + return v + else: + return None + + def pixmap(self) -> QPixmap: + return self.__pixmapItem.pixmap() + + def setLevels(self, levels: Tuple[float, float]) -> None: + if levels != self.__levels: + self.__levels = levels + self.__updatePixmap() + self.update() + + def setColorMap(self, colormap: ColorMap): + self.__colormap = colormap + self.__updatePixmap() + + def colorMap(self,) -> ColorMap: + return self.__colormap + + def __updatePixmap(self): + if self.__data is not None: + cmap = self.__colormap + ll, lh = self.__levels + ll, lh = cmap.adjust_levels(ll, lh) + argb, _ = pg.makeARGB( + self.__data, lut=cmap.colortable, levels=(ll, lh)) + argb[np.isnan(self.__data)] = (100, 100, 100, 255) + qimage = pg.makeQImage(argb, transpose=False) + self.__pixmap = QPixmap.fromImage(qimage) + else: + self.__pixmap = QPixmap() + + self.__pixmapItem.setPixmap(self.__pixmap) + hmsize = QSizeF(self.__pixmap.size()) + size = QFontMetrics(self.font()).lineSpacing() + self.__pixmapItem.setMinimumSize(hmsize) + self.__pixmapItem.setPreferredSize(hmsize * size) + self.layout().invalidate() + + def heatmapCellAt(self, pos: QPointF) -> Tuple[int, int]: + """Return the cell row, column from `pos` in local coordinates. + """ + if self.__pixmap.isNull() or not \ + self.__pixmapItem.geometry().contains(pos): + return -1, -1 + assert self.__data is not None + item_clicked = self.__pixmapItem + pos = self.mapToItem(item_clicked, pos) + size = self.__pixmapItem.size() + + x, y = pos.x(), pos.y() + + N, M = self.__data.shape + fx = x / size.width() + fy = y / size.height() + i = min(int(math.floor(fy * N)), N - 1) + j = min(int(math.floor(fx * M)), M - 1) + return i, j + + def heatmapCellRect(self, row: int, column: int) -> QRectF: + """Return a rectangle in local coordinates containing the cell + at `row` and `column`. + """ + size = self.__pixmap.size() + if not (0 <= column < size.width() or 0 <= row < size.height()): + return QRectF() + + topleft = QPointF(column, row) + bottomright = QPointF(column + 1, row + 1) + t = self.__pixmapItem.pixmapTransform() + rect = t.mapRect(QRectF(topleft, bottomright)) + rect.translated(self.__pixmapItem.pos()) + return rect + + def rowRect(self, row): + """ + Return a QRectF in local coordinates containing the entire row. + """ + rect = self.heatmapCellRect(row, 0) + rect.setLeft(0) + rect.setRight(self.size().width()) + return rect + + def heatmapCellToolTip(self, row, column): + return "{}, {}: {:g}".format(row, column, self.__data[row, column]) + + def hoverMoveEvent(self, event): + pos = event.pos() + row, column = self.heatmapCellAt(pos) + if row != -1: + tooltip = self.heatmapCellToolTip(row, column) + # TODO: Move/delegate to (Scene) helpEvent + self.setToolTip(tooltip) + return super().hoverMoveEvent(event) + + +def remove_item(item: QGraphicsItem) -> None: + scene = item.scene() + if scene is not None: + scene.removeItem(item) + else: + item.setParentItem(None) + + +class GradientLegendWidget(QGraphicsWidget): + def __init__( + self, low, high, colormap: ColorMap, parent=None, **kwargs + ): + kwargs.setdefault( + "sizePolicy", QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + ) + super().__init__(None, **kwargs) + self.low = low + self.high = high + self.colormap = colormap + + layout = QGraphicsLinearLayout(Qt.Vertical) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.setLayout(layout) + self.__axis = axis = pg.AxisItem(orientation="top", maxTickLength=3) + axis.setRange(low, high) + layout.addItem(axis) + pen = QPen(self.palette().color(QPalette.Text)) + axis.setPen(pen) + self.__pixitem = GraphicsPixmapWidget( + parent=self, scaleContents=True, aspectMode=Qt.IgnoreAspectRatio + ) + self.__pixitem.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) + self.__pixitem.setMinimumHeight(12) + layout.addItem(self.__pixitem) + self.__update() + + if parent is not None: + self.setParentItem(parent) + + def setColorMap(self, colormap: ColorMap) -> None: + """Set the color map""" + self.colormap = colormap + self.__update() + + def colorMap(self) -> ColorMap: + return self.colormap + + def __update(self): + data = np.linspace(self.low, self.high, num=1000) + data = data.reshape((1, -1)) + ll, lh = self.colormap.adjust_levels(self.low, self.high) + argb, _ = pg.makeARGB(data, lut=self.colormap.colortable, + levels=(ll, lh)) + qimg = pg.makeQImage(argb, transpose=False) + self.__pixitem.setPixmap(QPixmap.fromImage(qimg)) + low, high = self.low, self.high + if self.colormap.center is not None and low < self.colormap.center < high: + ticks = [(low, f"{low:.2f}"), (0, "0"), (high, f"{high:.2f}")] + else: + ticks = [(low, f"{low:.2f}"), (high, f"{high:.2f}")] + self.__axis.setTicks([ticks]) + + self.updateGeometry() + + def changeEvent(self, event: QEvent) -> None: + if event.type() == QEvent.PaletteChange: + pen = QPen(self.palette().color(QPalette.Text)) + self.__axis.setPen(pen) + super().changeEvent(event) + + +class SelectionManager(QObject): + """ + Selection manager for heatmap rows + """ + selection_changed = Signal() + selection_finished = Signal() + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self.selections = [] + self.selection_ranges = [] + self.selection_ranges_temp = [] + self.selection_rects = [] + self.heatmaps = [] + self._heatmap_ranges: Dict[GraphicsHeatmapWidget, Tuple[int, int]] = {} + self._start_row = 0 + + def clear(self): + self.remove_rows(self.selection) + + def set_heatmap_widgets(self, widgets): + # type: (Sequence[Sequence[GraphicsHeatmapWidget]] )-> None + self.remove_rows(self.selections) + self.heatmaps = list(zip(*widgets)) + + # Compute row ranges for all heatmaps + self._heatmap_ranges = {} + for group in zip(*widgets): + start = end = 0 + for heatmap in group: + end += heatmap.heatmapData().shape[0] + self._heatmap_ranges[heatmap] = (start, end) + start = end + + def select_rows(self, rows, heatmap=None, clear=True): + """Add `rows` to selection. If `heatmap` is provided the rows + are mapped from the local indices to global heatmap indices. If `clear` + then remove previous rows. + """ + if heatmap is not None: + start, _ = self._heatmap_ranges[heatmap] + rows = [start + r for r in rows] + + old_selection = list(self.selections) + if clear: + self.selections = rows + else: + self.selections = sorted(set(self.selections + rows)) + + if self.selections != old_selection: + self.update_selection_rects() + self.selection_changed.emit() + + def remove_rows(self, rows): + """Remove `rows` from the selection. + """ + old_selection = list(self.selections) + self.selections = sorted(set(self.selections) - set(rows)) + if old_selection != self.selections: + self.update_selection_rects() + self.selection_changed.emit() + + def combined_ranges(self, ranges): + combined_ranges = set() + for start, end in ranges: + if start <= end: + rng = range(start, end + 1) + else: + rng = range(start, end - 1, -1) + combined_ranges.update(rng) + return sorted(combined_ranges) + + def selection_start(self, heatmap_widget, event): + """ Selection started by `heatmap_widget` due to `event`. + """ + pos = heatmap_widget.mapFromScene(event.scenePos()) + row, _ = heatmap_widget.heatmapCellAt(pos) + + start, _ = self._heatmap_ranges[heatmap_widget] + row = start + row + self._start_row = row + range = (row, row) + self.selection_ranges_temp = [] + if event.modifiers() & Qt.ControlModifier: + self.selection_ranges_temp = self.selection_ranges + self.selection_ranges = self.remove_range( + self.selection_ranges, row, row, append=True) + elif event.modifiers() & Qt.ShiftModifier: + self.selection_ranges.append(range) + elif event.modifiers() & Qt.AltModifier: + self.selection_ranges = self.remove_range( + self.selection_ranges, row, row, append=False) + else: + self.selection_ranges = [range] + self.select_rows(self.combined_ranges(self.selection_ranges)) + + def selection_update(self, heatmap_widget, event): + """ Selection updated by `heatmap_widget due to `event` (mouse drag). + """ + pos = heatmap_widget.mapFromScene(event.scenePos()) + row, _ = heatmap_widget.heatmapCellAt(pos) + if row < 0: + return + + start, _ = self._heatmap_ranges[heatmap_widget] + row = start + row + if event.modifiers() & Qt.ControlModifier: + self.selection_ranges = self.remove_range( + self.selection_ranges_temp, self._start_row, row, append=True) + elif event.modifiers() & Qt.AltModifier: + self.selection_ranges = self.remove_range( + self.selection_ranges, self._start_row, row, append=False) + else: + if self.selection_ranges: + self.selection_ranges[-1] = (self._start_row, row) + else: + self.selection_ranges = [(row, row)] + + self.select_rows(self.combined_ranges(self.selection_ranges)) + + def selection_finish(self, heatmap_widget, event): + """ Selection finished by `heatmap_widget due to `event`. + """ + if heatmap_widget is not None: + pos = heatmap_widget.mapFromScene(event.scenePos()) + row, _ = heatmap_widget.heatmapCellAt(pos) + start, _ = self._heatmap_ranges[heatmap_widget] + row = start + row + if event.modifiers() & Qt.ControlModifier: + pass + elif event.modifiers() & Qt.AltModifier: + self.selection_ranges = self.remove_range( + self.selection_ranges, self._start_row, row, append=False) + else: + if len(self.selection_ranges) > 0: + self.selection_ranges[-1] = (self._start_row, row) + self.select_rows(self.combined_ranges(self.selection_ranges)) + self.selection_finished.emit() + + def selection_add(self, start, end, heatmap=None, clear=True, + remove=False, append=False): + """ Add/remove a selection range from `start` to `end`. + """ + if heatmap is not None: + _start, _ = self._heatmap_ranges[heatmap] + start = _start + start + end = _start + end + + if clear: + self.selection_ranges = [] + if remove: + self.selection_ranges = self.remove_range( + self.selection_ranges, start, end, append=append) + else: + self.selection_ranges.append((start, end)) + self.select_rows(self.combined_ranges(self.selection_ranges)) + self.selection_finished.emit() + + def remove_range(self, ranges, start, end, append=False): + if start > end: + start, end = end, start + comb_ranges = [i for i in self.combined_ranges(ranges) + if i > end or i < start] + if append: + comb_ranges += [i for i in range(start, end + 1) + if i not in self.combined_ranges(ranges)] + comb_ranges = sorted(comb_ranges) + return self.combined_to_ranges(comb_ranges) + + def combined_to_ranges(self, comb_ranges): + ranges = [] + if len(comb_ranges) > 0: + i, start, end = 0, comb_ranges[0], comb_ranges[0] + for val in comb_ranges[1:]: + i += 1 + if start + i < val: + ranges.append((start, end)) + i, start = 0, val + end = val + ranges.append((start, end)) + return ranges + + def update_selection_rects(self): + """ Update the selection rects. + """ + def group_selections(selections): + """Group selections along with heatmaps. + """ + rows2hm = self.rows_to_heatmaps() + selections = iter(selections) + try: + start = end = next(selections) + except StopIteration: + return + end_heatmaps = rows2hm[end] + try: + while True: + new_end = next(selections) + new_end_heatmaps = rows2hm[new_end] + if new_end > end + 1 or new_end_heatmaps != end_heatmaps: + yield start, end, end_heatmaps + start = end = new_end + end_heatmaps = new_end_heatmaps + else: + end = new_end + + except StopIteration: + yield start, end, end_heatmaps + + def selection_rect(start, end, heatmaps): + rect = QRectF() + for heatmap in heatmaps: + h_start, _ = self._heatmap_ranges[heatmap] + rect |= heatmap.mapToScene(heatmap.rowRect(start - h_start)).boundingRect() + rect |= heatmap.mapToScene(heatmap.rowRect(end - h_start)).boundingRect() + return rect + + self.selection_rects = [] + for start, end, heatmaps in group_selections(self.selections): + rect = selection_rect(start, end, heatmaps) + self.selection_rects.append(rect) + + def rows_to_heatmaps(self): + heatmap_groups = zip(*self.heatmaps) + rows2hm = {} + for heatmaps in heatmap_groups: + hm = heatmaps[0] + start, end = self._heatmap_ranges[hm] + rows2hm.update(dict.fromkeys(range(start, end), heatmaps)) + return rows2hm + + +Parts = HeatmapGridWidget.Parts +RowItem = HeatmapGridWidget.RowItem +ColumnItem = HeatmapGridWidget.ColumnItem diff --git a/Orange/widgets/visualize/utils/tests/__init__.py b/Orange/widgets/visualize/utils/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Orange/widgets/visualize/utils/tests/test_heatmap.py b/Orange/widgets/visualize/utils/tests/test_heatmap.py new file mode 100644 index 00000000000..24419d01e3c --- /dev/null +++ b/Orange/widgets/visualize/utils/tests/test_heatmap.py @@ -0,0 +1,167 @@ +import numpy as np + +from AnyQt.QtCore import Qt, QPoint +from AnyQt.QtTest import QTest, QSignalSpy +from AnyQt.QtWidgets import QGraphicsScene, QGraphicsView + +from orangecanvas.gui.test import mouseMove +from orangewidget.tests.base import GuiTest + +from Orange.clustering.hierarchical import Tree, SingletonData, ClusterData +from Orange.widgets.visualize.utils.heatmap import HeatmapGridWidget, ColorMap + + +class TestHeatmapGridWidget(GuiTest): + scene: QGraphicsScene + view: QGraphicsView + + def setUp(self) -> None: + super().setUp() + self.view = QGraphicsView() + self.scene = QGraphicsScene(self.view) + self.view.setScene(self.scene) + + def tearDown(self) -> None: + self.scene.clear() + self.scene.deleteLater() + self.scene = None + self.view.deleteLater() + self.view = None + super().tearDown() + + _c2 = Tree(ClusterData((0, 1), 0.5), ( + Tree(SingletonData((0, 0), 0, 0), ()), + Tree(SingletonData((1, 1), 0, 1), ()), + )) + _Data = { + "0-0": HeatmapGridWidget.Parts( + rows=[], columns=[], data=np.zeros(shape=(0, 0)), span=(0, 0) + ), + "1-0": HeatmapGridWidget.Parts( + rows=[], columns=[], data=np.zeros(shape=(0, 0)), span=(0, 0) + ), + "0-1": HeatmapGridWidget.Parts( + rows=[], columns=[HeatmapGridWidget.ColumnItem("a", [0])], + data=np.zeros(shape=(0, 1)), span=(0, 1) + ), + "1-1": HeatmapGridWidget.Parts( + rows=[HeatmapGridWidget.RowItem("a", [0])], + columns=[HeatmapGridWidget.ColumnItem("a", [0])], + data=np.zeros(shape=(1, 1)), span=(0, 1), + row_names=["a"], col_names=["b"], + ), + "2-2-split": HeatmapGridWidget.Parts( + rows=[ + HeatmapGridWidget.RowItem("a", [0]), + HeatmapGridWidget.RowItem("b", [1]), + ], + columns=[ + HeatmapGridWidget.ColumnItem("a", [0]), + HeatmapGridWidget.ColumnItem("a", [1]), + ], + data=np.zeros(shape=(2, 2)), span=(-1, 1), + row_names=["a", "b"], + col_names=["b", "b"], + ), + "2-2-cl": HeatmapGridWidget.Parts( + rows=[HeatmapGridWidget.RowItem("", [0, 1], _c2)], + columns=[HeatmapGridWidget.ColumnItem("", [0, 1], _c2)], + data=np.zeros(shape=(2, 2)), span=(-1, 1), + row_names=["a", "b"], + col_names=["b", "b"], + ), + "2-2": HeatmapGridWidget.Parts( + rows=[HeatmapGridWidget.RowItem("", [0, 1])], + columns=[HeatmapGridWidget.ColumnItem("", [0, 1])], + data=np.zeros(shape=(2, 2)), span=(-1, 1), + row_names=["a", "b"], + col_names=["b", "b"], + ) + } + + def test_widget(self): + w = HeatmapGridWidget() + self.scene.addItem(w) + + for p in self._Data.values(): + w.setHeatmaps(p) + + w.headerGeometry() + w.footerGeometry() + + def test_widget_annotations(self): + w = HeatmapGridWidget() + self.scene.addItem(w) + w.setHeatmaps(self._Data["2-2"]) + # Coverage. The game. + w.setLegendVisible(True) + w.setLegendVisible(False) + + w.setShowAverages(True) + w.setShowAverages(False) + + w.setRowLabels(None) + w.setRowLabels(["1", "2"]) + + w.setRowLabelsVisible(False) + w.setRowLabelsVisible(True) + + w.setColumnLabels(None) + w.setColumnLabels(["1", "2"]) + + w.setAspectRatioMode(Qt.IgnoreAspectRatio) + w.setAspectRatioMode(Qt.KeepAspectRatio) + w.setAspectRatioMode(Qt.KeepAspectRatioByExpanding) + + for pos in ( + HeatmapGridWidget.NoPosition, + HeatmapGridWidget.PositionTop, + HeatmapGridWidget.PositionBottom, + HeatmapGridWidget.PositionTop | HeatmapGridWidget.PositionBottom, + ): + w.setColumnLabelsPosition(pos) + + w.setRowSideColorAnnotations(np.array([[255] * 3, [0] * 3]), "c") + w.setRowSideColorAnnotations(None) + + def test_selection(self): + w = HeatmapGridWidget() + self.scene.addItem(w) + w.setHeatmaps(self._Data["2-2"]) + view = self.view + w.resize(w.effectiveSizeHint(Qt.PreferredSize)) + h = w.layout().itemAt(w.Row0, w.Col0 + 1) + pos = view.mapFromScene(h.scenePos()) + spy = QSignalSpy(w.selectionFinished) + QTest.mouseClick( + view.viewport(), Qt.LeftButton, pos=pos + QPoint(1, 1) + ) + self.assertSequenceEqual(list(spy), [[]]) + self.assertSequenceEqual(w.selectedRows(), [0]) + spy = QSignalSpy(w.selectionFinished) + QTest.mouseClick( + view.viewport(), Qt.LeftButton, Qt.ControlModifier, + pos=pos + QPoint(1, 1) + ) + self.assertSequenceEqual(list(spy), [[]]) + self.assertSequenceEqual(w.selectedRows(), []) + + spy = QSignalSpy(w.selectionFinished) + QTest.mousePress(view.viewport(), Qt.LeftButton, pos=pos + QPoint(1, 1)) + mouseMove(view.viewport(), Qt.LeftButton, pos=pos + QPoint(20, 20)) + QTest.mouseRelease(view.viewport(), Qt.LeftButton, + pos=pos + QPoint(30, 40)) + self.assertSequenceEqual(list(spy), [[]]) + + spy_fin = QSignalSpy(w.selectionFinished) + spy_chn = QSignalSpy(w.selectionChanged) + w.selectRows([1]) + self.assertSequenceEqual(list(spy_fin), []) + self.assertSequenceEqual(list(spy_chn), [[]]) + + def test_colormap(self): + w = HeatmapGridWidget() + self.scene.addItem(w) + w.setHeatmaps(self._Data["2-2"]) + w.setColorMap(ColorMap([[255] * 3, [0] * 3])) + w.setColorMap(ColorMap([[255] * 3, [0] * 3], center=0))