Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f1eb2b2
Initial working version with points layer and biaplotter highlight fu…
zoccoler Jul 1, 2025
df4ff77
Improve object focusing with affine transformation handling (working …
zoccoler Jul 23, 2025
fef58af
Enhance focus functionality for Labels layer
zoccoler Jul 23, 2025
89da4f7
Refactor object focusing and affine transform logic
zoccoler Jul 24, 2025
069e298
Add support for focusing on Surface, Shapes, and Tracks layers
zoccoler Jul 24, 2025
c982779
Fix shape selection indexing in _focus_object
zoccoler Jul 25, 2025
4e99fa7
Refactor affine matrix construction to use napari Affine
zoccoler Jul 28, 2025
33e02d1
Fix dimension calculation in _focus_object function for points layer
zoccoler Jul 28, 2025
d27bc82
Comment out unused default zoom calculation
zoccoler Aug 13, 2025
6f75fc8
Merge branch 'main' into highlight_objects
zoccoler Aug 13, 2025
366ca65
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 13, 2025
af4f460
Add tests for focusing on highlighted points in 3D
zoccoler Aug 13, 2025
ee5d555
Merge branch 'highlight_objects' of https://github.com/zoccoler/napar…
zoccoler Aug 13, 2025
41d206b
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 13, 2025
96c07b3
Fix missing round brackets and format code with black
zoccoler Aug 13, 2025
97d2942
Merge branch 'highlight_objects' of https://github.com/zoccoler/napar…
zoccoler Aug 13, 2025
c5dce52
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 13, 2025
9e9ef64
Refactor track center selection with ternary operator
zoccoler Aug 13, 2025
19a5ee0
Remove test files
zoccoler Aug 13, 2025
3201318
use layer names for id, not UUID
jo-mueller Aug 14, 2025
2b4bcd5
identify layer by name, not unique_id
jo-mueller Aug 14, 2025
5dae1c1
replaced print with napari info
jo-mueller Aug 14, 2025
02f7b10
shortened code
jo-mueller Aug 14, 2025
a88247a
fixed test
jo-mueller Aug 14, 2025
c5ba625
Merge pull request #1 from jo-mueller/use-layer-names-for-indexing,-n…
zoccoler Oct 10, 2025
11ef3e0
Merge branch 'main' into highlight_objects
zoccoler Oct 10, 2025
b470a84
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 10, 2025
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 src/napari_clusters_plotter/_algorithm_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def _get_features(self):
_features = layer.features[self.common_columns].copy()

# Add layer name as a categorical column
_features["layer"] = layer.unique_id
_features["layer"] = layer.name
_features["layer"] = _features["layer"].astype("category")
features = pd.concat([features, _features], axis=0)

Expand Down
6 changes: 2 additions & 4 deletions src/napari_clusters_plotter/_dim_reduction_and_clustering.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def _process_result(self, result):

# add the columns to the features
layer_feature_subset = features_clustered[
features_clustered["layer"] == layer.unique_id
features_clustered["layer"] == layer.name
]
current_features[column_name] = layer_feature_subset[
column_name
Expand Down Expand Up @@ -113,9 +113,7 @@ def _process_result(self, result: pd.DataFrame):
for layer in self.layers:
current_features = layer.features
for column in column_names:
layer_feature_subset = result[
result["layer"] == layer.unique_id
]
layer_feature_subset = result[result["layer"] == layer.name]

# add the columns to the features
current_features[column] = layer_feature_subset[column].values
Expand Down
179 changes: 174 additions & 5 deletions src/napari_clusters_plotter/_new_plotter_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
)
from napari.utils.colormaps import ALL_COLORMAPS
from napari.utils.notifications import show_info, show_warning
from napari.utils.transforms import Affine
from qtpy import uic
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QColor
Expand Down Expand Up @@ -146,7 +147,7 @@ def _on_export_clusters(self):
# get the layer to export from
for layer in self.layers:
features_subset = features[
features["layer"] == layer.unique_id
features["layer"] == layer.name
].reset_index()
indices = features_subset[hue_column].values == selected_cluster
if not np.any(indices):
Expand Down Expand Up @@ -222,6 +223,9 @@ def _setup_callbacks(self):
self._on_bin_auto_toggled
)

self.plotting_widget.active_artist.highlighted_changed_signal.connect(
self._on_highlighted_changed
)
self.control_widget.pushButton_export_layer.clicked.connect(
self._on_export_clusters
)
Expand All @@ -240,9 +244,7 @@ def _on_finish_draw(self, color_indices: np.ndarray):

features = self._get_features()
for layer in self.layers:
layer_indices = features[
features["layer"] == layer.unique_id
].index
layer_indices = features[features["layer"] == layer.name].index

# store latest cluster indeces in the features table
layer.features["MANUAL_CLUSTER_ID"] = pd.Series(
Expand Down Expand Up @@ -755,7 +757,7 @@ def _update_layer_colors(self, use_color_indices: bool = False) -> None:
active_artist.color_indices
)
layer_indices = features[
features["layer"] == selected_layer.unique_id
features["layer"] == selected_layer.name
].index
self._set_layer_color(
selected_layer, rgba_colors[layer_indices]
Expand Down Expand Up @@ -833,6 +835,173 @@ def _reset(self):
self.control_widget.hue_box.setCurrentText("MANUAL_CLUSTER_ID")
self.plot_needs_update.emit()

def _on_highlighted_changed(self, boolean_object_selected: bool):
"""
Focus the viewer on the highlighted object in the layer.
"""
if not np.any(boolean_object_selected):
return
if np.count_nonzero(boolean_object_selected) > 1:
napari.utils.notifications.show_info(
"Multiple objects selected - only single objects can be highlighted."
)
return
features = self._get_features()
layer = features[boolean_object_selected]["layer"].values[0]
boolean_object_selected_in_layer = boolean_object_selected[
features["layer"] == layer
]
_focus_object(
self.viewer.layers[layer], boolean_object_selected_in_layer
)


def _apply_affine_transform(coords, n_dims, affine_matrix):
"""Apply an affine transformation to one point.

Parameters
----------
coords : np.ndarray
Coordinates to transform (shape: (1, n_dims)).
n_dims : int
Number of dimensions of the coordinates.
affine_matrix : np.ndarray
Affine transformation matrix (shape: (n_dims + 1, n_dims + 1)).

Returns
-------
np.ndarray
Transformed coordinates (shape: (1, n_dims)).
"""
coords_homogeneous = np.ones((1, n_dims + 1))
coords_homogeneous[0, :n_dims] = coords
transformed_coords_homogeneous = coords_homogeneous @ affine_matrix.T
return transformed_coords_homogeneous[0, :n_dims]


def _focus_object(layer, boolean_object_selected):
"""Focus the viewer on the selected object in the layer.

Parameters
----------
layer : napari.layers.Layer
The layer containing the object to focus on.
boolean_object_selected : np.ndarray
Boolean array indicating which object is selected (shape: (n_objects,)).
"""
viewer = napari.current_viewer()
# Build affine matrix from rotate, scale, shear, translate layer properties
rotate = layer.rotate
scale = layer.scale
shear = layer.shear
translate = layer.translate
affine_data2physical = Affine(
rotate=rotate,
scale=scale,
shear=shear,
translate=translate,
).affine_matrix
affine_physical2world = layer.affine.affine_matrix
# Combine the two affine transformations
affine_net = affine_data2physical @ affine_physical2world

if isinstance(layer, napari.layers.Points):
center = layer.data[boolean_object_selected][0]
n_dims = layer.data.shape[-1]
transformed_center = _apply_affine_transform(
center, n_dims, affine_net
)

# Set the selected data in the layer (only displays if single layer is selected)
layer.selected_data = set(
np.argwhere(boolean_object_selected).flatten()
)
elif isinstance(layer, napari.layers.Labels):
selected_label = np.nonzero(boolean_object_selected)[0][0] + 1
label_mask = layer.data == selected_label
center = np.mean(np.argwhere(label_mask), axis=0)
n_dims = len(layer.data.shape)
transformed_center = _apply_affine_transform(
center, n_dims, affine_net
)
# Set the selected data in the layer (only displays if single layer is selected)
layer.selected_label = selected_label
elif isinstance(layer, napari.layers.Surface):
center = layer.data[0][boolean_object_selected][0]
n_dims = layer.data[0].shape[-1]
transformed_center = _apply_affine_transform(
center, n_dims, affine_net
)
elif isinstance(layer, napari.layers.Shapes):
selected_shape = layer.data[
np.nonzero(boolean_object_selected)[0][0]
] # needs integer index because data is a list of arrays
center = np.mean(selected_shape, axis=0)
n_dims = selected_shape.shape[-1]
transformed_center = _apply_affine_transform(
center, n_dims, affine_net
)
layer.selected_data = set(
np.argwhere(boolean_object_selected).flatten()
)
elif isinstance(layer, napari.layers.Tracks):
selected_track = layer.data[boolean_object_selected][0]
n_dims = layer.data.shape[-1] - 1 # exclude track ID dimension
center = selected_track[-3:] if n_dims == 3 else selected_track[-4:]
transformed_center = _apply_affine_transform(
center, n_dims, affine_net
)

_set_viewer_camera(viewer, transformed_center)


# TODO: Optionally uncomment this and call it in _set_viewer_camera if we want to zoom-in on highlighted objects
# def _calculate_default_zoom(viewer, margin: float = 0.05):
# """ Calculate the default zoom level for the viewer based on the scene size and margin.

# Uses napari private methods to get the scene parameters and calculate the zoom level without applying it.

# Parameters
# ----------
# viewer : napari.Viewer
# The napari viewer instance.
# margin : float, optional
# Margin to apply around the scene, by default 0.05 (5%).

# Returns
# -------
# float
# The default zoom level for the viewer with the current layers.
# """
# extent, scene_size, corner = viewer._get_scene_parameters()
# scale_factor = viewer._get_scale_factor(margin)
# if viewer.dims.ndisplay == 2:
# default_zoom = viewer._get_2d_camera_zoom(
# scene_size, scale_factor
# )
# elif viewer.dims.ndisplay == 3:
# default_zoom = viewer._get_3d_camera_zoom(
# extent, scale_factor
# )
# return default_zoom


def _set_viewer_camera(viewer, coords):
"""Set the viewer camera to focus on the given coordinates.

Parameters
----------
viewer : napari.Viewer
The napari viewer instance.
coords : np.ndarray
The coordinates of a point to focus the camera on.
"""
viewer.dims.current_step = tuple(coords)
viewer.camera.center = coords
# Zooming-in on highlighted objects (optional, not implemented for now)
# default_zoom = _calculate_default_zoom(viewer)
# viewer.camera.zoom = 4 * default_zoom


def _export_cluster_to_layer(
layer: "napari.layers.Layer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def test_layer_update(make_napari_viewer, widget_config):
for layer in possible_selection:
for feature in widget.common_columns:
assert feature in collected_features.columns
assert layer.unique_id in collected_features["layer"].values
assert layer.name in collected_features["layer"].values


def test_feature_update(make_napari_viewer, widget_config):
Expand Down
84 changes: 84 additions & 0 deletions src/napari_clusters_plotter/_tests/test_plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,90 @@ def test_cluster_visibility_toggle(make_napari_viewer, create_sample_layers):
plotter_widget._on_show_plot_overlay(state=False)


def test_focus_object_on_highlighted_single_selected_points_layer(
make_napari_viewer,
):
from napari_clusters_plotter import PlotterWidget

viewer = make_napari_viewer()
viewer.dims.ndisplay = 3
layer, _ = create_multi_point_layer()
viewer.add_layer(layer)
widget = PlotterWidget(viewer)
viewer.window.add_dock_widget(widget, area="right")

# Simulate a boolean selection mask with a single True value
boolean_object_selected = np.zeros(len(layer.data), dtype=bool)
boolean_object_selected[3] = True

# Set highlighted property
widget.plotting_widget.active_artist.highlighted = boolean_object_selected

# Check that the viewer camera is centered on the selected point
np.testing.assert_allclose(
viewer.camera.center, layer.data[3][-3:], rtol=1e-5
)
# Check that the viewer's current step is set to the selected point
np.testing.assert_allclose(
viewer.dims.current_step[0], layer.data[3][0], rtol=1e-5
)


def test_focus_object_on_highlighted_multi_selected_points_layers(
make_napari_viewer,
):
from napari_clusters_plotter import PlotterWidget

viewer = make_napari_viewer()
viewer.dims.ndisplay = 3
layer, layer2 = create_multi_point_layer()
translate = np.array(
[0, 0, 2]
) # translation vector from create_sample_layers
viewer.add_layer(layer)
viewer.add_layer(layer2)
widget = PlotterWidget(viewer)
viewer.window.add_dock_widget(widget, area="right")
viewer.layers.selection = {layer, layer2} # select both layers

# Simulate a boolean selection mask with a single True value
boolean_object_selected = np.zeros(
len(layer.data) + len(layer2.data), dtype=bool
)
boolean_object_selected[
np.random.randint(0, len(boolean_object_selected))
] = True
layer_name = widget._get_features()[boolean_object_selected][
"layer"
].values[0]
layer = viewer.layers[layer_name]

# Set highlighted property
widget.plotting_widget.active_artist.highlighted = boolean_object_selected

# Check that the viewer camera is centered on the selected point (considering layer translation)
index_in_data = np.where(
boolean_object_selected[widget._get_features()["layer"] == layer_name]
)[0][0]
assert np.allclose(
np.asarray(viewer.camera.center),
layer.data[index_in_data][-3:] + layer.translate[-3:],
rtol=1e-5,
)
# Check that the viewer's current step is set to the selected point
assert np.allclose(
viewer.dims.current_step[0], layer.data[index_in_data][0], rtol=1e-5
)


if __name__ == "__main__":
import napari

test_focus_object_on_highlighted_multi_selected_points_layers(
napari.Viewer
)


@pytest.mark.parametrize(
"create_sample_layers",
[
Expand Down
Loading