diff --git a/.travis/build_doc.sh b/.travis/build_doc.sh index c4cd39c0ad8..2fc9d3ea770 100644 --- a/.travis/build_doc.sh +++ b/.travis/build_doc.sh @@ -11,6 +11,9 @@ images="$(git diff --name-only origin/master..HEAD | echo "Checking if images are indexed:" while read image; do [ -f "$image" ] || continue + if [[ "$image" == *"_unindexed"* ]]; then + continue + fi imtype=$(identify -verbose "$image" | awk '/^ *Type: /{ print $2 }') echo "$image $imtype" if ! echo "$imtype" | grep -Eq '(Palette|Grayscale)'; then diff --git a/Orange/data/tests/test_variable.py b/Orange/data/tests/test_variable.py index 8299270aa29..bb6f92533fe 100644 --- a/Orange/data/tests/test_variable.py +++ b/Orange/data/tests/test_variable.py @@ -190,31 +190,6 @@ def test_repr(self): repr(var), "DiscreteVariable(name='a', values=['1', '2', '3', '4', '5', '6', '7'])") - @unittest.skipUnless(is_on_path("PyQt4") or is_on_path("PyQt5"), "PyQt is not importable") - def test_colors(self): - var = DiscreteVariable.make("a", values=["F", "M"]) - self.assertIsNone(var._colors) - self.assertEqual(var.colors.shape, (2, 3)) - self.assertFalse(var.colors.flags.writeable) - - var.colors = np.arange(6).reshape((2, 3)) - np.testing.assert_almost_equal(var.colors, [[0, 1, 2], [3, 4, 5]]) - self.assertFalse(var.colors.flags.writeable) - with self.assertRaises(ValueError): - var.colors[0] = [42, 41, 40] - - var = DiscreteVariable.make("x", values=["A", "B"]) - var.attributes["colors"] = ['#0a0b0c', '#0d0e0f'] - np.testing.assert_almost_equal(var.colors, [[10, 11, 12], [13, 14, 15]]) - - # Test ncolors adapts to nvalues - var = DiscreteVariable.make('foo', values=['d', 'r']) - self.assertEqual(len(var.colors), 2) - var.add_value('e') - self.assertEqual(len(var.colors), 3) - var.add_value('k') - self.assertEqual(len(var.colors), 4) - def test_no_nonstringvalues(self): self.assertRaises(TypeError, DiscreteVariable, "foo", values=["a", 42]) a = DiscreteVariable("foo", values=["a", "b", "c"]) @@ -394,6 +369,23 @@ def varcls_modified(self, name): var.ordered = True return var + def test_copy_checks_len_values(self): + var = DiscreteVariable("gender", values=["F", "M"]) + self.assertEqual(var.values, ["F", "M"]) + + self.assertRaises(ValueError, var.copy, values=["F", "M", "N"]) + self.assertRaises(ValueError, var.copy, values=["F"]) + self.assertRaises(ValueError, var.copy, values=[]) + + var2 = var.copy() + self.assertEqual(var2.values, ["F", "M"]) + + var2 = var.copy(values=None) + self.assertEqual(var2.values, ["F", "M"]) + + var2 = var.copy(values=["W", "M"]) + self.assertEqual(var2.values, ["W", "M"]) + @variabletest(ContinuousVariable) class TestContinuousVariable(VariableTest): @@ -423,17 +415,6 @@ def test_adjust_decimals(self): a.val_from_str_add("5.1234") self.assertEqual(a.str_val(4.65432), "4.6543") - def test_colors(self): - a = ContinuousVariable("a") - self.assertEqual(a.colors, ((0, 0, 255), (255, 255, 0), False)) - - a = ContinuousVariable("a") - a.attributes["colors"] = ['#010203', '#040506', True] - self.assertEqual(a.colors, ((1, 2, 3), (4, 5, 6), True)) - - a.colors = ((3, 2, 1), (6, 5, 4), True) - self.assertEqual(a.colors, ((3, 2, 1), (6, 5, 4), True)) - def varcls_modified(self, name): var = super().varcls_modified(name) var.number_of_decimals = 5 @@ -621,21 +602,6 @@ def test_make_proxy_cont(self): self.assertEqual(hash(abc), hash(abc1)) self.assertEqual(hash(abc1), hash(abc2)) - def test_proxy_has_separate_colors(self): - abc = ContinuousVariable("abc") - abc1 = abc.make_proxy() - abc2 = abc1.make_proxy() - - original_colors = abc.colors - red_to_green = (255, 0, 0), (0, 255, 0), False - blue_to_red = (0, 0, 255), (255, 0, 0), False - - abc1.colors = red_to_green - abc2.colors = blue_to_red - self.assertEqual(abc.colors, original_colors) - self.assertEqual(abc1.colors, red_to_green) - self.assertEqual(abc2.colors, blue_to_red) - def test_proxy_has_separate_attributes(self): image = StringVariable("image") image1 = image.make_proxy() diff --git a/Orange/data/variable.py b/Orange/data/variable.py index f2391520665..b12ae54c7ed 100644 --- a/Orange/data/variable.py +++ b/Orange/data/variable.py @@ -11,8 +11,8 @@ import scipy.sparse as sp from Orange.data import _variable -from Orange.util import Registry, hex_to_color, Reprable,\ - OrangeDeprecationWarning +from Orange.util import Registry, Reprable, OrangeDeprecationWarning + __all__ = ["Unknown", "MISSING_VALUES", "make_variable", "is_discrete_values", "Value", "Variable", "ContinuousVariable", "DiscreteVariable", @@ -323,20 +323,11 @@ def __init__(self, name="", compute_value=None, *, sparse=False): self.source_variable = None self.sparse = sparse self.attributes = {} - self._colors = None @property def name(self): return self._name - @property - def colors(self): # unreachable; pragma: no cover - return self._colors - - @colors.setter - def colors(self, value): - self._colors = value - def make_proxy(self): """ Copy the variable and set the master to `self.master` or to `self`. @@ -519,17 +510,6 @@ def format_str(self): def format_str(self, value): self._format_str = value - @Variable.colors.getter - def colors(self): - if self._colors is not None: - return self._colors - try: - col1, col2, black = self.attributes["colors"] - return (hex_to_color(col1), hex_to_color(col2), black) - except (KeyError, ValueError): - # User-provided colors were not available or invalid - return ((0, 0, 255), (255, 255, 0), False) - # noinspection PyAttributeOutsideInit @number_of_decimals.setter def number_of_decimals(self, x): @@ -696,22 +676,6 @@ def mapper(value, col_idx=None): return mapper - @Variable.colors.getter - def colors(self): - if self._colors is not None: - colors = np.array(self._colors) - elif not self.values: - colors = np.zeros((0, 3)) # to match additional colors in vstacks - else: - from Orange.widgets.utils.colorpalette import ColorPaletteGenerator - default = tuple(ColorPaletteGenerator.palette(self)) - colors = self.attributes.get('colors', ()) - colors = tuple(hex_to_color(color) for color in colors) \ - + default[len(colors):] - colors = np.array(colors) - colors.flags.writeable = False - return colors - def to_val(self, s): """ Convert the given argument to a value of the variable (`float`). @@ -744,7 +708,6 @@ def add_value(self, s): if not isinstance(s, str): raise TypeError("values of DiscreteVariables must be strings") self.values.append(s) - self._colors = None def val_from_str_add(self, s): """ @@ -787,9 +750,12 @@ def __reduce__(self): self.values, self.ordered), \ __dict__ - def copy(self, compute_value=None, *, name=None, **_): + def copy(self, compute_value=None, *, name=None, values=None, **_): + if values is not None and len(values) != len(self.values): + raise ValueError( + "number of values must match the number of original values") return super().copy(compute_value=compute_value, name=name, - values=self.values, ordered=self.ordered) + values=values or self.values, ordered=self.ordered) class StringVariable(Variable): diff --git a/Orange/widgets/data/owcolor.py b/Orange/widgets/data/owcolor.py index 40000df8195..8e184dc379a 100644 --- a/Orange/widgets/data/owcolor.py +++ b/Orange/widgets/data/owcolor.py @@ -1,79 +1,154 @@ -""" -Widget for assigning colors to variables -""" from itertools import chain import numpy as np -from AnyQt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex -from AnyQt.QtGui import QColor, QFont, QImage, QBrush, qRgb -from AnyQt.QtWidgets import QHeaderView, QColorDialog, QTableView + +from AnyQt.QtCore import Qt, QSize, QAbstractTableModel, QModelIndex, QTimer +from AnyQt.QtGui import QColor, QFont, QBrush +from AnyQt.QtWidgets import QHeaderView, QColorDialog, QTableView, QComboBox import Orange +from Orange.util import color_to_hex from Orange.widgets import widget, settings, gui from Orange.widgets.gui import HorizontalGridDelegate -from Orange.widgets.utils.colorpalette import \ - ContinuousPaletteGenerator, ColorPaletteDlg +from Orange.widgets.utils import itemmodels, colorpalettes from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output +from orangewidget.settings import IncompatibleContext ColorRole = next(gui.OrangeUserRole) +StripRole = next(gui.OrangeUserRole) class AttrDesc: - def __init__(self, var, name=None, colors=None, values=None): + """ + Describes modifications that will be applied to variable. + + Provides methods that return either the modified value or the original + + Attributes: + var (Variable): an instance of variable + new_name (str or `None`): a changed name or `None` + """ + def __init__(self, var): self.var = var - self.name = name - self.colors = colors - self.values = values + self.new_name = None + + @property + def name(self): + return self.new_name or self.var.name + + @name.setter + def name(self, name): + self.new_name = name + + +class DiscAttrDesc(AttrDesc): + """ + Describes modifications that will be applied to variable. + + Provides methods that return either the modified value or the original + + Attributes: + var (DiscreteVariable): an instance of variable + name (str or `None`): a changed name or `None` + new_colors (list of tuple or None): new colors as tuples (R, G, B) + new_values (list of str or None): new names for values, if changed + """ + def __init__(self, var): + super().__init__(var) + self.new_colors = None + self.new_values = None + + @property + def colors(self): + if self.new_colors is None: + return self.var.colors + else: + return self.new_colors - def get_name(self): - return self.name or self.var.name + def set_color(self, i, color): + if self.new_colors is None: + self.new_colors = list(self.var.colors) + self.new_colors[i] = color - def get_colors(self): - return self.colors or self.var.colors + @property + def values(self): + return tuple(self.new_values or self.var.values) - def get_values(self): - return self.values or self.var.values + def set_value(self, i, value): + if not self.new_values: + self.new_values = self.var.values.copy() + self.new_values[i] = value + + def create_variable(self): + new_var = self.var.copy(name=self.name, values=self.values) + new_var.colors = np.asarray(self.colors) + return new_var + + +class ContAttrDesc(AttrDesc): + """ + Describes modifications that will be applied to variable. + + Provides methods that return either the modified value or the original + + Attributes: + var (ContinuousVariable): an instance of variable + name (str or `None`): a changed name or `None` + palette_name (str or None): name of palette or None if unmodified + """ + def __init__(self, var): + super().__init__(var) + if var.palette.name not in colorpalettes.ContinuousPalettes: + self.new_palette_name = colorpalettes.DefaultContinuousPaletteName + else: + self.new_palette_name = None + + @property + def palette_name(self): + return self.new_palette_name or self.var.palette.name + + @palette_name.setter + def palette_name(self, palette_name): + self.new_palette_name = palette_name + + def create_variable(self): + new_var = self.var.copy(name=self.name) + new_var.attributes["palette"] = self.palette_name + return new_var -# noinspection PyMethodOverriding class ColorTableModel(QAbstractTableModel): - """Base color model for discrete and continuous attributes. The model - handles the first column; other columns are handled in the derived classes """ + Base color model for discrete and continuous variables. The model handles: + - the first column - variable name (including setData) + - flags + - row count, computed as len(attrdescs) + Attribute: + attrdescs (list of AttrDesc): attrdescs with user-defined changes + """ def __init__(self): QAbstractTableModel.__init__(self) - self.variables = [] + self.attrdescs = [] @staticmethod - def _encode_color(color): - return "#{}{}{}".format(*[("0" + hex(x)[2:])[-2:] for x in color]) - - @staticmethod - def flags(_): + def flags(_): # pragma: no cover return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable - def set_data(self, variables): + def set_data(self, attrdescs): self.modelAboutToBeReset.emit() - self.variables = variables + self.attrdescs = attrdescs self.modelReset.emit() def rowCount(self, parent=QModelIndex()): - return 0 if parent.isValid() else self.n_rows() - - def columnCount(self, parent=QModelIndex()): - return 0 if parent.isValid() else self.n_columns() - - def n_rows(self): - return len(self.variables) + return 0 if parent.isValid() else len(self.attrdescs) def data(self, index, role=Qt.DisplayRole): - # pylint: disable=missing-docstring - # Only valid for the first column + # Only valid for the first column; derived classes implement the rest row = index.row() if role in (Qt.DisplayRole, Qt.EditRole): - return self.variables[row].get_name() + return self.attrdescs[row].name if role == Qt.FontRole: font = QFont() font.setBold(True) @@ -83,10 +158,9 @@ def data(self, index, role=Qt.DisplayRole): return None def setData(self, index, value, role): - # pylint: disable=missing-docstring - # Only valid for the first column + # Only valid for the first column; derived classes implement the rest if role == Qt.EditRole: - self.variables[index.row()].name = value + self.attrdescs[index.row()].name = value else: return False self.dataChanged.emit(index, index) @@ -94,48 +168,47 @@ def setData(self, index, value, role): class DiscColorTableModel(ColorTableModel): - """A model that stores the colors corresponding to values of discrete - variables. Colors are shown as decorations.""" - - # The class only overloads the methods from the base class - # pylint: disable=missing-docstring - def n_columns(self): - return bool(self.variables) and \ - 1 + max(len(row.var.values) for row in self.variables) + """ + A model that stores the colors corresponding to values of discrete + variables. Colors are shown as decorations. + """ + def columnCount(self, parent=QModelIndex()): + if parent.isValid(): + return 0 + return 1 + max((len(row.var.values) for row in self.attrdescs), + default=0) def data(self, index, role=Qt.DisplayRole): # pylint: disable=too-many-return-statements row, col = index.row(), index.column() if col == 0: - return ColorTableModel.data(self, index, role) - desc = self.variables[row] + return super().data(index, role) + + desc = self.attrdescs[row] if col > len(desc.var.values): return None if role in (Qt.DisplayRole, Qt.EditRole): - return desc.get_values()[col - 1] - color = desc.get_colors()[col - 1] + return desc.values[col - 1] + + color = desc.colors[col - 1] if role == Qt.DecorationRole: return QColor(*color) if role == Qt.ToolTipRole: - return self._encode_color(color) + return color_to_hex(color) if role == ColorRole: return color return None - # noinspection PyMethodOverriding def setData(self, index, value, role): row, col = index.row(), index.column() if col == 0: - return ColorTableModel.setData(self, index, value, role) - desc = self.variables[row] + return super().setData(index, value, role) + + desc = self.attrdescs[row] if role == ColorRole: - if not desc.colors: - desc.colors = desc.var.colors.tolist() - desc.colors[col - 1] = value[:3] + desc.set_color(col - 1, value[:3]) elif role == Qt.EditRole: - if not desc.values: - desc.values = list(desc.var.values) - desc.values[col - 1] = value + desc.set_value(col - 1, value) else: return False self.dataChanged.emit(index, index) @@ -144,38 +217,36 @@ def setData(self, index, value, role): class ContColorTableModel(ColorTableModel): """A model that stores the colors corresponding to values of discrete - variables. Colors are shown as decorations.""" + variables. Colors are shown as decorations. + + Attributes: + mouse_row (int): the row over which the mouse is hovering + """ + def __init__(self): + super().__init__() + self.mouse_row = None + + def set_mouse_row(self, row): + self.mouse_row = row - # The class only overloads the methods from the base class, except - # copy_to_all that is documented - # pylint: disable=missing-docstring @staticmethod - def n_columns(): - return 3 + def columnCount(parent=QModelIndex()): + return 0 if parent.isValid() else 3 def data(self, index, role=Qt.DisplayRole): def _column0(): return ColorTableModel.data(self, index, role) def _column1(): - if role == Qt.DecorationRole: - continuous_palette = \ - ContinuousPaletteGenerator(*desc.get_colors()) - line = continuous_palette.getRGB(np.arange(0, 1, 1 / 256)) - data = np.arange(0, 256, dtype=np.int8). \ - reshape((1, 256)). \ - repeat(16, 0) - img = QImage(data, 256, 16, QImage.Format_Indexed8) - img.setColorCount(256) - img.setColorTable([qRgb(*x) for x in line]) - img.data = data - return img + palette = colorpalettes.ContinuousPalettes[desc.palette_name] if role == Qt.ToolTipRole: - colors = desc.get_colors() - return f"{self._encode_color(colors[0])} " \ - f"- {self._encode_color(colors[1])}" + return palette.friendly_name if role == ColorRole: - return desc.get_colors() + return palette + if role == StripRole: + return palette.color_strip(128, 16) + if role == Qt.SizeHintRole: + return QSize(150, 16) return None def _column2(): @@ -188,7 +259,7 @@ def _column2(): return None row, col = index.row(), index.column() - desc = self.variables[row] + desc = self.attrdescs[row] if 0 <= col <= 2: return [_column0, _column1, _column2][col]() @@ -196,34 +267,77 @@ def _column2(): def setData(self, index, value, role): row, col = index.row(), index.column() if col == 0: - return ColorTableModel.setData(self, index, value, role) + return super().setData(index, value, role) if role == ColorRole: - self.variables[row].colors = value + self.attrdescs[row].palette_name = value.name else: return False self.dataChanged.emit(index, index) return True def copy_to_all(self, index): - colors = self.variables[index.row()].get_colors() - for desc in self.variables: - desc.colors = colors - self.dataChanged.emit(self.index(0, 1), self.index(self.n_rows(), 1)) + palette_name = self.attrdescs[index.row()].palette_name + for desc in self.attrdescs: + desc.palette_name = palette_name + self.dataChanged.emit(self.index(0, 1), self.index(self.rowCount(), 1)) + + +class ColorStripDelegate(HorizontalGridDelegate): + def __init__(self, view): + super().__init__() + self.view = view + + def createEditor(self, parent, option, index): + class Combo(QComboBox): + def __init__(self, parent, initial_data): + super().__init__(parent) + model = itemmodels.ContinuousPalettesModel(icon_width=128) + self.setModel(model) + self.setCurrentIndex(model.indexOf(initial_data)) + self.setIconSize(QSize(128, 16)) + QTimer.singleShot(0, self.showPopup) + + def hidePopup(self): + super().hidePopup() + view.closeEditor(self, ColorStripDelegate.NoHint) + + def select(i): + self.view.model().setData( + index, + combo.model().index(i, 0).data(Qt.UserRole), + ColorRole) + + view = self.view + combo = Combo(parent, index.data(ColorRole)) + combo.currentIndexChanged[int].connect(select) + return combo + + def paint(self, painter, option, index): + strip = index.data(StripRole) + rect = option.rect + painter.drawPixmap( + rect.x() + 13, rect.y() + (rect.height() - strip.height()) / 2, + strip) + super().paint(painter, option, index) class ColorTable(QTableView): - """The base table view for discrete and continuous attributes.""" + """ + The base table view for discrete and continuous attributes. - # pylint: disable=missing-docstring + Sets the basic properties of the table and implementes mouseRelease that + calls handle_click with appropriate index. It also prepares a grid_deleagte + that is used in derived classes. + """ def __init__(self, model): QTableView.__init__(self) self.horizontalHeader().hide() self.verticalHeader().hide() self.setShowGrid(False) self.setSelectionMode(QTableView.NoSelection) - self.setEditTriggers(QTableView.NoEditTriggers) - self.setItemDelegate(HorizontalGridDelegate()) self.setModel(model) + # View doesn't take ownership of delegates, so we store it here + self.grid_delegate = HorizontalGridDelegate() def mouseReleaseEvent(self, event): index = self.indexAt(event.pos()) @@ -235,10 +349,18 @@ def mouseReleaseEvent(self, event): class DiscreteTable(ColorTable): """Table view for discrete variables""" + def __init__(self, model): + super().__init__(model) + self.horizontalHeader().setSectionResizeMode( + QHeaderView.ResizeToContents) + self.setItemDelegate(self.grid_delegate) + self.setEditTriggers(QTableView.NoEditTriggers) def handle_click(self, index, x_offset): - """Handle click events for the first column (call the inherited - edit method) and the second (call method for changing the palette)""" + """ + Handle click events for the first column (call the edit method) + and the second (call method for changing the palette) + """ if self.model().data(index, Qt.EditRole) is None: return if index.column() == 0 or x_offset > 24: @@ -260,51 +382,37 @@ def change_color(self, index): class ContinuousTable(ColorTable): """Table view for continuous variables""" - def __init__(self, master, model): - ColorTable.__init__(self, model) - self.master = master + def __init__(self, model): + super().__init__(model) self.viewport().setMouseTracking(True) - self.model().mouse_row = None + # View doesn't take ownership of delegates, so we must store it + self.color_delegate = ColorStripDelegate(self) + self.setItemDelegateForColumn(0, self.grid_delegate) + self.setItemDelegateForColumn(1, self.color_delegate) + self.setColumnWidth(1, 256) + self.setEditTriggers( + QTableView.SelectedClicked | QTableView.DoubleClicked) def mouseMoveEvent(self, event): """Store the hovered row index in the model, trigger viewport update""" pos = event.pos() ind = self.indexAt(pos) - self.model().mouse_row = ind.row() + self.model().set_mouse_row(ind.row()) super().mouseMoveEvent(event) self.viewport().update() def leaveEvent(self, _): """Remove the stored the hovered row index, trigger viewport update""" - self.model().mouse_row = None + self.model().set_mouse_row(None) self.viewport().update() def handle_click(self, index, _): """Call the specific methods for handling clicks for each column""" - if index.column() == 0: + if index.column() < 2: self.edit(index) - elif index.column() == 1: - self.change_color(index) elif index.column() == 2: self.model().copy_to_all(index) - def change_color(self, index): - """Invoke palette editor and set the color""" - from_c, to_c, black = self.model().data(index, ColorRole) - master = self.master - dlg = ColorPaletteDlg(master) - dlg.createContinuousPalette("", "Gradient palette", black, - QColor(*from_c), QColor(*to_c)) - dlg.setColorSchemas(master.color_settings, master.selected_schema_index) - if dlg.exec(): - self.model().setData(index, - (dlg.contLeft.getColor().getRgb(), - dlg.contRight.getColor().getRgb(), - dlg.contpassThroughBlack), - ColorRole) - master.color_settings = dlg.getColorSchemas() - master.selected_schema_index = dlg.selectedSchemaIndex - class OWColor(widget.OWWidget): name = "Color" @@ -319,92 +427,82 @@ class Outputs: settingsHandler = settings.PerfectDomainContextHandler( match_values=settings.PerfectDomainContextHandler.MATCH_VALUES_ALL) - disc_colors = settings.ContextSetting([]) - cont_colors = settings.ContextSetting([]) + disc_descs = settings.ContextSetting([]) + cont_descs = settings.ContextSetting([]) color_settings = settings.Setting(None) selected_schema_index = settings.Setting(0) auto_apply = settings.Setting(True) + settings_version = 2 + want_main_area = False def __init__(self): super().__init__() self.data = None self.orig_domain = self.domain = None - self.disc_dict = {} - self.cont_dict = {} box = gui.hBox(self.controlArea, "Discrete Variables") self.disc_model = DiscColorTableModel() - disc_view = self.disc_view = DiscreteTable(self.disc_model) - disc_view.horizontalHeader().setSectionResizeMode( - QHeaderView.ResizeToContents) + self.disc_view = DiscreteTable(self.disc_model) self.disc_model.dataChanged.connect(self._on_data_changed) - box.layout().addWidget(disc_view) + box.layout().addWidget(self.disc_view) box = gui.hBox(self.controlArea, "Numeric Variables") self.cont_model = ContColorTableModel() - cont_view = self.cont_view = ContinuousTable(self, self.cont_model) - cont_view.setColumnWidth(1, 256) + self.cont_view = ContinuousTable(self.cont_model) self.cont_model.dataChanged.connect(self._on_data_changed) - box.layout().addWidget(cont_view) + box.layout().addWidget(self.cont_view) box = gui.auto_apply(self.controlArea, self, "auto_apply") box.button.setFixedWidth(180) box.layout().insertStretch(0) @staticmethod - def sizeHint(): + def sizeHint(): # pragma: no cover return QSize(500, 570) @Inputs.data def set_data(self, data): - """Handle data input signal""" self.closeContext() - self.disc_colors = [] - self.cont_colors = [] + self.disc_descs = [] + self.cont_descs = [] if data is None: self.data = self.domain = None else: self.data = data for var in chain(data.domain.variables, data.domain.metas): if var.is_discrete: - self.disc_colors.append(AttrDesc(var)) + self.disc_descs.append(DiscAttrDesc(var)) elif var.is_continuous: - self.cont_colors.append(AttrDesc(var)) + self.cont_descs.append(ContAttrDesc(var)) - self.disc_model.set_data(self.disc_colors) - self.cont_model.set_data(self.cont_colors) + self.disc_model.set_data(self.disc_descs) + self.cont_model.set_data(self.cont_descs) + self.openContext(data) self.disc_view.resizeColumnsToContents() self.cont_view.resizeColumnsToContents() - self.openContext(data) - self.disc_dict = {k.var.name: k for k in self.disc_colors} - self.cont_dict = {k.var.name: k for k in self.cont_colors} self.unconditional_commit() - def _on_data_changed(self, *args): + def _on_data_changed(self): self.commit() def commit(self): - def make(vars): + def make(variables): new_vars = [] - for var in vars: - source = self.disc_dict if var.is_discrete else self.cont_dict + for var in variables: + source = disc_dict if var.is_discrete else cont_dict desc = source.get(var.name) - if desc: - name = desc.get_name() - if var.is_discrete: - var = var.copy(name=name, values=desc.get_values()) - else: - var = var.copy(name=name) - var.colors = desc.colors - new_vars.append(var) + new_vars.append(desc.create_variable() if desc else var) return new_vars if self.data is None: self.Outputs.data.send(None) return + disc_dict = {desc.var.name: desc for desc in self.disc_descs} + cont_dict = {desc.var.name: desc for desc in self.cont_descs} + dom = self.data.domain new_domain = Orange.data.Domain( make(dom.attributes), make(dom.class_vars), make(dom.metas)) @@ -419,56 +517,52 @@ def _report_variables(variables): def was(n, o): return n if n == o else f"{n} (was: {o})" - # definition of td element for continuous gradient - # with support for pre-standard css (needed at least for Qt 4.8) max_values = max( (len(var.values) for var in variables if var.is_discrete), default=1) - defs = ("-webkit-", "-o-", "-moz-", "") - cont_tpl = '' \ - '' rows = "" + disc_dict = {k.var.name: k for k in self.disc_descs} + cont_dict = {k.var.name: k for k in self.cont_descs} for var in variables: if var.is_discrete: - desc = self.disc_dict[var.name] - values = " \n".join( - "{} {}". - format(square(*color), was(value, old_value)) + desc = disc_dict[var.name] + value_cols = " \n".join( + f"{square(*color)} {was(value, old_value)}" for color, value, old_value in - zip(desc.get_colors(), desc.get_values(), var.values)) + zip(desc.colors, desc.values, var.values)) elif var.is_continuous: - desc = self.cont_dict[var.name] - col = desc.get_colors() - colors = col[0][:3] + ("black, " * col[2], ) + col[1][:3] - values = cont_tpl.format(*colors * len(defs)) + desc = cont_dict[var.name] + pal = colorpalettes.ContinuousPalettes[desc.palette_name] + value_cols = f'' \ + f'{pal.friendly_name}' else: continue - names = was(desc.get_name(), desc.var.name) + names = was(desc.name, desc.var.name) rows += '\n' \ - ' {}{}\n\n'. \ - format(names, values) + f' {names}' \ + f' {value_cols}\n' \ + '\n' return rows if not self.data: return dom = self.data.domain sections = ( - (name, _report_variables(vars)) - for name, vars in ( + (name, _report_variables(variables)) + for name, variables in ( ("Features", dom.attributes), ("Outcome" + "s" * (len(dom.class_vars) > 1), dom.class_vars), ("Meta attributes", dom.metas))) - table = "".join("{}{}".format(name, rows) + table = "".join(f"{name}{rows}" for name, rows in sections if rows) if table: - self.report_raw("{}
".format(table)) + self.report_raw(r"{table}
") + + @classmethod + def migrate_context(cls, context, version): + if not version or version < 2: + raise IncompatibleContext if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/data/owpaintdata.py b/Orange/widgets/data/owpaintdata.py index 99a427944c0..ea1e667ea0c 100644 --- a/Orange/widgets/data/owpaintdata.py +++ b/Orange/widgets/data/owpaintdata.py @@ -25,7 +25,7 @@ from Orange.widgets import gui from Orange.widgets.settings import Setting -from Orange.widgets.utils import itemmodels, colorpalette +from Orange.widgets.utils import itemmodels, colorpalettes from Orange.util import scale, namegen from Orange.widgets.utils.widgetpreview import WidgetPreview @@ -710,13 +710,12 @@ def __init__(self, iterable, parent, flags, super().__init__(iterable, parent, flags, list_item_role, supportedDropActions) - self.colors = colorpalette.ColorPaletteGenerator( - len(colorpalette.DefaultRGBColors)) + self.colors = colorpalettes.DefaultRGBColors def data(self, index, role=Qt.DisplayRole): if self._is_index_valid(index) and \ role == Qt.DecorationRole and \ - 0 <= index.row() < self.colors.number_of_colors: + 0 <= index.row() < len(self): return gui.createAttributePixmap("", self.colors[index.row()]) return super().data(index, role) @@ -809,8 +808,7 @@ def __init__(self): else: self.__buffer = np.array(self.data) - self.colors = colorpalette.ColorPaletteGenerator( - len(colorpalette.DefaultRGBColors)) + self.colors = colorpalettes.DefaultRGBColors self.tools_cache = {} self._init_ui() @@ -1021,7 +1019,7 @@ def _check_and_set_data(data): y = np.zeros(len(data)) else: self.input_classes = y.values - self.input_colors = y.colors + self.input_colors = y.palette y = data[:, y].Y @@ -1041,11 +1039,9 @@ def reset_to_input(self): index = self.selected_class_label() if self.input_colors is not None: - colors = self.input_colors + palette = self.input_colors else: - colors = colorpalette.DefaultRGBColors - palette = colorpalette.ColorPaletteGenerator( - number_of_colors=len(colors), rgb_colors=colors) + palette = colorpalettes.DefaultRGBColors self.colors = palette self.class_model.colors = palette self.class_model[:] = self.input_classes @@ -1102,7 +1098,7 @@ def _class_count_changed(self): self.labels = list(self.class_model) self.removeClassLabel.setEnabled(len(self.class_model) > 1) self.addClassLabel.setEnabled( - len(self.class_model) < self.colors.number_of_colors) + len(self.class_model) < len(self.colors)) if self.selected_class_label() is None: itemmodels.select_row(self.classValuesView, 0) diff --git a/Orange/widgets/data/tests/test_owcolor.py b/Orange/widgets/data/tests/test_owcolor.py index 738ecf8ea22..3feb5727ab9 100644 --- a/Orange/widgets/data/tests/test_owcolor.py +++ b/Orange/widgets/data/tests/test_owcolor.py @@ -4,32 +4,418 @@ from unittest.mock import patch, Mock import numpy as np -from AnyQt.QtCore import Qt -from AnyQt.QtGui import QColor +from AnyQt.QtCore import Qt, QSize, QRect +from AnyQt.QtGui import QBrush -from Orange.data import Table, ContinuousVariable, Domain -from Orange.widgets.data.owcolor import OWColor, ColorRole, DiscColorTableModel +from Orange.data import Table, ContinuousVariable, DiscreteVariable, Domain +from Orange.util import color_to_hex +from Orange.widgets.utils import colorpalettes +from Orange.widgets.data import owcolor +from Orange.widgets.data.owcolor import ColorRole from Orange.widgets.tests.base import WidgetTest +from orangewidget.tests.base import GuiTest + + +class AttrDescTest(unittest.TestCase): + def test_name(self): + x = ContinuousVariable("x") + desc = owcolor.AttrDesc(x) + self.assertEqual(desc.name, "x") + desc.name = "y" + self.assertEqual(desc.name, "y") + desc.name = None + self.assertEqual(desc.name, "x") + + +class DiscAttrTest(unittest.TestCase): + def setUp(self): + x = DiscreteVariable("x", ["a", "b", "c"]) + self.desc = owcolor.DiscAttrDesc(x) + + def test_colors(self): + desc = self.desc + colors = desc.colors.copy() + desc.set_color(2, (0, 0, 0)) + colors[2] = 0 + np.testing.assert_equal(desc.colors, colors) + + def test_values(self): + desc = self.desc + self.assertEqual(desc.values, ("a", "b", "c")) + desc.set_value(1, "d") + self.assertEqual(desc.values, ("a", "d", "c")) + + def test_create_variable(self): + desc = self.desc + desc.set_color(0, [1, 2, 3]) + desc.set_color(1, [4, 5, 6]) + desc.set_color(2, [7, 8, 9]) + desc.name = "z" + desc.set_value(1, "d") + var = desc.create_variable() + self.assertIsInstance(var, DiscreteVariable) + self.assertEqual(var.name, "z") + self.assertEqual(var.values, ["a", "d", "c"]) + np.testing.assert_equal(var.colors, [[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + + palette = desc.var.attributes["palette"] = object() + var = desc.create_variable() + self.assertIs(desc.var.attributes["palette"], palette) + self.assertFalse(hasattr(var.attributes, "palette")) + + +class ContAttrDesc(unittest.TestCase): + def setUp(self): + x = ContinuousVariable("x") + self.desc = owcolor.ContAttrDesc(x) + + def test_palette(self): + desc = self.desc + palette = desc.palette_name + self.assertIsInstance(palette, str) + desc.palette_name = "foo" + self.assertEqual(desc.palette_name, "foo") + desc.palette_name = None + self.assertEqual(desc.palette_name, palette) + + def test_create_variable(self): + desc = self.desc + desc.name = "z" + palette_name = _find_other_palette( + colorpalettes.ContinuousPalettes[desc.palette_name]).name + desc.palette_name = palette_name + var = desc.create_variable() + self.assertIsInstance(var, ContinuousVariable) + self.assertEqual(var.name, "z") + self.assertEqual(var.palette.name, palette_name) + + colors = desc.var.attributes["colors"] = object() + var = desc.create_variable() + self.assertIs(desc.var.attributes["colors"], colors) + self.assertFalse(hasattr(var.attributes, "colors")) + + +class BaseTestColorTableModel: + def test_row_count(self): + model = self.model + + self.assertEqual(model.rowCount(), 0) + model.set_data(self.descs) + self.assertEqual(model.rowCount(), len(self.descs)) + self.assertEqual(model.rowCount(self.model.index(0, 0)), 0) + + def test_data(self): + self.model.set_data(self.descs) + data = self.model.data + + index = self.model.index(1, 0) + self.assertEqual(data(index, Qt.DisplayRole), self.descs[1].name) + self.assertEqual(data(index, Qt.EditRole), self.descs[1].name) + self.assertTrue(data(index, Qt.FontRole).bold()) + self.assertTrue(data(index, Qt.TextAlignmentRole) & Qt.AlignRight) + + self.descs[1].name = "bar" + self.assertEqual(data(index), "bar") + + index = self.model.index(2, 0) + self.assertEqual(data(index, Qt.DisplayRole), self.descs[2].name) + + + def test_set_data(self): + emit = Mock() + try: + self.model.dataChanged.connect(emit) + self.model.set_data(self.descs) + data = self.model.data + setData = self.model.setData + + index = self.model.index(1, 0) + assert self.descs[1].name != "foo" + self.assertFalse(setData(index, "foo", Qt.DisplayRole)) + emit.assert_not_called() + self.assertEqual(data(index, Qt.DisplayRole), self.descs[1].name) + self.assertTrue(setData(index, "foo", Qt.EditRole)) + emit.assert_called() + self.assertEqual(data(index, Qt.DisplayRole), "foo") + self.assertEqual(self.descs[1].name, "foo") + finally: + self.model.dataChanged.disconnect(emit) + + +class TestDiscColorTableModel(GuiTest, BaseTestColorTableModel): + def setUp(self): + x = DiscreteVariable("x", list("abc")) + y = DiscreteVariable("y", list("def")) + z = DiscreteVariable("z", list("ghijk")) + self.descs = [owcolor.DiscAttrDesc(v) for v in (x, y, z)] + self.model = owcolor.DiscColorTableModel() + + def test_column_count(self): + model = self.model + + self.assertEqual(model.columnCount(), 1) + model.set_data(self.descs[:2]) + self.assertEqual(model.columnCount(), 4) + model.set_data(self.descs) + self.assertEqual(model.columnCount(), 6) + + self.assertEqual(model.columnCount(model.index(0, 0)), 0) + + def test_data(self): + super().test_data() + + model = self.model + + self.assertIsNone(model.data(model.index(0, 4))) + + index = model.index(1, 2) + self.assertEqual(model.data(index, Qt.DisplayRole), "e") + self.assertEqual(model.data(index, Qt.EditRole), "e") + font = model.data(index, Qt.FontRole) + self.assertTrue(font is None or not font.bold()) + + var_colors = self.descs[1].var.colors[1] + color = model.data(index, Qt.DecorationRole) + np.testing.assert_equal(color.getRgb()[:3], var_colors) + + color = model.data(index, owcolor.ColorRole) + np.testing.assert_equal(color, var_colors) + + self.assertEqual( + model.data(index, Qt.ToolTipRole), color_to_hex(var_colors)) + + self.assertIsNone(model.data(model.index(0, 4))) + + index = model.index(2, 5) + self.assertEqual(model.data(index, Qt.DisplayRole), "k") + self.assertEqual(model.data(index, Qt.EditRole), "k") + font = model.data(index, Qt.FontRole) + self.assertTrue(font is None or not font.bold()) + + var_colors = self.descs[2].var.colors[4] + color = model.data(index, Qt.DecorationRole) + np.testing.assert_equal(color.getRgb()[:3], var_colors) + + color = model.data(index, owcolor.ColorRole) + np.testing.assert_equal(color, var_colors) + + self.assertEqual( + model.data(index, Qt.ToolTipRole), color_to_hex(var_colors)) + + self.descs[2].set_value(4, "foo") + self.assertEqual(model.data(index, Qt.DisplayRole), "foo") + + def test_set_data(self): + super().test_set_data() + + model = self.model + emit = Mock() + try: + model.dataChanged.connect(emit) + + index = model.index(2, 5) + + self.assertEqual(model.data(index, Qt.DisplayRole), "k") + self.assertEqual(model.data(index, Qt.EditRole), "k") + self.assertFalse(model.setData(index, "foo", Qt.DisplayRole)) + emit.assert_not_called() + self.assertEqual(model.data(index, Qt.DisplayRole), "k") + self.assertTrue(model.setData(index, "foo", Qt.EditRole)) + emit.assert_called() + emit.reset_mock() + self.assertEqual(model.data(index, Qt.DisplayRole), "foo") + self.assertEqual(self.descs[2].values, ("g", "h", "i", "j", "foo")) + + new_color = [0, 1, 2] + self.assertTrue(model.setData(index, new_color + [255], ColorRole)) + emit.assert_called() + emit.reset_mock() + color = model.data(index, Qt.DecorationRole) + rgb = [color.red(), color.green(), color.blue()] + self.assertEqual(rgb, new_color) + + color = model.data(index, owcolor.ColorRole) + self.assertEqual(list(color), new_color) + + self.assertEqual( + model.data(index, Qt.ToolTipRole), color_to_hex(new_color)) + + np.testing.assert_equal(self.descs[2].colors[4], rgb) + finally: + model.dataChanged.disconnect(emit) + + +def _find_other_palette(initial): + for palette in colorpalettes.ContinuousPalettes.values(): + if palette.name != initial.name: + return palette + return None # pragma: no cover + + +class TestContColorTableModel(GuiTest, BaseTestColorTableModel): + def setUp(self): + z = ContinuousVariable("z") + w = ContinuousVariable("w") + u = ContinuousVariable("u") + self.descs = [owcolor.ContAttrDesc(v) for v in (z, w, u)] + self.model = owcolor.ContColorTableModel() + + def test_column_count(self): + model = self.model + + model.set_data(self.descs) + self.assertEqual(model.columnCount(), 3) + self.assertEqual(model.columnCount(model.index(0, 0)), 0) + + def test_data(self): + super().test_data() + + model = self.model + index = model.index(1, 1) + palette = colorpalettes.ContinuousPalettes[self.descs[1].palette_name] + self.assertEqual(model.data(index, Qt.ToolTipRole), + palette.friendly_name) + self.assertEqual(model.data(index, ColorRole), palette) + with patch.object(palette, "color_strip") as color_strip: + strip = model.data(index, owcolor.StripRole) + self.assertIs(strip, color_strip.return_value) + color_strip.assert_called_with(128, 16) + self.assertIsInstance(model.data(index, Qt.SizeHintRole), QSize) + self.assertIsNone(model.data(index, Qt.FontRole)) + + palette = _find_other_palette(self.descs[1]) + self.descs[1].palette_name = palette.name + self.assertIs(model.data(index, ColorRole), palette) + + index = self.model.index(1, 2) + self.assertIsNone(model.data(index, Qt.ToolTipRole)) + self.assertIsInstance(model.data(index, Qt.SizeHintRole), QSize) + self.assertIsInstance(model.data(index, Qt.ForegroundRole), QBrush) + self.assertIsNone(model.data(index, Qt.DisplayRole)) + model.set_mouse_row(0) + self.assertIsNone(model.data(index, Qt.DisplayRole)) + model.set_mouse_row(1) + self.assertEqual(model.data(index, Qt.DisplayRole), "Copy to all") + + def test_set_data(self): + super().test_set_data() + + model = self.model + index = model.index(1, 1) + index2 = model.index(2, 1) + initial = model.data(index, ColorRole) + initial2 = model.data(index, ColorRole) + assert initial.name == initial2.name + palette = _find_other_palette(initial) + + emit = Mock() + try: + model.dataChanged.connect(emit) + + self.assertFalse(model.setData(index, None, Qt.DisplayRole)) + emit.assert_not_called() + + self.assertTrue(model.setData(index, palette, ColorRole)) + emit.assert_called() + self.assertIs(model.data(index2, ColorRole), initial2) + + self.assertEqual(model.data(index, Qt.ToolTipRole), + palette.friendly_name) + self.assertEqual(model.data(index, ColorRole), palette) + self.assertEqual(self.descs[1].palette_name, palette.name) + with patch.object(palette, "color_strip") as color_strip: + strip = model.data(index, owcolor.StripRole) + self.assertIs(strip, color_strip.return_value) + color_strip.assert_called_with(128, 16) + finally: + model.dataChanged.disconnect(emit) + + def test_copy_to_all(self): + super().test_set_data() + + model = self.model + index = model.index(1, 1) + initial = model.data(index, ColorRole) + palette = _find_other_palette(initial) + + emit = Mock() + try: + model.dataChanged.connect(emit) + model.setData(index, palette, ColorRole) + emit.assert_called() + emit.reset_mock() + + model.copy_to_all(index) + emit.assert_called_once() + for row, desc in enumerate(self.descs): + self.assertEqual( + model.data(model.index(row, 1), ColorRole).name, + palette.name) + self.assertEqual(desc.palette_name, palette.name) + finally: + model.dataChanged.disconnect(emit) + + +class TestColorStripDelegate(GuiTest): + def setUp(self): + z = ContinuousVariable("z") + w = ContinuousVariable("w") + u = ContinuousVariable("u") + self.descs = [owcolor.ContAttrDesc(v) for v in (z, w, u)] + self.model = owcolor.ContColorTableModel() + self.model.set_data(self.descs) + self.table = owcolor.ContinuousTable(self.model) + + def test_color_combo(self): + model = self.model + index = model.index(1, 1) + initial = model.data(index, ColorRole) + palette = _find_other_palette(initial) + model.setData(index, palette, ColorRole) + self.assertEqual(self.descs[1].palette_name, palette.name) + + combo = self.table.color_delegate.createEditor(None, Mock(), index) + self.assertEqual(combo.currentText(), palette.friendly_name) + palette = _find_other_palette(palette) + combo.setCurrentIndex(combo.findText(palette.friendly_name)) + self.assertEqual(self.descs[1].palette_name, palette.name) + + with patch.object(self.table, "closeEditor") as closeEditor: + combo.hidePopup() + closeEditor.assert_called() + + @patch.object(owcolor.HorizontalGridDelegate, "paint") + def test_paint(self, _): + model = self.model + index = model.index(1, 1) + painter = Mock() + option = Mock() + option.rect = QRect(10, 20, 30, 40) + index.data = Mock() + index.data.return_value = Mock() + index.data.return_value.height = Mock(return_value=42) + self.table.color_delegate.paint(painter, option, index) + self.assertIs(painter.drawPixmap.call_args[0][2], + index.data.return_value) class TestOWColor(WidgetTest): def setUp(self): - self.widget = self.create_widget(OWColor) + self.widget = self.create_widget(owcolor.OWColor) self.iris = Table("iris") def test_reuse_old_settings(self): self.send_signal(self.widget.Inputs.data, self.iris) - assert isinstance(self.widget, OWColor) + assert isinstance(self.widget, owcolor.OWColor) self.widget.saveSettings() - w = self.create_widget(OWColor, reset_default_settings=False) + w = self.create_widget(owcolor.OWColor, reset_default_settings=False) self.send_signal(self.widget.Inputs.data, self.iris, widget=w) def test_invalid_input_colors(self): a = ContinuousVariable("a") a.attributes["colors"] = "invalid" - _ = a.colors t = Table.from_domain(Domain([a])) self.send_signal(self.widget.Inputs.data, t) @@ -41,6 +427,15 @@ def test_unconditional_commit_on_new_signal(self): self.send_signal(self.widget.Inputs.data, self.iris) commit.assert_called() + def test_commit_on_data_changed(self): + widget = self.widget + model = widget.cont_model + self.send_signal(widget.Inputs.data, self.iris) + with patch.object(widget, 'commit') as commit: + commit.reset_mock() + model.setData(model.index(0, 0), "y", Qt.EditRole) + commit.assert_called() + def test_lose_data(self): widget = self.widget send = widget.Outputs.data.send = Mock() @@ -56,80 +451,37 @@ def test_lose_data(self): self.assertEqual(widget.disc_model.rowCount(), 0) self.assertEqual(widget.cont_model.rowCount(), 0) - def test_base_model(self): + def test_model_content(self): widget = self.widget - dm = widget.disc_model - cm = widget.disc_model - data = Table("heart_disease") self.send_signal(widget.Inputs.data, data) + + dm = widget.disc_model self.assertEqual( [dm.data(dm.index(i, 0)) for i in range(dm.rowCount())], [var.name for var in data.domain.variables if var.is_discrete] ) + + cm = widget.disc_model self.assertEqual( [dm.data(cm.index(i, 0)) for i in range(cm.rowCount())], [var.name for var in data.domain.variables if var.is_discrete] ) - dm.setData(dm.index(1, 0), "foo", Qt.EditRole) - self.assertEqual(dm.data(dm.index(1, 0)), "foo") - self.assertEqual(widget.disc_colors[1].name, "foo") - - widget.disc_colors[1].name = "bar" - self.assertEqual(dm.data(dm.index(1, 0)), "bar") - - def test_disc_model(self): - widget = self.widget - dm = widget.disc_model - - data = Table("heart_disease") - self.send_signal(widget.Inputs.data, data) - - # Consider these two as sanity checks - self.assertEqual(dm.data(dm.index(0, 0)), "gender") - self.assertEqual(dm.data(dm.index(1, 0)), "chest pain") - - self.assertEqual(dm.columnCount(), 5) # 1 + four types of chest pain - self.assertEqual(dm.data(dm.index(0, 1)), "female") - self.assertEqual(dm.data(dm.index(0, 2)), "male") - self.assertIsNone(dm.data(dm.index(0, 3))) - self.assertIsNone(dm.data(dm.index(0, 4))) - self.assertIsNone(dm.data(dm.index(0, 3), ColorRole)) - self.assertIsNone(dm.data(dm.index(0, 3), Qt.DecorationRole)) - self.assertIsNone(dm.data(dm.index(0, 3), Qt.ToolTipRole)) - - chest_pain = data.domain["chest pain"] - self.assertEqual( - [dm.data(dm.index(1, i)) for i in range(1, 5)], - list(chest_pain.values)) - np.testing.assert_equal( - [dm.data(dm.index(1, i), ColorRole) for i in range(1, 5)], - list(chest_pain.colors)) - self.assertEqual( - [dm.data(dm.index(1, i), Qt.DecorationRole) for i in range(1, 5)], - [QColor(*color) for color in chest_pain.colors]) - self.assertEqual( - [dm.data(dm.index(1, i), Qt.ToolTipRole) for i in range(1, 5)], - list(map(DiscColorTableModel._encode_color, chest_pain.colors))) - - dm.setData(dm.index(0, 1), "F", Qt.EditRole) - self.assertEqual(dm.data(dm.index(0, 1)), "F") - self.assertEqual(widget.disc_colors[0].values, ["F", "male"]) + def test_report(self): + self.widget.send_report() - widget.disc_colors[0].values[1] = "M" - self.assertEqual(dm.data(dm.index(0, 2)), "M") + self.send_signal(self.widget.Inputs.data, self.iris) + self.widget.send_report() - dm.setData(dm.index(0, 1), (1, 2, 3, 4), ColorRole) - self.assertEqual(list(dm.data(dm.index(0, 1), ColorRole)), [1, 2, 3]) - self.assertEqual(list(widget.disc_colors[0].colors[0]), [1, 2, 3]) + self.send_signal(self.widget.Inputs.data, Table("zoo")) + self.widget.send_report() - widget.disc_colors[0].colors[1] = (4, 5, 6) - self.assertEqual(list(dm.data(dm.index(0, 2), ColorRole)), [4, 5, 6]) + self.send_signal(self.widget.Inputs.data, None) + self.widget.send_report() - def test_report(self): - self.send_signal(self.widget.Inputs.data, self.iris) - self.widget.send_report() # don't crash + def test_string_variables(self): + self.send_signal(self.widget.Inputs.data, Table("zoo")) if __name__ == "__main__": diff --git a/Orange/widgets/data/tests/test_oweditdomain.py b/Orange/widgets/data/tests/test_oweditdomain.py index 45857126e8f..79c0d29f728 100644 --- a/Orange/widgets/data/tests/test_oweditdomain.py +++ b/Orange/widgets/data/tests/test_oweditdomain.py @@ -7,10 +7,11 @@ import numpy as np from numpy.testing import assert_array_equal -from AnyQt.QtCore import QModelIndex, QItemSelectionModel, Qt, QItemSelection +from AnyQt.QtCore import QItemSelectionModel, Qt, QItemSelection from AnyQt.QtWidgets import QAction, QComboBox, QLineEdit, QStyleOptionViewItem from AnyQt.QtTest import QTest, QSignalSpy +from Orange.widgets.utils import colorpalettes from orangewidget.tests.utils import simulate from orangewidget.utils.itemmodels import PyListModel @@ -121,14 +122,17 @@ def test_input_from_owcolor(self): """Check widget's data sent from OWColor widget""" owcolor = self.create_widget(OWColor) self.send_signal("Data", self.iris, widget=owcolor) - owcolor.disc_model.setData(QModelIndex(), (250, 97, 70, 255), ColorRole) - owcolor.cont_model.setData( - QModelIndex(), ((255, 80, 114, 255), (255, 255, 0, 255), False), - ColorRole) + disc_model = owcolor.disc_model + disc_model.setData(disc_model.index(0, 1), (1, 2, 3), ColorRole) + cont_model = owcolor.cont_model + palette = list(colorpalettes.ContinuousPalettes.values())[-1] + cont_model.setData(cont_model.index(1, 1), palette, ColorRole) owcolor_output = self.get_output("Data", owcolor) self.send_signal("Data", owcolor_output) self.assertEqual(self.widget.data, owcolor_output) - self.assertIsNotNone(self.widget.data.domain.class_vars[-1].colors) + np.testing.assert_equal(self.widget.data.domain.class_var.colors[0], + (1, 2, 3)) + self.assertIs(self.widget.data.domain.attributes[1].palette, palette) def test_list_attributes_remain_lists(self): a = ContinuousVariable("a") diff --git a/Orange/widgets/data/utils/histogram.py b/Orange/widgets/data/utils/histogram.py index 7f08e976946..3a921068410 100644 --- a/Orange/widgets/data/utils/histogram.py +++ b/Orange/widgets/data/utils/histogram.py @@ -12,7 +12,6 @@ import Orange.statistics.util as ut from Orange.data.util import one_hot -from Orange.widgets.utils.colorpalette import ContinuousPaletteGenerator class BarItem(QGraphicsWidget): @@ -351,11 +350,12 @@ def _draw_histogram(self): def _get_colors(self): """Compute colors for different kinds of histograms.""" - if self.target_var and self.target_var.is_discrete: - colors = [[QColor(*color) for color in self.target_var.colors]] * self.n_bins + target = self.target_var + if target and target.is_discrete: + colors = [list(target.palette)[:len(target.values)]] * self.n_bins elif self.target_var and self.target_var.is_continuous: - palette = ContinuousPaletteGenerator(*self.target_var.colors) + palette = self.target_var.palette bins = np.arange(self.n_bins)[:, np.newaxis] edges = self.edges if self.attribute.is_discrete else self.edges[1:-1] @@ -369,7 +369,7 @@ def _get_colors(self): mean = ut.nanmean(biny) / ut.nanmax(self.y) else: mean = 0 # bin is empty, color does not matter - colors.append([palette[mean]]) + colors.append([palette.value_to_qcolor(mean)]) else: colors = [[QColor('#ccc')]] * self.n_bins diff --git a/Orange/widgets/evaluate/owcalibrationplot.py b/Orange/widgets/evaluate/owcalibrationplot.py index de13a8cf029..8ae09dd7064 100644 --- a/Orange/widgets/evaluate/owcalibrationplot.py +++ b/Orange/widgets/evaluate/owcalibrationplot.py @@ -15,7 +15,7 @@ from Orange.widgets.evaluate.contexthandlers import \ EvaluationResultsContextHandler from Orange.widgets.evaluate.utils import results_for_preview -from Orange.widgets.utils import colorpalette, colorbrewer +from Orange.widgets.utils import colorpalettes from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output, Msg from Orange.widgets import report @@ -254,14 +254,11 @@ def _initialize(self, results): names = ["#{}".format(i + 1) for i in range(n)] self.classifier_names = names - scheme = colorbrewer.colorSchemes["qualitative"]["Dark2"] - if n > len(scheme): - scheme = colorpalette.DefaultRGBColors - self.colors = colorpalette.ColorPaletteGenerator(n, scheme) + self.colors = colorpalettes.get_default_curve_colors(n) for i in range(n): item = self.classifiers_list_box.item(i) - item.setIcon(colorpalette.ColorPixmap(self.colors[i])) + item.setIcon(colorpalettes.ColorIcon(self.colors[i])) self.selected_classifiers = list(range(n)) self.target_cb.addItems(results.domain.class_var.values) diff --git a/Orange/widgets/evaluate/owliftcurve.py b/Orange/widgets/evaluate/owliftcurve.py index ed443d1ae63..b3f23ef9255 100644 --- a/Orange/widgets/evaluate/owliftcurve.py +++ b/Orange/widgets/evaluate/owliftcurve.py @@ -19,7 +19,7 @@ from Orange.widgets.evaluate.contexthandlers import \ EvaluationResultsContextHandler from Orange.widgets.evaluate.utils import check_results_adequacy -from Orange.widgets.utils import colorpalette, colorbrewer +from Orange.widgets.utils import colorpalettes from Orange.widgets.evaluate.owrocanalysis import convex_hull from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input @@ -162,16 +162,13 @@ def _initialize(self, results): if names is None: names = ["#{}".format(i + 1) for i in range(N)] - scheme = colorbrewer.colorSchemes["qualitative"]["Dark2"] - if N > len(scheme): - scheme = colorpalette.DefaultRGBColors - self.colors = colorpalette.ColorPaletteGenerator(N, scheme) + self.colors = colorpalettes.get_default_curve_colors(N) self.classifier_names = names self.selected_classifiers = list(range(N)) for i in range(N): item = self.classifiers_list_box.item(i) - item.setIcon(colorpalette.ColorPixmap(self.colors[i])) + item.setIcon(colorpalettes.ColorIcon(self.colors[i])) self.target_cb.addItems(results.data.domain.class_var.values) diff --git a/Orange/widgets/evaluate/owrocanalysis.py b/Orange/widgets/evaluate/owrocanalysis.py index 6dbbe748ee1..6c4a1b3ecda 100644 --- a/Orange/widgets/evaluate/owrocanalysis.py +++ b/Orange/widgets/evaluate/owrocanalysis.py @@ -21,7 +21,7 @@ EvaluationResultsContextHandler from Orange.widgets.evaluate.utils import \ check_results_adequacy, results_for_preview -from Orange.widgets.utils import colorpalette, colorbrewer +from Orange.widgets.utils import colorpalettes from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input from Orange.widgets import report @@ -462,16 +462,13 @@ def _initialize(self, results): names = ["#{}".format(i + 1) for i in range(len(results.predicted))] - scheme = colorbrewer.colorSchemes["qualitative"]["Dark2"] - if len(names) > len(scheme): - scheme = colorpalette.DefaultRGBColors - self.colors = colorpalette.ColorPaletteGenerator(len(names), scheme) + self.colors = colorpalettes.get_default_curve_colors(len(names)) self.classifier_names = names self.selected_classifiers = list(range(len(names))) for i in range(len(names)): listitem = self.classifiers_list_box.item(i) - listitem.setIcon(colorpalette.ColorPixmap(self.colors[i])) + listitem.setIcon(colorpalettes.ColorIcon(self.colors[i])) class_var = results.data.domain.class_var self.target_cb.addItems(class_var.values) diff --git a/Orange/widgets/gui.py b/Orange/widgets/gui.py index ff157f68eca..4ed4665e9de 100644 --- a/Orange/widgets/gui.py +++ b/Orange/widgets/gui.py @@ -12,7 +12,7 @@ Qt, QSize, QItemSelection, ) from AnyQt.QtGui import QColor -from AnyQt.QtWidgets import QWidget, QItemDelegate, QListView +from AnyQt.QtWidgets import QWidget, QItemDelegate, QListView, QComboBox # re-export relevant objects from orangewidget.utils.combobox import ComboBox as OrangeComboBox @@ -79,13 +79,23 @@ "createAttributePixmap", "attributeIconDict", "attributeItem", "listView", "ListViewWithSizeHint", "listBox", "OrangeListBox", "TableValueRole", "TableClassValueRole", "TableDistribution", - "TableVariable", "TableBarItem" + "TableVariable", "TableBarItem", "palette_combo_box" ] log = logging.getLogger(__name__) +def palette_combo_box(initial_palette): + from Orange.widgets.utils import itemmodels + cb = QComboBox() + model = itemmodels.ContinuousPalettesModel() + cb.setModel(model) + cb.setCurrentIndex(model.indexOf(initial_palette)) + cb.setIconSize(QSize(64, 16)) + return cb + + def createAttributePixmap(char, background=Qt.black, color=Qt.white): """ Create a QIcon with a given character. The icon is 13 pixels high and wide. @@ -859,3 +869,7 @@ def paint(self, painter, option, index): text = str(index.data(Qt.DisplayRole)) self.drawDisplay(painter, option, text_rect, text) painter.restore() + + +from Orange.widgets.utils.colorpalettes import patch_variable_colors +patch_variable_colors() diff --git a/Orange/widgets/unsupervised/owdistancemap.py b/Orange/widgets/unsupervised/owdistancemap.py index c5e4390d4cc..0ffe5276c63 100644 --- a/Orange/widgets/unsupervised/owdistancemap.py +++ b/Orange/widgets/unsupervised/owdistancemap.py @@ -8,11 +8,8 @@ QFormLayout, QGraphicsRectItem, QGraphicsGridLayout, QApplication, QSizePolicy ) -from AnyQt.QtGui import ( - QFontMetrics, QPen, QIcon, QPixmap, QLinearGradient, QPainter, QColor, - QBrush, QTransform, QFont -) -from AnyQt.QtCore import Qt, QRect, QRectF, QSize, QPointF +from AnyQt.QtGui import QFontMetrics, QPen, QTransform, QFont +from AnyQt.QtCore import Qt, QRect, QRectF, QPointF from AnyQt.QtCore import pyqtSignal as Signal import pyqtgraph as pg @@ -23,7 +20,7 @@ from Orange.data.domain import filter_visible from Orange.widgets import widget, gui, settings -from Orange.widgets.utils import itemmodels, colorbrewer +from Orange.widgets.utils import itemmodels, colorpalettes from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.utils.graphicstextlist import TextListWidget @@ -248,11 +245,6 @@ def hoverMoveEvent(self, event): self.setToolTip("") -_color_palettes = sorted(colorbrewer.colorSchemes["sequential"].items()) + \ - [("Blue-Yellow", {2: [(0, 0, 255), (255, 255, 0)]})] -_default_colormap_index = len(_color_palettes) - 1 - - class OWDistanceMap(widget.OWWidget): name = "Distance Map" description = "Visualize a distance matrix." @@ -275,7 +267,7 @@ class Outputs: sorting = settings.Setting(NoOrdering) - colormap = settings.Setting(_default_colormap_index) + palette_name = settings.Setting(colorpalettes.DefaultContinuousPaletteName) color_gamma = settings.Setting(0.0) color_low = settings.Setting(0.0) color_high = settings.Setting(1.0) @@ -308,13 +300,9 @@ def __init__(self): callback=self._invalidate_ordering) box = gui.vBox(self.controlArea, "Colors") - self.colormap_cb = gui.comboBox( - box, self, "colormap", callback=self._update_color) - self.colormap_cb.setIconSize(QSize(64, 16)) - self.palettes = list(_color_palettes) - - init_color_combo(self.colormap_cb, self.palettes, QSize(64, 16)) - self.colormap_cb.setCurrentIndex(self.colormap) + 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, @@ -621,19 +609,11 @@ def _set_labels(self, labels): self.bottom_labels.setMaximumHeight(constraint) def _update_color(self): + palette = self.color_box.currentData() + self.palette_name = palette.name if self.matrix_item: - name, colors = self.palettes[self.colormap] - n, colors = max(colors.items()) - colors = numpy.array(colors, dtype=numpy.ubyte) - low, high = self.color_low * 255, self.color_high * 255 - points = numpy.linspace(low, high, n) - space = numpy.linspace(0, 255, 255) - - r = numpy.interp(space, points, colors[:, 0], left=255, right=0) - g = numpy.interp(space, points, colors[:, 1], left=255, right=0) - b = numpy.interp(space, points, colors[:, 2], left=255, right=0) - colortable = numpy.c_[r, g, b] - self.matrix_item.setLookupTable(colortable) + colors = palette.lookup_table(self.color_low, self.color_high) + self.matrix_item.setLookupTable(colors) def _invalidate_selection(self): ranges = self.matrix_item.selections() @@ -721,44 +701,6 @@ def _point_size(self, height): font.setPointSize(height - fix) return height - fix -########################## -# Color palette management -########################## - - -def palette_gradient(colors, discrete=False): - n = len(colors) - stops = numpy.linspace(0.0, 1.0, n, endpoint=True) - gradstops = [(float(stop), color) for stop, color in zip(stops, colors)] - grad = QLinearGradient(QPointF(0, 0), QPointF(1, 0)) - grad.setStops(gradstops) - return grad - - -def palette_pixmap(colors, size): - img = QPixmap(size) - img.fill(Qt.transparent) - - painter = QPainter(img) - grad = palette_gradient(colors) - grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode) - painter.setPen(Qt.NoPen) - painter.setBrush(QBrush(grad)) - painter.drawRect(0, 0, size.width(), size.height()) - painter.end() - return img - - -def init_color_combo(cb, palettes, iconsize): - cb.clear() - iconsize = cb.iconSize() - - for name, palette in palettes: - n, colors = max(palette.items()) - colors = [QColor(*c) for c in colors] - cb.addItem(QIcon(palette_pixmap(colors, iconsize)), name, - palette) - # run widget with `python -m Orange.widgets.unsupervised.owdistancemap` if __name__ == "__main__": # pragma: no cover diff --git a/Orange/widgets/unsupervised/owdistancematrix.py b/Orange/widgets/unsupervised/owdistancematrix.py index cda9e31788c..cf8679e4685 100644 --- a/Orange/widgets/unsupervised/owdistancematrix.py +++ b/Orange/widgets/unsupervised/owdistancematrix.py @@ -1,4 +1,3 @@ -from math import isnan import itertools import numpy as np @@ -8,13 +7,12 @@ from AnyQt.QtCore import Qt, QAbstractTableModel, QModelIndex, \ QItemSelectionModel, QItemSelection, QSize -from Orange.data import Table, Variable, ContinuousVariable, DiscreteVariable +from Orange.data import Table, Variable from Orange.misc import DistMatrix from Orange.widgets import widget, gui from Orange.widgets.data.owtable import ranges from Orange.widgets.gui import OrangeUserRole from Orange.widgets.settings import Setting, ContextSetting, ContextHandler -from Orange.widgets.utils.colorpalette import ContinuousPaletteGenerator from Orange.widgets.utils.itemmodels import VariableListModel from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.widget import Input, Output @@ -48,15 +46,10 @@ def set_labels(self, labels, variable=None, values=None): self.labels = labels self.variable = variable self.values = values - if isinstance(variable, ContinuousVariable): - palette = ContinuousPaletteGenerator(*variable.colors) - off, m = values.min(), values.max() - fact = off != m and 1 / (m - off) - self.label_colors = [palette[x] if not isnan(x) else Qt.lightGray - for x in (values - off) * fact] + if self.values is not None: + self.label_colors = variable.palette.values_to_qcolors(values) else: self.label_colors = None - self.endResetModel() def dimension(self, parent=None): @@ -67,14 +60,9 @@ def dimension(self, parent=None): columnCount = rowCount = dimension def color_for_label(self, ind, light=100): - color = Qt.lightGray - if isinstance(self.variable, ContinuousVariable): - color = self.label_colors[ind].lighter(light) - elif isinstance(self.variable, DiscreteVariable): - value = self.values[ind] - if not isnan(value): - color = QColor(*self.variable.colors[int(value)]) - return QBrush(color) + if self.label_colors is None: + return Qt.lightGray + return QBrush(self.label_colors[ind].lighter(light)) def color_for_cell(self, row, col): return QBrush(QColor.fromHsv(120, self.colors[row, col], 255)) @@ -103,7 +91,7 @@ def headerData(self, ind, orientation, role): return self.labels[ind] # On some systems, Qt doesn't respect the following role in the header if role == Qt.BackgroundRole: - return self.color_for_label(ind, 200) + return self.color_for_label(ind, 150) class TableBorderItem(QItemDelegate): @@ -380,5 +368,5 @@ def _rgb(brush): if __name__ == "__main__": # pragma: no cover import Orange.distance data = Orange.data.Table("iris") - dist = Orange.distance.Euclidean(data[:50]) + dist = Orange.distance.Euclidean(data[::5]) WidgetPreview(OWDistanceMatrix).run(dist) diff --git a/Orange/widgets/unsupervised/owhierarchicalclustering.py b/Orange/widgets/unsupervised/owhierarchicalclustering.py index ba6b1914999..0520c18ba88 100644 --- a/Orange/widgets/unsupervised/owhierarchicalclustering.py +++ b/Orange/widgets/unsupervised/owhierarchicalclustering.py @@ -34,7 +34,7 @@ from Orange.data.util import get_unique_names from Orange.widgets import widget, gui, settings -from Orange.widgets.utils import colorpalette, itemmodels, combobox +from Orange.widgets.utils import colorpalettes, itemmodels, combobox from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.utils.widgetpreview import WidgetPreview @@ -585,7 +585,7 @@ def _re_enumerate_selections(self): items = sorted(self._selection.items(), key=lambda item: item[0].node.value.first) - palette = colorpalette.ColorPaletteGenerator(len(items)) + palette = colorpalettes.LimitedDiscretePalette(len(items)) for i, (item, selection_item) in enumerate(items): # delete and then reinsert to update the ordering del self._selection[item] diff --git a/Orange/widgets/unsupervised/owsom.py b/Orange/widgets/unsupervised/owsom.py index cbc57823d7e..ab40e474886 100644 --- a/Orange/widgets/unsupervised/owsom.py +++ b/Orange/widgets/unsupervised/owsom.py @@ -24,8 +24,8 @@ from Orange.widgets.utils.widgetpreview import WidgetPreview from Orange.widgets.utils.annotated_data import \ create_annotated_table, create_groups_table, ANNOTATED_DATA_SIGNAL_NAME -from Orange.widgets.utils.colorpalette import \ - ContinuousPaletteGenerator, ColorPaletteGenerator +from Orange.widgets.utils.colorpalettes import \ + BinnedContinuousPalette, LimitedDiscretePalette from Orange.widgets.visualize.utils import CanvasRectangle, CanvasText from Orange.widgets.visualize.utils.plotutils import wrap_legend_items @@ -481,7 +481,7 @@ def redraw_selection(self, marks=None): mark_brush = QBrush(QColor(224, 255, 255)) sels = self.selection is not None and np.max(self.selection) - palette = ColorPaletteGenerator(number_of_colors=sels + 1) + palette = LimitedDiscretePalette(number_of_colors=sels + 1) brushes = [QBrush(Qt.NoBrush)] + \ [QBrush(palette[i].lighter(165)) for i in range(sels)] @@ -587,7 +587,7 @@ def _get_color_column(self): int_col[np.isnan(color_column)] = len(self.colors) else: int_col = np.zeros(len(color_column), dtype=int) - # The following line is not necessary because rows with missing + # The following line is unnecessary because rows with missing # numeric data are excluded. Uncomment it if you change SOM to # tolerate missing values. # int_col[np.isnan(color_column)] = len(self.colors) @@ -618,7 +618,7 @@ def _tooltip(self, colors, distribution): def _draw_pie_charts(self, sizes): fx, fy = self._grid_factors color_column = self._get_color_column() - colors = self.colors + [Qt.gray] + colors = self.colors.qcolors_w_nan for y in range(self.size_y): for x in range(self.size_x - self.hexagonal * (y % 2)): r = sizes[x, y] @@ -836,7 +836,7 @@ def set_color_bins(self): self.thresholds = self.bin_labels = self.colors = None elif self.attr_color.is_discrete: self.thresholds = self.bin_labels = None - self.colors = [QColor(*color) for color in self.attr_color.colors] + self.colors = self.attr_color.palette else: col = self.data.get_column_view(self.attr_color)[0].astype(float) if self.attr_color.is_time: @@ -845,9 +845,9 @@ def set_color_bins(self): binning = decimal_binnings(col, min_bins=4)[-1] self.thresholds = binning.thresholds[1:-1] self.bin_labels = (binning.labels[1:-1], binning.short_labels[1:-1]) - palette = ContinuousPaletteGenerator(*self.attr_color.colors) - nbins = len(self.thresholds) + 1 - self.colors = [palette[i / (nbins - 1)] for i in range(nbins)] + palette = BinnedContinuousPalette.from_palette( + self.attr_color.palette, binning.thresholds) + self.colors = palette def create_legend(self): if self.legend is not None: diff --git a/Orange/widgets/unsupervised/tests/test_owsom.py b/Orange/widgets/unsupervised/tests/test_owsom.py index 099484a8d92..c3666d463a5 100644 --- a/Orange/widgets/unsupervised/tests/test_owsom.py +++ b/Orange/widgets/unsupervised/tests/test_owsom.py @@ -202,9 +202,7 @@ def test_attr_color_change(self): combo.setCurrentIndex(ind_gen) combo.activated[int].emit(ind_gen) self.assertTrue(widget.controls.pie_charts.isEnabled()) - self.assertEqual( - [(c.red(), c.green(), c.blue()) for c in widget.colors], - [tuple(c) for c in gender.colors]) + np.testing.assert_equal(widget.colors.palette, gender.colors) self.assertIsNone(widget.thresholds) widget._redraw.assert_called() @@ -216,7 +214,6 @@ def test_attr_color_change(self): combo.activated[int].emit(ind_age) self.assertTrue(widget.controls.pie_charts.isEnabled()) self.assertIsNotNone(widget.thresholds) - self.assertEqual(len(widget.colors), len(widget.thresholds) + 1) widget._redraw.assert_called() @_patch_recompute_som diff --git a/Orange/widgets/utils/colorbrewer.py b/Orange/widgets/utils/colorbrewer.py index 234a7931e5e..30267353f89 100644 --- a/Orange/widgets/utils/colorbrewer.py +++ b/Orange/widgets/utils/colorbrewer.py @@ -1,3 +1,9 @@ +import warnings + +warnings.warn("Module 'colorbrewer' is obsolete and will be removed.\n" + "Use palettes from 'Orange.widget.utils.colorpalettes'.", + DeprecationWarning) + colorSchemes = { 'diverging': { 'RdYlGn': {3: [(252, 141, 89), (255, 255, 191), (145, 207, 96)], diff --git a/Orange/widgets/utils/colorpalette.py b/Orange/widgets/utils/colorpalette.py index c9e579b77eb..e52c6a7167c 100644 --- a/Orange/widgets/utils/colorpalette.py +++ b/Orange/widgets/utils/colorpalette.py @@ -3,6 +3,7 @@ import math from numbers import Number from collections import Iterable +import warnings import numpy as np @@ -21,6 +22,10 @@ from Orange.widgets import gui from Orange.widgets.utils import colorbrewer +warnings.warn( + "Module colorpalette is obsolete; use colorpalettes", DeprecationWarning) + + DefaultRGBColors = [ (70, 190, 250), (237, 70, 47), (170, 242, 43), (245, 174, 50), (255, 255, 0), (255, 0, 255), (0, 255, 255), (128, 0, 255), (0, 128, 255), (255, 223, 128), diff --git a/Orange/widgets/utils/colorpalettes.py b/Orange/widgets/utils/colorpalettes.py new file mode 100644 index 00000000000..85119ecd397 --- /dev/null +++ b/Orange/widgets/utils/colorpalettes.py @@ -0,0 +1,658 @@ +import colorsys +import warnings +from typing import Sequence + +import numpy as np + +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QImage, QPixmap, QColor, QIcon + +from Orange.util import Enum, hex_to_color, color_to_hex + +NAN_COLOR = (128, 128, 128) + +__all__ = ["Palette", "IndexedPalette", + "DiscretePalette", "LimitedDiscretePalette", "DiscretePalettes", + "DefaultDiscretePalette", "DefaultDiscretePaletteName", + "DefaultRGBColors", "Dark2Colors", + "ContinuousPalette", "ContinuousPalettes", "BinnedContinuousPalette", + "DefaultContinuousPalette", "DefaultContinuousPaletteName", + "ColorIcon", "get_default_curve_colors", "patch_variable_colors", + "NAN_COLOR"] + + +class Palette: + """ + Base class for enumerable named categorized palettes used for visualization + of discrete and numeric data + + Attributes: + name (str): unique name + friendly_name (str): name to be shown in user interfaces + category (str): category for user interfaces + palette (np.ndarray): palette; an array of shape (n, 3) + nan_color (np.ndarray): an array of shape (1, 3) storing the colors used + for missing values + flags (Palette.Flags): flags describing palettes properties + - ColorBlindSafe: palette is color-blind safe + - Diverging: palette passes through some neutral color (white, + black) which appears in the middle. For binned continuous + palettes the pure neutral color does not need to appear in a bin + - Discrete: palette contains a small number of colors, like + palettes for discrete values and binned palettes + """ + Flags = Enum("PaletteFlags", + dict(NoFlags=0, + ColorBlindSafe=1, + Diverging=2, + Discrete=4), + type=int, + qualname="Palette.Flags") + NoFlags, ColorBlindSafe, Diverging, Discrete = Flags + + def __init__(self, friendly_name, name, palette, nan_color=NAN_COLOR, + *, category=None, flags=0): + self.name = name + self.friendly_name = friendly_name + self.category = category or name.split("_")[0].title() + self.palette = np.array(palette).astype(np.ubyte) + self.nan_color = nan_color + self.flags = flags + + # qcolors and qcolor_w_nan must not be cached because QColor is mutable + # and may be modified by the caller (and is, in many widgets) + @property + def qcolors(self): + """An array of QColors in the palette""" + return np.array([QColor(*col) for col in self.palette]) + + @property + def qcolors_w_nan(self): + """An array of QColors in the palette + the color for nan values""" + return np.array([QColor(*col) for col in self.palette] + + [QColor(*self.nan_color)]) + + def copy(self): + return type(self)(self.friendly_name, self.name, self.palette.copy(), + self.nan_color, + category=self.category, flags=self.flags) + + +class IndexedPalette(Palette): + def __len__(self): + return len(self.palette) + + def __getitem__(self, x): + if isinstance(x, (Sequence, np.ndarray)): + return self.values_to_qcolors(x) + elif isinstance(x, slice): + return [QColor(*col) for col in self.palette.__getitem__(x)] + else: + return self.value_to_qcolor(x) + + +class DiscretePalette(IndexedPalette): + def __init__(self, friendly_name, name, palette, nan_color=NAN_COLOR, + *, category=None, flags=Palette.Discrete): + super().__init__(friendly_name, name, palette, nan_color, + category=category, flags=flags) + + @classmethod + def from_colors(cls, palette): + """ + Create a palette from an (n x 3) array of ints in range (0, 255) + """ + return cls("Custom", "Custom", palette) + + @staticmethod + def _color_indices(x): + x = np.asanyarray(x) + nans = np.isnan(x) + if np.any(nans): + x = x.copy() + x[nans] = -1 + return x.astype(int), nans + + def values_to_colors(self, x): + """ + Map values x to colors; values may include nan's + + Args: + x (np.ndarray): an array of values between 0 and len(palette) - 1 + + Returns: + An array of ubytes of shape (len(x), 3), representing RGB triplets + """ + x, nans = self._color_indices(x) + colors = self.palette[x] + colors[nans, :] = self.nan_color + return colors + + def values_to_qcolors(self, x): + """ + Map values x to QColors; values may include nan's + + Args: + x (np.ndarray): an array of values between 0 and len(palette) - 1 + + Returns: + An array of len(x) QColors + """ + x, _ = self._color_indices(x) + return self.qcolors_w_nan[x] + + def value_to_color(self, x): + """ + Return an RGB triplet (as np.ndarray) corresponding to value x. + x may also be nan. + """ + if np.isnan(x): + return self.nan_color + return self.palette[int(x)] + + def value_to_qcolor(self, x): + """ + Return a QColor corresponding to value x. x may also be nan. + """ + color = self.nan_color if np.isnan(x) else self.palette[int(x)] + return QColor(*color) + + +class LimitedDiscretePalette(DiscretePalette): + """ + A palette derived from DiscretePalette that has the prescribed number of + colors. + + Colors are taken from DefaultRGBColors (the default discrete palette), + unless the desired number of colors is too large. In this case, colors + are generated by making a circle around the HSV space. + """ + def __init__(self, number_of_colors, nan_color=NAN_COLOR, + *, category=None, flags=Palette.Discrete, force_hsv=False): + if number_of_colors <= len(DefaultRGBColors) and not force_hsv: + palette = DefaultRGBColors.palette[:number_of_colors] + else: + hues = np.linspace(0, 1, number_of_colors, endpoint=False) + palette = 255 * np.array( + [colorsys.hsv_to_rgb(h, 1, 1) for h in hues]) + super().__init__("custom", "custom", palette, nan_color, + category=category, flags=flags) + + +class ContinuousPalette(Palette): + """ + Palette for continuous values + """ + def __init__(self, friendly_name, name, palette, nan_color=NAN_COLOR, + *, category=None, flags=Palette.NoFlags): + super().__init__( + friendly_name, name, + np.asarray(palette, dtype=np.ubyte), nan_color, + category=category, flags=flags) + + @staticmethod + def _color_indices(x, low=None, high=None): + x = np.asarray(x) + nans = np.isnan(x) + if np.all(nans): + return np.full(len(x), -1), nans + + if low is None: + low = np.nanmin(x) + if high is None: + high = np.nanmax(x) + diff = high - low + if diff == 0: + x = np.full(len(x), 128) + else: + x = 255 * (x - low) / diff + x = np.clip(x, 0, 255) + x[nans] = -1 + return np.round(x).astype(int), nans + + def values_to_colors(self, x, low=None, high=None): + """ + Return an array of colors assigned to given values by the palette + + Args: + x (np.array): colors to be mapped + low (float or None): minimal value; if None, determined from data + high (float or None): maximal value; if None, determined from data + + Returns: + an array of shape (len(x), 3) with RGB values for each point + """ + x, nans = self._color_indices(x, low, high) + colors = self.palette[x] + colors[nans, :] = self.nan_color + return colors + + def values_to_qcolors(self, x, low=None, high=None): + """ + Return an array of colors assigned to given values by the palette + + Args: + x (np.array): colors to be mapped + low (float or None): minimal value; if None, determined from data + high (float or None): maximal value; if None, determined from data + + Returns: + an array of shape (len(x), ) with QColors for each point + """ + x, _ = self._color_indices(x, low, high) + return self.qcolors_w_nan[x] + + @staticmethod + def _color_index(x, low=0, high=1): + if np.isnan(x): + return -1 + diff = high - low + if diff == 0: + return 128 + return int(np.clip(np.round(255 * (x - low) / diff), 0, 255)) + + def value_to_color(self, x, low=0, high=1): + """ + Return an RGB triplet (as np.ndarray) corresponding to value x. + x may also be nan. + """ + x = self._color_index(x, low, high) + if x == -1: + return NAN_COLOR + return self.palette[x] + + def value_to_qcolor(self, x, low=0, high=1): + """ + Return a QColor corresponding to value x. x may also be nan. + """ + if np.isnan(x): + color = self.nan_color + else: + x = self._color_index(x, low, high) + color = self.palette[x] + return QColor(*color) + + __getitem__ = value_to_qcolor + + def lookup_table(self, low=None, high=None): + """ + A lookup table for this pallette. + + Arguments `low` and `high` (between 0 and 255) can be used to use + just a part of palette. + + Args: + low (float or None): minimal value + high (float or None): maximal value + + Returns: + an array of shape (255, 3) with RGB values + """ + return self.values_to_colors(np.arange(256) / 256, low, high) + + def color_strip(self, strip_length, strip_width, orientation=Qt.Horizontal): + """ + Return a pixmap of given dimensions to be used for legends. + + Args: + strip_length (int): length of the strip; may be horizontal or vertical + strip_width (int): width of the strip + orientation: strip orientation + + Returns: + QPixmap with a strip + """ + points = np.linspace(0, 255, strip_length, dtype=np.uint8) + section = self.palette[np.newaxis, points, :].astype(np.ubyte) + img = np.vstack((section,) * strip_width) + if orientation == Qt.Horizontal: + width, height = strip_length, strip_width + else: + width, height = strip_width, strip_length + img = img.swapaxes(0, 1)[::-1].copy() + pad_width = (-img.strides[1]) % 4 + if pad_width: + padding = np.zeros((img.shape[0], pad_width, 3), np.ubyte) + img = np.hstack((img, padding)) + img = QImage(img, width, height, img.strides[0], QImage.Format_RGB888) + img = QPixmap.fromImage(img) + return img + + @classmethod + def from_colors(cls, color1, color2, pass_through=False): + """ + Deprecated constructor for tests and easier migration from + Variable.color. Constructs a palette that goes from color1 to color2. + + pass_throug can be a color through which the palette will pass, + or `True` to pass through black. Default is `False`. + """ + if pass_through is True: + colors = (color1, (0, 0, 0), color2) + xf = [0, 127, 255] + elif pass_through: + assert isinstance(pass_through, tuple) + colors = (color1, pass_through, color2) + xf = [0, 127, 255] + else: + colors = (color1, color2) + xf = [0, 255] + name = repr(colors) + friendly_name = "Custom" + x = np.arange(256) + r = np.interp(x, xf, np.array([color[0] for color in colors])) + g = np.interp(x, xf, np.array([color[1] for color in colors])) + b = np.interp(x, xf, np.array([color[2] for color in colors])) + palette = np.vstack((r, g, b)).T + return cls(friendly_name, name, palette, + flags=Palette.Diverging if pass_through else Palette.NoFlags) + + +class BinnedContinuousPalette(IndexedPalette): + """ + Continuous palettes that bins values. + + Besides the derived attributes, it has an attribute `bins` (np.ndarray), + which contains bin boundaries, including the lower and the higher + boundary, which are essentially ignored. + """ + def __init__(self, friendly_name, name, bin_colors, bins, + nan_color=NAN_COLOR, + *, category=None, flags=Palette.Discrete): + super().__init__(friendly_name, name, bin_colors, nan_color, + category=category, flags=flags) + self.bins = bins + + @classmethod + def from_palette(cls, palette, bins): + """ + Construct a `BinnedPalette` from `ContinuousPalette` by picking the + colors representing the centers of the bins. + + If given a `BinnedPalette`, the constructor returns a copy. + + Args: + palette (ContinuousPalette or BinnedPalette): original palette + bins (np.ndarray): bin boundaries + """ + if isinstance(palette, cls): + # Note that this silently ignores bins. This is done on purpose + # to let predefined binned palettes override bins. Plus, it is + # generally impossible to compute a binned palette with different + # bins. + return palette.copy() + if isinstance(palette, ContinuousPalette): + assert len(bins) >= 2 + mids = (bins[:-1] + bins[1:]) / 2 + bin_colors = palette.values_to_colors(mids, bins[0], bins[-1]) + return cls( + palette.friendly_name, palette.name, bin_colors, bins, + palette.nan_color, category=palette.category, + flags=palette.flags | Palette.Discrete) + raise TypeError(f"can't create palette from '{type(palette).__name__}'") + + def _bin_indices(self, x): + nans = np.isnan(x) + binx = np.digitize(x, self.bins[1:-1], right=True) + binx.clip(0, len(self.bins) - 1) + binx[nans] = -1 + return binx, nans + + def values_to_colors(self, x): + """ + Return an array of colors assigned to given values by the palette + + Args: + x (np.array): colors to be mapped + + Returns: + an array of shape (len(x), 3) with RGB values for each point + """ + + binx, nans = self._bin_indices(x) + colors = self.palette[binx] + colors[nans] = self.nan_color + return colors + + def values_to_qcolors(self, x): + """ + Return an array of colors assigned to given values by the palette + + Args: + x (np.array): colors to be mapped + + Returns: + an array of shape (len(x), ) with QColors for each point + """ + binx, _ = self._bin_indices(x) + return self.qcolors_w_nan[binx] + + def value_to_color(self, x): + """ + Return an RGB triplet (as np.ndarray) corresponding to value x. + x may also be nan. + """ + return self.values_to_colors([x])[0] + + def value_to_qcolor(self, x): + """ + Return a QColor corresponding to value x. x may also be nan. + """ + if np.isnan(x): + color = self.nan_color + else: + binx, _ = self._bin_indices([x]) + color = self.palette[binx[0]] + return QColor(*color) + + def copy(self): + return type(self)(self.friendly_name, self.name, self.palette.copy(), + self.bins.copy(), self.nan_color, + category=self.category, flags=self.flags) + + +DefaultRGBColors = DiscretePalette("Default", "Default", [ + [70, 190, 250], [237, 70, 47], [170, 242, 43], [245, 174, 50], + [255, 255, 0], [255, 0, 255], [0, 255, 255], [128, 0, 255], + [0, 128, 255], [255, 223, 128], [127, 111, 64], [92, 46, 0], + [0, 84, 0], [192, 192, 0], [0, 127, 127], [128, 0, 0], + [127, 0, 127]]) + +Dark2Colors = DiscretePalette("Dark", "Dark", [ + [27, 158, 119], [217, 95, 2], [117, 112, 179], [231, 41, 138], + [102, 166, 30], [230, 171, 2], [166, 118, 29], [102, 102, 102]]) + +DiscretePalettes = { + "default": DefaultRGBColors, + "dark": Dark2Colors +} + +DefaultDiscretePaletteName = "default" +DefaultDiscretePalette = DiscretePalettes[DefaultDiscretePaletteName] + + +# pylint: disable=line-too-long +ContinuousPalettes = { + 'diverging_bwr_40_95_c42': ContinuousPalette( + 'Coolwarm', 'diverging_bwr_40_95_c42', + [[33, 81, 219], [37, 82, 219], [42, 83, 219], [46, 84, 220], [49, 85, 220], [53, 86, 220], [56, 87, 220], [59, 88, 220], [62, 89, 221], [65, 91, 221], [67, 92, 221], [70, 93, 221], [72, 94, 221], [75, 95, 222], [77, 96, 222], [80, 97, 222], [82, 99, 222], [84, 100, 223], [86, 101, 223], [88, 102, 223], [90, 103, 223], [92, 104, 223], [94, 105, 224], [96, 107, 224], [98, 108, 224], [100, 109, 224], [102, 110, 224], [104, 111, 225], [105, 112, 225], [107, 114, 225], [109, 115, 225], [111, 116, 225], [112, 117, 226], [114, 118, 226], [116, 120, 226], [117, 121, 226], [119, 122, 226], [121, 123, 227], [122, 124, 227], [124, 126, 227], [125, 127, 227], [127, 128, 227], [129, 129, 228], [130, 130, 228], [132, 132, 228], [133, 133, 228], [135, 134, 228], [136, 135, 229], [138, 136, 229], [139, 138, 229], [141, 139, 229], [142, 140, 229], [143, 141, 229], [145, 143, 230], [146, 144, 230], [148, 145, 230], [149, 146, 230], [151, 148, 230], [152, 149, 231], [153, 150, 231], [155, 151, 231], [156, 153, 231], [158, 154, 231], [159, 155, 231], [160, 156, 232], [162, 158, 232], [163, 159, 232], [164, 160, 232], [166, 161, 232], [167, 163, 232], [168, 164, 233], [170, 165, 233], [171, 166, 233], [172, 168, 233], [174, 169, 233], [175, 170, 233], [176, 172, 234], [178, 173, 234], [179, 174, 234], [180, 175, 234], [181, 177, 234], [183, 178, 234], [184, 179, 235], [185, 180, 235], [187, 182, 235], [188, 183, 235], [189, 184, 235], [190, 186, 235], [192, 187, 235], [193, 188, 236], [194, 190, 236], [195, 191, 236], [197, 192, 236], [198, 193, 236], [199, 195, 236], [200, 196, 236], [202, 197, 237], [203, 199, 237], [204, 200, 237], [205, 201, 237], [207, 203, 237], [208, 204, 237], [209, 205, 237], [210, 207, 238], [211, 208, 238], [213, 209, 238], [214, 211, 238], [215, 212, 238], [216, 213, 238], [218, 215, 238], [219, 216, 238], [220, 217, 239], [221, 218, 239], [222, 220, 239], [224, 221, 239], [225, 222, 239], [226, 224, 239], [227, 225, 239], [228, 226, 239], [229, 227, 239], [230, 228, 238], [231, 229, 238], [232, 229, 238], [233, 230, 237], [234, 231, 237], [235, 231, 236], [236, 231, 235], [237, 231, 234], [238, 231, 233], [238, 231, 232], [239, 230, 231], [239, 230, 230], [240, 229, 228], [240, 228, 227], [241, 227, 225], [241, 226, 223], [241, 225, 221], [241, 224, 220], [241, 222, 218], [242, 221, 216], [242, 219, 214], [242, 218, 212], [242, 216, 210], [242, 215, 208], [242, 213, 206], [242, 212, 204], [242, 210, 203], [242, 209, 201], [242, 207, 199], [242, 206, 197], [242, 204, 195], [241, 203, 193], [241, 201, 191], [241, 199, 189], [241, 198, 187], [241, 196, 185], [241, 195, 183], [241, 193, 181], [241, 192, 180], [240, 190, 178], [240, 189, 176], [240, 187, 174], [240, 185, 172], [240, 184, 170], [240, 182, 168], [239, 181, 166], [239, 179, 165], [239, 178, 163], [239, 176, 161], [238, 175, 159], [238, 173, 157], [238, 172, 155], [238, 170, 153], [237, 168, 152], [237, 167, 150], [237, 165, 148], [237, 164, 146], [236, 162, 144], [236, 161, 142], [236, 159, 140], [235, 157, 139], [235, 156, 137], [235, 154, 135], [234, 153, 133], [234, 151, 131], [234, 150, 130], [233, 148, 128], [233, 147, 126], [232, 145, 124], [232, 143, 122], [232, 142, 121], [231, 140, 119], [231, 139, 117], [230, 137, 115], [230, 136, 113], [230, 134, 112], [229, 132, 110], [229, 131, 108], [228, 129, 106], [228, 128, 105], [227, 126, 103], [227, 124, 101], [226, 123, 99], [226, 121, 98], [225, 120, 96], [225, 118, 94], [224, 116, 92], [224, 115, 91], [223, 113, 89], [223, 111, 87], [222, 110, 85], [222, 108, 84], [221, 106, 82], [221, 105, 80], [220, 103, 79], [219, 102, 77], [219, 100, 75], [218, 98, 73], [218, 96, 72], [217, 95, 70], [216, 93, 68], [216, 91, 67], [215, 90, 65], [215, 88, 63], [214, 86, 62], [213, 84, 60], [213, 82, 58], [212, 81, 56], [211, 79, 55], [211, 77, 53], [210, 75, 51], [209, 73, 50], [209, 71, 48], [208, 69, 46], [207, 68, 45], [207, 66, 43], [206, 64, 41], [205, 61, 40], [205, 59, 38], [204, 57, 36], [203, 55, 34], [202, 53, 33], [202, 51, 31], [201, 48, 29], [200, 46, 27], [200, 43, 26], [199, 41, 24], [198, 38, 22], [197, 35, 20], [197, 32, 18], [196, 28, 16], [195, 25, 14], [194, 20, 12], [193, 15, 10], [193, 9, 8], [192, 2, 6]], + flags=Palette.Diverging + ), + 'diverging_gkr_60_10_c40': ContinuousPalette( + 'Green-Red', 'diverging_gkr_60_10_c40', + [[54, 166, 22], [54, 165, 23], [54, 164, 23], [54, 162, 24], [54, 161, 24], [54, 160, 24], [54, 159, 25], [54, 158, 25], [54, 157, 26], [54, 155, 26], [54, 154, 26], [54, 153, 27], [54, 152, 27], [54, 151, 27], [54, 149, 27], [54, 148, 28], [54, 147, 28], [54, 146, 28], [54, 145, 28], [54, 144, 29], [54, 142, 29], [54, 141, 29], [54, 140, 29], [54, 139, 30], [54, 138, 30], [54, 137, 30], [54, 135, 30], [54, 134, 30], [53, 133, 30], [53, 132, 31], [53, 131, 31], [53, 130, 31], [53, 129, 31], [53, 127, 31], [53, 126, 31], [53, 125, 32], [53, 124, 32], [53, 123, 32], [53, 122, 32], [52, 121, 32], [52, 119, 32], [52, 118, 32], [52, 117, 32], [52, 116, 32], [52, 115, 32], [52, 114, 33], [51, 113, 33], [51, 112, 33], [51, 110, 33], [51, 109, 33], [51, 108, 33], [51, 107, 33], [51, 106, 33], [50, 105, 33], [50, 104, 33], [50, 103, 33], [50, 102, 33], [50, 100, 33], [49, 99, 33], [49, 98, 33], [49, 97, 33], [49, 96, 33], [49, 95, 33], [48, 94, 33], [48, 93, 33], [48, 92, 33], [48, 91, 33], [48, 90, 33], [47, 88, 33], [47, 87, 33], [47, 86, 33], [47, 85, 33], [46, 84, 33], [46, 83, 33], [46, 82, 33], [46, 81, 33], [45, 80, 33], [45, 79, 33], [45, 78, 33], [45, 77, 33], [44, 76, 33], [44, 75, 33], [44, 74, 33], [44, 73, 33], [43, 71, 33], [43, 70, 33], [43, 69, 33], [42, 68, 33], [42, 67, 32], [42, 66, 32], [42, 65, 32], [41, 64, 32], [41, 63, 32], [41, 62, 32], [40, 61, 32], [40, 60, 32], [40, 59, 32], [39, 58, 32], [39, 57, 32], [39, 56, 31], [38, 55, 31], [38, 54, 31], [38, 53, 31], [37, 52, 31], [37, 51, 31], [37, 50, 31], [36, 49, 31], [36, 48, 31], [36, 47, 30], [35, 46, 30], [35, 45, 30], [35, 44, 30], [34, 43, 30], [34, 42, 30], [34, 41, 30], [33, 40, 30], [33, 39, 29], [33, 39, 29], [32, 38, 29], [32, 37, 29], [32, 36, 29], [32, 35, 29], [32, 35, 29], [32, 34, 29], [33, 34, 29], [33, 33, 29], [33, 33, 29], [34, 32, 29], [35, 32, 29], [35, 32, 29], [36, 32, 29], [37, 32, 29], [38, 32, 29], [40, 32, 29], [41, 33, 30], [42, 33, 30], [44, 33, 30], [45, 34, 30], [47, 34, 30], [48, 34, 31], [50, 35, 31], [51, 35, 31], [53, 36, 31], [54, 36, 32], [56, 37, 32], [58, 37, 32], [59, 38, 32], [61, 38, 33], [62, 39, 33], [64, 39, 33], [66, 40, 33], [67, 40, 34], [69, 41, 34], [71, 41, 34], [72, 42, 34], [74, 42, 34], [75, 43, 35], [77, 43, 35], [79, 44, 35], [80, 45, 35], [82, 45, 36], [84, 46, 36], [85, 46, 36], [87, 46, 36], [89, 47, 37], [90, 47, 37], [92, 48, 37], [94, 48, 37], [95, 49, 38], [97, 49, 38], [99, 50, 38], [100, 50, 38], [102, 51, 39], [104, 51, 39], [106, 52, 39], [107, 52, 39], [109, 53, 39], [111, 53, 40], [112, 54, 40], [114, 54, 40], [116, 55, 40], [117, 55, 41], [119, 56, 41], [121, 56, 41], [123, 57, 41], [124, 57, 42], [126, 58, 42], [128, 58, 42], [130, 59, 42], [131, 59, 42], [133, 59, 43], [135, 60, 43], [137, 60, 43], [138, 61, 43], [140, 61, 44], [142, 62, 44], [144, 62, 44], [145, 63, 44], [147, 63, 44], [149, 64, 45], [151, 64, 45], [152, 65, 45], [154, 65, 45], [156, 65, 46], [158, 66, 46], [160, 66, 46], [161, 67, 46], [163, 67, 46], [165, 68, 47], [167, 68, 47], [169, 69, 47], [170, 69, 47], [172, 70, 47], [174, 70, 48], [176, 70, 48], [178, 71, 48], [180, 71, 48], [181, 72, 49], [183, 72, 49], [185, 73, 49], [187, 73, 49], [189, 73, 49], [191, 74, 50], [192, 74, 50], [194, 75, 50], [196, 75, 50], [198, 76, 50], [200, 76, 51], [202, 76, 51], [204, 77, 51], [205, 77, 51], [207, 78, 52], [209, 78, 52], [211, 79, 52], [213, 79, 52], [215, 79, 52], [217, 80, 53], [219, 80, 53], [220, 81, 53], [222, 81, 53], [224, 82, 53], [226, 82, 54], [228, 82, 54], [230, 83, 54], [232, 83, 54], [234, 84, 54], [236, 84, 55], [238, 85, 55], [240, 85, 55], [241, 85, 55], [243, 86, 55], [245, 86, 56], [247, 87, 56], [249, 87, 56], [251, 87, 56], [253, 88, 56]], + flags=Palette.Diverging + ), + 'linear_bgyw_15_100_c68': ContinuousPalette( + 'Blue-Green-Yellow', 'linear_bgyw_15_100_c68', + [[26, 0, 134], [26, 0, 136], [26, 0, 137], [26, 0, 139], [26, 1, 141], [26, 1, 143], [26, 2, 145], [26, 3, 146], [26, 4, 148], [26, 4, 150], [26, 5, 152], [27, 6, 153], [27, 7, 155], [27, 8, 157], [27, 9, 158], [27, 10, 160], [27, 11, 162], [27, 12, 164], [27, 13, 165], [27, 14, 167], [27, 15, 168], [27, 16, 170], [27, 17, 172], [27, 19, 173], [27, 20, 175], [27, 21, 176], [27, 22, 178], [27, 23, 180], [27, 24, 181], [27, 25, 183], [27, 26, 184], [27, 27, 186], [27, 28, 187], [27, 29, 188], [27, 30, 190], [27, 31, 191], [27, 32, 193], [27, 33, 194], [27, 34, 195], [27, 35, 197], [27, 36, 198], [27, 37, 199], [27, 39, 200], [27, 40, 201], [28, 41, 203], [28, 42, 204], [28, 43, 205], [28, 44, 206], [28, 45, 207], [28, 47, 208], [28, 48, 209], [29, 49, 210], [29, 50, 210], [29, 52, 211], [29, 53, 212], [29, 54, 213], [30, 55, 213], [30, 57, 214], [30, 58, 214], [30, 59, 215], [31, 61, 215], [31, 62, 215], [31, 64, 215], [31, 65, 215], [32, 67, 215], [32, 68, 215], [32, 70, 215], [32, 71, 214], [33, 73, 214], [33, 75, 213], [33, 76, 212], [33, 78, 211], [33, 80, 209], [33, 82, 207], [33, 84, 205], [32, 86, 202], [32, 88, 199], [32, 90, 196], [32, 92, 193], [32, 94, 190], [32, 96, 187], [33, 98, 184], [33, 100, 181], [34, 101, 178], [35, 103, 175], [36, 105, 173], [37, 106, 170], [38, 108, 167], [39, 109, 164], [40, 111, 161], [41, 112, 159], [42, 113, 156], [43, 115, 153], [44, 116, 150], [45, 118, 148], [46, 119, 145], [47, 120, 142], [47, 121, 140], [48, 123, 137], [49, 124, 134], [50, 125, 132], [51, 126, 129], [52, 128, 126], [53, 129, 124], [54, 130, 121], [55, 131, 118], [55, 132, 116], [56, 134, 113], [57, 135, 110], [58, 136, 108], [59, 137, 105], [59, 138, 102], [60, 139, 100], [61, 140, 97], [62, 142, 94], [62, 143, 92], [63, 144, 89], [64, 145, 87], [65, 146, 85], [66, 147, 83], [67, 148, 80], [68, 149, 78], [68, 150, 76], [69, 151, 74], [70, 152, 72], [72, 153, 70], [73, 154, 68], [74, 155, 66], [75, 156, 65], [76, 157, 63], [77, 158, 61], [79, 159, 60], [80, 160, 58], [81, 161, 56], [83, 161, 55], [84, 162, 53], [86, 163, 52], [87, 164, 50], [89, 165, 49], [90, 166, 47], [92, 167, 46], [93, 168, 44], [95, 168, 43], [97, 169, 42], [98, 170, 41], [100, 171, 39], [102, 172, 38], [103, 173, 37], [105, 173, 36], [107, 174, 34], [109, 175, 33], [111, 176, 32], [113, 177, 31], [115, 177, 30], [116, 178, 29], [118, 179, 28], [120, 180, 28], [122, 180, 27], [124, 181, 26], [126, 182, 25], [128, 183, 25], [130, 183, 24], [132, 184, 23], [134, 185, 23], [136, 186, 23], [139, 186, 22], [141, 187, 22], [143, 188, 22], [145, 188, 22], [147, 189, 22], [149, 190, 22], [151, 190, 22], [154, 191, 23], [156, 192, 23], [158, 192, 23], [160, 193, 24], [162, 194, 24], [164, 194, 25], [166, 195, 25], [169, 196, 26], [171, 196, 26], [173, 197, 27], [175, 198, 28], [177, 198, 28], [179, 199, 29], [181, 200, 30], [183, 200, 31], [185, 201, 32], [187, 202, 33], [189, 202, 33], [191, 203, 34], [192, 204, 35], [194, 204, 36], [196, 205, 37], [198, 206, 38], [200, 206, 40], [202, 207, 41], [203, 208, 42], [205, 208, 43], [207, 209, 44], [209, 210, 46], [210, 210, 47], [212, 211, 48], [214, 212, 50], [215, 213, 51], [217, 213, 53], [218, 214, 54], [220, 215, 56], [222, 216, 57], [223, 216, 59], [224, 217, 61], [226, 218, 63], [227, 219, 64], [229, 220, 66], [230, 220, 68], [231, 221, 70], [232, 222, 72], [233, 223, 74], [234, 224, 77], [235, 225, 79], [236, 226, 82], [237, 227, 84], [238, 228, 87], [238, 229, 90], [239, 230, 93], [239, 231, 96], [239, 232, 99], [240, 233, 103], [240, 234, 106], [240, 235, 110], [241, 236, 114], [241, 237, 118], [242, 238, 122], [242, 239, 126], [243, 240, 131], [243, 241, 135], [244, 242, 140], [245, 243, 145], [245, 244, 150], [246, 244, 155], [247, 245, 160], [247, 246, 165], [248, 247, 171], [249, 247, 176], [249, 248, 182], [250, 249, 188], [250, 250, 194], [251, 250, 200], [252, 251, 207], [252, 252, 213], [253, 252, 220], [253, 253, 226], [254, 253, 233], [254, 254, 240], [255, 254, 248], [255, 255, 255]], + ), + 'linear_bmy_10_95_c78': ContinuousPalette( + 'Blue-Magenta-Yellow', 'linear_bmy_10_95_c78', + [[0, 12, 125], [0, 13, 126], [0, 13, 128], [0, 14, 130], [0, 14, 132], [0, 14, 134], [0, 15, 135], [0, 15, 137], [0, 16, 139], [0, 16, 140], [0, 17, 142], [0, 17, 144], [0, 17, 145], [0, 18, 147], [0, 18, 148], [0, 18, 150], [0, 19, 151], [0, 19, 153], [0, 19, 154], [0, 20, 155], [0, 20, 157], [0, 20, 158], [0, 20, 159], [0, 21, 160], [0, 21, 161], [0, 21, 162], [0, 21, 163], [0, 21, 164], [0, 21, 165], [0, 22, 166], [0, 22, 167], [0, 22, 167], [0, 22, 168], [0, 22, 169], [0, 22, 169], [0, 22, 169], [10, 22, 170], [21, 22, 170], [29, 21, 170], [35, 21, 170], [41, 21, 170], [47, 21, 169], [52, 20, 169], [57, 20, 168], [62, 19, 167], [67, 19, 166], [71, 18, 165], [76, 18, 164], [80, 17, 163], [83, 17, 162], [87, 16, 161], [90, 15, 160], [94, 15, 159], [97, 14, 159], [100, 14, 158], [103, 13, 157], [106, 13, 156], [108, 12, 155], [111, 11, 154], [114, 11, 153], [116, 10, 153], [119, 10, 152], [121, 9, 151], [124, 8, 151], [126, 8, 150], [128, 7, 149], [130, 7, 149], [133, 6, 148], [135, 6, 147], [137, 6, 147], [139, 5, 146], [141, 5, 146], [143, 4, 145], [145, 4, 145], [147, 4, 144], [149, 3, 144], [151, 3, 143], [153, 3, 143], [155, 2, 142], [157, 2, 142], [159, 2, 142], [161, 1, 141], [163, 1, 141], [165, 1, 140], [167, 1, 140], [169, 0, 139], [171, 0, 139], [172, 0, 138], [174, 0, 138], [176, 0, 137], [178, 0, 137], [180, 0, 136], [182, 0, 136], [184, 0, 135], [185, 0, 135], [187, 0, 135], [189, 0, 134], [191, 0, 134], [193, 0, 133], [195, 0, 133], [196, 0, 132], [198, 0, 132], [200, 0, 131], [202, 0, 131], [204, 0, 130], [206, 0, 130], [207, 0, 129], [209, 0, 129], [211, 0, 128], [213, 0, 128], [214, 0, 127], [216, 0, 127], [218, 0, 126], [219, 0, 126], [221, 0, 125], [222, 0, 124], [224, 1, 124], [226, 2, 123], [227, 4, 123], [229, 6, 122], [230, 8, 122], [232, 11, 121], [233, 13, 120], [234, 16, 120], [236, 18, 119], [237, 20, 119], [238, 22, 118], [240, 24, 117], [241, 26, 117], [242, 28, 116], [244, 30, 115], [245, 32, 115], [246, 34, 114], [247, 36, 113], [248, 38, 113], [249, 40, 112], [251, 42, 111], [252, 44, 111], [253, 46, 110], [254, 48, 109], [255, 50, 108], [255, 52, 108], [255, 54, 107], [255, 56, 106], [255, 58, 106], [255, 61, 105], [255, 63, 104], [255, 65, 103], [255, 67, 102], [255, 69, 102], [255, 71, 101], [255, 73, 100], [255, 75, 99], [255, 77, 98], [255, 80, 98], [255, 82, 97], [255, 84, 96], [255, 86, 95], [255, 88, 94], [255, 90, 93], [255, 93, 92], [255, 95, 91], [255, 97, 91], [255, 99, 90], [255, 101, 89], [255, 103, 88], [255, 106, 87], [255, 108, 86], [255, 110, 85], [255, 112, 84], [255, 114, 83], [255, 116, 82], [255, 118, 81], [255, 120, 80], [255, 122, 78], [255, 124, 77], [255, 126, 76], [255, 127, 75], [255, 129, 74], [255, 131, 73], [255, 133, 72], [255, 135, 71], [255, 136, 69], [255, 138, 68], [255, 140, 67], [255, 142, 66], [255, 143, 64], [255, 145, 63], [255, 147, 62], [255, 149, 60], [255, 150, 59], [255, 152, 57], [255, 154, 56], [255, 155, 54], [255, 157, 53], [255, 159, 51], [255, 160, 50], [255, 162, 48], [255, 163, 47], [255, 165, 46], [255, 166, 44], [255, 168, 43], [255, 169, 42], [255, 171, 41], [255, 172, 40], [255, 174, 39], [255, 175, 38], [255, 177, 38], [255, 178, 37], [255, 180, 36], [255, 181, 35], [255, 182, 35], [255, 184, 34], [255, 185, 34], [255, 187, 33], [255, 188, 32], [255, 189, 32], [255, 191, 31], [255, 192, 31], [255, 194, 31], [255, 195, 30], [255, 196, 30], [255, 198, 30], [255, 199, 29], [255, 200, 29], [255, 202, 29], [255, 203, 29], [255, 204, 29], [255, 206, 29], [255, 207, 28], [255, 208, 28], [255, 210, 28], [255, 211, 28], [255, 212, 28], [255, 214, 28], [255, 215, 29], [255, 216, 29], [255, 218, 29], [255, 219, 29], [255, 220, 29], [255, 222, 29], [255, 223, 30], [255, 224, 30], [255, 226, 30], [255, 227, 30], [255, 228, 31], [255, 230, 31], [255, 231, 31], [255, 232, 32], [255, 233, 32], [255, 235, 33], [255, 236, 33], [255, 237, 34], [255, 239, 34], [255, 240, 35], [255, 241, 35]], + ), + 'linear_grey_10_95_c0': ContinuousPalette( + 'Dim gray', 'linear_grey_10_95_c0', + [[27, 27, 27], [28, 28, 28], [29, 29, 29], [29, 29, 29], [30, 30, 30], [31, 31, 31], [31, 31, 31], [32, 32, 32], [33, 33, 33], [34, 34, 34], [34, 34, 34], [35, 35, 35], [36, 36, 36], [36, 36, 36], [37, 37, 37], [38, 38, 38], [38, 38, 38], [39, 39, 39], [40, 40, 40], [40, 40, 40], [41, 41, 41], [42, 42, 42], [43, 43, 43], [43, 43, 43], [44, 44, 44], [45, 45, 45], [45, 45, 45], [46, 46, 46], [47, 47, 47], [48, 48, 48], [48, 48, 48], [49, 49, 49], [50, 50, 50], [50, 50, 50], [51, 51, 51], [52, 52, 52], [53, 53, 53], [53, 53, 53], [54, 54, 54], [55, 55, 55], [56, 56, 56], [56, 56, 56], [57, 57, 57], [58, 58, 58], [59, 59, 59], [59, 59, 59], [60, 60, 60], [61, 61, 61], [62, 62, 62], [62, 62, 62], [63, 63, 63], [64, 64, 64], [65, 65, 65], [65, 65, 65], [66, 66, 66], [67, 67, 67], [68, 68, 68], [68, 68, 68], [69, 69, 69], [70, 70, 70], [71, 71, 71], [71, 71, 71], [72, 72, 72], [73, 73, 73], [74, 74, 74], [74, 74, 74], [75, 75, 75], [76, 76, 76], [77, 77, 77], [78, 78, 78], [78, 78, 78], [79, 79, 79], [80, 80, 80], [81, 81, 81], [81, 82, 82], [82, 82, 82], [83, 83, 83], [84, 84, 84], [85, 85, 85], [85, 85, 85], [86, 86, 86], [87, 87, 87], [88, 88, 88], [89, 89, 89], [89, 89, 89], [90, 90, 90], [91, 91, 91], [92, 92, 92], [93, 93, 93], [93, 93, 93], [94, 94, 94], [95, 95, 95], [96, 96, 96], [97, 97, 97], [97, 97, 97], [98, 98, 98], [99, 99, 99], [100, 100, 100], [101, 101, 101], [102, 102, 102], [102, 102, 102], [103, 103, 103], [104, 104, 104], [105, 105, 105], [106, 106, 106], [106, 106, 106], [107, 107, 107], [108, 108, 108], [109, 109, 109], [110, 110, 110], [111, 111, 111], [111, 111, 111], [112, 112, 112], [113, 113, 113], [114, 114, 114], [115, 115, 115], [116, 116, 116], [116, 116, 116], [117, 117, 117], [118, 118, 118], [119, 119, 119], [120, 120, 120], [121, 121, 121], [121, 121, 121], [122, 122, 122], [123, 123, 123], [124, 124, 124], [125, 125, 125], [126, 126, 126], [126, 127, 127], [127, 127, 127], [128, 128, 128], [129, 129, 129], [130, 130, 130], [131, 131, 131], [132, 132, 132], [132, 132, 132], [133, 133, 133], [134, 134, 134], [135, 135, 135], [136, 136, 136], [137, 137, 137], [138, 138, 138], [138, 138, 138], [139, 139, 139], [140, 140, 140], [141, 141, 141], [142, 142, 142], [143, 143, 143], [144, 144, 144], [145, 145, 145], [145, 145, 145], [146, 146, 146], [147, 147, 147], [148, 148, 148], [149, 149, 149], [150, 150, 150], [151, 151, 151], [152, 152, 152], [152, 152, 152], [153, 153, 153], [154, 154, 154], [155, 155, 155], [156, 156, 156], [157, 157, 157], [158, 158, 158], [159, 159, 159], [159, 159, 159], [160, 160, 160], [161, 161, 161], [162, 162, 162], [163, 163, 163], [164, 164, 164], [165, 165, 165], [166, 166, 166], [167, 167, 167], [167, 167, 167], [168, 168, 168], [169, 169, 169], [170, 170, 170], [171, 171, 171], [172, 172, 172], [173, 173, 173], [174, 174, 174], [175, 175, 175], [176, 176, 176], [176, 176, 176], [177, 177, 177], [178, 178, 178], [179, 179, 179], [180, 180, 180], [181, 181, 181], [182, 182, 182], [183, 183, 183], [184, 184, 184], [185, 185, 185], [185, 186, 186], [186, 186, 186], [187, 187, 187], [188, 188, 188], [189, 189, 189], [190, 190, 190], [191, 191, 191], [192, 192, 192], [193, 193, 193], [194, 194, 194], [195, 195, 195], [196, 196, 196], [196, 196, 196], [197, 197, 197], [198, 198, 198], [199, 199, 199], [200, 200, 200], [201, 201, 201], [202, 202, 202], [203, 203, 203], [204, 204, 204], [205, 205, 205], [206, 206, 206], [207, 207, 207], [208, 208, 208], [208, 209, 209], [209, 209, 209], [210, 210, 210], [211, 211, 211], [212, 212, 212], [213, 213, 213], [214, 214, 214], [215, 215, 215], [216, 216, 216], [217, 217, 217], [218, 218, 218], [219, 219, 219], [220, 220, 220], [221, 221, 221], [222, 222, 222], [223, 223, 223], [223, 224, 223], [224, 224, 224], [225, 225, 225], [226, 226, 226], [227, 227, 227], [228, 228, 228], [229, 229, 229], [230, 230, 230], [231, 231, 231], [232, 232, 232], [233, 233, 233], [234, 234, 234], [235, 235, 235], [236, 236, 236], [237, 237, 237], [238, 238, 238], [239, 239, 239], [240, 240, 240], [241, 241, 241]], + ), + 'linear_kryw_0_100_c71': ContinuousPalette( + 'Fire', 'linear_kryw_0_100_c71', + [[0, 0, 0], [7, 0, 0], [13, 0, 0], [18, 0, 0], [22, 0, 0], [26, 0, 0], [29, 0, 0], [32, 0, 0], [34, 0, 0], [37, 0, 0], [39, 0, 0], [41, 0, 0], [43, 0, 0], [45, 0, 0], [47, 0, 0], [49, 0, 0], [50, 0, 0], [52, 0, 0], [54, 1, 0], [55, 1, 0], [57, 0, 0], [58, 0, 0], [60, 0, 0], [61, 0, 0], [63, 0, 0], [64, 0, 0], [66, 1, 0], [67, 1, 0], [69, 1, 0], [70, 1, 0], [72, 1, 0], [73, 1, 0], [75, 1, 0], [76, 1, 0], [78, 1, 0], [79, 1, 0], [81, 1, 0], [82, 1, 0], [84, 1, 0], [85, 1, 0], [87, 1, 0], [88, 1, 0], [90, 1, 0], [92, 1, 0], [93, 1, 0], [95, 1, 0], [96, 1, 0], [98, 1, 0], [99, 1, 0], [101, 1, 0], [103, 1, 0], [104, 1, 0], [106, 1, 0], [107, 2, 0], [109, 2, 0], [111, 2, 0], [112, 2, 0], [114, 2, 0], [116, 2, 0], [117, 2, 0], [119, 2, 0], [121, 2, 0], [122, 2, 0], [124, 2, 0], [126, 2, 0], [127, 2, 0], [129, 2, 0], [131, 2, 0], [132, 3, 0], [134, 3, 0], [136, 3, 0], [137, 3, 0], [139, 3, 0], [141, 3, 0], [142, 3, 0], [144, 3, 0], [146, 3, 0], [147, 3, 0], [149, 3, 0], [151, 4, 0], [153, 4, 0], [154, 4, 0], [156, 4, 0], [158, 4, 0], [159, 4, 0], [161, 4, 0], [163, 4, 0], [165, 5, 0], [166, 5, 0], [168, 5, 0], [170, 5, 0], [172, 5, 0], [173, 5, 0], [175, 5, 0], [177, 6, 0], [179, 6, 0], [180, 6, 0], [182, 6, 0], [184, 6, 0], [186, 7, 0], [188, 7, 0], [189, 7, 0], [191, 7, 0], [193, 7, 0], [195, 8, 0], [197, 8, 0], [198, 8, 0], [200, 8, 0], [202, 9, 0], [204, 9, 0], [206, 9, 0], [207, 9, 0], [209, 10, 0], [211, 10, 0], [213, 11, 0], [215, 11, 0], [216, 11, 0], [218, 12, 0], [220, 12, 0], [222, 13, 0], [224, 13, 0], [225, 14, 0], [227, 14, 0], [229, 15, 0], [231, 16, 0], [232, 17, 0], [234, 18, 0], [236, 19, 0], [237, 21, 0], [239, 23, 0], [240, 25, 0], [242, 27, 0], [243, 30, 0], [244, 32, 0], [245, 35, 0], [246, 38, 0], [247, 41, 0], [248, 44, 0], [248, 47, 0], [249, 50, 0], [250, 53, 0], [250, 56, 0], [251, 59, 0], [251, 62, 0], [252, 65, 0], [252, 68, 0], [252, 70, 0], [253, 73, 0], [253, 76, 0], [253, 78, 0], [253, 81, 0], [254, 84, 0], [254, 86, 0], [254, 89, 0], [254, 91, 0], [254, 93, 0], [254, 96, 0], [254, 98, 0], [254, 100, 0], [255, 103, 0], [255, 105, 0], [255, 107, 0], [255, 109, 0], [255, 111, 0], [255, 114, 0], [255, 116, 0], [255, 118, 0], [255, 120, 0], [255, 122, 0], [255, 124, 0], [255, 126, 0], [255, 128, 0], [255, 130, 0], [255, 132, 0], [255, 134, 0], [255, 136, 0], [255, 138, 0], [255, 140, 0], [255, 142, 0], [255, 143, 0], [255, 145, 0], [255, 147, 0], [255, 149, 0], [255, 151, 0], [255, 153, 0], [255, 154, 0], [255, 156, 1], [255, 158, 1], [255, 160, 1], [255, 161, 1], [255, 163, 1], [255, 165, 1], [255, 167, 1], [255, 168, 1], [255, 170, 1], [255, 172, 1], [255, 173, 2], [255, 175, 2], [255, 177, 2], [255, 178, 2], [255, 180, 2], [255, 182, 2], [255, 183, 3], [255, 185, 3], [255, 187, 3], [255, 188, 3], [255, 190, 3], [255, 192, 4], [255, 193, 4], [255, 195, 4], [255, 196, 4], [255, 198, 5], [255, 200, 5], [255, 201, 5], [255, 203, 5], [255, 204, 6], [255, 206, 6], [255, 208, 6], [255, 209, 7], [255, 211, 7], [255, 212, 8], [255, 214, 8], [255, 215, 9], [255, 217, 9], [255, 219, 10], [255, 220, 10], [255, 222, 11], [255, 223, 11], [255, 225, 12], [255, 226, 13], [255, 228, 13], [255, 229, 14], [255, 231, 15], [255, 233, 16], [255, 234, 17], [255, 236, 19], [255, 237, 21], [255, 239, 23], [255, 240, 26], [255, 242, 31], [255, 243, 36], [255, 245, 43], [255, 246, 51], [255, 247, 60], [255, 249, 71], [255, 250, 84], [255, 251, 98], [255, 252, 114], [255, 253, 132], [255, 254, 150], [255, 254, 168], [255, 254, 187], [255, 255, 205], [255, 255, 222], [255, 255, 239], [255, 255, 255]], + ), + 'diverging_protanopic_deuteranopic_bwy_60_95_c32': ContinuousPalette( + 'Diverging protanopic', 'diverging_protanopic_deuteranopic_bwy_60_95_c32', + [[58, 144, 254], [62, 145, 254], [65, 146, 254], [68, 146, 254], [70, 147, 254], [73, 148, 254], [76, 148, 254], [78, 149, 254], [80, 150, 254], [83, 151, 253], [85, 151, 253], [87, 152, 253], [89, 153, 253], [91, 153, 253], [94, 154, 253], [96, 155, 253], [97, 155, 253], [99, 156, 253], [101, 157, 253], [103, 158, 253], [105, 158, 253], [107, 159, 253], [109, 160, 252], [110, 160, 252], [112, 161, 252], [114, 162, 252], [115, 163, 252], [117, 163, 252], [119, 164, 252], [120, 165, 252], [122, 165, 252], [124, 166, 252], [125, 167, 252], [127, 168, 251], [128, 168, 251], [130, 169, 251], [131, 170, 251], [133, 171, 251], [134, 171, 251], [136, 172, 251], [137, 173, 251], [139, 173, 251], [140, 174, 251], [141, 175, 251], [143, 176, 250], [144, 176, 250], [146, 177, 250], [147, 178, 250], [148, 179, 250], [150, 179, 250], [151, 180, 250], [152, 181, 250], [154, 182, 250], [155, 182, 250], [156, 183, 250], [158, 184, 249], [159, 185, 249], [160, 185, 249], [161, 186, 249], [163, 187, 249], [164, 187, 249], [165, 188, 249], [166, 189, 249], [168, 190, 249], [169, 191, 249], [170, 191, 248], [171, 192, 248], [173, 193, 248], [174, 194, 248], [175, 194, 248], [176, 195, 248], [177, 196, 248], [179, 197, 248], [180, 197, 248], [181, 198, 247], [182, 199, 247], [183, 200, 247], [185, 200, 247], [186, 201, 247], [187, 202, 247], [188, 203, 247], [189, 203, 247], [190, 204, 247], [192, 205, 246], [193, 206, 246], [194, 206, 246], [195, 207, 246], [196, 208, 246], [197, 209, 246], [198, 210, 246], [199, 210, 246], [201, 211, 246], [202, 212, 245], [203, 213, 245], [204, 213, 245], [205, 214, 245], [206, 215, 245], [207, 216, 245], [208, 217, 245], [209, 217, 245], [211, 218, 244], [212, 219, 244], [213, 220, 244], [214, 220, 244], [215, 221, 244], [216, 222, 244], [217, 223, 244], [218, 224, 244], [219, 224, 243], [220, 225, 243], [221, 226, 243], [222, 227, 243], [223, 228, 243], [224, 228, 243], [226, 229, 243], [227, 230, 242], [228, 231, 242], [229, 231, 242], [230, 232, 242], [231, 233, 242], [232, 234, 241], [233, 234, 241], [234, 235, 241], [234, 236, 240], [235, 236, 240], [236, 236, 239], [236, 237, 238], [237, 237, 237], [237, 237, 236], [238, 237, 235], [238, 236, 234], [238, 236, 232], [238, 236, 231], [238, 235, 229], [237, 234, 228], [237, 234, 226], [237, 233, 224], [236, 232, 223], [236, 231, 221], [236, 231, 219], [235, 230, 218], [235, 229, 216], [234, 228, 214], [234, 228, 213], [233, 227, 211], [233, 226, 209], [233, 225, 208], [232, 224, 206], [232, 224, 204], [231, 223, 202], [231, 222, 201], [230, 221, 199], [230, 220, 197], [229, 220, 196], [229, 219, 194], [228, 218, 192], [228, 217, 191], [227, 216, 189], [227, 216, 187], [226, 215, 186], [226, 214, 184], [226, 213, 182], [225, 213, 181], [225, 212, 179], [224, 211, 177], [224, 210, 176], [223, 209, 174], [223, 209, 172], [222, 208, 171], [222, 207, 169], [221, 206, 167], [220, 206, 166], [220, 205, 164], [219, 204, 162], [219, 203, 161], [218, 203, 159], [218, 202, 157], [217, 201, 156], [217, 200, 154], [216, 199, 152], [216, 199, 151], [215, 198, 149], [215, 197, 148], [214, 196, 146], [214, 196, 144], [213, 195, 143], [212, 194, 141], [212, 193, 139], [211, 193, 138], [211, 192, 136], [210, 191, 134], [210, 190, 133], [209, 190, 131], [208, 189, 129], [208, 188, 128], [207, 187, 126], [207, 187, 125], [206, 186, 123], [206, 185, 121], [205, 184, 120], [204, 184, 118], [204, 183, 116], [203, 182, 115], [203, 181, 113], [202, 181, 111], [201, 180, 110], [201, 179, 108], [200, 178, 106], [199, 178, 105], [199, 177, 103], [198, 176, 102], [198, 175, 100], [197, 175, 98], [196, 174, 97], [196, 173, 95], [195, 172, 93], [194, 172, 92], [194, 171, 90], [193, 170, 88], [193, 169, 87], [192, 169, 85], [191, 168, 83], [191, 167, 81], [190, 166, 80], [189, 166, 78], [189, 165, 76], [188, 164, 75], [187, 164, 73], [187, 163, 71], [186, 162, 69], [185, 161, 68], [185, 161, 66], [184, 160, 64], [183, 159, 62], [183, 159, 60], [182, 158, 59], [181, 157, 57], [180, 156, 55], [180, 156, 53], [179, 155, 51], [178, 154, 49], [178, 153, 47], [177, 153, 45], [176, 152, 43], [176, 151, 41], [175, 151, 39], [174, 150, 36], [173, 149, 34], [173, 149, 32], [172, 148, 29], [171, 147, 26], [171, 146, 23], [170, 146, 20], [169, 145, 17], [168, 144, 13], [168, 144, 8]], + category="Color blind", flags=Palette.ColorBlindSafe | Palette.Diverging + ), + 'diverging_tritanopic_cwr_75_98_c20': ContinuousPalette( + 'Diverging tritanopic', 'diverging_tritanopic_cwr_75_98_c20', + [[41, 202, 231], [46, 202, 231], [50, 202, 231], [54, 203, 231], [57, 203, 231], [60, 203, 232], [64, 204, 232], [67, 204, 232], [70, 205, 232], [72, 205, 232], [75, 205, 232], [78, 206, 232], [80, 206, 233], [83, 207, 233], [85, 207, 233], [87, 207, 233], [89, 208, 233], [92, 208, 233], [94, 208, 234], [96, 209, 234], [98, 209, 234], [100, 210, 234], [102, 210, 234], [104, 210, 234], [106, 211, 234], [108, 211, 235], [110, 211, 235], [111, 212, 235], [113, 212, 235], [115, 213, 235], [117, 213, 235], [119, 213, 235], [120, 214, 236], [122, 214, 236], [124, 214, 236], [125, 215, 236], [127, 215, 236], [129, 216, 236], [130, 216, 236], [132, 216, 237], [134, 217, 237], [135, 217, 237], [137, 217, 237], [138, 218, 237], [140, 218, 237], [141, 219, 237], [143, 219, 238], [144, 219, 238], [146, 220, 238], [147, 220, 238], [149, 220, 238], [150, 221, 238], [152, 221, 238], [153, 222, 239], [155, 222, 239], [156, 222, 239], [158, 223, 239], [159, 223, 239], [160, 223, 239], [162, 224, 239], [163, 224, 240], [165, 225, 240], [166, 225, 240], [167, 225, 240], [169, 226, 240], [170, 226, 240], [172, 226, 240], [173, 227, 241], [174, 227, 241], [176, 228, 241], [177, 228, 241], [178, 228, 241], [180, 229, 241], [181, 229, 241], [182, 229, 242], [184, 230, 242], [185, 230, 242], [186, 230, 242], [188, 231, 242], [189, 231, 242], [190, 232, 242], [191, 232, 243], [193, 232, 243], [194, 233, 243], [195, 233, 243], [197, 233, 243], [198, 234, 243], [199, 234, 243], [200, 235, 244], [202, 235, 244], [203, 235, 244], [204, 236, 244], [205, 236, 244], [207, 236, 244], [208, 237, 244], [209, 237, 245], [210, 237, 245], [212, 238, 245], [213, 238, 245], [214, 239, 245], [215, 239, 245], [216, 239, 245], [218, 240, 246], [219, 240, 246], [220, 240, 246], [221, 241, 246], [223, 241, 246], [224, 241, 246], [225, 242, 246], [226, 242, 247], [227, 243, 247], [229, 243, 247], [230, 243, 247], [231, 244, 247], [232, 244, 247], [233, 244, 247], [235, 245, 247], [236, 245, 248], [237, 245, 248], [238, 246, 248], [239, 246, 248], [240, 246, 248], [242, 247, 248], [243, 247, 248], [244, 247, 248], [245, 247, 248], [246, 247, 247], [246, 247, 247], [247, 247, 247], [248, 246, 246], [248, 246, 246], [249, 246, 245], [249, 245, 244], [250, 245, 244], [250, 244, 243], [250, 243, 242], [250, 243, 241], [251, 242, 241], [251, 241, 240], [251, 241, 239], [251, 240, 238], [251, 239, 237], [251, 239, 237], [251, 238, 236], [252, 237, 235], [252, 237, 234], [252, 236, 233], [252, 235, 233], [252, 235, 232], [252, 234, 231], [252, 233, 230], [252, 232, 229], [252, 232, 229], [253, 231, 228], [253, 230, 227], [253, 230, 226], [253, 229, 225], [253, 228, 224], [253, 228, 224], [253, 227, 223], [253, 226, 222], [253, 226, 221], [253, 225, 220], [253, 224, 220], [254, 224, 219], [254, 223, 218], [254, 222, 217], [254, 221, 216], [254, 221, 216], [254, 220, 215], [254, 219, 214], [254, 219, 213], [254, 218, 212], [254, 217, 212], [254, 217, 211], [254, 216, 210], [254, 215, 209], [254, 215, 208], [254, 214, 208], [254, 213, 207], [255, 213, 206], [255, 212, 205], [255, 211, 204], [255, 210, 204], [255, 210, 203], [255, 209, 202], [255, 208, 201], [255, 208, 201], [255, 207, 200], [255, 206, 199], [255, 206, 198], [255, 205, 197], [255, 204, 197], [255, 204, 196], [255, 203, 195], [255, 202, 194], [255, 202, 193], [255, 201, 193], [255, 200, 192], [255, 199, 191], [255, 199, 190], [255, 198, 190], [255, 197, 189], [255, 197, 188], [255, 196, 187], [255, 195, 186], [255, 195, 186], [255, 194, 185], [255, 193, 184], [255, 193, 183], [255, 192, 182], [255, 191, 182], [255, 190, 181], [255, 190, 180], [255, 189, 179], [255, 188, 179], [255, 188, 178], [255, 187, 177], [255, 186, 176], [255, 186, 175], [255, 185, 175], [255, 184, 174], [255, 184, 173], [255, 183, 172], [255, 182, 172], [255, 181, 171], [255, 181, 170], [255, 180, 169], [255, 179, 169], [254, 179, 168], [254, 178, 167], [254, 177, 166], [254, 177, 165], [254, 176, 165], [254, 175, 164], [254, 174, 163], [254, 174, 162], [254, 173, 162], [254, 172, 161], [254, 172, 160], [254, 171, 159], [254, 170, 159], [254, 170, 158], [254, 169, 157], [254, 168, 156], [254, 167, 156], [254, 167, 155], [253, 166, 154], [253, 165, 153], [253, 165, 153], [253, 164, 152], [253, 163, 151], [253, 163, 150], [253, 162, 150], [253, 161, 149], [253, 160, 148]], + category="Color blind", flags=Palette.ColorBlindSafe | Palette.Diverging + ), + 'linear_protanopic_deuteranopic_kbw_5_98_c40': ContinuousPalette( + 'Linear protanopic', 'linear_protanopic_deuteranopic_kbw_5_98_c40', + [[17, 17, 17], [17, 18, 19], [18, 19, 21], [19, 19, 23], [19, 20, 24], [20, 21, 26], [20, 22, 28], [20, 23, 29], [21, 23, 31], [21, 24, 33], [21, 25, 34], [22, 25, 36], [22, 26, 38], [22, 27, 39], [22, 27, 41], [22, 28, 43], [22, 29, 45], [22, 30, 46], [23, 30, 48], [23, 31, 50], [23, 32, 52], [23, 33, 54], [23, 33, 55], [23, 34, 57], [23, 35, 59], [22, 36, 61], [22, 36, 63], [22, 37, 64], [22, 38, 66], [22, 39, 68], [22, 39, 70], [21, 40, 72], [21, 41, 74], [21, 42, 75], [20, 43, 77], [20, 43, 79], [20, 44, 81], [19, 45, 83], [19, 46, 84], [19, 46, 86], [18, 47, 88], [18, 48, 90], [18, 49, 91], [17, 50, 93], [17, 50, 95], [17, 51, 96], [16, 52, 98], [16, 53, 99], [16, 54, 101], [16, 54, 103], [16, 55, 104], [16, 56, 106], [16, 57, 107], [15, 58, 109], [15, 59, 110], [15, 59, 112], [15, 60, 113], [15, 61, 115], [15, 62, 116], [15, 63, 118], [15, 63, 119], [15, 64, 121], [15, 65, 122], [15, 66, 124], [15, 67, 126], [15, 68, 127], [15, 68, 129], [15, 69, 130], [15, 70, 132], [15, 71, 133], [15, 72, 135], [15, 73, 136], [15, 73, 138], [15, 74, 139], [15, 75, 141], [15, 76, 142], [15, 77, 144], [15, 78, 146], [15, 79, 147], [15, 79, 149], [14, 80, 150], [14, 81, 152], [14, 82, 153], [14, 83, 155], [14, 84, 157], [14, 85, 158], [14, 86, 160], [14, 86, 161], [13, 87, 163], [13, 88, 165], [13, 89, 166], [13, 90, 168], [13, 91, 169], [13, 92, 171], [12, 92, 173], [12, 93, 174], [12, 94, 176], [12, 95, 178], [11, 96, 179], [11, 97, 181], [11, 98, 182], [10, 99, 184], [10, 100, 186], [10, 100, 187], [9, 101, 189], [9, 102, 191], [9, 103, 192], [8, 104, 194], [8, 105, 196], [7, 106, 197], [7, 107, 199], [7, 108, 201], [6, 109, 202], [6, 109, 204], [5, 110, 206], [5, 111, 207], [4, 112, 209], [4, 113, 211], [3, 114, 212], [3, 115, 214], [2, 116, 216], [2, 117, 217], [1, 118, 219], [1, 119, 221], [0, 120, 222], [0, 120, 224], [0, 121, 226], [1, 122, 227], [1, 123, 229], [2, 124, 230], [3, 125, 232], [5, 126, 233], [7, 127, 235], [11, 128, 236], [14, 129, 237], [18, 130, 238], [22, 131, 239], [26, 132, 240], [31, 132, 241], [35, 133, 242], [40, 134, 242], [45, 135, 242], [49, 136, 242], [54, 137, 242], [59, 138, 242], [64, 139, 241], [69, 140, 240], [74, 141, 239], [79, 141, 238], [84, 142, 236], [89, 143, 234], [93, 144, 232], [98, 145, 230], [103, 146, 228], [107, 147, 226], [111, 148, 223], [115, 149, 220], [119, 150, 217], [123, 151, 214], [127, 152, 212], [131, 153, 208], [134, 154, 205], [137, 155, 202], [141, 155, 199], [144, 156, 196], [147, 157, 193], [150, 158, 189], [152, 159, 186], [155, 160, 183], [158, 161, 180], [160, 162, 177], [163, 163, 174], [165, 164, 170], [168, 165, 167], [170, 166, 164], [172, 167, 161], [175, 168, 158], [177, 169, 155], [179, 170, 152], [181, 171, 149], [183, 172, 146], [185, 173, 143], [187, 174, 140], [189, 175, 137], [191, 176, 134], [193, 177, 131], [195, 178, 128], [197, 179, 125], [199, 180, 122], [201, 181, 119], [202, 182, 116], [204, 183, 113], [206, 184, 109], [208, 184, 106], [209, 185, 103], [211, 186, 100], [213, 187, 96], [214, 188, 93], [216, 189, 90], [218, 190, 86], [219, 191, 82], [221, 192, 79], [222, 193, 75], [224, 194, 72], [225, 195, 68], [227, 196, 64], [228, 197, 60], [229, 198, 57], [231, 199, 53], [232, 200, 49], [233, 201, 46], [235, 202, 42], [236, 203, 39], [237, 204, 37], [239, 205, 35], [240, 206, 34], [241, 207, 34], [242, 208, 35], [243, 209, 37], [244, 210, 39], [245, 211, 43], [246, 212, 47], [247, 214, 52], [248, 215, 57], [249, 216, 62], [250, 217, 68], [251, 218, 74], [251, 219, 80], [252, 220, 86], [253, 221, 93], [253, 222, 99], [254, 223, 106], [254, 224, 112], [255, 225, 119], [255, 226, 126], [255, 227, 132], [255, 228, 139], [255, 229, 146], [255, 230, 152], [255, 231, 159], [255, 232, 165], [255, 233, 171], [255, 235, 177], [255, 236, 183], [254, 237, 189], [254, 238, 195], [254, 239, 201], [253, 240, 206], [253, 241, 211], [253, 242, 216], [253, 243, 221], [252, 245, 226], [252, 246, 231], [252, 247, 235], [252, 248, 239], [252, 249, 243]], + category="Color blind", flags=Palette.ColorBlindSafe + ), + 'linear_tritanopic_krjcw_5_95_c24': ContinuousPalette( + 'Linear tritanopic', 'linear_tritanopic_krjcw_5_95_c24', + [[17, 17, 17], [20, 17, 17], [22, 18, 17], [24, 18, 17], [26, 18, 17], [28, 19, 17], [30, 19, 17], [32, 19, 17], [34, 20, 17], [35, 20, 17], [37, 20, 17], [39, 20, 17], [41, 21, 17], [42, 21, 18], [44, 21, 18], [46, 21, 18], [47, 22, 18], [49, 22, 18], [51, 22, 18], [52, 22, 18], [54, 22, 19], [56, 22, 19], [57, 23, 19], [59, 23, 19], [61, 23, 19], [62, 23, 19], [64, 23, 20], [65, 24, 20], [67, 24, 20], [68, 24, 20], [70, 24, 21], [71, 25, 21], [73, 25, 21], [75, 25, 21], [76, 25, 22], [78, 26, 22], [79, 26, 22], [81, 26, 22], [82, 26, 23], [83, 27, 23], [85, 27, 23], [86, 27, 24], [88, 28, 24], [89, 28, 24], [91, 28, 25], [92, 29, 25], [93, 29, 25], [95, 30, 26], [96, 30, 26], [97, 31, 27], [99, 31, 27], [100, 32, 28], [101, 32, 28], [102, 33, 29], [104, 33, 29], [105, 34, 30], [106, 35, 30], [107, 35, 31], [108, 36, 31], [110, 36, 32], [111, 37, 33], [112, 38, 33], [113, 39, 34], [114, 39, 35], [115, 40, 35], [116, 41, 36], [117, 42, 37], [118, 43, 37], [119, 44, 38], [120, 45, 39], [121, 46, 40], [122, 46, 41], [122, 47, 41], [123, 48, 42], [124, 49, 43], [125, 51, 44], [125, 52, 45], [126, 53, 46], [127, 54, 47], [127, 55, 48], [128, 56, 49], [128, 57, 50], [129, 58, 51], [129, 60, 53], [130, 61, 54], [130, 62, 55], [130, 63, 56], [131, 65, 57], [131, 66, 59], [131, 67, 60], [131, 69, 61], [131, 70, 63], [131, 72, 64], [131, 73, 66], [131, 74, 67], [131, 76, 69], [131, 77, 70], [131, 79, 72], [131, 80, 73], [130, 81, 75], [130, 83, 76], [130, 84, 78], [130, 85, 79], [130, 87, 81], [129, 88, 82], [129, 89, 84], [129, 91, 86], [129, 92, 87], [128, 93, 89], [128, 95, 90], [128, 96, 92], [128, 97, 93], [127, 99, 95], [127, 100, 97], [126, 101, 98], [126, 103, 100], [126, 104, 101], [125, 105, 103], [125, 106, 105], [124, 108, 106], [124, 109, 108], [123, 110, 110], [123, 112, 111], [122, 113, 113], [122, 114, 115], [121, 115, 116], [120, 117, 118], [120, 118, 120], [119, 119, 121], [118, 121, 123], [118, 122, 125], [117, 123, 126], [116, 124, 128], [115, 126, 130], [114, 127, 132], [114, 128, 133], [113, 129, 135], [112, 131, 137], [111, 132, 139], [110, 133, 140], [109, 134, 142], [107, 136, 144], [106, 137, 146], [105, 138, 147], [104, 140, 149], [103, 141, 151], [101, 142, 153], [100, 143, 155], [98, 145, 156], [97, 146, 158], [95, 147, 160], [94, 148, 162], [92, 150, 164], [90, 151, 166], [88, 152, 167], [86, 153, 169], [84, 155, 171], [82, 156, 173], [80, 157, 175], [77, 158, 177], [75, 160, 179], [72, 161, 181], [69, 162, 182], [66, 164, 184], [63, 165, 186], [60, 166, 188], [56, 167, 190], [53, 168, 192], [50, 170, 193], [46, 171, 195], [43, 172, 197], [40, 173, 198], [36, 174, 200], [33, 175, 201], [29, 177, 203], [26, 178, 204], [23, 179, 206], [19, 180, 207], [16, 181, 208], [13, 182, 210], [11, 183, 211], [9, 184, 212], [8, 185, 213], [8, 186, 215], [9, 187, 216], [11, 188, 217], [13, 189, 218], [16, 190, 219], [19, 191, 220], [22, 192, 221], [26, 193, 222], [29, 194, 223], [33, 195, 224], [36, 196, 225], [40, 197, 226], [43, 198, 227], [47, 199, 227], [50, 200, 228], [54, 201, 229], [57, 202, 230], [61, 203, 230], [64, 203, 231], [68, 204, 232], [71, 205, 233], [75, 206, 233], [78, 207, 234], [81, 208, 234], [85, 209, 235], [88, 209, 235], [92, 210, 236], [95, 211, 236], [98, 212, 237], [102, 213, 237], [105, 213, 238], [108, 214, 238], [112, 215, 239], [115, 216, 239], [118, 217, 239], [121, 217, 240], [125, 218, 240], [128, 219, 240], [131, 220, 241], [135, 220, 241], [138, 221, 241], [141, 222, 241], [144, 223, 242], [148, 223, 242], [151, 224, 242], [154, 225, 242], [157, 225, 242], [160, 226, 242], [164, 227, 243], [167, 227, 243], [170, 228, 243], [173, 229, 243], [177, 229, 243], [180, 230, 243], [183, 231, 243], [186, 231, 243], [189, 232, 243], [193, 232, 243], [196, 233, 243], [199, 234, 243], [202, 234, 243], [205, 235, 243], [209, 235, 242], [212, 236, 242], [215, 236, 242], [218, 237, 242], [221, 238, 242], [225, 238, 242], [228, 239, 241], [231, 239, 241], [234, 240, 241], [237, 240, 241], [241, 241, 241]], + category="Color blind", flags=Palette.ColorBlindSafe + ), + 'isoluminant_cgo_80_c38': ContinuousPalette( + 'Isoluminant', 'isoluminant_cgo_80_c38', + [[112, 209, 255], [112, 210, 255], [112, 210, 255], [112, 210, 255], [112, 210, 255], [112, 210, 254], [112, 210, 254], [112, 210, 253], [112, 210, 252], [112, 210, 251], [112, 210, 250], [112, 210, 250], [113, 211, 249], [113, 211, 248], [113, 211, 247], [113, 211, 247], [113, 211, 246], [113, 211, 245], [113, 211, 244], [113, 211, 243], [113, 211, 243], [113, 211, 242], [113, 211, 241], [114, 212, 240], [114, 212, 239], [114, 212, 238], [114, 212, 238], [114, 212, 237], [114, 212, 236], [114, 212, 235], [114, 212, 234], [115, 212, 234], [115, 212, 233], [115, 212, 232], [115, 212, 231], [115, 212, 230], [115, 213, 229], [115, 213, 229], [116, 213, 228], [116, 213, 227], [116, 213, 226], [116, 213, 225], [116, 213, 225], [116, 213, 224], [116, 213, 223], [117, 213, 222], [117, 213, 221], [117, 213, 220], [117, 213, 219], [117, 213, 219], [118, 213, 218], [118, 214, 217], [118, 214, 216], [118, 214, 215], [118, 214, 214], [119, 214, 214], [119, 214, 213], [119, 214, 212], [119, 214, 211], [119, 214, 210], [120, 214, 209], [120, 214, 208], [120, 214, 208], [120, 214, 207], [121, 214, 206], [121, 214, 205], [121, 214, 204], [122, 214, 203], [122, 214, 202], [122, 214, 201], [122, 214, 201], [123, 214, 200], [123, 215, 199], [123, 215, 198], [124, 215, 197], [124, 215, 196], [124, 215, 195], [125, 215, 194], [125, 215, 193], [125, 215, 193], [126, 215, 192], [126, 215, 191], [126, 215, 190], [127, 215, 189], [127, 215, 188], [128, 215, 187], [128, 215, 186], [129, 215, 185], [129, 215, 184], [129, 215, 184], [130, 215, 183], [130, 215, 182], [131, 215, 181], [131, 215, 180], [132, 215, 179], [132, 215, 178], [133, 215, 177], [133, 215, 176], [134, 215, 175], [134, 215, 174], [135, 215, 173], [136, 215, 172], [136, 215, 172], [137, 215, 171], [137, 215, 170], [138, 215, 169], [139, 215, 168], [139, 215, 167], [140, 215, 166], [141, 214, 165], [141, 214, 164], [142, 214, 163], [143, 214, 162], [144, 214, 161], [144, 214, 160], [145, 214, 160], [146, 214, 159], [147, 214, 158], [147, 214, 157], [148, 214, 156], [149, 214, 155], [150, 214, 154], [151, 213, 153], [152, 213, 153], [153, 213, 152], [154, 213, 151], [154, 213, 150], [155, 213, 149], [156, 213, 148], [157, 213, 148], [158, 212, 147], [159, 212, 146], [160, 212, 145], [161, 212, 144], [162, 212, 144], [163, 212, 143], [164, 211, 142], [165, 211, 142], [166, 211, 141], [167, 211, 140], [168, 211, 140], [169, 211, 139], [170, 210, 138], [171, 210, 138], [172, 210, 137], [173, 210, 136], [174, 210, 136], [175, 209, 135], [176, 209, 135], [177, 209, 134], [178, 209, 134], [179, 209, 133], [180, 208, 133], [181, 208, 132], [182, 208, 132], [183, 208, 131], [184, 207, 131], [185, 207, 130], [186, 207, 130], [187, 207, 129], [188, 207, 129], [189, 206, 128], [190, 206, 128], [191, 206, 127], [192, 206, 127], [193, 205, 127], [194, 205, 126], [195, 205, 126], [196, 205, 125], [197, 204, 125], [197, 204, 125], [198, 204, 124], [199, 204, 124], [200, 203, 124], [201, 203, 123], [202, 203, 123], [203, 203, 123], [204, 202, 122], [205, 202, 122], [206, 202, 122], [207, 202, 121], [208, 201, 121], [209, 201, 121], [209, 201, 121], [210, 201, 120], [211, 200, 120], [212, 200, 120], [213, 200, 120], [214, 199, 119], [215, 199, 119], [216, 199, 119], [217, 199, 119], [217, 198, 119], [218, 198, 119], [219, 198, 118], [220, 197, 118], [221, 197, 118], [222, 197, 118], [223, 197, 118], [224, 196, 118], [224, 196, 118], [225, 196, 118], [226, 195, 118], [227, 195, 118], [228, 195, 118], [229, 194, 118], [229, 194, 118], [230, 194, 118], [231, 194, 118], [232, 193, 118], [233, 193, 118], [233, 193, 118], [234, 192, 118], [235, 192, 118], [236, 192, 118], [237, 191, 118], [237, 191, 118], [238, 191, 118], [239, 190, 118], [240, 190, 119], [241, 190, 119], [241, 189, 119], [242, 189, 119], [243, 189, 119], [244, 188, 119], [244, 188, 120], [245, 188, 120], [246, 188, 120], [247, 187, 120], [247, 187, 121], [248, 187, 121], [249, 186, 121], [249, 186, 121], [250, 186, 122], [251, 185, 122], [252, 185, 122], [252, 185, 122], [253, 184, 123], [254, 184, 123], [254, 184, 123], [255, 183, 124], [255, 183, 124], [255, 183, 124], [255, 182, 125], [255, 182, 125], [255, 182, 125], [255, 181, 126], [255, 181, 126], [255, 181, 127], [255, 180, 127], [255, 180, 127], [255, 180, 128], [255, 179, 128], [255, 179, 129], [255, 179, 129], [255, 178, 129]], + category="Other" + ), + 'rainbow_bgyr_35_85_c73': ContinuousPalette( + 'Rainbow', 'rainbow_bgyr_35_85_c73', + [[0, 53, 249], [0, 56, 246], [0, 58, 243], [0, 61, 240], [0, 63, 237], [0, 66, 234], [0, 68, 231], [0, 71, 228], [0, 73, 225], [0, 75, 223], [0, 77, 220], [0, 79, 217], [0, 81, 214], [0, 83, 211], [0, 85, 208], [0, 87, 205], [0, 89, 202], [0, 91, 199], [0, 92, 196], [0, 94, 194], [0, 96, 191], [0, 98, 188], [0, 99, 185], [0, 101, 182], [0, 103, 179], [0, 104, 176], [0, 106, 174], [0, 108, 171], [0, 109, 168], [0, 111, 165], [0, 112, 163], [0, 113, 160], [0, 115, 157], [0, 116, 155], [0, 117, 152], [0, 118, 150], [7, 119, 147], [14, 120, 145], [20, 122, 142], [24, 123, 140], [28, 124, 137], [32, 125, 135], [35, 126, 133], [38, 127, 130], [41, 128, 128], [43, 129, 126], [45, 130, 123], [47, 131, 121], [49, 132, 118], [51, 133, 116], [52, 134, 114], [53, 135, 111], [55, 136, 109], [56, 137, 106], [57, 138, 104], [58, 139, 101], [59, 140, 99], [59, 141, 96], [60, 142, 94], [61, 143, 91], [61, 144, 88], [62, 145, 86], [62, 146, 83], [62, 147, 80], [63, 148, 78], [63, 149, 75], [63, 150, 72], [63, 152, 69], [63, 153, 66], [63, 154, 63], [63, 155, 60], [63, 156, 57], [63, 157, 53], [63, 158, 50], [63, 159, 47], [63, 160, 43], [63, 161, 40], [64, 162, 36], [64, 163, 33], [65, 164, 30], [66, 165, 27], [68, 166, 24], [70, 166, 21], [72, 167, 19], [74, 168, 17], [77, 169, 16], [79, 169, 15], [82, 170, 14], [85, 171, 13], [88, 171, 13], [90, 172, 13], [93, 172, 14], [96, 173, 14], [99, 174, 14], [101, 174, 14], [104, 175, 15], [106, 175, 15], [109, 176, 16], [112, 177, 16], [114, 177, 16], [117, 178, 17], [119, 178, 17], [122, 179, 17], [124, 180, 18], [126, 180, 18], [129, 181, 19], [131, 181, 19], [134, 182, 19], [136, 182, 20], [138, 183, 20], [141, 183, 20], [143, 184, 21], [145, 185, 21], [148, 185, 21], [150, 186, 22], [152, 186, 22], [154, 187, 23], [157, 187, 23], [159, 188, 23], [161, 188, 24], [163, 189, 24], [166, 189, 24], [168, 190, 25], [170, 191, 25], [172, 191, 26], [175, 192, 26], [177, 192, 26], [179, 193, 27], [181, 193, 27], [183, 194, 27], [186, 194, 28], [188, 195, 28], [190, 195, 28], [192, 196, 29], [194, 196, 29], [196, 197, 30], [199, 197, 30], [201, 198, 30], [203, 198, 31], [205, 199, 31], [207, 199, 31], [209, 200, 32], [211, 200, 32], [214, 201, 33], [216, 201, 33], [218, 202, 33], [220, 202, 34], [222, 203, 34], [224, 203, 34], [226, 203, 35], [229, 204, 35], [231, 204, 35], [233, 205, 36], [235, 205, 36], [237, 205, 36], [239, 205, 37], [241, 205, 37], [242, 205, 37], [244, 205, 37], [245, 205, 37], [247, 204, 37], [248, 204, 36], [249, 203, 36], [250, 202, 36], [251, 201, 35], [251, 200, 35], [252, 199, 34], [252, 197, 34], [253, 196, 33], [253, 195, 33], [253, 193, 32], [253, 192, 32], [254, 191, 31], [254, 189, 30], [254, 188, 30], [254, 187, 29], [254, 185, 29], [255, 184, 28], [255, 182, 27], [255, 181, 27], [255, 180, 26], [255, 178, 25], [255, 177, 25], [255, 176, 24], [255, 174, 24], [255, 173, 23], [255, 171, 22], [255, 170, 22], [255, 168, 21], [255, 167, 20], [255, 166, 20], [255, 164, 19], [255, 163, 18], [255, 161, 18], [255, 160, 17], [255, 158, 16], [255, 157, 16], [255, 156, 15], [255, 154, 14], [255, 153, 13], [255, 151, 13], [255, 150, 12], [255, 148, 11], [255, 147, 10], [255, 145, 10], [255, 144, 9], [255, 142, 8], [255, 141, 7], [255, 139, 7], [255, 138, 6], [255, 136, 5], [255, 134, 5], [255, 133, 4], [255, 131, 3], [255, 130, 3], [255, 128, 2], [255, 127, 2], [255, 125, 1], [255, 123, 1], [255, 122, 0], [255, 120, 0], [255, 118, 0], [255, 117, 0], [255, 115, 0], [255, 113, 0], [255, 112, 0], [255, 110, 0], [255, 108, 0], [255, 106, 0], [255, 104, 0], [255, 103, 0], [255, 101, 0], [255, 99, 0], [255, 97, 0], [255, 95, 0], [255, 93, 0], [255, 91, 0], [255, 89, 0], [255, 87, 0], [255, 85, 0], [255, 83, 0], [255, 81, 0], [255, 79, 0], [255, 76, 0], [255, 74, 0], [255, 72, 0], [255, 69, 0], [255, 67, 0], [255, 64, 0], [255, 61, 0], [255, 59, 0], [255, 56, 0], [255, 53, 0], [255, 49, 0], [255, 46, 0], [255, 42, 0]], + category="Other" + ), +} + + +DefaultContinuousPaletteName = "linear_bgyw_15_100_c68" +DefaultContinuousPalette = ContinuousPalettes[DefaultContinuousPaletteName] + + +class ColorIcon(QIcon): + def __init__(self, color, size=12): + p = QPixmap(size, size) + p.fill(color) + super().__init__(p) + + +def get_default_curve_colors(n): + if n <= len(Dark2Colors): + return list(Dark2Colors)[:n] + if n <= len(DefaultRGBColors): + return list(DefaultRGBColors)[:n] + else: + return list(LimitedDiscretePalette(n)) + + +def patch_variable_colors(): + # This function patches Variable with properties and private attributes: + # pylint: disable=protected-access + from Orange.data import Variable, DiscreteVariable, ContinuousVariable + + def get_colors(var): + return var._colors + + def set_colors(var, colors): + var._colors = colors + var._palette = None + var.attributes["colors"] = [ + color_to_hex(color) if isinstance(color, (Sequence, np.ndarray)) + else color + for color in colors] + if "palette" in var.attributes: + del var.attributes["palette"] + + def get_palette(var): + return var._palette + + def set_palette(var, palette): + var._palette = palette + var.attributes["palette"] = palette.name + var._colors = None + if "colors" in var.attributes: + del var.attributes["colors"] + + def continuous_get_colors(var): + warnings.warn("ContinuousVariable.color is deprecated; " + "use ContinuousVariable.palette", + DeprecationWarning, stacklevel=2) + if var._colors is None: + try: + col1, col2, black = var.attributes["colors"] + var._colors = (hex_to_color(col1), hex_to_color(col2), black) + except (KeyError, ValueError): # unavailable or invalid + if var._palette or "palette" in var.attributes: + palette = var.palette + col1 = tuple(palette.palette[0]) + col2 = tuple(palette.palette[-1]) + black = bool(palette.flags & palette.Diverging) + var._colors = col1, col2, black + else: + var._colors = ((0, 0, 255), (255, 255, 0), False) + return var._colors + + def continuous_get_palette(var): + if var._palette is None: + if "palette" in var.attributes: + var._palette = ContinuousPalettes.get(var.attributes["palette"], + DefaultContinuousPalette) + elif var._colors is not None or "colors" in var.attributes: + col1, col2, black = var.colors + var._palette = ContinuousPalette.from_colors(col1, col2, black) + else: + var._palette = DefaultContinuousPalette + return var._palette + + def discrete_get_colors(var): + if var._colors is None or len(var._colors) < len(var.values): + if var._palette is not None or "palette" in var.attributes: + var._colors = var.palette.palette[:len(var.values)] + else: + var._colors = np.empty((0, 3), dtype=object) + colors = var.attributes.get("colors") + if colors: + try: + var._colors = np.vstack( + ([hex_to_color(color) for color in colors], + var._colors[len(colors):])) + except ValueError: + pass + if len(var._colors) < len(var.values): + var._colors = LimitedDiscretePalette(len(var.values)).palette + var._colors.flags.writeable = False + return var._colors + + def discrete_set_colors(var, colors): + colors = colors.copy() + colors.flags.writeable = False + set_colors(var, colors) + + def discrete_get_palette(var): + if var._palette is None: + if "palette" in var.attributes: + var._palette = DiscretePalettes.get(var.attributes["palette"], + DefaultDiscretePalette) + elif var._colors is not None or "colors" in var.attributes: + var._palette = DiscretePalette.from_colors(var.colors) + else: + var._palette = LimitedDiscretePalette(len(var.values)) + return var._palette + + Variable._colors = None + Variable._palette = None + Variable.colors = property(get_colors, set_colors) + Variable.palette = property(get_palette, set_palette) + + DiscreteVariable.colors = property(discrete_get_colors, discrete_set_colors) + DiscreteVariable.palette = property(discrete_get_palette, set_palette) + + ContinuousVariable.colors = property(continuous_get_colors, set_colors) + ContinuousVariable.palette = property(continuous_get_palette, set_palette) diff --git a/Orange/widgets/utils/itemmodels.py b/Orange/widgets/utils/itemmodels.py index 547ec078abb..ea20c55424e 100644 --- a/Orange/widgets/utils/itemmodels.py +++ b/Orange/widgets/utils/itemmodels.py @@ -10,8 +10,8 @@ from xml.sax.saxutils import escape from AnyQt.QtCore import ( - Qt, QObject, QAbstractListModel, QAbstractTableModel, QModelIndex, - QItemSelectionModel, QMimeData, QT_VERSION + Qt, QObject, QAbstractListModel, QModelIndex, + QItemSelectionModel ) from AnyQt.QtCore import pyqtSignal as Signal from AnyQt.QtGui import QColor @@ -25,6 +25,7 @@ PyListModel, AbstractSortTableModel as _AbstractSortTableModel ) +from Orange.widgets.utils.colorpalettes import ContinuousPalettes, ContinuousPalette from Orange.data import Variable, Storage, DiscreteVariable, ContinuousVariable from Orange.data.domain import filter_visible from Orange.widgets import gui @@ -570,6 +571,66 @@ def safe_text(text): return text +class ContinuousPalettesModel(QAbstractListModel): + """ + Model for combo boxes + """ + def __init__(self, parent=None, categories=None, icon_width=64): + super().__init__(parent) + self.icon_width = icon_width + + palettes = list(ContinuousPalettes.values()) + if categories is None: + # Use dict, not set, to keep order of categories + categories = dict.fromkeys(palette.category for palette in palettes) + + self.items = [] + for category in categories: + self.items.append(category) + self.items += [palette for palette in palettes + if palette.category == category] + if len(categories) == 1: + del self.items[0] + + def rowCount(self, parent): + return 0 if parent.isValid() else len(self.items) + + @staticmethod + def columnCount(parent): + return 0 if parent.isValid() else 1 + + def data(self, index, role): + item = self.items[index.row()] + if isinstance(item, str): + if role in [Qt.EditRole, Qt.DisplayRole]: + return item + else: + if role in [Qt.EditRole, Qt.DisplayRole]: + return item.friendly_name + if role == Qt.DecorationRole: + return item.color_strip(self.icon_width, 16) + if role == Qt.UserRole: + return item + return None + + def flags(self, index): + item = self.items[index.row()] + if isinstance(item, ContinuousPalette): + return Qt.ItemIsEnabled | Qt.ItemIsSelectable + else: + return Qt.ItemIsEnabled + + def indexOf(self, x): + if isinstance(x, str): + for i, item in enumerate(self.items): + if not isinstance(item, str) \ + and x in (item.name, item.friendly_name): + return i + elif isinstance(x, ContinuousPalette): + return self.items.index(x) + return None + + class ListSingleSelectionModel(QItemSelectionModel): """ Item selection model for list item models with single selection. diff --git a/Orange/widgets/utils/tests/test_colorpalettes.py b/Orange/widgets/utils/tests/test_colorpalettes.py new file mode 100644 index 00000000000..c35172133e7 --- /dev/null +++ b/Orange/widgets/utils/tests/test_colorpalettes.py @@ -0,0 +1,708 @@ +# pylint: disable=protected-access +import unittest +from unittest.mock import Mock, patch + +import numpy as np +from AnyQt.QtCore import Qt +from AnyQt.QtGui import QImage, QColor, QIcon + +from orangewidget.tests.base import GuiTest +from Orange.data import DiscreteVariable, ContinuousVariable, Variable +# pylint: disable=wildcard-import,unused-wildcard-import +from Orange.widgets.utils.colorpalettes import * + + +class PaletteTest(unittest.TestCase): + def test_copy(self): + palette = DiscretePalette( + "custom", "c123", [(1, 2, 3), (4, 5, 6), (7, 8, 9), (10, 11, 12)]) + copy = palette.copy() + self.assertEqual(copy.friendly_name, "custom") + self.assertEqual(copy.name, "c123") + np.testing.assert_equal(palette.palette, copy.palette) + copy.palette[0, 0] += 1 + self.assertEqual(palette.palette[0, 0], 1) + + def test_qcolors(self): + palcolors = [(1, 2, 3), (4, 5, 6)] + nan_color = (7, 8, 9) + palette = DiscretePalette( + "custom", "c123", palcolors, nan_color=nan_color) + self.assertEqual([col.getRgb()[:3] for col in palette.qcolors], + palcolors) + self.assertEqual([col.getRgb()[:3] for col in palette.qcolors_w_nan], + palcolors + [nan_color]) + + +class IndexPaletteTest(unittest.TestCase): + """Tested through DiscretePalette because IndexedPalette is too abstract""" + def setUp(self) -> None: + self.palette = DiscretePalette( + "custom", "c123", [(1, 2, 3), (4, 5, 6), (7, 8, 9), (10, 11, 12)]) + + def test_len(self): + self.assertEqual(len(self.palette), 4) + + def test_getitem(self): + self.assertEqual(self.palette[1].getRgb()[:3], (4, 5, 6)) + self.assertEqual([col.getRgb()[:3] for col in self.palette[1:3]], + [(4, 5, 6), (7, 8, 9)]) + self.assertEqual([col.getRgb()[:3] for col in self.palette[0, 3, 0]], + [(1, 2, 3), (10, 11, 12), (1, 2, 3)]) + self.assertEqual([col.getRgb()[:3] + for col in self.palette[np.array([0, 3, 0])]], + [(1, 2, 3), (10, 11, 12), (1, 2, 3)]) + + +class DiscretePaletteTest(unittest.TestCase): + def setUp(self) -> None: + self.palette = DiscretePalette.from_colors( + [(1, 2, 3), (4, 5, 6), (7, 8, 9), (10, 11, 12)]) + + def test_from_colors(self): + self.assertEqual(self.palette[2].getRgb()[:3], (7, 8, 9)) + + def test_color_indices(self): + a, nans = DiscretePalette._color_indices([1, 2, 3]) + self.assertIsInstance(a, np.ndarray) + self.assertEqual(a.dtype, int) + np.testing.assert_equal(a, [1, 2, 3]) + np.testing.assert_equal(nans, [False, False, False]) + + a, nans = DiscretePalette._color_indices([1, 2.0, np.nan]) + self.assertIsInstance(a, np.ndarray) + self.assertEqual(a.dtype, int) + np.testing.assert_equal(a, [1, 2, -1]) + np.testing.assert_equal(nans, [False, False, True]) + + a, nans = DiscretePalette._color_indices(np.array([1, 2, 3])) + self.assertIsInstance(a, np.ndarray) + self.assertEqual(a.dtype, int) + np.testing.assert_equal(a, [1, 2, 3]) + np.testing.assert_equal(nans, [False, False, False]) + + x = np.array([1, 2.0, np.nan]) + a, nans = DiscretePalette._color_indices(x) + self.assertIsInstance(a, np.ndarray) + self.assertEqual(a.dtype, int) + np.testing.assert_equal(a, [1, 2, -1]) + np.testing.assert_equal(nans, [False, False, True]) + self.assertTrue(np.isnan(x[2])) + + x = np.array([]) + a, nans = DiscretePalette._color_indices(x) + self.assertIsInstance(a, np.ndarray) + self.assertEqual(a.dtype, int) + np.testing.assert_equal(a, []) + np.testing.assert_equal(nans, []) + + def test_values_to_colors(self): + palette = self.palette + + x = np.array([1, 2.0, np.nan]) + colors = palette.values_to_colors(x) + np.testing.assert_equal(colors, [[4, 5, 6], [7, 8, 9], NAN_COLOR]) + + x = [1, 2.0, np.nan] + colors = palette.values_to_colors(x) + np.testing.assert_equal(colors, [[4, 5, 6], [7, 8, 9], NAN_COLOR]) + + def test_values_to_qcolors(self): + palette = self.palette + + x = np.array([1, 2.0, np.nan]) + colors = palette.values_to_qcolors(x) + self.assertEqual([col.getRgb()[:3] for col in colors], + [(4, 5, 6), (7, 8, 9), NAN_COLOR]) + + x = [1, 2.0, np.nan] + colors = palette.values_to_qcolors(x) + self.assertEqual([col.getRgb()[:3] for col in colors], + [(4, 5, 6), (7, 8, 9), NAN_COLOR]) + + def test_value_to_color(self): + palette = self.palette + np.testing.assert_equal(palette.value_to_color(0), [1, 2, 3]) + np.testing.assert_equal(palette.value_to_color(1.), [4, 5, 6]) + np.testing.assert_equal(palette.value_to_color(np.nan), NAN_COLOR) + + def test_value_to_qcolor(self): + palette = self.palette + np.testing.assert_equal( + palette.value_to_qcolor(0).getRgb(), (1, 2, 3, 255)) + np.testing.assert_equal( + palette.value_to_qcolor(1.).getRgb(), (4, 5, 6, 255)) + np.testing.assert_equal( + palette.value_to_qcolor(np.nan).getRgb()[:3], NAN_COLOR) + + def test_default(self): + self.assertIs(DefaultDiscretePalette, + DiscretePalettes[DefaultDiscretePaletteName]) + + +class LimitedDiscretePaletteTest(unittest.TestCase): + def test_small_palettes(self): + palette = LimitedDiscretePalette(3) + np.testing.assert_equal(palette.palette, DefaultRGBColors.palette[:3]) + + palette = LimitedDiscretePalette(len(DefaultRGBColors.palette)) + np.testing.assert_equal(palette.palette, DefaultRGBColors.palette) + + palette = LimitedDiscretePalette(len(DefaultRGBColors.palette) + 1) + self.assertFalse(np.all(np.array(palette.palette[:-1]) + == np.array(DefaultRGBColors.palette))) + + def test_large_palettes(self): + n = len(DefaultRGBColors.palette) + 1 + palette = LimitedDiscretePalette(n) + self.assertEqual(len({tuple(col) for col in palette.palette}), n) + + palette = LimitedDiscretePalette(100) + self.assertEqual(len({tuple(col) for col in palette.palette}), 100) + + def test_forced_hsv_palettes(self): + palette = LimitedDiscretePalette(5, force_hsv=True) + self.assertFalse(np.all(np.array(palette.palette) + == np.array(DefaultRGBColors.palette[:5]))) + + +class ContinuousPaletteTest(GuiTest): + @staticmethod + def assert_equal_within(a, b, diff): + a = a.astype(float) # make sure a is a signed type + np.testing.assert_array_less(np.abs(a - b), diff) + + @staticmethod + def test_color_indices(): + x = [0, 1, 2, 1, 0, np.nan, 1] + a, nans = ContinuousPalette._color_indices(x) + np.testing.assert_equal(a, [0, 128, 255, 128, 0, -1, 128]) + np.testing.assert_equal(nans, [False] * 5 + [True, False]) + + x = [np.nan, np.nan, np.nan] + a, nans = ContinuousPalette._color_indices(x) + np.testing.assert_equal(a, [-1, -1, -1]) + np.testing.assert_equal(nans, [True, True, True]) + + x = [] + a, nans = ContinuousPalette._color_indices(x) + np.testing.assert_equal(a, []) + np.testing.assert_equal(nans, []) + + @staticmethod + def test_color_indices_low_high(): + x = [0, 1, 2, 1, 4, np.nan, 3] + a, nans = ContinuousPalette._color_indices(x) + np.testing.assert_equal(a, [0, 64, 128, 64, 255, -1, 191]) + np.testing.assert_equal(nans, [False] * 5 + [True, False]) + + x = [0, 1, 2, 1, 4, np.nan, 3] + a, nans = ContinuousPalette._color_indices(x, low=2) + np.testing.assert_equal(a, [0, 0, 0, 0, 255, -1, 128]) + np.testing.assert_equal(nans, [False] * 5 + [True, False]) + + x = [0, 1, 2, 1, 4, np.nan, 3] + a, nans = ContinuousPalette._color_indices(x, high=2) + np.testing.assert_equal(a, [0, 128, 255, 128, 255, -1, 255]) + np.testing.assert_equal(nans, [False] * 5 + [True, False]) + + x = [0, 1, 2, 1, 4, np.nan, 3] + a, nans = ContinuousPalette._color_indices(x, low=1, high=3) + np.testing.assert_equal(a, [0, 0, 128, 0, 255, -1, 255]) + np.testing.assert_equal(nans, [False] * 5 + [True, False]) + + x = [0, 1, 2, 1, 4, np.nan, 3] + a, nans = ContinuousPalette._color_indices(x, low=0, high=8) + np.testing.assert_equal(a, [0, 32, 64, 32, 128, -1, 96]) + np.testing.assert_equal(nans, [False] * 5 + [True, False]) + + x = [1, 1, 1, np.nan] + a, nans = ContinuousPalette._color_indices(x) + np.testing.assert_equal(a, [128, 128, 128, -1]) + np.testing.assert_equal(nans, [False] * 3 + [True]) + + x = [np.nan, np.nan, np.nan] + a, nans = ContinuousPalette._color_indices(x) + np.testing.assert_equal(a, [-1, -1, -1]) + np.testing.assert_equal(nans, [True, True, True]) + + x = [] + a, nans = ContinuousPalette._color_indices(x) + np.testing.assert_equal(a, []) + np.testing.assert_equal(nans, []) + + def test_values_to_colors(self): + def assert_equal_colors(x, indices, **args): + expected = [palette.palette[idx] if idx >= 0 else NAN_COLOR + for idx in indices] + np.testing.assert_equal( + palette.values_to_colors(x, **args), + expected) + np.testing.assert_equal( + [col.getRgb()[:3] + for col in palette.values_to_qcolors(x, **args)], + expected) + + palette = list(ContinuousPalettes.values())[-1] + assert_equal_colors( + [0, 1, 2, 1, 4, np.nan, 3], + [0, 64, 128, 64, 255, -1, 191]) + + assert_equal_colors( + [0, 1, 2, 1, 4, np.nan, 3], + [0, 0, 0, 0, 255, -1, 128], low=2) + + assert_equal_colors( + [0, 1, 2, 1, 4, np.nan, 3], + [0, 128, 255, 128, 255, -1, 255], high=2) + + assert_equal_colors( + [0, 1, 2, 1, 4, np.nan, 3], + [0, 0, 128, 0, 255, -1, 255], low=1, high=3) + + assert_equal_colors( + [0, 1, 2, 1, 4, np.nan, 3], + [0, 32, 64, 32, 128, -1, 96], low=0, high=8) + + assert_equal_colors( + [1, 1, 1, np.nan], + [128, 128, 128, -1]) + + assert_equal_colors( + [np.nan, np.nan, np.nan], + [-1, -1, -1]) + + self.assertEqual(len(palette.values_to_colors([])), 0) + self.assertEqual(len(palette.values_to_qcolors([])), 0) + + def test_value_to_color(self): + def assert_equal_color(x, index, **args): + self.assertEqual(palette._color_index(x, **args), index) + expected = palette.palette[index] if index != -1 else NAN_COLOR + np.testing.assert_equal( + palette.value_to_color(x, **args), + expected) + np.testing.assert_equal( + palette.value_to_qcolor(x, **args).getRgb()[:3], + expected) + if not args: + np.testing.assert_equal( + palette[x].getRgb()[:3], + expected) + + palette = list(ContinuousPalettes.values())[-1] + + assert_equal_color(1, 255) + assert_equal_color(1, 128, high=2) + assert_equal_color(0, 128, low=5, high=-5) + assert_equal_color(5, 128, high=10) + assert_equal_color(-15, 128, low=-20, high=-10) + assert_equal_color(-10, 255, low=-20, high=-10) + assert_equal_color(-20, 0, low=-20, high=-10) + assert_equal_color(0, 128, low=13, high=13) + + assert_equal_color(2, 255) + assert_equal_color(-1, 0) + assert_equal_color(0, 0, low=0.5) + assert_equal_color(1, 255, high=0.5) + + assert_equal_color(np.nan, -1) + assert_equal_color(np.nan, -1, high=2) + assert_equal_color(np.nan, -1, low=5, high=-5) + assert_equal_color(np.nan, -1, low=5, high=5) + assert_equal_color(np.nan, -1, low=5) + + def test_lookup_table(self): + palette = list(ContinuousPalettes.values())[-1] + np.testing.assert_equal(palette.lookup_table(), palette.palette) + + indices = np.r_[[0] * 12, np.arange(0, 255, 2), [255] * 116] + colors = palette.palette[indices] + self.assert_equal_within( + palette.lookup_table(12 / 256, 140 / 256), colors, 5) + + def test_color_strip_horizontal(self): + palette = list(ContinuousPalettes.values())[-1] + img = palette.color_strip(57, 17) + self.assertEqual(img.width(), 57) + self.assertEqual(img.height(), 17) + + img = palette.color_strip(256, 3) + img = img.toImage().convertToFormat(QImage.Format_RGB888) + for i in range(3): + ptr = img.scanLine(i) + ptr.setsize(256 * 3) + a = np.array(ptr).reshape(256, 3) + np.testing.assert_equal(a, palette.palette) + + img = palette.color_strip(64, 3) + img = img.toImage().convertToFormat(QImage.Format_RGB888) + for i in range(3): + ptr = img.scanLine(i) + ptr.setsize(64 * 3) + a = np.array(ptr).reshape(64, 3) + # Colors differ due to rounding when computing indices + self.assert_equal_within(a, palette.palette[::4], 15) + + def test_color_strip_vertical(self): + palette = list(ContinuousPalettes.values())[-1] + img = palette.color_strip(57, 13, Qt.Vertical) + self.assertEqual(img.width(), 13) + self.assertEqual(img.height(), 57) + + img = palette.color_strip(256, 3, Qt.Vertical) + img = img.toImage().convertToFormat(QImage.Format_RGB888) + for i in range(256): + ptr = img.scanLine(i) + ptr.setsize(3 * 3) + a = np.array(ptr).reshape(3, 3) + self.assertTrue(np.all(a == palette.palette[255 - i])) + + + def test_from_colors(self): + palette = ContinuousPalette.from_colors((255, 255, 0), (0, 255, 255)) + colors = palette.palette + np.testing.assert_equal(colors[:, 0], np.arange(255, -1, -1)) + np.testing.assert_equal(colors[:, 1], 255) + np.testing.assert_equal(colors[:, 2], np.arange(256)) + + palette = ContinuousPalette.from_colors((127, 0, 0), (0, 0, 255), True) + colors = palette.palette + line = np.r_[np.arange(127, -1, -1), np.zeros(128)] + self.assert_equal_within(colors[:, 0], line, 2) + np.testing.assert_equal(colors[:, 1], 0) + self.assert_equal_within(colors[:, 2], 2 * line[::-1], 2) + + palette = ContinuousPalette.from_colors((255, 0, 0), (0, 0, 255), + pass_through=(255, 255, 0)) + colors = palette.palette + self.assert_equal_within( + colors[:, 0], + np.r_[[255] * 128, np.arange(255, 0, -2)], 3) + self.assert_equal_within( + colors[:, 1], + np.r_[np.arange(0, 255, 2), np.arange(255, 0, -2)], 3) + self.assert_equal_within( + colors[:, 2], + np.r_[[0] * 128, np.arange(0, 255, 2)], 3) + + def test_default(self): + self.assertIs(DefaultContinuousPalette, + ContinuousPalettes[DefaultContinuousPaletteName]) + + +class BinnedPaletteTest(unittest.TestCase): + def setUp(self): + self.palette = list(ContinuousPalettes.values())[-1] + self.bins = np.arange(10, 101, 10) + self.binned = BinnedContinuousPalette.from_palette( + self.palette, self.bins) + + def test_from_palette_continuous(self): + np.testing.assert_equal(self.binned.bins, self.bins) + np.testing.assert_equal( + self.binned.palette, + self.palette.values_to_colors([15, 25, 35, 45, 55, 65, 75, 85, 95], + low=10, high=100) + ) + + bins = np.array([100, 200]) + binned = BinnedContinuousPalette.from_palette(self.palette, bins) + np.testing.assert_equal(binned.bins, bins) + np.testing.assert_equal(binned.palette, [self.palette.palette[128]]) + + def test_from_palette_binned(self): + binned2 = BinnedContinuousPalette.from_palette( + self.binned, np.arange(10)) + + self.assertIsNot(self.binned, binned2) + np.testing.assert_equal(binned2.bins, self.bins) + np.testing.assert_equal( + binned2.palette, + self.palette.values_to_colors([15, 25, 35, 45, 55, 65, 75, 85, 95], + low=10, high=100) + ) + + def test_from_palette_discrete(self): + self.assertRaises( + TypeError, + BinnedContinuousPalette.from_palette, DefaultRGBColors, [1, 2, 3]) + + def test_bin_indices(self): + for x in ([15, 61, 150, np.nan, -5], + np.array([15, 61, 150, np.nan, -5])): + indices, nans = self.binned._bin_indices(x) + np.testing.assert_equal(indices, [0, 5, 8, -1, 0]) + np.testing.assert_equal(nans, [False, False, False, True, False]) + + def test_values_to_colors(self): + for x in ([15, 61, 150, np.nan, -5], + np.array([15, 61, 150, np.nan, -5])): + expected = [self.binned.palette[idx] if idx >= 0 else NAN_COLOR + for idx in [0, 5, 8, -1, 0]] + np.testing.assert_equal( + self.binned.values_to_colors(x), + expected) + np.testing.assert_equal( + [col.getRgb()[:3] + for col in self.binned.values_to_qcolors(x)], + expected) + + for col, exp in zip(x, expected): + np.testing.assert_equal( + self.binned.value_to_color(col), exp) + np.testing.assert_equal( + self.binned.value_to_qcolor(col).getRgb()[:3], exp) + + def test_copy(self): + copy = self.binned.copy() + np.testing.assert_equal(self.binned.palette, copy.palette) + np.testing.assert_equal(self.binned.bins, copy.bins) + copy.palette[0, 0] += 1 + self.assertNotEqual(self.binned.palette[0, 0], copy.palette[0, 0]) + copy.bins[0] += 1 + self.assertNotEqual(self.bins[0], copy.bins[0]) + + +class UtilsTest(GuiTest): + def test_coloricon(self): + color = QColor(1, 2, 3) + icon = ColorIcon(color, 16) + self.assertIsInstance(icon, QIcon) + sizes = icon.availableSizes() + self.assertEqual(len(sizes), 1) + size = sizes[0] + self.assertEqual(size.width(), 16) + self.assertEqual(size.height(), 16) + pixmap = icon.pixmap(size) + img = pixmap.toImage().convertToFormat(QImage.Format_RGB888) + ptr = img.bits() + ptr.setsize(16 * 16 * 3) + a = np.array(ptr).reshape(256, 3) + self.assertTrue(np.all(a == [1, 2, 3])) + + def test_get_default_curve_colors(self): + def equal_colors(n, palette): + colors = get_default_curve_colors(n) + self.assertEqual(len(colors), n) + self.assertTrue(all(color.getRgb() == palcol.getRgb() + for color, palcol in zip(colors, palette))) + + n_dark = len(Dark2Colors) + n_rgb = len(DefaultRGBColors) + equal_colors(2, Dark2Colors) + equal_colors(n_dark, Dark2Colors) + equal_colors(n_dark + 1, DefaultRGBColors) + equal_colors(n_rgb, DefaultRGBColors) + + colors = get_default_curve_colors(n_rgb + 1) + self.assertTrue( + all(color.getRgb() == palcol.getRgb() + for color, palcol in zip(colors, + LimitedDiscretePalette(n_rgb + 1)))) + + +class PatchedVariableTest(unittest.TestCase): + def test_colors(self): + var = Variable("x") + colors = [Mock(), Mock()] + var.colors = colors + self.assertIs(var.colors, colors) + + def test_palette(self): + var = Variable("x") + palette = Mock() + var.palette = palette + self.assertIs(var.palette, palette) + + def test_exclusive(self): + var = Variable("x") + colors = [Mock(), Mock()] + palette = Mock() + var.colors = colors + var.palette = palette + self.assertIsNone(var.colors) + self.assertTrue("palette" in var.attributes) + self.assertFalse("colors" in var.attributes) + + var.colors = colors + self.assertIsNone(var.palette) + self.assertTrue("colors" in var.attributes) + self.assertFalse("palette" in var.attributes) + + +class PatchedDiscreteVariableTest(unittest.TestCase): + def test_colors(self): + var = DiscreteVariable.make("a", values=["F", "M"]) + self.assertIsNone(var._colors) + self.assertEqual(var.colors.shape, (2, 3)) + self.assertFalse(var.colors.flags.writeable) + + var.colors = np.arange(6).reshape((2, 3)) + np.testing.assert_almost_equal(var.colors, [[0, 1, 2], [3, 4, 5]]) + self.assertEqual(var.attributes["colors"], ["#000102", "#030405"]) + self.assertFalse(var.colors.flags.writeable) + with self.assertRaises(ValueError): + var.colors[0] = [42, 41, 40] + + var = DiscreteVariable.make("x", values=["A", "B"]) + var.attributes["colors"] = ['#0a0b0c', '#0d0e0f'] + np.testing.assert_almost_equal(var.colors, [[10, 11, 12], [13, 14, 15]]) + + # Test ncolors adapts to nvalues + var = DiscreteVariable.make('foo', values=['d', 'r']) + self.assertEqual(len(var.colors), 2) + var.add_value('e') + self.assertEqual(len(var.colors), 3) + var.add_value('k') + self.assertEqual(len(var.colors), 4) + + def test_colors_fallback_to_palette(self): + var = DiscreteVariable.make("a", values=["F", "M"]) + var.palette = Dark2Colors + colors = var.colors + self.assertEqual(len(colors), 2) + for color, palcol in zip(colors, Dark2Colors): + np.testing.assert_equal(color, palcol.getRgb()[:3]) + + var = DiscreteVariable.make("a", values=[f"{i}" for i in range(40)]) + var.palette = Dark2Colors + colors = var.colors + self.assertEqual(len(colors), 40) + for color, palcol in zip(colors, LimitedDiscretePalette(40)): + np.testing.assert_equal(color, palcol.getRgb()[:3]) + + def test_colors_default(self): + var = DiscreteVariable.make("a", values=["F", "M"]) + colors = var.colors + self.assertEqual(len(colors), 2) + for color, palcol in zip(colors, DefaultRGBColors): + np.testing.assert_equal(color, palcol.getRgb()[:3]) + + var = DiscreteVariable.make("a", values=[f"{i}" for i in range(40)]) + colors = var.colors + self.assertEqual(len(colors), 40) + for color, palcol in zip(colors, LimitedDiscretePalette(40)): + np.testing.assert_equal(color, palcol.getRgb()[:3]) + + var = DiscreteVariable.make("a", values=["M", "F"]) + var.attributes["colors"] = "foo" + colors = var.colors + self.assertEqual(len(colors), 2) + for color, palcol in zip(colors, DefaultRGBColors): + np.testing.assert_equal(color, palcol.getRgb()[:3]) + + def test_colors_no_values(self): + var = DiscreteVariable.make("a", values=[]) + colors = var.colors + self.assertEqual(len(colors), 0) + + var = DiscreteVariable.make("a", values=[]) + var.palette = DefaultRGBColors + colors = var.colors + self.assertEqual(len(colors), 0) + + def test_get_palette(self): + var = DiscreteVariable.make("a", values=["M", "F"]) + palette = var.palette + self.assertEqual(len(palette), 2) + np.testing.assert_equal(palette.palette, DefaultRGBColors.palette[:2]) + + var = DiscreteVariable.make("a", values=["M", "F"]) + var.attributes["palette"] = "dark" + palette = var.palette + self.assertIs(palette, Dark2Colors) + + var = DiscreteVariable.make("a", values=["M", "F"]) + var.attributes["colors"] = ['#0a0b0c', '#0d0e0f'] + palette = var.palette + np.testing.assert_equal(palette.palette, [[10, 11, 12], [13, 14, 15]]) + + +class PatchedContinuousVariableTest(unittest.TestCase): + def test_colors(self): + with self.assertWarns(DeprecationWarning): + a = ContinuousVariable("a") + self.assertEqual(a.colors, ((0, 0, 255), (255, 255, 0), False)) + + a = ContinuousVariable("a") + a.attributes["colors"] = ['#010203', '#040506', True] + self.assertEqual(a.colors, ((1, 2, 3), (4, 5, 6), True)) + + a.colors = ((3, 2, 1), (6, 5, 4), True) + self.assertEqual(a.colors, ((3, 2, 1), (6, 5, 4), True)) + + def test_colors_from_palette(self): + with self.assertWarns(DeprecationWarning): + a = ContinuousVariable("a") + a.palette = palette = ContinuousPalettes["rainbow_bgyr_35_85_c73"] + colors = a.colors + self.assertEqual(colors, (tuple(palette.palette[0]), + tuple(palette.palette[255]), + False)) + + a = ContinuousVariable("a") + a.attributes["palette"] = "rainbow_bgyr_35_85_c73" + colors = a.colors + self.assertEqual(colors, (tuple(palette.palette[0]), + tuple(palette.palette[255]), + False)) + + a = ContinuousVariable("a") + a.palette = palette = ContinuousPalettes["diverging_bwr_40_95_c42"] + colors = a.colors + self.assertEqual(colors, (tuple(palette.palette[0]), + tuple(palette.palette[255]), + True)) + + def test_palette(self): + palette = ContinuousPalettes["rainbow_bgyr_35_85_c73"] + + a = ContinuousVariable("a") + a.palette = palette + self.assertIs(a.palette, palette) + + a = ContinuousVariable("a") + a.attributes["palette"] = palette.name + self.assertIs(a.palette, palette) + + a = ContinuousVariable("a") + self.assertIs(a.palette, DefaultContinuousPalette) + + with patch.object(ContinuousPalette, "from_colors") as from_colors: + a = ContinuousVariable("a") + a.attributes["colors"] = ('#0a0b0c', '#0d0e0f', False) + palette = a.palette + from_colors.assert_called_with((10, 11, 12), (13, 14, 15), False) + self.assertIs(palette, from_colors.return_value) + + with patch.object(ContinuousPalette, "from_colors") as from_colors: + a = ContinuousVariable("a") + a.colors = (10, 11, 12), (13, 14, 15), False + palette = a.palette + from_colors.assert_called_with((10, 11, 12), (13, 14, 15), False) + self.assertIs(palette, from_colors.return_value) + + + def test_proxy_has_separate_colors(self): + abc = ContinuousVariable("abc") + abc1 = abc.make_proxy() + abc2 = abc1.make_proxy() + + with self.assertWarns(DeprecationWarning): + original_colors = abc.colors + red_to_green = (255, 0, 0), (0, 255, 0), False + blue_to_red = (0, 0, 255), (255, 0, 0), False + + abc1.colors = red_to_green + abc2.colors = blue_to_red + with self.assertWarns(DeprecationWarning): + self.assertEqual(abc.colors, original_colors) + self.assertEqual(abc1.colors, red_to_green) + self.assertEqual(abc2.colors, blue_to_red) + + +patch_variable_colors() + +if __name__ == "__main__": + unittest.main() diff --git a/Orange/widgets/utils/tests/test_itemmodels.py b/Orange/widgets/utils/tests/test_itemmodels.py index 85ef07d48e9..da691df0bf1 100644 --- a/Orange/widgets/utils/tests/test_itemmodels.py +++ b/Orange/widgets/utils/tests/test_itemmodels.py @@ -11,11 +11,13 @@ from Orange.data import \ Domain, \ ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable +from Orange.widgets.utils import colorpalettes from Orange.widgets.utils.itemmodels import \ AbstractSortTableModel, PyTableModel,\ - PyListModel, VariableListModel, DomainModel,\ + PyListModel, VariableListModel, DomainModel, ContinuousPalettesModel, \ _as_contiguous_range from Orange.widgets.gui import TableVariable +from orangewidget.tests.base import GuiTest class TestUtils(unittest.TestCase): @@ -336,5 +338,105 @@ def test_read_only(self): self.assertSequenceEqual(model, domain) +class TestContinuousPalettesModel(GuiTest): + def setUp(self): + self.palette1, self.palette2 = \ + list(colorpalettes.ContinuousPalettes.values())[:2] + + def test_all_categories(self): + model = ContinuousPalettesModel() + shown = {palette.name for palette in model.items + if isinstance(palette, colorpalettes.Palette)} + expected = {palette.name + for palette in colorpalettes.ContinuousPalettes.values()} + self.assertEqual(expected, shown) + + shown = {name for name in model.items if isinstance(name, str)} + expected = {palette.category + for palette in colorpalettes.ContinuousPalettes.values()} + self.assertEqual(expected, shown) + + def test_category_selection(self): + categories = ('Diverging', 'Linear') + model = ContinuousPalettesModel(categories=categories) + shown = {palette.name + for palette in model.items + if isinstance(palette, colorpalettes.Palette)} + expected = {palette.name + for palette in colorpalettes.ContinuousPalettes.values() + if palette.category in categories} + self.assertEqual(expected, shown) + self.assertIn("Diverging", model.items) + self.assertIn("Linear", model.items) + + def test_single_category(self): + category = 'Diverging' + model = ContinuousPalettesModel(categories=(category, )) + shown = {palette.name + for palette in model.items + if isinstance(palette, colorpalettes.Palette)} + expected = {palette.name + for palette in colorpalettes.ContinuousPalettes.values() + if palette.category == category} + self.assertEqual(expected, shown) + self.assertEqual(len(model.items), len(shown)) + + def test_count(self): + model = ContinuousPalettesModel() + model.items = [self.palette1, self.palette1] + self.assertEqual(model.rowCount(QModelIndex()), 2) + self.assertEqual(model.columnCount(QModelIndex()), 1) + + def test_data(self): + model = ContinuousPalettesModel() + model.items = ["Palettes", self.palette1, self.palette2] + data = model.data + index = model.index + + self.assertEqual(data(index(0, 0), Qt.EditRole), "Palettes") + self.assertEqual(data(index(1, 0), Qt.EditRole), + self.palette1.friendly_name) + self.assertEqual(data(index(2, 0), Qt.EditRole), + self.palette2.friendly_name) + + self.assertEqual(data(index(0, 0), Qt.DisplayRole), "Palettes") + self.assertEqual(data(index(1, 0), Qt.DisplayRole), + self.palette1.friendly_name) + self.assertEqual(data(index(2, 0), Qt.DisplayRole), + self.palette2.friendly_name) + + self.assertIsNone(data(index(0, 0), Qt.DecorationRole)) + with patch.object(self.palette1, "color_strip") as color_strip: + self.assertIs(data(index(1, 0), Qt.DecorationRole), + color_strip.return_value) + with patch.object(self.palette2, "color_strip") as color_strip: + self.assertIs(data(index(2, 0), Qt.DecorationRole), + color_strip.return_value) + + self.assertIsNone(data(index(0, 0), Qt.UserRole)) + self.assertIs(data(index(1, 0), Qt.UserRole), self.palette1) + self.assertIs(data(index(2, 0), Qt.UserRole), self.palette2) + + self.assertIsNone(data(index(2, 0), Qt.FontRole)) + + def test_select_flags(self): + model = ContinuousPalettesModel() + model.items = ["Palettes", self.palette1, self.palette2] + self.assertFalse(model.flags(model.index(0, 0)) & Qt.ItemIsSelectable) + self.assertTrue(model.flags(model.index(1, 0)) & Qt.ItemIsSelectable) + self.assertTrue(model.flags(model.index(2, 0)) & Qt.ItemIsSelectable) + + def testIndexOf(self): + model = ContinuousPalettesModel() + model.items = ["Palettes", self.palette1, self.palette2] + self.assertEqual(model.indexOf(self.palette1), 1) + self.assertEqual(model.indexOf(self.palette1.name), 1) + self.assertEqual(model.indexOf(self.palette1.friendly_name), 1) + self.assertEqual(model.indexOf(self.palette2), 2) + self.assertEqual(model.indexOf(self.palette2.name), 2) + self.assertEqual(model.indexOf(self.palette2.friendly_name), 2) + self.assertIsNone(model.indexOf(42)) + + if __name__ == "__main__": unittest.main() diff --git a/Orange/widgets/visualize/owheatmap.py b/Orange/widgets/visualize/owheatmap.py index ef52521a574..3d3faf51498 100644 --- a/Orange/widgets/visualize/owheatmap.py +++ b/Orange/widgets/visualize/owheatmap.py @@ -17,8 +17,7 @@ QFormLayout, QApplication, QComboBox, QWIDGETSIZE_MAX ) from AnyQt.QtGui import ( - QFontMetrics, QPen, QPixmap, QColor, QLinearGradient, QPainter, - QTransform, QIcon, QBrush, + QFontMetrics, QPen, QPixmap, QTransform, QStandardItemModel, QStandardItem, ) from AnyQt.QtCore import ( @@ -34,10 +33,9 @@ import Orange.distance from Orange.clustering import hierarchical, kmeans +from Orange.widgets.utils import colorpalettes from Orange.widgets.utils.itemmodels import DomainModel from Orange.widgets.utils.stickygraphicsview import StickyGraphicsView -from Orange.widgets.utils import colorbrewer - from Orange.widgets.utils.graphicstextlist import scaled, TextListWidget from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) @@ -58,72 +56,6 @@ def leaf_indices(tree): return [leaf.value.index for leaf in hierarchical.leaves(tree)] -def palette_gradient(colors): - n = len(colors) - stops = np.linspace(0.0, 1.0, n, endpoint=True) - gradstops = [(float(stop), color) for stop, color in zip(stops, colors)] - grad = QLinearGradient(QPointF(0, 0), QPointF(1, 0)) - grad.setStops(gradstops) - return grad - - -def palette_pixmap(colors, size): - img = QPixmap(size) - img.fill(Qt.transparent) - - grad = palette_gradient(colors) - grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode) - - painter = QPainter(img) - painter.setPen(Qt.NoPen) - painter.setBrush(QBrush(grad)) - painter.drawRect(0, 0, size.width(), size.height()) - painter.end() - return img - - -def color_palette_model(palettes, iconsize=QSize(64, 16)): - model = QStandardItemModel() - for name, palette in palettes: - _, colors = max(palette.items()) - colors = [QColor(*c) for c in colors] - item = QStandardItem(name) - item.setIcon(QIcon(palette_pixmap(colors, iconsize))) - item.setData(palette, Qt.UserRole) - model.appendRow([item]) - return model - - -def color_palette_table(colors, - underflow=None, overflow=None, - gamma=None): - colors = np.array(colors, dtype=np.ubyte) - points = np.linspace(0, 255, len(colors)) - space = np.linspace(0, 255, 255) - - if underflow is None: - underflow = [None, None, None] - - if overflow is None: - overflow = [None, None, None] - - if gamma is None or gamma < 0.0001: - r = np.interp(space, points, colors[:, 0], - left=underflow[0], right=overflow[0]) - g = np.interp(space, points, colors[:, 1], - left=underflow[1], right=overflow[1]) - b = np.interp(space, points, colors[:, 2], - left=underflow[2], right=overflow[2]) - else: - r = interp_exp(space, points, colors[:, 0], gamma=gamma, - left=underflow[0], right=overflow[0]) - g = interp_exp(space, points, colors[:, 1], gamma=gamma, - left=underflow[0], right=overflow[0]) - b = interp_exp(space, points, colors[:, 2], gamma=gamma, - left=underflow[0], right=overflow[0]) - return np.c_[r, g, b] - - def levels_with_thresholds(low, high, threshold_low, threshold_high, center_palette): lt = low + (high - low) * threshold_low ht = low + (high - low) * threshold_high @@ -132,55 +64,6 @@ def levels_with_thresholds(low, high, threshold_low, threshold_high, center_pale lt = -max(abs(lt), abs(ht)) return lt, ht - -def interp_exp(x, xp, fp, gamma=0.0, left=None, right=None,): - assert np.all(np.diff(xp) > 0) - x = np.asanyarray(x) - xp = np.asanyarray(xp) - fp = np.asanyarray(fp) - - if xp.shape != fp.shape: - raise ValueError("xp and fp must have the same shape") - - ind = np.searchsorted(xp, x, side="right") - - f = np.zeros(len(x)) - - under = ind == 0 - over = ind == len(xp) - between = ~under & ~over - - f[under] = left if left is not None else fp[0] - f[over] = right if right is not None else fp[-1] - - if right is not None: - # Fix points exactly on the right boundary. - f[x == xp[-1]] = fp[-1] - - ind = ind[between] - - def exp_ramp(x, gamma): - assert gamma >= 0 - if gamma < np.finfo(float).eps: - return x - else: - return (np.exp(gamma * x) - 1) / (np.exp(gamma) - 1.) - - def gamma_fun(x, gamma): - out = np.array(x) - out[x < 0.5] = exp_ramp(x[x < 0.5] * 2, gamma) / 2 - out[x > 0.5] = 1 - exp_ramp((1 - x[x > 0.5]) * 2, gamma) / 2 - return out - - y0, y1 = fp[ind - 1], fp[ind] - x0, x1 = xp[ind - 1], xp[ind] - - m = (x[between] - x0) / (x1 - x0) - m = gamma_fun(m, gamma) - f[between] = (1 - m) * y0 + m * y1 - - return f - # TODO: # * Richer Tool Tips # * Color map edit/manage @@ -261,14 +144,6 @@ class Parts(NamedTuple): levels = property(lambda self: self.span) -_color_palettes = (sorted(colorbrewer.colorSchemes["sequential"].items()) + - [("Blue-Yellow", {2: [(0, 0, 255), (255, 255, 0)]}), - ("Green-Black-Red", {3: [(0, 255, 0), (0, 0, 0), - (255, 0, 0)]})]) -_default_palette_index = \ - [name for name, _, in _color_palettes].index("Blue-Yellow") - - def cbselect(cb: QComboBox, value, role: Qt.ItemDataRole = Qt.EditRole) -> None: """ Find and select the `value` in the `cb` QComboBox. @@ -365,10 +240,8 @@ class Outputs: # Disable cluster leaf ordering for inputs bigger than this MaxOrderedClustering = 1000 - gamma = settings.Setting(0) threshold_low = settings.Setting(0.0) threshold_high = settings.Setting(1.0) - center_palette = settings.Setting(False) merge_kmeans = settings.Setting(False) merge_kmeans_k = settings.Setting(50) @@ -381,15 +254,12 @@ class Outputs: annotation_var = settings.ContextSetting(None) # Discrete variable used to split that data/heatmaps (vertically) split_by_var = settings.ContextSetting(None) - # Stored color palette settings - color_settings = settings.Setting(None) - user_palettes = settings.Setting([]) # 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_index = settings.Setting(_default_palette_index) + palette_name = settings.Setting(colorpalettes.DefaultContinuousPaletteName) column_label_pos = settings.Setting(PositionTop) selected_rows = settings.Setting(None, schema_only=True) @@ -460,18 +330,10 @@ def _(): # GUI definition colorbox = gui.vBox(self.controlArea, "Color") - self.color_cb = gui.comboBox(colorbox, self, "palette_index") - self.color_cb.setIconSize(QSize(64, 16)) - palettes = _color_palettes + self.user_palettes - - self.palette_index = min(self.palette_index, len(palettes) - 1) + 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) - model = color_palette_model(palettes, self.color_cb.iconSize()) - model.setParent(self) - self.color_cb.setModel(model) - self.color_cb.activated.connect(self.update_color_schema) - - self.color_cb.setCurrentIndex(self.palette_index) # TODO: Add 'Manage/Add/Remove' action. form = QFormLayout( @@ -488,21 +350,12 @@ def _(): colorbox, self, "threshold_high", minValue=0.0, maxValue=1.0, step=0.05, ticks=True, intOnly=False, createLabel=False, callback=self.update_highslider) - gammaslider = gui.hSlider( - colorbox, self, "gamma", minValue=0.0, maxValue=20.0, - step=1.0, ticks=True, intOnly=False, - createLabel=False, callback=self.update_color_schema - ) form.addRow("Low:", lowslider) form.addRow("High:", highslider) - form.addRow("Gamma:", gammaslider) colorbox.layout().addLayout(form) - gui.checkBox(colorbox, self, 'center_palette', 'Center colors at 0', - callback=self.update_color_schema) - mergebox = gui.vBox(self.controlArea, "Merge",) gui.checkBox(mergebox, self, "merge_kmeans", "Merge by k-means", callback=self.__update_row_clustering) @@ -635,6 +488,11 @@ def _(idx, cb=cb): self.selection_rects = [] self.selected_rows = [] + @property + def center_palette(self): + palette = self.color_cb.currentData() + return bool(palette.flags & palette.Diverging) + def set_row_clustering(self, method: Clustering) -> None: assert isinstance(method, Clustering) if self.row_clustering != method: @@ -653,12 +511,7 @@ def sizeHint(self): return QSize(800, 400) def color_palette(self): - data = self.color_cb.itemData(self.palette_index, role=Qt.UserRole) - if data is None: - return [] - else: - _, colors = max(data.items()) - return color_palette_table(colors, gamma=self.gamma) + return self.color_cb.currentData().lookup_table() def clear(self): self.data = None @@ -1441,6 +1294,7 @@ def update_highslider(self): self.update_color_schema() 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) diff --git a/Orange/widgets/visualize/owpythagorastree.py b/Orange/widgets/visualize/owpythagorastree.py index 5eb1c63d10f..5a5b9c6edff 100644 --- a/Orange/widgets/visualize/owpythagorastree.py +++ b/Orange/widgets/visualize/owpythagorastree.py @@ -365,30 +365,13 @@ def _classification_update_legend_colors(self): self.scene.addItem(self.legend) def _regression_update_legend_colors(self): - def _get_colors_domain(domain): - class_var = domain.class_var - start, end, pass_through_black = class_var.colors - if pass_through_black: - lst_colors = [QColor(*c) for c - in [start, (0, 0, 0), end]] - else: - lst_colors = [QColor(*c) for c in [start, end]] - return lst_colors - # The colors are the class mean + palette = self.model.domain.class_var.palette if self.target_class_index == 1: - values = (np.min(self.data.Y), np.max(self.data.Y)) - colors = _get_colors_domain(self.model.domain) - while len(values) != len(colors): - values.insert(1, -1) - items = list(zip(values, colors)) + items = ((np.min(self.data.Y), np.max(self.data.Y)), palette) # Colors are the stddev elif self.target_class_index == 2: - values = (0, np.std(self.data.Y)) - colors = _get_colors_domain(self.model.domain) - while len(values) != len(colors): - values.insert(1, -1) - items = list(zip(values, colors)) + items = ((0, np.std(self.data.Y)), palette) else: items = None @@ -414,6 +397,7 @@ class TreeGraphicsScene(UpdateItemsOnSelectGraphicsScene): if __name__ == "__main__": # pragma: no cover from Orange.modelling import TreeLearner data = Table('iris') + # data = Table('housing') model = TreeLearner(max_depth=1000)(data) model.instances = data WidgetPreview(OWPythagorasTree).run(model) diff --git a/Orange/widgets/visualize/owscatterplotgraph.py b/Orange/widgets/visualize/owscatterplotgraph.py index 1e76f199f9f..8d57128ff33 100644 --- a/Orange/widgets/visualize/owscatterplotgraph.py +++ b/Orange/widgets/visualize/owscatterplotgraph.py @@ -22,11 +22,11 @@ ) from pyqtgraph.graphicsItems.TextItem import TextItem +from Orange.widgets.utils import colorpalettes from Orange.util import OrangeDeprecationWarning from Orange.widgets import gui from Orange.widgets.settings import Setting from Orange.widgets.utils import classdensity -from Orange.widgets.utils.colorpalette import ColorPaletteGenerator from Orange.widgets.utils.plot import OWPalette from Orange.widgets.visualize.owscatterplotgraph_obsolete import ( OWScatterPlotGraph as OWScatterPlotGraphObs @@ -47,7 +47,7 @@ class PaletteItemSample(ItemSample): def __init__(self, palette, scale, label_formatter=None): """ :param palette: palette used for showing continuous values - :type palette: ContinuousPaletteGenerator + :type palette: BinnedContinuousPalette :param scale: an instance of DiscretizedScale that defines the conversion of values into bins :type scale: DiscretizedScale @@ -73,12 +73,11 @@ def boundingRect(self): def paint(self, p, *args): p.setRenderHint(p.Antialiasing) p.translate(5, 5) - scale = self.scale font = p.font() font.setPixelSize(11) p.setFont(font) - for i, label in enumerate(self.labels): - color = QColor(*self.palette.getRGB((i + 0.5) / scale.bins)) + colors = self.palette.qcolors + for i, color, label in zip(itertools.count(), colors, self.labels): p.setPen(Qt.NoPen) p.setBrush(QBrush(color)) p.drawRect(0, i * 15, 15, 15) @@ -221,6 +220,9 @@ def __init__(self, min_v, max_v): self.decimals = max(decimals, 0) self.width = resolution + def get_bins(self): + return self.offset + self.width * np.arange(self.bins + 1) + class InteractiveViewBox(ViewBox): def __init__(self, graph, enable_menu=False): @@ -869,13 +871,13 @@ def get_colors(self): Returns: (tuple): a list of pens and list of brushes """ - self.palette = self.master.get_palette() c_data = self.master.get_color_data() c_data = self._filter_visible(c_data) subset = self.master.get_subset_mask() subset = self._filter_visible(subset) self.subset_is_shown = subset is not None if c_data is None: # same color + self.palette = None return self._get_same_colors(subset) elif self.master.is_continuous_color(): return self._get_continuous_colors(c_data, subset) @@ -915,23 +917,25 @@ def _get_continuous_colors(self, c_data, subset): except the former is darker. If the data has a subset, the brush is transparent for points that are not in the subset. """ + palette = self.master.get_palette() + if np.isnan(c_data).all(): - self.scale = None - else: - self.scale = DiscretizedScale(np.nanmin(c_data), np.nanmax(c_data)) - c_data -= self.scale.offset - c_data /= self.scale.width - c_data = np.floor(c_data) + 0.5 - c_data /= self.scale.bins - c_data = np.clip(c_data, 0, 1) - pen = self.palette.getRGB(c_data) + self.palette = palette + return self._get_continuous_nan_colors(len(c_data)) + + self.scale = DiscretizedScale(np.nanmin(c_data), np.nanmax(c_data)) + bins = self.scale.get_bins() + self.palette = \ + colorpalettes.BinnedContinuousPalette.from_palette(palette, bins) + colors = self.palette.values_to_colors(c_data) brush = np.hstack( - [pen, np.full((len(pen), 1), self.alpha_value, dtype=int)]) - pen *= 100 - pen //= self.DarkerValue + (colors, + np.full((len(c_data), 1), self.alpha_value, dtype=np.ubyte))) + pen = (colors.astype(dtype=float) * 100 / self.DarkerValue + ).astype(np.ubyte) - # Reuse pens and brushes with the same colors because PyQtGraph then builds - # smaller pixmap atlas, which makes the drawing faster + # Reuse pens and brushes with the same colors because PyQtGraph then + # builds smaller pixmap atlas, which makes the drawing faster def reuse(cache, fn, *args): if args not in cache: @@ -952,10 +956,19 @@ def create_brush(col): brush[subset, 3] = 255 cached_brushes = {} - brush = np.array([reuse(cached_brushes, create_brush, *col) for col in brush.tolist()]) + brush = np.array([reuse(cached_brushes, create_brush, *col) + for col in brush.tolist()]) return pen, brush + def _get_continuous_nan_colors(self, n): + nan_color = QColor(*self.palette.nan_color) + nan_pen = _make_pen(nan_color.darker(1.2), 1.5) + pen = np.full(n, nan_pen) + nan_brush = QBrush(nan_color) + brush = np.full(n, nan_brush) + return pen, brush + def _get_discrete_colors(self, c_data, subset): """ Return the pens and colors whose color represent an index into @@ -963,27 +976,23 @@ def _get_discrete_colors(self, c_data, subset): except the former is darker. If the data has a subset, the brush is transparent for points that are not in the subset. """ - n_colors = self.palette.number_of_colors + self.palette = self.master.get_palette() c_data = c_data.copy() - c_data[np.isnan(c_data)] = n_colors + c_data[np.isnan(c_data)] = len(self.palette) c_data = c_data.astype(int) - colors = np.r_[self.palette.getRGB(np.arange(n_colors)), - [[128, 128, 128]]] + colors = self.palette.qcolors_w_nan pens = np.array( - [_make_pen(QColor(*col).darker(self.DarkerValue), 1.5) - for col in colors]) + [_make_pen(col.darker(self.DarkerValue), 1.5) for col in colors]) pen = pens[c_data] - alpha = self.alpha_value if subset is None else 255 - brushes = np.array([ - [QBrush(QColor(0, 0, 0, 0)), - QBrush(QColor(col[0], col[1], col[2], alpha))] - for col in colors]) + if subset is None and self.alpha_value < 255: + for col in colors: + col.setAlpha(self.alpha_value) + brushes = np.array([QBrush(col) for col in colors]) brush = brushes[c_data] if subset is not None: - brush = np.where(subset, brush[:, 1], brush[:, 0]) - else: - brush = brush[:, 1] + black = np.full(len(brush), QBrush(QColor(0, 0, 0, 0))) + brush = np.where(subset, brush, black) return pen, brush def update_colors(self): @@ -1067,7 +1076,8 @@ def get_colors_sel(self): _make_pen(QColor(255, 190, 0, 255), SELECTION_WIDTH + 1), nopen) else: - palette = ColorPaletteGenerator(number_of_colors=sels + 1) + palette = colorpalettes.LimitedDiscretePalette( + number_of_colors=sels + 1) pen = np.choose( self._filter_visible(self.selection), [nopen] + [_make_pen(palette[i], SELECTION_WIDTH + 1) @@ -1282,8 +1292,9 @@ def _update_colored_legend(self, legend, labels, symbols): return if isinstance(symbols, str): symbols = itertools.repeat(symbols, times=len(labels)) - for i, (label, symbol) in enumerate(zip(labels, symbols)): - color = QColor(*self.palette.getRGB(i)) + colors = self.palette.values_to_colors(np.arange(len(labels))) + for color, label, symbol in zip(colors, labels, symbols): + color = QColor(*color) pen = _make_pen(color.darker(self.DarkerValue), 1.5) color.setAlpha(255 if self.subset_is_shown else self.alpha_value) brush = QBrush(color) diff --git a/Orange/widgets/visualize/owtreeviewer.py b/Orange/widgets/visualize/owtreeviewer.py index d7c970f4548..2b76b9772dc 100644 --- a/Orange/widgets/visualize/owtreeviewer.py +++ b/Orange/widgets/visualize/owtreeviewer.py @@ -19,7 +19,6 @@ from Orange.widgets.settings import ContextSetting, ClassValuesContextHandler, \ Setting from Orange.widgets import gui -from Orange.widgets.utils.colorpalette import ContinuousPaletteGenerator from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.visualize.utils.tree.skltreeadapter import SklTreeAdapter @@ -276,15 +275,13 @@ def ctree(self, model=None): else: self.clf_dataset = self.dataset class_var = self.domain.class_var + self.scene.colors = class_var.palette if class_var.is_discrete: - self.scene.colors = [QColor(*col) for col in class_var.colors] self.color_label.setText("Target class: ") self.color_combo.addItem("None") self.color_combo.addItems(self.domain.class_vars[0].values) self.color_combo.setCurrentIndex(self.target_class_index) else: - self.scene.colors = \ - ContinuousPaletteGenerator(*model.domain.class_var.colors) self.color_label.setText("Color by: ") self.color_combo.addItems(self.COL_OPTIONS) self.color_combo.setCurrentIndex(self.regression_colors) @@ -392,7 +389,8 @@ def toggle_node_color_cls(self): else: modus = np.argmax(distr) p = distr[modus] / (total or 1) - color = colors[int(modus)].lighter(300 - 200 * p) + color = colors.value_to_qcolor(int(modus)) + color = color.lighter(300 - 200 * p) node.backgroundBrush = QBrush(color) self.scene.update() @@ -414,11 +412,11 @@ def toggle_node_color_reg(self): elif self.regression_colors == self.COL_MEAN: minv = np.nanmin(self.dataset.Y) maxv = np.nanmax(self.dataset.Y) - fact = 1 / (maxv - minv) if minv != maxv else 1 colors = self.scene.colors for node in self.scene.nodes(): node_mean = self.tree_adapter.get_distribution(node.node_inst)[0][0] - node.backgroundBrush = QBrush(colors[fact * (node_mean - minv)]) + color = colors.value_to_qcolor(node_mean, minv, maxv) + node.backgroundBrush = QBrush(color) else: nodes = list(self.scene.nodes()) variances = [self.tree_adapter.get_distribution(node.node_inst)[0][1] @@ -438,6 +436,7 @@ def _get_tree_adapter(self, model): if __name__ == "__main__": # pragma: no cover from Orange.modelling.tree import TreeLearner data = Table("titanic") + # data = Table("housing") clf = TreeLearner()(data) clf.instances = data WidgetPreview(OWTreeGraph).run(clf) diff --git a/Orange/widgets/visualize/owvenndiagram.py b/Orange/widgets/visualize/owvenndiagram.py index 13b82016833..78cd98ebad8 100644 --- a/Orange/widgets/visualize/owvenndiagram.py +++ b/Orange/widgets/visualize/owvenndiagram.py @@ -29,7 +29,7 @@ ContinuousVariable) from Orange.statistics import util from Orange.widgets import widget, gui, settings -from Orange.widgets.utils import itemmodels, colorpalette +from Orange.widgets.utils import itemmodels, colorpalettes from Orange.widgets.utils.annotated_data import (create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME) from Orange.widgets.utils.sql import check_sql_input @@ -255,7 +255,7 @@ def _createDiagram(self): self.disjoint = disjoint(set(s.items) for s in self.itemsets.values()) vennitems = [] - colors = colorpalette.ColorPaletteHSV(n) + colors = colorpalettes.LimitedDiscretePalette(n, force_hsv=True) for i, (_, item) in enumerate(self.itemsets.items()): count = len(set(item.items)) diff --git a/Orange/widgets/visualize/pythagorastreeviewer.py b/Orange/widgets/visualize/pythagorastreeviewer.py index afc539c9f0f..0c263820950 100644 --- a/Orange/widgets/visualize/pythagorastreeviewer.py +++ b/Orange/widgets/visualize/pythagorastreeviewer.py @@ -27,7 +27,6 @@ ) from Orange.widgets.utils import to_html -from Orange.widgets.utils.colorpalette import ContinuousPaletteGenerator from Orange.widgets.visualize.utils.tree.rules import Rule from Orange.widgets.visualize.utils.tree.treeadapter import TreeAdapter @@ -44,8 +43,8 @@ class PythagorasTreeViewer(QGraphicsWidget): Examples -------- >>> from Orange.widgets.visualize.utils.tree.treeadapter import ( - >>> TreeAdapter - >>> ) + ... TreeAdapter + ... ) Pass tree through constructor. >>> tree_view = PythagorasTreeViewer(parent=scene, adapter=tree_adapter) @@ -597,9 +596,8 @@ def tooltip(self): """ @property - @abstractmethod def color_palette(self): - pass + return self.tree.domain.class_var.palette def _rules_str(self): rules = self.tree.rules(self.label) @@ -622,10 +620,6 @@ class DiscreteTreeNode(TreeNode): """ - @property - def color_palette(self): - return [QColor(*c) for c in self.tree.domain.class_var.colors] - @property def color(self): distribution = self.tree.get_distribution(self.label)[0] @@ -633,11 +627,13 @@ def color(self): if self.target_class_index: p = distribution[self.target_class_index - 1] / total - color = self.color_palette[self.target_class_index - 1].lighter(200 - 100 * p) + color = self.color_palette[self.target_class_index - 1] + color = color.lighter(200 - 100 * p) else: modus = np.argmax(distribution) p = distribution[modus] / (total or 1) - color = self.color_palette[int(modus)].lighter(400 - 300 * p) + color = self.color_palette[int(modus)] + color = color.lighter(400 - 300 * p) return color @property @@ -688,10 +684,6 @@ class ContinuousTreeNode(TreeNode): 'Standard deviation': COLOR_STD, } - @property - def color_palette(self): - return ContinuousPaletteGenerator(*self.tree.domain.class_var.colors) - @property def color(self): if self.target_class_index is self.COLOR_MEAN: @@ -707,14 +699,16 @@ def _color_mean(self): max_mean = np.max(self.tree.instances.Y) instances = self.tree.get_instances_in_nodes(self.label) mean = np.mean(instances.Y) - return self.color_palette[(mean - min_mean) / (max_mean - min_mean)] + return self.color_palette.value_to_qcolor( + mean, low=min_mean, high=max_mean) def _color_var(self): """Color the nodes with respect to the variance of instances inside.""" min_std, max_std = 0, np.std(self.tree.instances.Y) instances = self.tree.get_instances_in_nodes(self.label) std = np.std(instances.Y) - return self.color_palette[(std - min_std) / (max_std - min_std)] + return self.color_palette.value_to_qcolor( + std, low=min_std, high=max_std) @property def tooltip(self): diff --git a/Orange/widgets/visualize/tests/test_owheatmap.py b/Orange/widgets/visualize/tests/test_owheatmap.py index e1902f55dc5..75ebe91e6ed 100644 --- a/Orange/widgets/visualize/tests/test_owheatmap.py +++ b/Orange/widgets/visualize/tests/test_owheatmap.py @@ -7,8 +7,11 @@ import numpy as np from sklearn.exceptions import ConvergenceWarning +from AnyQt.QtCore import Qt, QModelIndex + from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable from Orange.preprocess import Continuize +from Orange.widgets.utils import colorpalettes from Orange.widgets.visualize.owheatmap import OWHeatMap, Clustering from Orange.widgets.tests.base import WidgetTest, WidgetOutputsTestMixin, datasets @@ -216,14 +219,14 @@ def test_set_split_var(self): self.assertIs(w.split_by_var, None) self.assertEqual(len(w.heatmapparts.rows), 1) - def test_center_palette(self): + def test_palette_centering(self): data = np.arange(2).reshape(-1, 1) table = Table.from_numpy(Domain([ContinuousVariable("y")]), data) self.send_signal(self.widget.Inputs.data, table) - cb_model = self.widget.color_cb.model() - ind = cb_model.indexFromItem(cb_model.findItems("Green-Black-Red")[0]).row() - self.widget.palette_index = ind + self.widget.color_palette = lambda: \ + colorpalettes.ContinuousPalette.from_colors( + (0, 255, 0), (255, 0, 0), (0, 0, 0)).lookup_table() desired_uncentered = [[0, 255, 0], [255, 0, 0]] @@ -232,12 +235,23 @@ def test_center_palette(self): [255, 0, 0]] for center, desired in [(False, desired_uncentered), (True, desired_centered)]: - self.widget.center_palette = center - self.widget.update_color_schema() - heatmap_widget = self.widget.heatmap_widget_grid[0][0] - image = heatmap_widget.heatmap_item.pixmap().toImage() - colors = image_row_colors(image) - np.testing.assert_almost_equal(colors, desired) + 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() + colors = image_row_colors(image) + np.testing.assert_almost_equal(colors, desired) + + def test_palette_center(self): + widget = self.widget + model = widget.color_cb.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) + self.assertEqual(widget.center_palette, + bool(palette.flags & palette.Diverging)) def test_migrate_settings_v3(self): w = self.create_widget( diff --git a/Orange/widgets/visualize/tests/test_owscatterplot.py b/Orange/widgets/visualize/tests/test_owscatterplot.py index 5cecac77da5..c494e71786e 100644 --- a/Orange/widgets/visualize/tests/test_owscatterplot.py +++ b/Orange/widgets/visualize/tests/test_owscatterplot.py @@ -13,7 +13,7 @@ WidgetTest, WidgetOutputsTestMixin, datasets, ProjectionWidgetTestMixin ) from Orange.widgets.tests.utils import simulate -from Orange.widgets.utils.colorpalette import DefaultRGBColors +from Orange.widgets.utils.colorpalettes import DefaultRGBColors from Orange.widgets.visualize.owscatterplot import ( OWScatterPlot, ScatterPlotVizRank, OWScatterPlotGraph) from Orange.widgets.visualize.utils.widget import MAX_COLORS @@ -835,13 +835,13 @@ def test_regression_line_coeffs(self): self.assertEqual(line1.pos().x(), 0) self.assertEqual(line1.pos().y(), 0) self.assertEqual(line1.angle, 45) - self.assertEqual(line1.pen.color().getRgb()[:3], graph.palette[0]) + self.assertEqual(line1.pen.color().getRgb(), graph.palette[0].getRgb()) line2 = graph.reg_line_items[2] self.assertEqual(line2.pos().x(), 0) self.assertEqual(line2.pos().y(), 1) self.assertAlmostEqual(line2.angle, np.degrees(np.arctan2(2, 1))) - self.assertEqual(line2.pen.color().getRgb()[:3], graph.palette[1]) + self.assertEqual(line2.pen.color().getRgb(), graph.palette[1].getRgb()) graph.orthonormal_regression = True graph.update_regression_line() @@ -850,13 +850,13 @@ def test_regression_line_coeffs(self): self.assertEqual(line1.pos().x(), 0) self.assertAlmostEqual(line1.pos().y(), -0.6180339887498949) self.assertEqual(line1.angle, 58.28252558853899) - self.assertEqual(line1.pen.color().getRgb()[:3], graph.palette[0]) + self.assertEqual(line1.pen.color().getRgb(), graph.palette[0].getRgb()) line2 = graph.reg_line_items[2] self.assertEqual(line2.pos().x(), 0) self.assertEqual(line2.pos().y(), 1) self.assertAlmostEqual(line2.angle, np.degrees(np.arctan2(2, 1))) - self.assertEqual(line2.pen.color().getRgb()[:3], graph.palette[1]) + self.assertEqual(line2.pen.color().getRgb(), graph.palette[1].getRgb()) def test_orthonormal_line(self): color = QColor(1, 2, 3) diff --git a/Orange/widgets/visualize/tests/test_owscatterplotbase.py b/Orange/widgets/visualize/tests/test_owscatterplotbase.py index 080f83a71df..8b9391a9b7b 100644 --- a/Orange/widgets/visualize/tests/test_owscatterplotbase.py +++ b/Orange/widgets/visualize/tests/test_owscatterplotbase.py @@ -11,8 +11,7 @@ from Orange.widgets.settings import SettingProvider from Orange.widgets.tests.base import WidgetTest -from Orange.widgets.utils.colorpalette import ColorPaletteGenerator, \ - ContinuousPaletteGenerator, NAN_GREY +from Orange.widgets.utils import colorpalettes from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase, \ ScatterPlotItem, SELECTION_WIDTH from Orange.widgets.widget import OWWidget @@ -41,9 +40,9 @@ class MockWidget(OWWidget): def get_palette(self): if self.is_continuous_color(): - return ContinuousPaletteGenerator(Qt.white, Qt.black, False) + return colorpalettes.DefaultContinuousPalette else: - return ColorPaletteGenerator(12) + return colorpalettes.DefaultDiscretePalette class TestOWScatterPlotBase(WidgetTest): @@ -566,7 +565,7 @@ def test_colors_continuous_nan(self): graph.reset_graph() pens = graph.scatterplot_item.data["pen"] brushes = graph.scatterplot_item.data["brush"] - nan_color = QColor(*NAN_GREY) + nan_color = QColor(*colorpalettes.NAN_COLOR) self.assertEqual(pens[4].color().hue(), nan_color.hue()) self.assertEqual(brushes[4].color().hue(), nan_color.hue()) @@ -1077,6 +1076,7 @@ def test_show_legend(self): for color_labels in (None, ["c", "d"], None): for visible in (True, False, True): graph.show_legend = visible + graph.palette = graph.master.get_palette() graph.update_legends() self.assertIs( shape_legend.call_args[0][0], diff --git a/Orange/widgets/visualize/utils/__init__.py b/Orange/widgets/visualize/utils/__init__.py index 2488392909d..e703527c486 100644 --- a/Orange/widgets/visualize/utils/__init__.py +++ b/Orange/widgets/visualize/utils/__init__.py @@ -676,7 +676,7 @@ def __init__(self, scene, x=0, y=0, width=0, height=0, onclick=None): super().__init__(x, y, width, height, None) self.onclick = onclick - if brush_color: + if brush_color is not None: self.setBrush(QBrush(brush_color)) if pen: self.setPen(pen) diff --git a/Orange/widgets/visualize/utils/owlegend.py b/Orange/widgets/visualize/utils/owlegend.py index a9c8584733c..0e27c2e410b 100644 --- a/Orange/widgets/visualize/utils/owlegend.py +++ b/Orange/widgets/visualize/utils/owlegend.py @@ -8,6 +8,8 @@ from AnyQt.QtGui import QColor, QBrush, QPen, QLinearGradient, QFont from AnyQt.QtCore import Qt, QPointF, QSizeF, QPoint, QSize, QRect +from Orange.widgets.utils.colorpalettes import ContinuousPalette + class Anchorable(QGraphicsWidget): """Anchorable base class. @@ -395,6 +397,18 @@ def sizeHint(self, size_hint, size_constraint=None, *args, **kwargs): return self._size_hint +class ColorStripItem(QGraphicsWidget): + def __init__(self, palette, parent, orientation): + super().__init__(parent) + self.__strip = palette.color_strip(150, 13, orientation) + + def paint(self, painter, option, widget): + painter.drawPixmap(0, 0, self.__strip) + + def sizeHint(self, *_): + return QSizeF(self.__strip.width(), self.__strip.height()) + + class ContinuousLegendItem(QGraphicsLinearLayout): """Continuous legend item. @@ -424,7 +438,10 @@ def __init__(self, palette, values, parent, font=None, self.__palette = palette self.__values = values - self.__gradient = LegendGradient(palette, parent, orientation) + if isinstance(palette, ContinuousPalette): + self.__gradient = ColorStripItem(palette, parent, orientation) + else: + self.__gradient = LegendGradient(palette, parent, orientation) self.__labels_layout = QGraphicsLinearLayout(orientation) str_vals = self._format_values(values) @@ -563,7 +580,8 @@ def set_items(self, values): def _convert_to_color(obj): if isinstance(obj, QColor): return obj - elif isinstance(obj, tuple) or isinstance(obj, list): + elif isinstance(obj, tuple) or isinstance(obj, list) \ + or isinstance(obj, np.ndarray): assert len(obj) in (3, 4) return QColor(*obj) else: @@ -601,7 +619,7 @@ def set_domain(self, domain): raise AttributeError('[OWDiscreteLegend] The class var provided ' 'was not discrete.') - self.set_items(zip(class_var.values, class_var.colors.tolist())) + self.set_items(zip(class_var.values, list(class_var.colors))) def set_items(self, values): for class_name, color in values: @@ -643,28 +661,14 @@ def set_domain(self, domain): # The first and last values must represent the range, the rest should # be dummy variables, as they are not shown anywhere values = self.__range - - start, end, pass_through_black = class_var.colors - # If pass through black, push black in between and add index to vals - if pass_through_black: - colors = [self._convert_to_color(c) for c - in [start, '#000000', end]] - values.insert(1, -1) - else: - colors = [self._convert_to_color(c) for c in [start, end]] - - self.set_items(list(zip(values, colors))) + self.set_items((values, class_var.palette)) def set_items(self, values): - vals, colors = list(zip(*values)) - - # If the orientation is vertical, it makes more sense for the smaller - # value to be shown on the bottom - if self.orientation == Qt.Vertical and vals[0] < vals[len(vals) - 1]: - colors, vals = list(reversed(colors)), list(reversed(vals)) - + vals, palette = values + if self.orientation == Qt.Vertical: + vals = list(reversed(vals)) self._layout.addItem(ContinuousLegendItem( - palette=colors, + palette=palette, values=vals, parent=self, font=self.font, diff --git a/Orange/widgets/visualize/utils/widget.py b/Orange/widgets/visualize/utils/widget.py index cdab4dc9761..6b4b71b4caa 100644 --- a/Orange/widgets/visualize/utils/widget.py +++ b/Orange/widgets/visualize/utils/widget.py @@ -19,9 +19,6 @@ from Orange.widgets.utils.annotated_data import ( create_annotated_table, ANNOTATED_DATA_SIGNAL_NAME, create_groups_table ) -from Orange.widgets.utils.colorpalette import ( - ColorPaletteGenerator, ContinuousPaletteGenerator, DefaultRGBColors -) from Orange.widgets.utils.plot import OWPlotGUI from Orange.widgets.utils.sql import check_sql_input from Orange.widgets.visualize.owscatterplotgraph import OWScatterPlotBase @@ -224,16 +221,7 @@ def get_palette(self): This method must be overridden if the widget offers coloring that is not based on attribute values. """ - if self.attr_color is None: - return None - colors = self.attr_color.colors - if self.attr_color.is_discrete: - return ColorPaletteGenerator( - number_of_colors=min(len(colors), MAX_COLORS), - rgb_colors=colors if len(colors) <= MAX_COLORS - else DefaultRGBColors) - else: - return ContinuousPaletteGenerator(*colors) + return self.attr_color and self.attr_color.palette def can_draw_density(self): """ diff --git a/doc/visual-programming/source/widgets/data/color.md b/doc/visual-programming/source/widgets/data/color.md index 4456a3f9f47..c6b21ee677a 100644 --- a/doc/visual-programming/source/widgets/data/color.md +++ b/doc/visual-programming/source/widgets/data/color.md @@ -11,49 +11,36 @@ Set color legend for variables. - Data: data set with a new color legend -The **Color** widget enables you to set the color legend in your visualizations according to your own preferences. This option provides you with the tools for emphasizing your results and offers a great variety of color options for presenting your data. It can be combined with most visualizations widgets. +The **Color** widget sets the color legend for visualizations. ![](images/Color-stamped.png) -1. A list of discrete variables. You can set the color of each variable by double-clicking on it and opening the *Color palette* or the *Select color* window. The widget also enables text-editing. By clicking on a variable, you can change its name. -2. A list of continuous variables. You can customize the color gradients by double-clicking on them. The widget also enables text-editing. By clicking on a variable, you can change its name. If you hover over the right side side of the gradient, *Copy to all* appears. You can then apply your customized color gradient to all variables. +1. A list of discrete variables. Set the color of each variable by double-clicking on it. The widget also enables renaming variables by clicking on their names. +2. A list of continuous variables. Click on the color strip to choose a different palette. To use the same palette for all variables, change it for one variable and click *Copy to all* that appears on the right. The widget also enables renaming variables by clicking on their names. 3. Produce a report. 4. Apply changes. If *Apply automatically* is ticked, changes will be communicated automatically. Alternatively, just click *Apply*. -Discrete variables ------------------- +![](images/Color-Continuous_unindexed.png) -![](images/Color-palette-discrete-stamped.png) +Palettes for numeric variables are grouped and tagged by their properties. -1. Choose a desired color from the palette of basic colors. -2. Move the cursor to choose a custom color from the color palette. -3. Choose a custom color from your previously saved color choices. -4. Specify the custom color by: - - entering the red, green, and blue components of the color as values between 0 (darkest) and 255 (brightest) - - entering the hue, saturation and luminescence components of the color as values in the range 0 to 255 -5. Add the created color to your custom colors. -6. Click *OK* to save your choices or *Cancel* to exit the the color palette. +- Diverging palettes have two colors on its ends and a central color (white or black) in the middle. Such palettes are particularly useful when the the values can be positive or negative, as some widgets (for instance the Heat map) will put the 0 at the middle point in the palette. -Numeric variables ------------------ +- Linear palettes are constructed so that human perception of the color change is linear with the change of the value. -![](images/Color-palette-numeric-stamped.png) +- Color blind palettes cover different types of color blindness, and can also be linear or diverging. -1. Choose a gradient from your saved profiles. The default profile is already set. -2. The gradient palette -3. Select the left side of the gradient. Double clicking the color opens the *Select Color* window. -4. Select the right side of the gradient. Double clicking the color opens the *Select Color* window. -5. Pass through black. -6. Click *OK* to save your choices or *Cancel* to exit the color palette. +- In isoluminant palettes, all colors have equal brightness. + +- Rainbow palettes are particularly nice in widgets that bin numeric values in visualizations. Example ------- -We chose to work with the *Iris* data set. We opened the color palette and selected three new colors for the three types of Irises. Then we opened the [Scatter Plot](../visualize/scatterplot.md) widget and viewed the changes made to the scatter plot. +We chose to work with the *heart_disease* data set. We opened the color palette and selected two new colors for diameter narrowing variable. Then we opened the [Scatter Plot](../visualize/scatterplot.md) widget and viewed the changes made to the scatter plot. -![](images/Color-Example-1.png) +![](images/Color-Example-Discrete.png) -For our second example, we wished to demonstrate the use of the **Color** widget with continuous variables. We put different types of Irises on the x axis and petal length on the y axis. We created a new color gradient and named it greed (green + red). -In order to show that sepal length is not a deciding factor in differentiating between different types of Irises, we chose to color the points according to sepal width. +To see the effect of color palettes for numeric variables, we color the points in the scatter plot by cholesterol and change the palette for this attribute in the Color widget. -![](images/Color-Example-2.png) +![](images/Color-Example-Continuous.png) diff --git a/doc/visual-programming/source/widgets/data/images/Color-Continuous_unindexed.png b/doc/visual-programming/source/widgets/data/images/Color-Continuous_unindexed.png new file mode 100644 index 00000000000..21dc6fa04d9 Binary files /dev/null and b/doc/visual-programming/source/widgets/data/images/Color-Continuous_unindexed.png differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-Example-1.png b/doc/visual-programming/source/widgets/data/images/Color-Example-1.png deleted file mode 100644 index 42291a10258..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-Example-1.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-Example-2.png b/doc/visual-programming/source/widgets/data/images/Color-Example-2.png deleted file mode 100644 index 548ab87aa05..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-Example-2.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-Example-Continuous.png b/doc/visual-programming/source/widgets/data/images/Color-Example-Continuous.png new file mode 100644 index 00000000000..d4aeec31ef8 Binary files /dev/null and b/doc/visual-programming/source/widgets/data/images/Color-Example-Continuous.png differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-Example-Discrete.png b/doc/visual-programming/source/widgets/data/images/Color-Example-Discrete.png new file mode 100644 index 00000000000..a68a69db838 Binary files /dev/null and b/doc/visual-programming/source/widgets/data/images/Color-Example-Discrete.png differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-palette-discrete-stamped.png b/doc/visual-programming/source/widgets/data/images/Color-palette-discrete-stamped.png deleted file mode 100644 index 5c94f6740d2..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-palette-discrete-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-palette-discrete.png b/doc/visual-programming/source/widgets/data/images/Color-palette-discrete.png deleted file mode 100644 index dc86629d816..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-palette-discrete.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-palette-numeric-stamped.png b/doc/visual-programming/source/widgets/data/images/Color-palette-numeric-stamped.png deleted file mode 100644 index 2733a437d8c..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-palette-numeric-stamped.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-palette-numeric.png b/doc/visual-programming/source/widgets/data/images/Color-palette-numeric.png deleted file mode 100644 index 6d8ab85bfe2..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color-palette-numeric.png and /dev/null differ diff --git a/doc/visual-programming/source/widgets/data/images/Color-stamped.png b/doc/visual-programming/source/widgets/data/images/Color-stamped.png index 1c0cd53abd1..295c8be7b9e 100644 Binary files a/doc/visual-programming/source/widgets/data/images/Color-stamped.png and b/doc/visual-programming/source/widgets/data/images/Color-stamped.png differ diff --git a/doc/visual-programming/source/widgets/data/images/Color.png b/doc/visual-programming/source/widgets/data/images/Color.png deleted file mode 100644 index e302e1a8874..00000000000 Binary files a/doc/visual-programming/source/widgets/data/images/Color.png and /dev/null differ