Repository: napari/napari

Issue:
Shuffle colors doesn't mix labels in auto mode anymore
### 🐛 Bug Report

Because we modulo the labels by the number of colors in auto mode since #6179, hitting the shuffle button doesn't actually mix up the labels anymore: labels that are `num_colors` apart end up being the same color no matter how many times we hit shuffle.

### 💡 Steps to Reproduce

```python
import numpy as np
import napari

period = 49

labels = np.arange(1, 1 + 3 * period).reshape(3, period)
viewer = napari.Viewer()
layer = viewer.add_labels(labels)

napari.run()
```

Then hit shuffle a few times.

Before #6179, (29287c50e), you get:

<img width="1509" alt="Screenshot 2023-11-14 at 8 48 33 pm" src="https://github.com/napari/napari/assets/492549/4d44612f-27b3-4005-b0b2-a316d8742882">

If you find two markers that have the same color, if you hit shuffle, *often* they will then have different colors. (This is not always the case, but that's expected.)

After #6179 (2f6c70737), you get:

<img width="1509" alt="Screenshot 2023-11-14 at 8 48 53 pm" src="https://github.com/napari/napari/assets/492549/141cbb12-d0cb-4f2c-bae2-1940f34bb2ed">

It doesn't matter how many times you hit shuffle, you will always vertical lines.

### 💡 Expected Behavior

Shuffle should result, with high probability, in labels that have the same color then having different color.

### 🌎 Environment

(I've omitted the plugins since this is unrelated to any plugin.)

```
napari: 0.5.0a2.dev472+ge6cf9239d
Platform: macOS-13.5-arm64-arm-64bit
System: MacOS 13.5
Python: 3.10.8 | packaged by conda-forge | (main, Nov 22 2022, 08:25:13) [Clang 14.0.6 ]
Qt: 5.15.4
PyQt5: 5.15.7
NumPy: 1.24.4
SciPy: 1.9.0
Dask: 2023.10.1
VisPy: 0.12.2
magicgui: 0.7.3.dev15+geff4e27
superqt: 0.5.4
in-n-out: 0.1.8
app-model: 0.2.0
npe2: 0.7.2

OpenGL:
  - GL version:  2.1 Metal - 83.1
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 1800x1169, scale 2.0

Settings path:
  - /Users/jni/Library/Application Support/napari/all_f332943dd4a538ce2ff3134fd0ff74396d8d8b23/settings.yaml
Plugins:
  - napari: 0.5.0a2.dev188+gefa55a5bd (77 contributions)
```


### 💡 Additional Context

_No response_
Shuffle colors doesn't mix labels in auto mode anymore
### 🐛 Bug Report

Because we modulo the labels by the number of colors in auto mode since #6179, hitting the shuffle button doesn't actually mix up the labels anymore: labels that are `num_colors` apart end up being the same color no matter how many times we hit shuffle.

### 💡 Steps to Reproduce

```python
import numpy as np
import napari

period = 49

labels = np.arange(1, 1 + 3 * period).reshape(3, period)
viewer = napari.Viewer()
layer = viewer.add_labels(labels)

napari.run()
```

Then hit shuffle a few times.

Before #6179, (29287c50e), you get:

<img width="1509" alt="Screenshot 2023-11-14 at 8 48 33 pm" src="https://github.com/napari/napari/assets/492549/4d44612f-27b3-4005-b0b2-a316d8742882">

If you find two markers that have the same color, if you hit shuffle, *often* they will then have different colors. (This is not always the case, but that's expected.)

After #6179 (2f6c70737), you get:

<img width="1509" alt="Screenshot 2023-11-14 at 8 48 53 pm" src="https://github.com/napari/napari/assets/492549/141cbb12-d0cb-4f2c-bae2-1940f34bb2ed">

It doesn't matter how many times you hit shuffle, you will always vertical lines.

### 💡 Expected Behavior

Shuffle should result, with high probability, in labels that have the same color then having different color.

### 🌎 Environment

(I've omitted the plugins since this is unrelated to any plugin.)

```
napari: 0.5.0a2.dev472+ge6cf9239d
Platform: macOS-13.5-arm64-arm-64bit
System: MacOS 13.5
Python: 3.10.8 | packaged by conda-forge | (main, Nov 22 2022, 08:25:13) [Clang 14.0.6 ]
Qt: 5.15.4
PyQt5: 5.15.7
NumPy: 1.24.4
SciPy: 1.9.0
Dask: 2023.10.1
VisPy: 0.12.2
magicgui: 0.7.3.dev15+geff4e27
superqt: 0.5.4
in-n-out: 0.1.8
app-model: 0.2.0
npe2: 0.7.2

OpenGL:
  - GL version:  2.1 Metal - 83.1
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 1800x1169, scale 2.0

Settings path:
  - /Users/jni/Library/Application Support/napari/all_f332943dd4a538ce2ff3134fd0ff74396d8d8b23/settings.yaml
Plugins:
  - napari: 0.5.0a2.dev188+gefa55a5bd (77 contributions)
```


### 💡 Additional Context

_No response_


Discussion:
> If you find two markers that have the same color, if you hit shuffle, _often_ they will then have different colors. (This is not always the case, but that's expected.)

Yes. These is expected changes of fixing bugs that lead to showing different color in the color picker and on the image. As shuffle just permutate this 49 colors. 
@Czaki that was not the point of the fix: the point of the fix was to ensure that the colors match between the picker and the image. That does not imply that different labels should have the same color. Of course, some bug fixes introduce other bugs, this is not a new problem in software engineering. 😂 

The number one job of the labels layer is to show different labels in a segmentation differently. Obviously, there are often more labels than there are perceptually-different colors, so it's not always possible, *but* the purpose of the shuffle button is to change the colors in such a way that labels that were indistinguishable are separated after a shuffle with high probability — so you can quickly tell, for example, if two cells that look the same are actually a false-merge/undersegmentation error or just a case of an unlucky color seed.
> If you find two markers that have the same color, if you hit shuffle, _often_ they will then have different colors. (This is not always the case, but that's expected.)

Yes. These is expected changes of fixing bugs that lead to showing different color in the color picker and on the image. As shuffle just permutate this 49 colors. 
@Czaki that was not the point of the fix: the point of the fix was to ensure that the colors match between the picker and the image. That does not imply that different labels should have the same color. Of course, some bug fixes introduce other bugs, this is not a new problem in software engineering. 😂 

The number one job of the labels layer is to show different labels in a segmentation differently. Obviously, there are often more labels than there are perceptually-different colors, so it's not always possible, *but* the purpose of the shuffle button is to change the colors in such a way that labels that were indistinguishable are separated after a shuffle with high probability — so you can quickly tell, for example, if two cells that look the same are actually a false-merge/undersegmentation error or just a case of an unlucky color seed.

Resolving patch:
diff --git a/napari/layers/labels/labels.py b/napari/layers/labels/labels.py
--- a/napari/layers/labels/labels.py
+++ b/napari/layers/labels/labels.py
@@ -58,6 +58,7 @@
     _cast_labels_data_to_texture_dtype_direct,
     _texture_dtype,
 )
+from napari.utils.colormaps.colormap_utils import shuffle_and_extend_colormap
 from napari.utils.events import EmitterGroup, Event
 from napari.utils.events.custom_types import Array
 from napari.utils.geometry import clamp_point_to_bounding_box
@@ -311,6 +312,7 @@ def __init__(
         self._random_colormap = label_colormap(
             self.num_colors, self.seed, self._background_label
         )
+        self._original_random_colormap = self._random_colormap
         self._direct_colormap = direct_colormap()
         self._color_mode = LabelColorMode.AUTO
         self._show_selected_label = False
@@ -511,7 +513,9 @@ def seed_rng(self, seed_rng: Optional[int]) -> None:
                 self.num_colors, self.seed, self._background_label
             )
         else:
-            self.colormap.shuffle(self._seed_rng)
+            self._random_colormap = shuffle_and_extend_colormap(
+                self._original_random_colormap, self._seed_rng
+            )
         self._cached_labels = None  # invalidate the cached color mapping
         self._selected_color = self.get_color(self.selected_label)
         self.events.colormap()  # Will update the LabelVispyColormap shader
@@ -532,6 +536,7 @@ def colormap(self, colormap: LabelColormapBase):
     def _set_colormap(self, colormap):
         if isinstance(colormap, LabelColormap):
             self._random_colormap = colormap
+            self._original_random_colormap = colormap
             self._colormap = self._random_colormap
             color_mode = LabelColorMode.AUTO
         else:
diff --git a/napari/utils/colormaps/colormap_utils.py b/napari/utils/colormaps/colormap_utils.py
--- a/napari/utils/colormaps/colormap_utils.py
+++ b/napari/utils/colormaps/colormap_utils.py
@@ -1,5 +1,6 @@
 import warnings
 from collections import OrderedDict
+from functools import lru_cache
 from threading import Lock
 from typing import Dict, Iterable, List, Optional, Tuple, Union
 
@@ -20,6 +21,7 @@
     ColormapInterpolationMode,
     DirectLabelColormap,
     LabelColormap,
+    minimum_dtype_for_labels,
 )
 from napari.utils.colormaps.inverse_colormaps import inverse_cmaps
 from napari.utils.colormaps.standardize_color import transform_color
@@ -472,6 +474,93 @@ def label_colormap(
     )
 
 
+@lru_cache
+def _primes(upto=2**16):
+    """Generate primes up to a given number.
+
+    Parameters
+    ----------
+    upto : int
+        The upper limit of the primes to generate.
+
+    Returns
+    -------
+    primes : np.ndarray
+        The primes up to the upper limit.
+    """
+    primes = np.arange(3, upto + 1, 2)
+    isprime = np.ones((upto - 1) // 2, dtype=bool)
+    max_factor = int(np.sqrt(upto))
+    for factor in primes[: max_factor // 2]:
+        if isprime[(factor - 2) // 2]:
+            isprime[(factor * 3 - 2) // 2 : None : factor] = 0
+    return np.concatenate(([2], primes[isprime]))
+
+
+def shuffle_and_extend_colormap(
+    colormap: LabelColormap, seed: int, min_random_choices: int = 5
+) -> LabelColormap:
+    """Shuffle the colormap colors and extend it to more colors.
+
+    The new number of colors will be a prime number that fits into the same
+    dtype as the current number of colors in the colormap.
+
+    Parameters
+    ----------
+    colormap : napari.utils.LabelColormap
+        Colormap to shuffle and extend.
+    seed : int
+        Seed for the random number generator.
+    min_random_choices : int
+        Minimum number of new table sizes to choose from. When choosing table
+        sizes, every choice gives a 1/size chance of two labels being mapped
+        to the same color. Since we try to stay within the same dtype as the
+        original colormap, if the number of original colors is close to the
+        maximum value for the dtype, there will not be enough prime numbers
+        up to the dtype max to ensure that two labels can always be
+        distinguished after one or few shuffles. In that case, we discard
+        some colors and choose the `min_random_choices` largest primes to fit
+        within the dtype.
+
+    Returns
+    -------
+    colormap : napari.utils.LabelColormap
+        Shuffled and extended colormap.
+    """
+    rng = np.random.default_rng(seed)
+    n_colors_prev = len(colormap.colors)
+    dtype = minimum_dtype_for_labels(n_colors_prev)
+    indices = np.arange(n_colors_prev)
+    rng.shuffle(indices)
+    shuffled_colors = colormap.colors[indices]
+
+    primes = _primes(
+        np.iinfo(dtype).max if np.issubdtype(dtype, np.integer) else 2**24
+    )
+    valid_primes = primes[primes > n_colors_prev]
+    if len(valid_primes) < min_random_choices:
+        valid_primes = primes[-min_random_choices:]
+    n_colors = rng.choice(valid_primes)
+    n_color_diff = n_colors - n_colors_prev
+
+    extended_colors = np.concatenate(
+        (
+            shuffled_colors[: min(n_color_diff, 0) or None],
+            shuffled_colors[rng.choice(indices, size=max(n_color_diff, 0))],
+        ),
+        axis=0,
+    )
+
+    new_colormap = LabelColormap(
+        name=colormap.name,
+        colors=extended_colors,
+        controls=np.linspace(0, 1, len(extended_colors) + 1),
+        interpolation='zero',
+        background_value=colormap.background_value,
+    )
+    return new_colormap
+
+
 def direct_colormap(color_dict=None):
     """Make a direct colormap from a dictionary mapping labels to colors.
 


Test patch:
diff --git a/napari/layers/labels/_tests/test_labels.py b/napari/layers/labels/_tests/test_labels.py
--- a/napari/layers/labels/_tests/test_labels.py
+++ b/napari/layers/labels/_tests/test_labels.py
@@ -1562,6 +1562,24 @@ def test_color_mapping_when_seed_is_changed():
     assert not np.allclose(mapped_colors1, mapped_colors2)
 
 
+@pytest.mark.parametrize('num_colors', [49, 50, 254, 255, 60000, 65534])
+def test_color_shuffling_above_num_colors(num_colors):
+    r"""Check that the color shuffle does not result in the same collisions.
+
+    See https://github.com/napari/napari/issues/6448.
+
+    Note that we don't support more than 2\ :sup:`16` colors, and behavior
+    with more colors is undefined, so we don't test it here.
+    """
+    labels = np.arange(1, 1 + 2 * num_colors).reshape((2, num_colors))
+    layer = Labels(labels, num_colors=num_colors)
+    colors0 = layer.colormap.map(labels)
+    assert np.all(colors0[0] == colors0[1])
+    layer.new_colormap()
+    colors1 = layer.colormap.map(labels)
+    assert not np.all(colors1[0] == colors1[1])
+
+
 def test_negative_label():
     """Test negative label values are supported."""
     data = np.random.randint(low=-1, high=20, size=(10, 10))