diff --git a/pyproject.toml b/pyproject.toml index 8ed2fd22..1d22ddaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dynamic = [ "version" ] dependencies = [ "dask-image", "matplotlib>=3.3", - "napari==0.6.6", + "napari>=0.6.6", "natsort", "numpy>=1.18.5,<2", "opencv-python-headless", diff --git a/src/napari_deeplabcut/_tests/compat/test_compat_internal.py b/src/napari_deeplabcut/_tests/compat/test_compat_internal.py index 47ec907b..73269ffb 100644 --- a/src/napari_deeplabcut/_tests/compat/test_compat_internal.py +++ b/src/napari_deeplabcut/_tests/compat/test_compat_internal.py @@ -140,7 +140,7 @@ class Keypoint: paste(layer) assert recolor_calls == [] - # features are popped before the early return; smoke test just checks no crash/no recolor + # Smoke test: no paste, no refresh, no recolor when all keypoints are already annotated. def test_make_paste_data_pastes_only_unannotated_points_and_recolors(paste_env): @@ -179,3 +179,60 @@ def test_make_paste_data_pastes_only_unannotated_points_and_recolors(paste_env): assert layer._selected_data == {1} assert layer.refresh_count == 1 assert paste_env.recolor_calls == [paste_env.store.layer] + + +def test_make_paste_data_handles_scalar_text_payload(paste_env): + """ + napari 0.7.0 may store copied Points text as a scalar / 0-d array, + for example np.array("", dtype=" None: """ - Napari-compat patch: override color_manager.guess_continuous. - By default : - The property is guessed as continuous if it is a float or contains over 16 elements. - We override this to guess continuous only if it is a float, - to avoid issues with categorical properties with many categories (e.g. body parts). - - - Uses try/except around private imports. - - If napari internals change, we log and no-op. + Napari-compat patch: override guess_continuous so that only floating + dtypes are treated as continuous. + + Why: + Napari historically guessed "continuous" for floats OR integer arrays + with many unique values, which is problematic for categorical features + like body-part labels. + + Compatibility: + - Supports old napari calls: guess_continuous(color_map) + - Supports newer napari calls: guess_continuous(color_map, feature_name) + - Gracefully no-ops if napari internals move again """ try: import numpy as np + except Exception as e: # pragma: no cover + logger.debug("Skipping color_manager patch (numpy import failed): %r", e) + return + + targets = [] + + try: from napari.layers.utils import color_manager + + targets.append(("napari.layers.utils.color_manager", color_manager)) + except Exception as e: # pragma: no cover + logger.debug("Could not import napari.layers.utils.color_manager: %r", e) + + try: + from napari.layers.utils import color_manager_utils + + targets.append(("napari.layers.utils.color_manager_utils", color_manager_utils)) except Exception as e: # pragma: no cover - logger.debug("Skipping color_manager patch (napari import failed): %r", e) + logger.debug("Could not import napari.layers.utils.color_manager_utils: %r", e) + + if not targets: + logger.debug("Skipping color_manager patch (no compatible napari module found)") return - def guess_continuous(property_): + def guess_continuous(color_map, feature_name=None, *args, **kwargs): + """ + Treat only floating dtypes as continuous. + + Parameters + ---------- + color_map : array-like + Feature/property values. + feature_name : str | None + Accepted for compatibility with newer napari versions. + *args, **kwargs + Ignored for forward compatibility. + """ try: - return issubclass(property_.dtype.type, np.floating) + dtype = getattr(color_map, "dtype", None) + if dtype is None: + color_map = np.asarray(color_map) + dtype = color_map.dtype + return np.issubdtype(dtype, np.floating) except Exception: # pragma: no cover return False - try: - color_manager.guess_continuous = guess_continuous - except Exception as e: # pragma: no cover - logger.debug("Skipping color_manager patch (assignment failed): %r", e) - return + for module_name, module in targets: + try: + module.guess_continuous = guess_continuous + logger.debug("Patched %s.guess_continuous", module_name) + except Exception as e: # pragma: no cover + logger.debug("Skipping patch for %s (assignment failed): %r", module_name, e) diff --git a/src/napari_deeplabcut/napari_compat/points_layer.py b/src/napari_deeplabcut/napari_compat/points_layer.py index 5ab69d21..ad263899 100644 --- a/src/napari_deeplabcut/napari_compat/points_layer.py +++ b/src/napari_deeplabcut/napari_compat/points_layer.py @@ -94,6 +94,9 @@ def _get_current_slice_point(controls: Any, layer: Any) -> tuple | Any | None: except Exception: pass + if hasattr(layer, "_data_slice"): + return layer._data_slice + if hasattr(layer, "_slice_indices"): return layer._slice_indices @@ -104,6 +107,99 @@ def _get_current_slice_point(controls: Any, layer: Any) -> tuple | Any | None: return None +def _select_pasted_points( + layer: Any, + *, + data_start: int, + view_start: int, + count: int, +) -> None: + """ + Select newly pasted points compatibly across napari versions. + + In napari 0.7.0, Points._selected_view is a read-only property backed by + layer._slicing_state._selected_view. Older/fake layers may expose + _selected_view directly. + """ + if count <= 0: + return + + selected_data = set(range(data_start, data_start + count)) + selected_view = list(range(view_start, view_start + count)) + + # napari 0.7.0 behavior: update the private Selection directly. + # This mirrors Points._paste_data(). + try: + private_selected_data = getattr(layer, "_selected_data", None) + if private_selected_data is not None and hasattr(private_selected_data, "update"): + private_selected_data.update(selected_data) + elif hasattr(layer, "_selected_data"): + layer._selected_data = selected_data + else: + layer.selected_data = selected_data + except Exception: + logger.debug("Failed to update selected data after paste", exc_info=True) + + # napari 0.7.0: _selected_view lives on the slicing state. + try: + slicing_state = getattr(layer, "_slicing_state", None) + if slicing_state is not None and hasattr(slicing_state, "_selected_view"): + slicing_state._selected_view = selected_view + return + except Exception: + logger.debug("Failed to update slicing-state selected view after paste", exc_info=True) + + # Test-layer / older-private fallback. + try: + if hasattr(layer, "_selected_view"): + layer._selected_view = selected_view + except Exception: + logger.debug("Failed to update selected view after paste", exc_info=True) + + +def _filter_text_payload(text: Any, mask: list[bool]) -> dict[str, Any] | None: + """ + Filter a napari Points text clipboard payload. + + napari may store text["string"] as either: + - a per-point array, e.g. array(["tail-2", "nose-1"], dtype=object) + - a scalar / 0-d array, e.g. array("", dtype=" Any: """Extract the copied slice point from old/new napari clipboard payloads.""" if hasattr(indices, "point"): @@ -350,18 +446,16 @@ def _paste_data(layer_self, _store=store): filtered_clipboard["features"] = new_features filtered_clipboard["indices"] = indices - if text is not None: - filtered_clipboard["text"] = { - "string": text["string"][unannotated], - "color": text["color"], - } + filtered_text = _filter_text_payload(text, unannotated) + if filtered_text is not None: + filtered_clipboard["text"] = filtered_text layer_self._clipboard = filtered_clipboard if not filtered_clipboard: return - npoints = len(layer_self._view_data) + npoints_view = len(layer_self._view_data) totpoints = len(layer_self.data) data = _offset_pasted_data( @@ -382,8 +476,14 @@ def _paste_data(layer_self, _store=store): _paste_colors(layer_self, filtered_clipboard) n_new = len(filtered_clipboard["data"]) - layer_self._selected_view = list(range(npoints, npoints + n_new)) - layer_self._selected_data = set(range(totpoints, totpoints + n_new)) + + _select_pasted_points( + layer_self, + data_start=totpoints, + view_start=npoints_view, + count=n_new, + ) + layer_self.refresh() try: