Repository: biolab/orange3

Issue:
Use palettes for >20 colors
@lanzagar commented that more frequent values should have more distinct palettes. This led to the idea that palettes generated by going through hues may not be a good idea and that we could instead use one of categorical palettes from https://colorcet.holoviz.org/ -- one that uses the whole spectra, not just, say, "cool" colors.

So the idea is to 

- [ ] The chosen categorical colorcet palette would be provided as one of `DiscretePalettes` (beside the current `default` and `dark`).
- [ ] `LimitedDiscretePalette` would no longer fallback to hues but instead use the first `n` colors from that colorcet palette. 
- [ ] whereas the current code that generates a hue palette,

    ```python
                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])
    ```

    would remain available in a separate class `HuePalette` to whomever who might actually want it.


Discussion:
These look super weird, but perhaps one just has to see them in practice. :)

Resolving patch:
diff --git a/Orange/widgets/utils/colorpalettes.py b/Orange/widgets/utils/colorpalettes.py
--- a/Orange/widgets/utils/colorpalettes.py
+++ b/Orange/widgets/utils/colorpalettes.py
@@ -14,8 +14,8 @@
 
 __all__ = ["Palette", "IndexedPalette",
            "DiscretePalette", "LimitedDiscretePalette", "DiscretePalettes",
-           "DefaultDiscretePalette", "DefaultDiscretePaletteName",
-           "DefaultRGBColors", "Dark2Colors",
+           "DefaultDiscretePalette", "DefaultDiscretePaletteName", "HuePalette",
+           "DefaultRGBColors", "Dark2Colors", "Glasbey",
            "ContinuousPalette", "ContinuousPalettes", "BinnedContinuousPalette",
            "DefaultContinuousPalette", "DefaultContinuousPaletteName",
            "ColorIcon", "get_default_curve_colors", "patch_variable_colors",
@@ -164,18 +164,35 @@ 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.
+    If the number of requested colors is small enough, they are taken from
+    DefaultRGBColors (the default discrete palette), otherwise from Glasbey.
     """
     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]
+                 *, category=None, flags=Palette.Discrete,
+                 force_glasbey=False, force_hsv=None):
+        if force_hsv is not None:
+            warnings.warn(
+                "LimitedDiscretePalette: argument 'force_hsv' is deprecated; "
+                "use 'force_glasbey' instead", DeprecationWarning)
+        if force_glasbey or force_hsv \
+                or number_of_colors > len(DefaultRGBColors):
+            palette = Glasbey
         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])
+            palette = DefaultRGBColors
+        super().__init__("custom", "custom",
+                         palette.palette[:number_of_colors], nan_color,
+                         category=category, flags=flags)
+
+
+class HuePalette(DiscretePalette):
+    """
+    A palette derived from DiscretePalette that generates the given number of
+    color by making a circle theough the HSV space.
+    """
+    def __init__(self, number_of_colors, nan_color=NAN_COLOR,
+                 *, category=None, flags=Palette.Discrete):
+        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)
 
@@ -463,9 +480,16 @@ def copy(self):
     [27, 158, 119], [217, 95, 2], [117, 112, 179], [231, 41, 138],
     [102, 166, 30], [230, 171, 2], [166, 118, 29], [102, 102, 102]])
 
+# pylint: disable=line-too-long
+Glasbey = DiscretePalette(
+    "Glasbey", "Glasbey",
+    [[215, 0, 0], [140, 60, 255], [2, 136, 0], [0, 172, 199], [152, 255, 0], [255, 127, 209], [108, 0, 79], [255, 165, 48], [88, 59, 0], [0, 87, 89], [0, 0, 221], [0, 253, 207], [161, 117, 106], [188, 183, 255], [149, 181, 120], [192, 4, 185], [100, 84, 116], [121, 0, 0], [7, 116, 216], [254, 245, 144], [0, 75, 0], [143, 122, 0], [255, 114, 102], [238, 185, 185], [94, 126, 102], [155, 228, 255], [236, 0, 119], [166, 123, 185], [90, 0, 164], [4, 198, 0], [158, 75, 0], [156, 59, 80], [203, 196, 0], [113, 130, 152], [0, 175, 138], [131, 136, 255], [93, 55, 59], [57, 0, 0], [253, 192, 255], [190, 231, 192], [219, 109, 1], [147, 184, 182], [228, 82, 255], [47, 83, 130], [196, 102, 144], [85, 98, 32], [197, 159, 114], [4, 130, 135], [105, 231, 128], [128, 39, 144], [109, 180, 255], [78, 51, 255], [134, 163, 2], [254, 3, 203], [194, 166, 197], [197, 87, 70], [118, 88, 61], [1, 104, 66], [0, 214, 213], [218, 224, 255], [249, 255, 0], [106, 104, 176], [195, 152, 0], [225, 205, 156], [218, 150, 255], [187, 3, 253], [146, 82, 130], [160, 0, 115], [87, 155, 85], [211, 140, 143], [55, 69, 39], [151, 165, 195], [142, 141, 95], [255, 70, 0], [200, 255, 250], [174, 109, 255], [110, 208, 167], [191, 255, 140], [140, 84, 177], [120, 54, 25], [255, 160, 121], [169, 0, 31], [255, 28, 69], [95, 17, 35], [103, 151, 148], [255, 95, 148], [76, 103, 116], [83, 146, 204], [170, 113, 49], [2, 207, 254], [0, 196, 108], [97, 53, 93], [144, 212, 47], [191, 213, 124], [81, 69, 162], [78, 35, 12], [124, 90, 0], [255, 206, 68], [131, 2, 207], [77, 253, 255], [137, 0, 61], [123, 82, 92], [0, 116, 157], [170, 131, 152], [129, 113, 143], [98, 101, 254], [195, 52, 137], [205, 41, 71], [255, 154, 181], [195, 93, 187], [33, 104, 2], [0, 142, 101], [99, 128, 35], [137, 135, 191], [152, 221, 213], [206, 127, 88], [210, 183, 91], [97, 0, 110], [153, 84, 68], [176, 199, 219], [242, 255, 210], [0, 236, 2], [205, 134, 189], [69, 0, 197], [122, 157, 127], [114, 113, 71], [147, 255, 186], [0, 84, 193], [173, 148, 236], [63, 164, 22], [95, 58, 128], [0, 76, 51], [124, 184, 211], [152, 42, 0], [57, 110, 100], [184, 0, 91], [255, 128, 61], [255, 210, 233], [128, 48, 90], [33, 52, 0], [161, 94, 111], [79, 181, 176], [159, 159, 70], [51, 124, 61], [194, 65, 0], [199, 232, 62], [108, 5, 232], [118, 188, 80], [165, 197, 169], [218, 84, 110], [216, 143, 56], [251, 124, 255], [75, 100, 73], [214, 195, 235], [122, 46, 54], [76, 143, 165], [70, 136, 255], [163, 0, 196], [234, 163, 213], [255, 188, 120], [71, 72, 0], [162, 199, 255], [145, 162, 234], [79, 105, 147], [231, 94, 178], [159, 145, 176], [88, 81, 43], [176, 94, 212], [134, 109, 225], [193, 110, 114], [228, 0, 227], [185, 183, 139], [57, 46, 0], [227, 126, 164], [173, 59, 48], [169, 187, 76], [105, 181, 131], [148, 210, 144], [176, 141, 70], [7, 95, 120], [0, 152, 137], [90, 15, 2], [91, 125, 128], [48, 88, 38], [229, 101, 59], [95, 64, 41], [115, 74, 188], [75, 83, 107], [201, 122, 221], [156, 50, 145], [200, 230, 242], [5, 171, 235], [167, 107, 154], [231, 176, 0], [96, 255, 99], [242, 222, 0], [119, 68, 1], [96, 37, 65], [104, 127, 203], [121, 159, 176], [13, 233, 162], [157, 248, 219], [132, 0, 118], [142, 109, 73], [227, 65, 48], [185, 73, 108], [122, 74, 134], [255, 208, 182], [76, 93, 199], [226, 179, 146], [255, 76, 238], [214, 240, 165], [191, 96, 38], [215, 163, 181], [189, 125, 0], [136, 111, 178], [255, 48, 161], [255, 233, 176], [51, 88, 76], [184, 140, 122], [108, 135, 82], [188, 148, 210], [27, 230, 254], [161, 60, 114], [163, 81, 168], [109, 0, 152], [137, 101, 123], [90, 88, 139], [249, 143, 139], [231, 215, 124], [112, 108, 1], [25, 89, 255], [23, 38, 255], [0, 216, 86], [248, 161, 253], [248, 161, 253], [177, 167, 213], [177, 167, 213], [177, 167, 213], [122, 71, 60], [218, 255, 230], [219, 5, 177], [243, 221, 255], [163, 228, 111], [138, 19, 35], [102, 104, 131], [232, 253, 112], [216, 171, 232], [224, 186, 213], [254, 83, 105], [117, 174, 155], [117, 174, 155], [117, 174, 155], [140, 89, 38], [119, 71, 105], [119, 71, 105]]
+)
+
 DiscretePalettes = {
     "default": DefaultRGBColors,
-    "dark": Dark2Colors
+    "dark": Dark2Colors,
+    "glasbey": Glasbey
 }
 
 DefaultDiscretePaletteName = "default"


Test patch:
diff --git a/Orange/widgets/utils/tests/test_colorpalettes.py b/Orange/widgets/utils/tests/test_colorpalettes.py
--- a/Orange/widgets/utils/tests/test_colorpalettes.py
+++ b/Orange/widgets/utils/tests/test_colorpalettes.py
@@ -143,29 +143,42 @@ def test_default(self):
 
 
 class LimitedDiscretePaletteTest(unittest.TestCase):
-    def test_small_palettes(self):
+    @staticmethod
+    def test_small_palettes():
+        defcols = len(DefaultRGBColors.palette)
+
         palette = LimitedDiscretePalette(3)
         np.testing.assert_equal(palette.palette, DefaultRGBColors.palette[:3])
 
-        palette = LimitedDiscretePalette(len(DefaultRGBColors.palette))
+        palette = LimitedDiscretePalette(defcols)
         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(defcols + 1)
+        np.testing.assert_equal(palette.palette, Glasbey.palette[:defcols + 1])
 
         palette = LimitedDiscretePalette(100)
-        self.assertEqual(len({tuple(col) for col in palette.palette}), 100)
+        np.testing.assert_equal(palette.palette, Glasbey.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])))
+    @staticmethod
+    def test_forced_glasbey_palettes():
+        palette = LimitedDiscretePalette(5, force_glasbey=True)
+        np.testing.assert_equal(palette.palette, Glasbey.palette[:5])
+
+    def test_deprecate_force_hsv_palettes(self):
+        with self.assertWarns(DeprecationWarning):
+            palette = LimitedDiscretePalette(3, force_hsv=False)
+            np.testing.assert_equal(palette.palette,
+                                    DefaultRGBColors.palette[:3])
+
+        with self.assertWarns(DeprecationWarning):
+            palette = LimitedDiscretePalette(5, force_hsv=True)
+            np.testing.assert_equal(palette.palette, Glasbey.palette[:5])
+
+
+class HuePaletteTest(unittest.TestCase):
+    def test_n_colors(self):
+        palette = HuePalette(42)
+        self.assertEqual(len(palette), 42)
 
 
 class ContinuousPaletteTest(GuiTest):
@@ -650,7 +663,8 @@ def test_get_palette(self):
         palette = var.palette
         np.testing.assert_equal(palette.palette, [[10, 11, 12], [13, 14, 15]])
 
-    def test_ignore_malfformed_atrtibutes(self):
+    @staticmethod
+    def test_ignore_malfformed_atrtibutes():
         var = DiscreteVariable("a", values=("M", "F"))
         var.attributes["colors"] = {"F": "foo", "M": "bar"}
         palette = var.palette