From 8aac37b3f9969069a8065dae4671d5fc2b95f148 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 4 May 2026 15:55:06 +0200 Subject: [PATCH 1/4] Relax napari version pin --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 2f56cbc58851f8f4a73c42ccf08dcdbc906364f2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 4 May 2026 16:22:44 +0200 Subject: [PATCH 2/4] Patch napari guess_continuous to use floats only Override napari's guess_continuous to treat only floating dtypes as continuous (avoids treating large-category integer arrays as continuous). The patch: adds robust numpy import handling, supports both old and new napari signatures (guess_continuous(color_map) and guess_continuous(color_map, feature_name)), tries to patch both napari.layers.utils.color_manager and color_manager_utils, uses np.issubdtype for dtype checks, and logs and no-ops cleanly if imports or assignments fail. --- src/napari_deeplabcut/napari_compat/color.py | 73 +++++++++++++++----- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/src/napari_deeplabcut/napari_compat/color.py b/src/napari_deeplabcut/napari_compat/color.py index c307dffc..154a4382 100644 --- a/src/napari_deeplabcut/napari_compat/color.py +++ b/src/napari_deeplabcut/napari_compat/color.py @@ -1,3 +1,4 @@ +# src/napari_deeplabcut/napari_compat/color.py from __future__ import annotations import logging @@ -7,30 +8,70 @@ def patch_color_manager_guess_continuous() -> 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) From 21fb8e6757147b528ee3fc2aa1bf7cdf59fc9a2b Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 4 May 2026 16:39:03 +0200 Subject: [PATCH 3/4] Update selection via public API after paste Replace direct writes to private _selected_view/_selected_data with the public selected_data setter after pasting points. The new code calls layer_self.selected_data inside a try/except and logs failures at debug level, preserving the existing refresh and recolor scheduling. This ensures selection is updated through the public API (improving compatibility and event handling) and avoids relying on private attributes. --- src/napari_deeplabcut/napari_compat/points_layer.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/napari_deeplabcut/napari_compat/points_layer.py b/src/napari_deeplabcut/napari_compat/points_layer.py index 5ab69d21..be5c3f98 100644 --- a/src/napari_deeplabcut/napari_compat/points_layer.py +++ b/src/napari_deeplabcut/napari_compat/points_layer.py @@ -361,7 +361,7 @@ def _paste_data(layer_self, _store=store): if not filtered_clipboard: return - npoints = len(layer_self._view_data) + len(layer_self._view_data) totpoints = len(layer_self.data) data = _offset_pasted_data( @@ -382,10 +382,13 @@ 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)) layer_self.refresh() + try: + layer_self.selected_data = set(range(totpoints, totpoints + n_new)) + except Exception: + logger.debug("Failed to update selected_data after paste", exc_info=True) + try: controls._schedule_recolor(_store.layer) except Exception: From 89d1aa91b6e140565ae1b508949018e33645f395 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Mon, 1 Jun 2026 10:51:10 +0200 Subject: [PATCH 4/4] Fix paste handling for Points text payloads Make paste logic more robust for napari Points layers: add _filter_text_payload to handle scalar (0-d) text arrays and length-mismatched text strings, and _select_pasted_points to update selection compatibly across napari versions (handles _slicing_state, _selected_data/_selected_view, and fallbacks). Also detect _data_slice when querying current slice. Update make_paste_data to use these helpers and preserve behavior (refresh after selection). Add tests covering scalar text payloads and unexpected-length text arrays to prevent crashes and ensure correct paste/recolor behavior. --- .../_tests/compat/test_compat_internal.py | 59 ++++++++- .../napari_compat/points_layer.py | 119 ++++++++++++++++-- 2 files changed, 166 insertions(+), 12 deletions(-) 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=" 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 - len(layer_self._view_data) + npoints_view = len(layer_self._view_data) totpoints = len(layer_self.data) data = _offset_pasted_data( @@ -382,12 +476,15 @@ def _paste_data(layer_self, _store=store): _paste_colors(layer_self, filtered_clipboard) n_new = len(filtered_clipboard["data"]) - layer_self.refresh() - try: - layer_self.selected_data = set(range(totpoints, totpoints + n_new)) - except Exception: - logger.debug("Failed to update selected_data after paste", exc_info=True) + _select_pasted_points( + layer_self, + data_start=totpoints, + view_start=npoints_view, + count=n_new, + ) + + layer_self.refresh() try: controls._schedule_recolor(_store.layer)