Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ dynamic = [ "version" ]
dependencies = [
"dask-image",
"matplotlib>=3.3",
"napari==0.6.6",
"napari>=0.6.6",
Comment thread
C-Achard marked this conversation as resolved.
"natsort",
"numpy>=1.18.5,<2",
"opencv-python-headless",
Expand Down
59 changes: 58 additions & 1 deletion src/napari_deeplabcut/_tests/compat/test_compat_internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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="<U1"), rather than one string per point.

Our paste compat code must not index that scalar with the unannotated mask.
"""
paste_env.layer._clipboard["text"] = {
"string": np.array("", dtype="<U1"),
"color": "white",
}

paste = make_paste_data(paste_env.controls, store=paste_env.store)
paste(paste_env.layer)

layer = paste_env.layer

# Smoke check: paste succeeded and only the unannotated point was pasted.
assert layer._data.shape == (2, 3)

assert len(layer.text.calls) == 1
assert layer.text.calls[0]["color"] == "white"

pasted_string = layer.text.calls[0]["string"]
assert isinstance(pasted_string, np.ndarray)
assert pasted_string.shape == ()
assert pasted_string.item() == ""

assert layer.refresh_count == 1
assert paste_env.recolor_calls == [paste_env.store.layer]


def test_make_paste_data_preserves_text_payload_when_text_length_does_not_match_mask(paste_env):
"""
If napari changes the shape of text["string"], paste should stay defensive:
only mask per-point text arrays whose length matches the feature mask.
"""
paste_env.layer._clipboard["text"] = {
"string": np.array(["unexpected-only-one"], dtype=object),
"color": "white",
}

paste = make_paste_data(paste_env.controls, store=paste_env.store)
paste(paste_env.layer)

layer = paste_env.layer

assert layer._data.shape == (2, 3)

assert len(layer.text.calls) == 1
np.testing.assert_array_equal(
layer.text.calls[0]["string"],
np.array(["unexpected-only-one"], dtype=object),
)
assert layer.text.calls[0]["color"] == "white"
73 changes: 57 additions & 16 deletions src/napari_deeplabcut/napari_compat/color.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# src/napari_deeplabcut/napari_compat/color.py
from __future__ import annotations

import logging
Expand All @@ -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)
116 changes: 108 additions & 8 deletions src/napari_deeplabcut/napari_compat/points_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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="<U1")

Only per-point arrays can be indexed with the pasted-point mask.
"""
if text is None:
return None

try:
text_string = text.get("string")
except Exception:
return text

filtered = dict(text)

try:
string_array = np.asarray(text_string)

if string_array.ndim == 0:
# Scalar text payload, e.g. empty string. Keep as-is.
filtered["string"] = text_string
elif len(string_array) == len(mask):
filtered["string"] = string_array[mask]
else:
# Unexpected length. Keep as-is rather than crashing paste.
logger.debug(
"Text clipboard string length %s does not match mask length %s; keeping text unchanged.",
len(string_array),
len(mask),
)
filtered["string"] = text_string
except Exception:
logger.debug("Failed to filter text clipboard payload", exc_info=True)
filtered["string"] = text_string

return filtered


def _get_clipboard_slice_point(indices: Any) -> Any:
"""Extract the copied slice point from old/new napari clipboard payloads."""
if hasattr(indices, "point"):
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
Loading