Repository: napari/napari

Issue:
Selecting points with unsortable features raises an error
## 🐛 Bug

An error is raised if one tries to select multiple points on a layer that has a feature column whose objects do not support ordering.

## To Reproduce

Create a simple layer like this:

```py
import napari
import numpy as np

class A:
    pass

v = napari.Viewer()
pl = v.add_points(np.array([[0, 0], [1, 1]]), features={'a': [A(), A()]})
```

Then try to select both points manually. This error is raised:

```py
File ~/git/napari/napari/layers/points/points.py:1245, in Points.selected_data(self, selected_data)
   1241 for k, v in self.properties.items():
   1242     # pandas uses `object` as dtype for strings by default, which
   1243     # combined with the axis argument breaks np.unique
   1244     axis = 0 if v.ndim > 1 else None
-> 1245     properties[k] = np.unique(v[index], axis=axis)
   1247 n_unique_properties = np.array([len(v) for v in properties.values()])
   1248 if np.all(n_unique_properties == 1):

File <__array_function__ internals>:180, in unique(*args, **kwargs)

File ~/venv/napari/lib/python3.10/site-packages/numpy/lib/arraysetops.py:272, in unique(ar, return_index, return_inverse, return_counts, axis)
    270 ar = np.asanyarray(ar)
    271 if axis is None:
--> 272     ret = _unique1d(ar, return_index, return_inverse, return_counts)
    273     return _unpack_tuple(ret)
    275 # axis was specified and not None

File ~/venv/napari/lib/python3.10/site-packages/numpy/lib/arraysetops.py:333, in _unique1d(ar, return_index, return_inverse, return_counts)
    331     aux = ar[perm]
    332 else:
--> 333     ar.sort()
    334     aux = ar
    335 mask = np.empty(aux.shape, dtype=np.bool_)

TypeError: '<' not supported between instances of 'A' and 'A'
```

This is caused by the use of `np.unique`, which is used to extract 'current_properties'. I don't have an immediate solution, but I don't think our attempt to get a value for `current_properties` should mess up our ability to select points themselves, since that machinery worked just fine.

@andy-sweet having worked on features, do you see any obvious solutions to this?


Discussion:
`np.unique` returns the sorted unique elements of an array, so if the object being stored isn't sortable (as in this case) then we can't use it.

We don't actually want the sorted elements, so maybe we can do something like

```python
properties[k] = np.array(tuple(set(v[index])))
```

though that is likely slower.

Ultimately, we just want to do something if there is one unique value (i.e. update current properties). I'd probably vote for writing a simple helper function that either returns that one unique value or `None` if there's more than one value. It should be much faster than either of these approaches since it only needs a single pass over the data, on each iteration checking if the new value is the current unique value, and returning `None` early otherwise. If this is substantially faster, we should probably use it instead of `np.unique` for the other similar checks above.


Resolving patch:
diff --git a/napari/layers/points/points.py b/napari/layers/points/points.py
--- a/napari/layers/points/points.py
+++ b/napari/layers/points/points.py
@@ -21,7 +21,11 @@
 from ..utils.color_manager import ColorManager
 from ..utils.color_transformations import ColorType
 from ..utils.interactivity_utils import displayed_plane_from_nd_line_segment
-from ..utils.layer_utils import _features_to_properties, _FeatureTable
+from ..utils.layer_utils import (
+    _features_to_properties,
+    _FeatureTable,
+    _unique_element,
+)
 from ..utils.text_manager import TextManager
 from ._points_constants import SYMBOL_ALIAS, Mode, Shading, Symbol
 from ._points_mouse_bindings import add, highlight, select
@@ -1208,46 +1212,40 @@ def selected_data(self, selected_data):
             self._set_highlight()
             return
         index = list(self._selected_data)
-        edge_colors = np.unique(self.edge_color[index], axis=0)
-        if len(edge_colors) == 1:
-            edge_color = edge_colors[0]
+        if (
+            unique_edge_color := _unique_element(self.edge_color[index])
+        ) is not None:
             with self.block_update_properties():
-                self.current_edge_color = edge_color
+                self.current_edge_color = unique_edge_color
 
-        face_colors = np.unique(self.face_color[index], axis=0)
-        if len(face_colors) == 1:
-            face_color = face_colors[0]
+        if (
+            unique_face_color := _unique_element(self.face_color[index])
+        ) is not None:
             with self.block_update_properties():
-                self.current_face_color = face_color
+                self.current_face_color = unique_face_color
 
         # Calculate the mean size across the displayed dimensions for
         # each point to be consistent with `_view_size`.
         mean_size = np.mean(
             self.size[np.ix_(index, self._dims_displayed)], axis=1
         )
-        size = np.unique(mean_size)
-        if len(size) == 1:
-            size = size[0]
+        if (unique_size := _unique_element(mean_size)) is not None:
             with self.block_update_properties():
-                self.current_size = size
+                self.current_size = unique_size
 
-        edge_width = np.unique(self.edge_width[index])
-        if len(edge_width) == 1:
-            edge_width = edge_width[0]
+        if (
+            unique_edge_width := _unique_element(self.edge_width[index])
+        ) is not None:
             with self.block_update_properties():
-                self.current_edge_width = edge_width
+                self.current_edge_width = unique_edge_width
 
-        properties = {}
+        unique_properties = {}
         for k, v in self.properties.items():
-            # pandas uses `object` as dtype for strings by default, which
-            # combined with the axis argument breaks np.unique
-            axis = 0 if v.ndim > 1 else None
-            properties[k] = np.unique(v[index], axis=axis)
+            unique_properties[k] = _unique_element(v[index])
 
-        n_unique_properties = np.array([len(v) for v in properties.values()])
-        if np.all(n_unique_properties == 1):
+        if all(p is not None for p in unique_properties.values()):
             with self.block_update_properties():
-                self.current_properties = properties
+                self.current_properties = unique_properties
         self._set_highlight()
 
     def interaction_box(self, index) -> Optional[np.ndarray]:
diff --git a/napari/layers/shapes/shapes.py b/napari/layers/shapes/shapes.py
--- a/napari/layers/shapes/shapes.py
+++ b/napari/layers/shapes/shapes.py
@@ -27,7 +27,7 @@
     transform_color_with_defaults,
 )
 from ..utils.interactivity_utils import nd_line_segment_to_displayed_data_ray
-from ..utils.layer_utils import _FeatureTable
+from ..utils.layer_utils import _FeatureTable, _unique_element
 from ..utils.text_manager import TextManager
 from ._shape_list import ShapeList
 from ._shapes_constants import Box, ColorMode, Mode, ShapeType, shape_classes
@@ -1148,42 +1148,42 @@ def selected_data(self, selected_data):
             selected_face_colors = self._data_view._face_color[
                 selected_data_indices
             ]
-            face_colors = np.unique(selected_face_colors, axis=0)
-            if len(face_colors) == 1:
-                face_color = face_colors[0]
+            if (
+                unique_face_color := _unique_element(selected_face_colors)
+            ) is not None:
                 with self.block_update_properties():
-                    self.current_face_color = face_color
+                    self.current_face_color = unique_face_color
 
             selected_edge_colors = self._data_view._edge_color[
                 selected_data_indices
             ]
-            edge_colors = np.unique(selected_edge_colors, axis=0)
-            if len(edge_colors) == 1:
-                edge_color = edge_colors[0]
+            if (
+                unique_edge_color := _unique_element(selected_edge_colors)
+            ) is not None:
                 with self.block_update_properties():
-                    self.current_edge_color = edge_color
-
-            edge_width = list(
-                {self._data_view.shapes[i].edge_width for i in selected_data}
+                    self.current_edge_color = unique_edge_color
+
+            unique_edge_width = _unique_element(
+                np.array(
+                    [
+                        self._data_view.shapes[i].edge_width
+                        for i in selected_data
+                    ]
+                )
             )
-            if len(edge_width) == 1:
-                edge_width = edge_width[0]
+            if unique_edge_width is not None:
                 with self.block_update_properties():
-                    self.current_edge_width = edge_width
+                    self.current_edge_width = unique_edge_width
 
-            properties = {}
+            unique_properties = {}
             for k, v in self.properties.items():
-                # pandas uses `object` as dtype for strings by default, which
-                # combined with the axis argument breaks np.unique
-                axis = 0 if v.ndim > 1 else None
-                properties[k] = np.unique(v[selected_data_indices], axis=axis)
+                unique_properties[k] = _unique_element(
+                    v[selected_data_indices]
+                )
 
-            n_unique_properties = np.array(
-                [len(v) for v in properties.values()]
-            )
-            if np.all(n_unique_properties == 1):
+            if all(p is not None for p in unique_properties.values()):
                 with self.block_update_properties():
-                    self.current_properties = properties
+                    self.current_properties = unique_properties
 
     def _set_color(self, color, attribute: str):
         """Set the face_color or edge_color property
diff --git a/napari/layers/utils/layer_utils.py b/napari/layers/utils/layer_utils.py
--- a/napari/layers/utils/layer_utils.py
+++ b/napari/layers/utils/layer_utils.py
@@ -958,3 +958,18 @@ def _features_to_properties(features: pd.DataFrame) -> Dict[str, np.ndarray]:
     :meth:`_FeatureTable.properties`
     """
     return {name: series.to_numpy() for name, series in features.items()}
+
+
+def _unique_element(array: Array) -> Optional[Any]:
+    """
+    Returns the unique element along the 0th axis, if it exists; otherwise, returns None.
+
+    This is faster than np.unique, does not require extra tricks for nD arrays, and
+    does not fail for non-sortable elements.
+    """
+    if len(array) == 0:
+        return None
+    el = array[0]
+    if np.any(array[1:] != el):
+        return None
+    return el


Test patch:
diff --git a/napari/layers/points/_tests/test_points.py b/napari/layers/points/_tests/test_points.py
--- a/napari/layers/points/_tests/test_points.py
+++ b/napari/layers/points/_tests/test_points.py
@@ -855,6 +855,18 @@ def test_select_properties_object_dtype():
     assert pl.selected_data == selection
 
 
+def test_select_properties_unsortable():
+    """selecting multiple points when they have properties that cannot be sorted should not fail
+
+    see https://github.com/napari/napari/issues/5174
+    """
+    properties = pd.DataFrame({'unsortable': [{}, {}]})
+    pl = Points(np.ones((2, 2)), properties=properties)
+    selection = {0, 1}
+    pl.selected_data = selection
+    assert pl.selected_data == selection
+
+
 def test_refresh_text():
     """Test refreshing the text after setting new properties"""
     shape = (10, 2)