diff --git a/Orange/widgets/unsupervised/owdistancemap.py b/Orange/widgets/unsupervised/owdistancemap.py index 9de6efc2135..cc39d41c2d1 100644 --- a/Orange/widgets/unsupervised/owdistancemap.py +++ b/Orange/widgets/unsupervised/owdistancemap.py @@ -5,8 +5,7 @@ import numpy from AnyQt.QtWidgets import ( - QFormLayout, QGraphicsRectItem, QGraphicsGridLayout, QApplication, - QSizePolicy + QGraphicsRectItem, QGraphicsGridLayout, QApplication, QSizePolicy ) from AnyQt.QtGui import QFontMetrics, QPen, QTransform, QFont from AnyQt.QtCore import Qt, QRect, QRectF, QPointF @@ -30,6 +29,7 @@ from Orange.widgets.visualize.utils.heatmap import ( GradientColorMap, GradientLegendWidget, ) +from Orange.widgets.utils.colorgradientselection import ColorGradientSelection def _remove_item(item): @@ -301,28 +301,23 @@ def __init__(self): callback=self._invalidate_ordering) box = gui.vBox(self.controlArea, "Colors") - self.color_box = gui.palette_combo_box(self.palette_name) - self.color_box.currentIndexChanged.connect(self._update_color) - box.layout().addWidget(self.color_box) - - form = QFormLayout( - formAlignment=Qt.AlignLeft, - labelAlignment=Qt.AlignLeft, - fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow + self.color_map_widget = cmw = ColorGradientSelection( + thresholds=(self.color_low, self.color_high), ) - form.addRow( - "Low:", - gui.hSlider(box, self, "color_low", minValue=0.0, maxValue=1.0, - step=0.05, ticks=True, intOnly=False, - createLabel=False, callback=self._update_color) - ) - form.addRow( - "High:", - gui.hSlider(box, self, "color_high", minValue=0.0, maxValue=1.0, - step=0.05, ticks=True, intOnly=False, - createLabel=False, callback=self._update_color) - ) - box.layout().addLayout(form) + model = itemmodels.ContinuousPalettesModel(parent=self) + cmw.setModel(model) + idx = cmw.findData(self.palette_name, model.KeyRole) + if idx != -1: + cmw.setCurrentIndex(idx) + + cmw.activated.connect(self._update_color) + + def _set_thresholds(low, high): + self.color_low, self.color_high = low, high + self._update_color() + + cmw.thresholdsChanged.connect(_set_thresholds) + box.layout().addWidget(self.color_map_widget) self.annot_combo = gui.comboBox( self.controlArea, self, "annotation_idx", box="Annotations", @@ -342,9 +337,7 @@ def __init__(self): self.grid = QGraphicsGridLayout() self.grid_widget.setLayout(self.grid) - self.gradient_legend = GradientLegendWidget( - 0, 1, self._color_map() - ) + self.gradient_legend = GradientLegendWidget(0, 1, self._color_map()) self.gradient_legend.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.gradient_legend.setMaximumWidth(250) self.grid.addItem(self.gradient_legend, 0, 1) @@ -613,17 +606,18 @@ def _set_labels(self, labels): self.bottom_labels.setMaximumHeight(constraint) def _color_map(self) -> GradientColorMap: - palette = self.color_box.currentData() + palette = self.color_map_widget.currentData() return GradientColorMap( palette.lookup_table(), thresholds=(self.color_low, max(self.color_high, self.color_low)), span=(0., self._matrix_range)) def _update_color(self): - palette = self.color_box.currentData() + palette = self.color_map_widget.currentData() self.palette_name = palette.name if self.matrix_item: - colors = palette.lookup_table(self.color_low, self.color_high) + cmap = self._color_map().replace(span=(0., 1.)) + colors = cmap.apply(numpy.arange(256) / 255.) self.matrix_item.setLookupTable(colors) self.gradient_legend.show() self.gradient_legend.setRange(0, self._matrix_range) diff --git a/Orange/widgets/utils/colorgradientselection.py b/Orange/widgets/utils/colorgradientselection.py new file mode 100644 index 00000000000..bf2687a84f3 --- /dev/null +++ b/Orange/widgets/utils/colorgradientselection.py @@ -0,0 +1,151 @@ +from typing import Any, Tuple + +from AnyQt.QtCore import Qt, QSize, QAbstractItemModel, Property +from AnyQt.QtWidgets import ( + QWidget, QSlider, QFormLayout, QComboBox, QStyle +) +from AnyQt.QtCore import Signal + +from Orange.widgets.utils import itemmodels + + +class ColorGradientSelection(QWidget): + activated = Signal(int) + + currentIndexChanged = Signal(int) + thresholdsChanged = Signal(float, float) + + def __init__(self, *args, thresholds=(0.0, 1.0), **kwargs): + super().__init__(*args, **kwargs) + + low = round(clip(thresholds[0], 0., 1.), 2) + high = round(clip(thresholds[1], 0., 1.), 2) + high = max(low, high) + self.__threshold_low, self.__threshold_high = low, high + form = QFormLayout( + formAlignment=Qt.AlignLeft, + labelAlignment=Qt.AlignLeft, + fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow + ) + form.setContentsMargins(0, 0, 0, 0) + self.gradient_cb = QComboBox( + None, objectName="gradient-combo-box", + ) + self.gradient_cb.setAttribute(Qt.WA_LayoutUsesWidgetRect) + icsize = self.style().pixelMetric( + QStyle.PM_SmallIconSize, None, self.gradient_cb + ) + self.gradient_cb.setIconSize(QSize(64, icsize)) + model = itemmodels.ContinuousPalettesModel() + model.setParent(self) + + self.gradient_cb.setModel(model) + self.gradient_cb.activated[int].connect(self.activated) + self.gradient_cb.currentIndexChanged.connect(self.currentIndexChanged) + + slider_low = QSlider( + objectName="threshold-low-slider", minimum=0, maximum=100, + value=int(low * 100), orientation=Qt.Horizontal, + tickPosition=QSlider.TicksBelow, pageStep=10, + toolTip=self.tr("Low gradient threshold"), + whatsThis=self.tr("Applying a low threshold will squeeze the " + "gradient from the lower end") + ) + slider_high = QSlider( + objectName="threshold-low-slider", minimum=0, maximum=100, + value=int(high * 100), orientation=Qt.Horizontal, + tickPosition=QSlider.TicksAbove, pageStep=10, + toolTip=self.tr("High gradient threshold"), + whatsThis=self.tr("Applying a high threshold will squeeze the " + "gradient from the higher end") + ) + form.setWidget(0, QFormLayout.SpanningRole, self.gradient_cb) + form.addRow(self.tr("Low:"), slider_low) + form.addRow(self.tr("High:"), slider_high) + self.slider_low = slider_low + self.slider_high = slider_high + self.slider_low.valueChanged.connect(self.__on_slider_low_moved) + self.slider_high.valueChanged.connect(self.__on_slider_high_moved) + self.setLayout(form) + + def setModel(self, model: QAbstractItemModel) -> None: + self.gradient_cb.setModel(model) + + def model(self) -> QAbstractItemModel: + return self.gradient_cb.model() + + def findData(self, data: Any, role: Qt.ItemDataRole) -> int: + return self.gradient_cb.findData(data, role) + + def setCurrentIndex(self, index: int) -> None: + self.gradient_cb.setCurrentIndex(index) + + def currentIndex(self) -> int: + return self.gradient_cb.currentIndex() + + currentIndex_ = Property( + int, currentIndex, setCurrentIndex, notify=currentIndexChanged) + + def currentData(self, role=Qt.UserRole) -> Any: + return self.gradient_cb.currentData(role) + + def thresholds(self) -> Tuple[float, float]: + return self.__threshold_low, self.__threshold_high + + thresholds_ = Property(object, thresholds, notify=thresholdsChanged) + + def thresholdLow(self) -> float: + return self.__threshold_low + + def setThresholdLow(self, low: float) -> None: + self.setThresholds(low, max(self.__threshold_high, low)) + + thresholdLow_ = Property( + float, thresholdLow, setThresholdLow, notify=thresholdsChanged) + + def thresholdHigh(self) -> float: + return self.__threshold_high + + def setThresholdHigh(self, high: float) -> None: + self.setThresholds(min(self.__threshold_low, high), high) + + thresholdHigh_ = Property( + float, thresholdLow, setThresholdLow, notify=thresholdsChanged) + + def __on_slider_low_moved(self, value: int) -> None: + high = self.slider_high + old = self.__threshold_low, self.__threshold_high + self.__threshold_low = value / 100. + if value >= high.value(): + self.__threshold_high = value / 100. + high.setSliderPosition(value) + new = self.__threshold_low, self.__threshold_high + if new != old: + self.thresholdsChanged.emit(*new) + + def __on_slider_high_moved(self, value: int) -> None: + low = self.slider_low + old = self.__threshold_low, self.__threshold_high + self.__threshold_high = value / 100. + if low.value() >= value: + self.__threshold_low = value / 100 + low.setSliderPosition(value) + new = self.__threshold_low, self.__threshold_high + if new != old: + self.thresholdsChanged.emit(*new) + + def setThresholds(self, low: float, high: float) -> None: + low = round(clip(low, 0., 1.), 2) + high = round(clip(high, 0., 1.), 2) + if low > high: + high = low + if self.__threshold_low != low or self.__threshold_high != high: + self.__threshold_high = high + self.__threshold_low = low + self.slider_low.setSliderPosition(low * 100) + self.slider_high.setSliderPosition(high * 100) + self.thresholdsChanged.emit(high, low) + + +def clip(a, amin, amax): + return min(max(a, amin), amax) diff --git a/Orange/widgets/utils/itemmodels.py b/Orange/widgets/utils/itemmodels.py index 3bae2370cc9..8a39505b24a 100644 --- a/Orange/widgets/utils/itemmodels.py +++ b/Orange/widgets/utils/itemmodels.py @@ -605,6 +605,7 @@ class ContinuousPalettesModel(QAbstractListModel): """ Model for combo boxes """ + KeyRole = Qt.UserRole + 1 def __init__(self, parent=None, categories=None, icon_width=64): super().__init__(parent) self.icon_width = icon_width @@ -641,6 +642,8 @@ def data(self, index, role): return item.color_strip(self.icon_width, 16) if role == Qt.UserRole: return item + if role == self.KeyRole: + return item.name return None def flags(self, index): diff --git a/Orange/widgets/utils/tests/test_colorgradientselection.py b/Orange/widgets/utils/tests/test_colorgradientselection.py new file mode 100644 index 00000000000..eee7ddf3cb3 --- /dev/null +++ b/Orange/widgets/utils/tests/test_colorgradientselection.py @@ -0,0 +1,70 @@ +import numpy as np + +from AnyQt.QtTest import QSignalSpy +from AnyQt.QtCore import Qt, QStringListModel + +from Orange.widgets.utils.colorgradientselection import ColorGradientSelection +from Orange.widgets.tests.base import GuiTest + +class TestColorGradientSelection(GuiTest): + def test_constructor(self): + w = ColorGradientSelection(thresholds=(0.1, 0.9)) + self.assertEqual(w.thresholds(), (0.1, 0.9)) + + w = ColorGradientSelection(thresholds=(-0.1, 1.1)) + self.assertEqual(w.thresholds(), (0.0, 1.0)) + + w = ColorGradientSelection(thresholds=(1.0, 0.0)) + self.assertEqual(w.thresholds(), (1.0, 1.0)) + + def test_setModel(self): + w = ColorGradientSelection() + model = QStringListModel(["A", "B"]) + w.setModel(model) + self.assertIs(w.model(), model) + self.assertEqual(w.findData("B", Qt.DisplayRole), 1) + current = QSignalSpy(w.currentIndexChanged) + w.setCurrentIndex(1) + self.assertEqual(w.currentIndex(), 1) + self.assertSequenceEqual(list(current), [[1]]) + + def test_thresholds(self): + w = ColorGradientSelection() + w.setThresholds(0.2, 0.8) + self.assertEqual(w.thresholds(), (0.2, 0.8)) + w.setThresholds(0.5, 0.5) + self.assertEqual(w.thresholds(), (0.5, 0.5)) + w.setThresholds(0.5, np.nextafter(0.5, 0)) + self.assertEqual(w.thresholds(), (0.5, 0.5)) + w.setThresholds(-1, 2) + self.assertEqual(w.thresholds(), (0., 1.)) + w.setThresholds(0.1, 0.0) + self.assertEqual(w.thresholds(), (0.1, 0.1)) + w.setThresholdLow(0.2) + self.assertEqual(w.thresholds(), (0.2, 0.2)) + self.assertEqual(w.thresholdLow(), 0.2) + w.setThresholdHigh(0.1) + self.assertEqual(w.thresholdHigh(), 0.1) + self.assertEqual(w.thresholds(), (0.1, 0.1)) + + def test_slider_move(self): + w = ColorGradientSelection() + w.adjustSize() + w.setThresholds(0.5, 0.5) + changed = QSignalSpy(w.thresholdsChanged) + sl, sh = w.slider_low, w.slider_high + sl.triggerAction(sl.SliderToMinimum) + self.assertEqual(len(changed), 1) + low, high = changed[-1] + self.assertLessEqual(low, high) + self.assertEqual(low, 0.0) + sl.triggerAction(sl.SliderToMaximum) + self.assertEqual(len(changed), 2) + low, high = changed[-1] + self.assertLessEqual(low, high) + self.assertEqual(low, 1.0) + sh.triggerAction(sl.SliderToMinimum) + self.assertEqual(len(changed), 3) + low, high = changed[-1] + self.assertLessEqual(low, high) + self.assertEqual(high, 0.0) diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py index 34368c77515..9349ed985b5 100644 --- a/Orange/widgets/visualize/owheatmap.py +++ b/Orange/widgets/visualize/owheatmap.py @@ -16,16 +16,16 @@ from AnyQt.QtCore import Qt, QSize, QRectF, QObject from orangewidget.utils.combobox import ComboBox, ComboBoxSearch -from Orange.data import Domain, Table, Variable, DiscreteVariable +from Orange.data import Domain, Table, Variable from Orange.data.sql.table import SqlTable import Orange.distance from Orange.clustering import hierarchical, kmeans -from Orange.widgets.utils import colorpalettes, apply_all +from Orange.widgets.utils import colorpalettes, apply_all, itemmodels from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView from Orange.widgets.utils.graphicsview import GraphicsWidgetView -from Orange.widgets.utils.colorpalettes import DiscretePalette, Palette +from Orange.widgets.utils.colorpalettes import Palette from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) @@ -35,6 +35,7 @@ from Orange.widgets.data.oweditdomain import table_column_data from Orange.widgets.visualize.utils.heatmap import HeatmapGridWidget, \ ColorMap, CategoricalColorMap, GradientColorMap +from Orange.widgets.utils.colorgradientselection import ColorGradientSelection from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.utils.state_summary import format_summary_details @@ -240,28 +241,23 @@ def _(): # GUI definition colorbox = gui.vBox(self.controlArea, "Color") - self.color_cb = gui.palette_combo_box(self.palette_name) - self.color_cb.currentIndexChanged.connect(self.update_color_schema) - colorbox.layout().addWidget(self.color_cb) - form = QFormLayout( - formAlignment=Qt.AlignLeft, - labelAlignment=Qt.AlignLeft, - fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow + self.color_map_widget = cmw = ColorGradientSelection( + thresholds=(self.threshold_low, self.threshold_high), ) - lowslider = gui.hSlider( - colorbox, self, "threshold_low", minValue=0.0, maxValue=1.0, - step=0.05, ticks=True, intOnly=False, - createLabel=False, callback=self.update_lowslider) - highslider = gui.hSlider( - colorbox, self, "threshold_high", minValue=0.0, maxValue=1.0, - step=0.05, ticks=True, intOnly=False, - createLabel=False, callback=self.update_highslider) + model = itemmodels.ContinuousPalettesModel(parent=self) + cmw.setModel(model) + idx = cmw.findData(self.palette_name, model.KeyRole) + if idx != -1: + cmw.setCurrentIndex(idx) - form.addRow("Low:", lowslider) - form.addRow("High:", highslider) + cmw.activated.connect(self.update_color_schema) - colorbox.layout().addLayout(form) + def _set_thresholds(low, high): + self.threshold_low, self.threshold_high = low, high + self.update_color_schema() + cmw.thresholdsChanged.connect(_set_thresholds) + colorbox.layout().addWidget(self.color_map_widget) mergebox = gui.vBox(self.controlArea, "Merge",) gui.checkBox(mergebox, self, "merge_kmeans", "Merge by k-means", @@ -417,7 +413,7 @@ class HeatmapScene(QGraphicsScene): @property def center_palette(self): - palette = self.color_cb.currentData() + palette = self.color_map_widget.currentData() return bool(palette.flags & palette.Diverging) @property @@ -448,7 +444,7 @@ def sizeHint(self) -> QSize: return super().sizeHint().expandedTo(QSize(900, 700)) def color_palette(self): - return self.color_cb.currentData().lookup_table() + return self.color_map_widget.currentData().lookup_table() def color_map(self) -> GradientColorMap: return GradientColorMap( @@ -917,20 +913,8 @@ def update_averages_stripe(self): if widget is not None: widget.setShowAverages(self.averages) - def update_lowslider(self): - low, high = self.controls.threshold_low, self.controls.threshold_high - if low.value() >= high.value(): - low.setSliderPosition(high.value() - 1) - self.update_color_schema() - - def update_highslider(self): - low, high = self.controls.threshold_low, self.controls.threshold_high - if low.value() >= high.value(): - high.setSliderPosition(low.value() + 1) - self.update_color_schema() - def update_color_schema(self): - self.palette_name = self.color_cb.currentData().name + self.palette_name = self.color_map_widget.currentData().name w = self.scene.widget if w is not None: w.setColorMap(self.color_map()) diff --git a/Orange/widgets/visualize/tests/test_owheatmap.py b/Orange/widgets/visualize/tests/test_owheatmap.py index 74ad017f5ac..d0dbf978055 100644 --- a/Orange/widgets/visualize/tests/test_owheatmap.py +++ b/Orange/widgets/visualize/tests/test_owheatmap.py @@ -124,15 +124,6 @@ def test_not_enough_data_settings_changed(self): self.assertFalse(msg.not_enough_instances.is_shown()) self.assertFalse(msg.not_enough_instances_k_means.is_shown()) - def test_color_low_high(self): - """ - Prevent horizontal sliders to set Low >= High. - GH-2025 - """ - self.widget.controls.threshold_low.setValue(4) - self.widget.controls.threshold_high.setValue(2) - self.assertGreater(self.widget.threshold_high, self.widget.threshold_low) - def test_data_column_nans(self): """ Send data with one column with all values set to NaN. @@ -256,12 +247,12 @@ def test_palette_centering(self): def test_palette_center(self): widget = self.widget - model = widget.color_cb.model() + model = widget.color_map_widget.model() for idx in range(model.rowCount(QModelIndex())): palette = model.data(model.index(idx, 0), Qt.UserRole) if palette is None: continue - widget.color_cb.setCurrentIndex(idx) + widget.color_map_widget.setCurrentIndex(idx) self.assertEqual(widget.center_palette, bool(palette.flags & palette.Diverging)) diff --git a/Orange/widgets/visualize/utils/heatmap.py b/Orange/widgets/visualize/utils/heatmap.py index 7d27cd61877..433e57f1095 100644 --- a/Orange/widgets/visualize/utils/heatmap.py +++ b/Orange/widgets/visualize/utils/heatmap.py @@ -116,7 +116,16 @@ def apply(self, data) -> np.ndarray: low, high = self.span low, high = self.adjust_levels(low, high) mask = np.isnan(data) - normalized = (data - low) / (high - low) + normalized = data - low + finfo = np.finfo(normalized.dtype) + if high - low <= 1 / finfo.max: + n_fact = finfo.max + else: + n_fact = 1. / (high - low) + # over/underflow to inf are expected and cliped with the rest in the + # next step + with np.errstate(over="ignore", under="ignore"): + normalized *= n_fact normalized = np.clip(normalized, 0, 1, out=normalized) table = np.empty_like(normalized, dtype=np.uint8) ncolors = len(self.colortable)