diff --git a/src/napari_clusters_plotter/_algorithm_widget.py b/src/napari_clusters_plotter/_algorithm_widget.py index b9c2f9c5..cfde141c 100644 --- a/src/napari_clusters_plotter/_algorithm_widget.py +++ b/src/napari_clusters_plotter/_algorithm_widget.py @@ -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) diff --git a/src/napari_clusters_plotter/_dim_reduction_and_clustering.py b/src/napari_clusters_plotter/_dim_reduction_and_clustering.py index 61570e41..527dc473 100644 --- a/src/napari_clusters_plotter/_dim_reduction_and_clustering.py +++ b/src/napari_clusters_plotter/_dim_reduction_and_clustering.py @@ -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 @@ -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 diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 0947ec03..10486d2f 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -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 @@ -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): @@ -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 ) @@ -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( @@ -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] @@ -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", diff --git a/src/napari_clusters_plotter/_tests/test_dimensionality_reduction.py b/src/napari_clusters_plotter/_tests/test_dimensionality_reduction.py index 2ef06358..096af358 100644 --- a/src/napari_clusters_plotter/_tests/test_dimensionality_reduction.py +++ b/src/napari_clusters_plotter/_tests/test_dimensionality_reduction.py @@ -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): diff --git a/src/napari_clusters_plotter/_tests/test_plotter.py b/src/napari_clusters_plotter/_tests/test_plotter.py index 4dc9de7e..d48df6bb 100644 --- a/src/napari_clusters_plotter/_tests/test_plotter.py +++ b/src/napari_clusters_plotter/_tests/test_plotter.py @@ -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", [