From f1eb2b2264928ea4cccb6cc311e4a5d7d249a4ed Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Tue, 1 Jul 2025 12:27:46 +0200 Subject: [PATCH 01/22] Initial working version with points layer and biaplotter highlight functionality --- .../_new_plotter_widget.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index fd018d49..a2489a89 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -183,6 +183,10 @@ def _setup_callbacks(self): self._on_bin_auto_toggled ) + self.plotting_widget.active_artist.highlighted_changed_signal.connect( + self._on_highlighted_changed + ) + def _on_finish_draw(self, color_indices: np.ndarray): """ Called when user finsihes drawing. Will change the hue combo box to the @@ -745,3 +749,28 @@ def _reset(self): self._update_layer_colors(use_color_indices=False) 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): + print("No object selected, not focusing.") + return + features = self._get_features() + print(f"Highlighted object selected: {boolean_object_selected}") + features_sub = features.iloc[np.argwhere(boolean_object_selected).flatten()] + layer = features_sub["layer"].values[0] + print(f"Focusing on layer: {layer}") + _focus_object(self.viewer.layers[layer], boolean_object_selected) + + +def _focus_object(layer, boolean_object_selected): + viewer = napari.current_viewer() + current_step = list(viewer.dims.current_step) + if isinstance(layer, napari.layers.Points): + center = layer.data[boolean_object_selected].squeeze() + current_step[0] = center[0] + viewer.camera.center = center[-3:] + viewer.dims.set_current_step(0,center[0]) + \ No newline at end of file From df4ff775431f0b63f89776a21754ccfe455ed24c Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Wed, 23 Jul 2025 12:38:17 +0200 Subject: [PATCH 02/22] Improve object focusing with affine transformation handling (working version for points layer) Refactors the object focusing logic to correctly apply combined affine transformations (rotation, scale, shear, translation) when centering the viewer on a selected object. Adds a helper function to build the affine matrix and ensures only the selected layer's objects are considered for focusing. --- .../_new_plotter_widget.py | 89 +++++++++++++++++-- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index a2489a89..d22bd2b9 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -758,19 +758,90 @@ def _on_highlighted_changed(self, boolean_object_selected: bool): print("No object selected, not focusing.") return features = self._get_features() - print(f"Highlighted object selected: {boolean_object_selected}") features_sub = features.iloc[np.argwhere(boolean_object_selected).flatten()] layer = features_sub["layer"].values[0] - print(f"Focusing on layer: {layer}") - _focus_object(self.viewer.layers[layer], boolean_object_selected) + boolean_object_selected_in_layer = boolean_object_selected[features['layer']==layer] + _focus_object(self.viewer.layers[layer], boolean_object_selected_in_layer) def _focus_object(layer, boolean_object_selected): viewer = napari.current_viewer() - current_step = list(viewer.dims.current_step) + # Build affine matrix from rotate, scale, shear, translate + rotate = layer.rotate + scale = layer.scale + shear = layer.shear + translate = layer.translate + affine_2 = _build_affine_matrix(rotate, scale, shear, translate) + + # Combine the two affine transformations + # The order matters: apply layer.affine first, then affine_2 + affine_net = affine_2 @ layer.affine.affine_matrix + if isinstance(layer, napari.layers.Points): - center = layer.data[boolean_object_selected].squeeze() - current_step[0] = center[0] - viewer.camera.center = center[-3:] - viewer.dims.set_current_step(0,center[0]) - \ No newline at end of file + center = layer.data[boolean_object_selected] # Shape: (n_points, n_dims) + # Convert to homogeneous coordinates by adding ones column + n_points, n_dims = center.shape + assert n_points == 1, "Only one point should be selected for focusing." + center_homogeneous = np.ones((n_points, n_dims + 1)) # single point highlighting + center_homogeneous[:, :n_dims] = center + # Apply the net affine transformation + transformed_center_homogeneous = center_homogeneous @ affine_net.T + # Extract the transformed coordinates (remove homogeneous coordinate) + transformed_center = transformed_center_homogeneous[:, :n_dims][0] # single point highlighting + # Set the viewer's sliders and camera center + viewer.dims.current_step = tuple(transformed_center) + viewer.camera.center = transformed_center + # Set the selected data in the layer (only displays if single layer is selected) + layer.selected_data = set(np.argwhere(boolean_object_selected).flatten()) + +def _build_affine_matrix(rotate, scale, shear, translate): + """ + Build an affine transformation matrix from individual components. + + Parameters + ---------- + rotate : np.ndarray + Rotation matrix (n×n for n-dimensional data) + scale : np.ndarray + Scale vector (n×1 for n-dimensional data) + shear : np.ndarray + Shear parameters (upper triangular values, length = n*(n-1)/2) + translate : np.ndarray + Translation vector (n×1 for n-dimensional data) + + Returns + ------- + np.ndarray + Affine transformation matrix ((n+1)×(n+1)) + """ + n_dims = len(scale) + + # Create the affine matrix + affine = np.eye(n_dims + 1) + + # Create scale matrix + scale_matrix = np.diag(scale) + + # Create shear matrix - generalized for any dimension + shear_matrix = np.eye(n_dims) + + # Fill upper triangular part with shear values + # The shear array contains values for all (i,j) pairs where i < j + shear_idx = 0 + for i in range(n_dims): + for j in range(i + 1, n_dims): + if shear_idx < len(shear): + shear_matrix[i, j] = shear[shear_idx] + shear_idx += 1 + + # Combine transformations: T * R * Sh * S + # The order matters: Scale first, then Shear, then Rotate + transform_matrix = rotate @ shear_matrix @ scale_matrix + + # Set the upper-left (n×n) block to the combined transformation + affine[:n_dims, :n_dims] = transform_matrix + + # Set the translation (last column, first n rows) + affine[:n_dims, -1] = translate + + return affine \ No newline at end of file From fef58af2e1d3090a50a2038d587e539966b0afe0 Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Wed, 23 Jul 2025 14:55:33 +0200 Subject: [PATCH 03/22] Enhance focus functionality for Labels layer Added support for focusing on selected objects in Labels layers by computing the center of the selected label and applying affine transformations. Introduced helper functions for affine transformation and default zoom calculation to improve camera centering and zooming behavior. --- .../_new_plotter_widget.py | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index d22bd2b9..9ebcf12b 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -766,6 +766,8 @@ def _on_highlighted_changed(self, boolean_object_selected: bool): def _focus_object(layer, boolean_object_selected): viewer = napari.current_viewer() + default_zoom = _calculate_default_zoom(viewer) + # Build affine matrix from rotate, scale, shear, translate rotate = layer.rotate scale = layer.scale @@ -782,18 +784,41 @@ def _focus_object(layer, boolean_object_selected): # Convert to homogeneous coordinates by adding ones column n_points, n_dims = center.shape assert n_points == 1, "Only one point should be selected for focusing." - center_homogeneous = np.ones((n_points, n_dims + 1)) # single point highlighting - center_homogeneous[:, :n_dims] = center + center_homogeneous = np.ones((1, n_dims + 1)) # single point highlighting + center_homogeneous[0, :n_dims] = center # Apply the net affine transformation transformed_center_homogeneous = center_homogeneous @ affine_net.T # Extract the transformed coordinates (remove homogeneous coordinate) - transformed_center = transformed_center_homogeneous[:, :n_dims][0] # single point highlighting + transformed_center = transformed_center_homogeneous[0, :n_dims] # single point highlighting # Set the viewer's sliders and camera center viewer.dims.current_step = tuple(transformed_center) viewer.camera.center = transformed_center + viewer.camera.zoom = 4 * default_zoom # Adjust zoom level # 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): + # For Labels layer, we need to find the center of the selected label + label_index = np.nonzero(boolean_object_selected)[0][0] # Get the first selected label index + print(label_index) + # Find the center of the selected label + selected_label = label_index + 1 + label_mask = layer.data == selected_label + if np.any(label_mask): + center = np.mean(np.argwhere(label_mask), axis=0) + center = np.expand_dims(center, axis=0) + # Convert to homogeneous coordinates by adding ones column + n_dims = len(layer.data.shape) + transformed_center = _apply_affine_transform(center, n_dims, affine_net) + # Set the viewer's sliders and camera center + viewer.dims.current_step = tuple(transformed_center) + viewer.camera.center = transformed_center + viewer.camera.zoom = 4 * default_zoom # Adjust zoom level + # Set the selected data in the layer (only displays if single layer is selected) + layer.selected_label = selected_label + # layer.show_selected_label = True + else: + print("No valid label selected, not focusing.") + def _build_affine_matrix(rotate, scale, shear, translate): """ Build an affine transformation matrix from individual components. @@ -844,4 +869,40 @@ def _build_affine_matrix(rotate, scale, shear, translate): # Set the translation (last column, first n rows) affine[:n_dims, -1] = translate - return affine \ No newline at end of file + return affine + +def _apply_affine_transform(coords, n_dims, affine_matrix): + """ Apply an affine transformation to coordinates. + + Parameters + ---------- + coords : np.ndarray + Coordinates to transform (shape: (n_points, 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: (n_points, 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 _calculate_default_zoom(viewer, margin: float = 0.05): + 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 \ No newline at end of file From 89da4f704d5bf12aed807138a8bfb2aa80c2067b Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Thu, 24 Jul 2025 11:22:23 +0200 Subject: [PATCH 04/22] Refactor object focusing and affine transform logic Improved the logic for focusing on selected objects in napari layers by refactoring affine transformation application and camera setting into dedicated functions. Enhanced code clarity, modularity, and error handling for single object selection, and streamlined the process for both Points and Labels layers. --- .../_new_plotter_widget.py | 174 +++++++++--------- 1 file changed, 92 insertions(+), 82 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 9ebcf12b..daf10cae 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -755,7 +755,9 @@ 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): - print("No object selected, not focusing.") + return + if np.count_nonzero(boolean_object_selected) > 1: + print("Focus only works for single object selection, not focusing.") return features = self._get_features() features_sub = features.iloc[np.argwhere(boolean_object_selected).flatten()] @@ -763,61 +765,27 @@ def _on_highlighted_changed(self, boolean_object_selected: bool): 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. -def _focus_object(layer, boolean_object_selected): - viewer = napari.current_viewer() - default_zoom = _calculate_default_zoom(viewer) - - # Build affine matrix from rotate, scale, shear, translate - rotate = layer.rotate - scale = layer.scale - shear = layer.shear - translate = layer.translate - affine_2 = _build_affine_matrix(rotate, scale, shear, translate) - - # Combine the two affine transformations - # The order matters: apply layer.affine first, then affine_2 - affine_net = affine_2 @ layer.affine.affine_matrix + 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)). - if isinstance(layer, napari.layers.Points): - center = layer.data[boolean_object_selected] # Shape: (n_points, n_dims) - # Convert to homogeneous coordinates by adding ones column - n_points, n_dims = center.shape - assert n_points == 1, "Only one point should be selected for focusing." - center_homogeneous = np.ones((1, n_dims + 1)) # single point highlighting - center_homogeneous[0, :n_dims] = center - # Apply the net affine transformation - transformed_center_homogeneous = center_homogeneous @ affine_net.T - # Extract the transformed coordinates (remove homogeneous coordinate) - transformed_center = transformed_center_homogeneous[0, :n_dims] # single point highlighting - # Set the viewer's sliders and camera center - viewer.dims.current_step = tuple(transformed_center) - viewer.camera.center = transformed_center - viewer.camera.zoom = 4 * default_zoom # Adjust zoom level - # 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): - # For Labels layer, we need to find the center of the selected label - label_index = np.nonzero(boolean_object_selected)[0][0] # Get the first selected label index - print(label_index) - # Find the center of the selected label - selected_label = label_index + 1 - label_mask = layer.data == selected_label - if np.any(label_mask): - center = np.mean(np.argwhere(label_mask), axis=0) - center = np.expand_dims(center, axis=0) - # Convert to homogeneous coordinates by adding ones column - n_dims = len(layer.data.shape) - transformed_center = _apply_affine_transform(center, n_dims, affine_net) - # Set the viewer's sliders and camera center - viewer.dims.current_step = tuple(transformed_center) - viewer.camera.center = transformed_center - viewer.camera.zoom = 4 * default_zoom # Adjust zoom level - # Set the selected data in the layer (only displays if single layer is selected) - layer.selected_label = selected_label - # layer.show_selected_label = True - else: - print("No valid label selected, not focusing.") + 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 _build_affine_matrix(rotate, scale, shear, translate): """ @@ -840,16 +808,12 @@ def _build_affine_matrix(rotate, scale, shear, translate): Affine transformation matrix ((n+1)×(n+1)) """ n_dims = len(scale) - # Create the affine matrix affine = np.eye(n_dims + 1) - # Create scale matrix scale_matrix = np.diag(scale) - - # Create shear matrix - generalized for any dimension + # Create shear matrix shear_matrix = np.eye(n_dims) - # Fill upper triangular part with shear values # The shear array contains values for all (i,j) pairs where i < j shear_idx = 0 @@ -858,51 +822,97 @@ def _build_affine_matrix(rotate, scale, shear, translate): if shear_idx < len(shear): shear_matrix[i, j] = shear[shear_idx] shear_idx += 1 - # Combine transformations: T * R * Sh * S - # The order matters: Scale first, then Shear, then Rotate + # Scale first, then Shear, then Rotate transform_matrix = rotate @ shear_matrix @ scale_matrix - # Set the upper-left (n×n) block to the combined transformation affine[:n_dims, :n_dims] = transform_matrix - # Set the translation (last column, first n rows) affine[:n_dims, -1] = translate - return affine -def _apply_affine_transform(coords, n_dims, affine_matrix): - """ Apply an affine transformation to coordinates. +def _focus_object(layer, boolean_object_selected): + """ Focus the viewer on the selected object in the layer. Parameters ---------- - coords : np.ndarray - Coordinates to transform (shape: (n_points, 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)). + 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_2 = _build_affine_matrix(rotate, scale, shear, translate) + # Combine the two affine transformations + affine_net = affine_2 @ layer.affine.affine_matrix + + if isinstance(layer, napari.layers.Points): + center = layer.data[boolean_object_selected] + n_dims = center.shape[-1] + transformed_center = _apply_affine_transform( + center, n_dims, affine_net + ) + _set_viewer_camera(viewer, transformed_center) + # 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) + center = np.expand_dims(center, axis=0) + n_dims = len(layer.data.shape) + transformed_center = _apply_affine_transform( + center, n_dims, affine_net + ) + _set_viewer_camera(viewer, transformed_center) + # Set the selected data in the layer (only displays if single layer is selected) + layer.selected_label = selected_label +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 ------- - np.ndarray - Transformed coordinates (shape: (n_points, n_dims)). + float + The default zoom level for the viewer with the current layers. """ - 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 _calculate_default_zoom(viewer, margin: float = 0.05): 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 \ No newline at end of file + 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. + """ + default_zoom = _calculate_default_zoom(viewer) + viewer.dims.current_step = tuple(coords) + viewer.camera.center = coords + viewer.camera.zoom = 4 * default_zoom \ No newline at end of file From 069e2983d575da160cea964e04974986f8e80fcb Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Thu, 24 Jul 2025 15:07:12 +0200 Subject: [PATCH 05/22] Add support for focusing on Surface, Shapes, and Tracks layers Extended the _focus_object function to handle napari Surface, Shapes, and Tracks layers. Each layer type now computes the center and applies the affine transformation before setting the viewer camera. Also fixed center calculation for Points and Labels layers. --- .../_new_plotter_widget.py | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index daf10cae..3b6be117 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -852,8 +852,8 @@ def _focus_object(layer, boolean_object_selected): affine_net = affine_2 @ layer.affine.affine_matrix if isinstance(layer, napari.layers.Points): - center = layer.data[boolean_object_selected] - n_dims = center.shape[-1] + center = layer.data[boolean_object_selected][0] + n_dims = len(layer.data.shape[-1]) transformed_center = _apply_affine_transform( center, n_dims, affine_net ) @@ -864,7 +864,6 @@ def _focus_object(layer, boolean_object_selected): 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) - center = np.expand_dims(center, axis=0) n_dims = len(layer.data.shape) transformed_center = _apply_affine_transform( center, n_dims, affine_net @@ -872,6 +871,37 @@ def _focus_object(layer, boolean_object_selected): _set_viewer_camera(viewer, transformed_center) # 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 + ) + _set_viewer_camera(viewer, transformed_center) + elif isinstance(layer, napari.layers.Shapes): + selected_shape = layer.data[boolean_object_selected] + center = np.mean(selected_shape, axis=0) + n_dims = selected_shape.shape[-1] + transformed_center = _apply_affine_transform( + center, n_dims, affine_net + ) + _set_viewer_camera(viewer, transformed_center) + 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 + if n_dims==3: + # 2D tracks + center = selected_track[-3:] # last three dimensions are t, y, x + else: + # 3D tracks + center = selected_track[-4:] + transformed_center = _apply_affine_transform( + center, n_dims, affine_net + ) + _set_viewer_camera(viewer, transformed_center) + + def _calculate_default_zoom(viewer, margin: float = 0.05): """ Calculate the default zoom level for the viewer based on the scene size and margin. From c982779aa8ff7521636ce6f2283a480cba687550 Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Fri, 25 Jul 2025 11:35:37 +0200 Subject: [PATCH 06/22] Fix shape selection indexing in _focus_object Replaces boolean indexing with integer indexing for shape selection in the _focus_object function to ensure compatibility with list of arrays in napari.layers.Shapes. --- src/napari_clusters_plotter/_new_plotter_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 3b6be117..35402119 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -879,7 +879,7 @@ def _focus_object(layer, boolean_object_selected): ) _set_viewer_camera(viewer, transformed_center) elif isinstance(layer, napari.layers.Shapes): - selected_shape = layer.data[boolean_object_selected] + 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( From 4e99fa7a5f22a3bf81b3f298bcea3d1529b66ede Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Mon, 28 Jul 2025 09:18:19 +0200 Subject: [PATCH 07/22] Refactor affine matrix construction to use napari Affine Replaces the custom _build_affine_matrix function with napari's Affine class for constructing affine transformation matrices. This simplifies the code and leverages napari's built-in utilities for affine transformations. --- .../_new_plotter_widget.py | 55 +++---------------- 1 file changed, 9 insertions(+), 46 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 35402119..8b1afdfe 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -14,6 +14,7 @@ cat10_mod_cmap_first_transparent, ) from napari.utils.colormaps import ALL_COLORMAPS +from napari.utils.transforms import Affine from qtpy import uic from qtpy.QtCore import Qt, Signal from qtpy.QtGui import QColor @@ -787,50 +788,6 @@ def _apply_affine_transform(coords, n_dims, affine_matrix): transformed_coords_homogeneous = coords_homogeneous @ affine_matrix.T return transformed_coords_homogeneous[0, :n_dims] -def _build_affine_matrix(rotate, scale, shear, translate): - """ - Build an affine transformation matrix from individual components. - - Parameters - ---------- - rotate : np.ndarray - Rotation matrix (n×n for n-dimensional data) - scale : np.ndarray - Scale vector (n×1 for n-dimensional data) - shear : np.ndarray - Shear parameters (upper triangular values, length = n*(n-1)/2) - translate : np.ndarray - Translation vector (n×1 for n-dimensional data) - - Returns - ------- - np.ndarray - Affine transformation matrix ((n+1)×(n+1)) - """ - n_dims = len(scale) - # Create the affine matrix - affine = np.eye(n_dims + 1) - # Create scale matrix - scale_matrix = np.diag(scale) - # Create shear matrix - shear_matrix = np.eye(n_dims) - # Fill upper triangular part with shear values - # The shear array contains values for all (i,j) pairs where i < j - shear_idx = 0 - for i in range(n_dims): - for j in range(i + 1, n_dims): - if shear_idx < len(shear): - shear_matrix[i, j] = shear[shear_idx] - shear_idx += 1 - # Combine transformations: T * R * Sh * S - # Scale first, then Shear, then Rotate - transform_matrix = rotate @ shear_matrix @ scale_matrix - # Set the upper-left (n×n) block to the combined transformation - affine[:n_dims, :n_dims] = transform_matrix - # Set the translation (last column, first n rows) - affine[:n_dims, -1] = translate - return affine - def _focus_object(layer, boolean_object_selected): """ Focus the viewer on the selected object in the layer. @@ -847,9 +804,15 @@ def _focus_object(layer, boolean_object_selected): scale = layer.scale shear = layer.shear translate = layer.translate - affine_2 = _build_affine_matrix(rotate, scale, shear, 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_2 @ layer.affine.affine_matrix + affine_net = affine_data2physical @ affine_physical2world if isinstance(layer, napari.layers.Points): center = layer.data[boolean_object_selected][0] From 33e02d18ec0dcff0637dfe05451438706e69c41f Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Mon, 28 Jul 2025 09:18:37 +0200 Subject: [PATCH 08/22] Fix dimension calculation in _focus_object function for points layer --- src/napari_clusters_plotter/_new_plotter_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 8b1afdfe..fddc46f1 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -816,7 +816,7 @@ def _focus_object(layer, boolean_object_selected): if isinstance(layer, napari.layers.Points): center = layer.data[boolean_object_selected][0] - n_dims = len(layer.data.shape[-1]) + n_dims = layer.data.shape[-1] transformed_center = _apply_affine_transform( center, n_dims, affine_net ) From d27bc82d4aad2bbee233ddb5bbf97c6213ccd9e8 Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Wed, 13 Aug 2025 13:41:48 +0200 Subject: [PATCH 09/22] Comment out unused default zoom calculation The _calculate_default_zoom function and its usage in _set_viewer_camera have been commented out. This prepares for potential future implementation of zooming in on highlighted objects, but currently leaves the functionality inactive. --- .../_new_plotter_widget.py | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index fddc46f1..5e139fec 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -865,35 +865,35 @@ def _focus_object(layer, boolean_object_selected): _set_viewer_camera(viewer, transformed_center) - -def _calculate_default_zoom(viewer, margin: float = 0.05): - """ Calculate the default zoom level for the viewer based on the scene size and margin. +# 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. +# 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%). +# 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 +# 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. @@ -905,7 +905,8 @@ def _set_viewer_camera(viewer, coords): coords : np.ndarray The coordinates of a point to focus the camera on. """ - default_zoom = _calculate_default_zoom(viewer) viewer.dims.current_step = tuple(coords) viewer.camera.center = coords - viewer.camera.zoom = 4 * default_zoom \ No newline at end of file + # Zooming-in on highlighted objects (optional, not implemented for now) + # default_zoom = _calculate_default_zoom(viewer) + # viewer.camera.zoom = 4 * default_zoom \ No newline at end of file From 366ca65d6fc074e52c198bffb0181c6580ad661b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:03:10 +0000 Subject: [PATCH 10/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/napari_clusters_plotter/_new_plotter_widget.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 87383c3e..78f92548 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -801,7 +801,7 @@ def _reset(self): self._update_layer_colors(use_color_indices=False) 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. @@ -827,7 +827,7 @@ def _apply_affine_transform(coords, n_dims, affine_matrix): n_dims : int Number of dimensions of the coordinates. affine_matrix : np.ndarray - Affine transformation matrix (shape: (n_dims + 1, n_dims + 1)). + Affine transformation matrix (shape: (n_dims + 1, n_dims + 1)). Returns ------- @@ -919,16 +919,16 @@ def _focus_object(layer, boolean_object_selected): # 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 @@ -948,7 +948,7 @@ def _focus_object(layer, boolean_object_selected): def _set_viewer_camera(viewer, coords): """ Set the viewer camera to focus on the given coordinates. - + Parameters ---------- viewer : napari.Viewer From af4f460b097bebc2ea2c45edbff64a02161b568a Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Wed, 13 Aug 2025 15:09:02 +0200 Subject: [PATCH 11/22] Add tests for focusing on highlighted points in 3D Introduces two tests to verify that the viewer camera centers and dims step update correctly when a single or multiple points layers have highlighted selections in 3D mode. These tests ensure proper behavior when focusing on selected points, including handling of layer translations. --- .../_tests/test_plotter.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/napari_clusters_plotter/_tests/test_plotter.py b/src/napari_clusters_plotter/_tests/test_plotter.py index c6d56040..dbc4fc77 100644 --- a/src/napari_clusters_plotter/_tests/test_plotter.py +++ b/src/napari_clusters_plotter/_tests/test_plotter.py @@ -583,3 +583,53 @@ def test_cluster_visibility_toggle(make_napari_viewer, create_sample_layers): plotter_widget._on_show_plot_overlay(state=False) plotter_widget._on_show_plot_overlay(state=True) 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[len(layer.data) + 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 (considering layer translation) + np.testing.assert_allclose(viewer.camera.center, layer2.data[3][-3:] + translate, 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], layer2.data[3][0], rtol=1e-5) + From 41d206b0c4c481529738451690cca579facfa72d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:10:26 +0000 Subject: [PATCH 12/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../_tests/test_plotter.py | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/napari_clusters_plotter/_tests/test_plotter.py b/src/napari_clusters_plotter/_tests/test_plotter.py index 09ea31ba..8cddef8c 100644 --- a/src/napari_clusters_plotter/_tests/test_plotter.py +++ b/src/napari_clusters_plotter/_tests/test_plotter.py @@ -751,7 +751,9 @@ 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): +def test_focus_object_on_highlighted_single_selected_points_layer( + make_napari_viewer, +): from napari_clusters_plotter import PlotterWidget viewer = make_napari_viewer() @@ -769,33 +771,46 @@ def test_focus_object_on_highlighted_single_selected_points_layer(make_napari_vi 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) + 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) + 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): +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 + 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 + 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.zeros( + len(layer.data) + len(layer2.data), dtype=bool + ) boolean_object_selected[len(layer.data) + 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 (considering layer translation) - np.testing.assert_allclose(viewer.camera.center, layer2.data[3][-3:] + translate, rtol=1e-5) + np.testing.assert_allclose( + viewer.camera.center, layer2.data[3][-3:] + translate, 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], layer2.data[3][0], rtol=1e-5) - + np.testing.assert_allclose( + viewer.dims.current_step[0], layer2.data[3][0], rtol=1e-5 + ) From 96c07b3e898bd563da87cea94eaffc1b034d6f50 Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Wed, 13 Aug 2025 15:16:06 +0200 Subject: [PATCH 13/22] Fix missing round brackets and format code with black --- .../_new_plotter_widget.py | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 78f92548..aa17eb40 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -223,6 +223,7 @@ def _setup_callbacks(self): 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 ) @@ -809,16 +810,25 @@ def _on_highlighted_changed(self, boolean_object_selected: bool): if not np.any(boolean_object_selected): return if np.count_nonzero(boolean_object_selected) > 1: - print("Focus only works for single object selection, not focusing.") + print( + "Focus only works for single object selection, not focusing." + ) return features = self._get_features() - features_sub = features.iloc[np.argwhere(boolean_object_selected).flatten()] + features_sub = features.iloc[ + np.argwhere(boolean_object_selected).flatten() + ] layer = features_sub["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) + 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. + """Apply an affine transformation to one point. Parameters ---------- @@ -839,8 +849,9 @@ def _apply_affine_transform(coords, n_dims, affine_matrix): 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. + """Focus the viewer on the selected object in the layer. Parameters ---------- @@ -873,7 +884,9 @@ def _focus_object(layer, boolean_object_selected): ) _set_viewer_camera(viewer, transformed_center) # Set the selected data in the layer (only displays if single layer is selected) - layer.selected_data = set(np.argwhere(boolean_object_selected).flatten()) + 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 @@ -893,18 +906,22 @@ def _focus_object(layer, boolean_object_selected): ) _set_viewer_camera(viewer, transformed_center) 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 + 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 ) _set_viewer_camera(viewer, transformed_center) - layer.selected_data = set(np.argwhere(boolean_object_selected).flatten()) + 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 - if n_dims==3: + n_dims = layer.data.shape[-1] - 1 # exclude track ID dimension + if n_dims == 3: # 2D tracks center = selected_track[-3:] # last three dimensions are t, y, x else: @@ -946,8 +963,9 @@ def _focus_object(layer, boolean_object_selected): # ) # return default_zoom + def _set_viewer_camera(viewer, coords): - """ Set the viewer camera to focus on the given coordinates. + """Set the viewer camera to focus on the given coordinates. Parameters ---------- From c5dce52411ddb2f05e9764e4d4b0313f77770cb4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:16:09 +0000 Subject: [PATCH 14/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/napari_clusters_plotter/_new_plotter_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index aa17eb40..9eb9d5c3 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -14,8 +14,8 @@ cat10_mod_cmap_first_transparent, ) from napari.utils.colormaps import ALL_COLORMAPS -from napari.utils.transforms import Affine 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 From 9e9ef64ac1e32f9adbbd912d8277baa0db3a75cd Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Wed, 13 Aug 2025 15:25:00 +0200 Subject: [PATCH 15/22] Refactor track center selection with ternary operator Simplified the logic for selecting the center of a track in the _focus_object function by using a ternary operator, as suggested by SIM108. Required by pre-commit. --- src/napari_clusters_plotter/_new_plotter_widget.py | 7 +------ try_highlgihted_test.ipynb | 0 try_plotter.ipynb | 0 3 files changed, 1 insertion(+), 6 deletions(-) create mode 100644 try_highlgihted_test.ipynb create mode 100644 try_plotter.ipynb diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 9eb9d5c3..0d4bf7a2 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -921,12 +921,7 @@ def _focus_object(layer, boolean_object_selected): 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 - if n_dims == 3: - # 2D tracks - center = selected_track[-3:] # last three dimensions are t, y, x - else: - # 3D tracks - center = selected_track[-4:] + center = selected_track[-3:] if n_dims == 3 else selected_track[-4:] transformed_center = _apply_affine_transform( center, n_dims, affine_net ) diff --git a/try_highlgihted_test.ipynb b/try_highlgihted_test.ipynb new file mode 100644 index 00000000..e69de29b diff --git a/try_plotter.ipynb b/try_plotter.ipynb new file mode 100644 index 00000000..e69de29b From 19a5ee007747f8cfa1185f1a629b488eda07e06b Mon Sep 17 00:00:00 2001 From: Marcelo Zoccoler Date: Wed, 13 Aug 2025 15:25:44 +0200 Subject: [PATCH 16/22] Remove test files --- try_highlgihted_test.ipynb | 0 try_plotter.ipynb | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 try_highlgihted_test.ipynb delete mode 100644 try_plotter.ipynb diff --git a/try_highlgihted_test.ipynb b/try_highlgihted_test.ipynb deleted file mode 100644 index e69de29b..00000000 diff --git a/try_plotter.ipynb b/try_plotter.ipynb deleted file mode 100644 index e69de29b..00000000 From 320131807629e26301017680a17fc3b841e17948 Mon Sep 17 00:00:00 2001 From: Johannes Soltwedel <38459088+jo-mueller@users.noreply.github.com> Date: Thu, 14 Aug 2025 12:24:34 +0200 Subject: [PATCH 17/22] use layer names for id, not UUID --- src/napari_clusters_plotter/_algorithm_widget.py | 2 +- src/napari_clusters_plotter/_new_plotter_widget.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) 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/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 0d4bf7a2..9a80b172 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -145,7 +145,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): @@ -243,7 +243,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 + features["layer"] == layer.name ].index # store latest cluster indeces in the features table @@ -727,7 +727,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] From 2b4bcd5e892d4dcb36059290b156f9bba5091fe5 Mon Sep 17 00:00:00 2001 From: Johannes Soltwedel <38459088+jo-mueller@users.noreply.github.com> Date: Thu, 14 Aug 2025 12:37:08 +0200 Subject: [PATCH 18/22] identify layer by name, not unique_id --- src/napari_clusters_plotter/_dim_reduction_and_clustering.py | 4 ++-- .../_tests/test_dimensionality_reduction.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/napari_clusters_plotter/_dim_reduction_and_clustering.py b/src/napari_clusters_plotter/_dim_reduction_and_clustering.py index 61570e41..483e28e4 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 @@ -114,7 +114,7 @@ def _process_result(self, result: pd.DataFrame): current_features = layer.features for column in column_names: layer_feature_subset = result[ - result["layer"] == layer.unique_id + result["layer"] == layer.name ] # add the columns to the features 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): From 5dae1c1394c70209f0df224a011f25eb9fe8902b Mon Sep 17 00:00:00 2001 From: Johannes Soltwedel <38459088+jo-mueller@users.noreply.github.com> Date: Thu, 14 Aug 2025 12:37:36 +0200 Subject: [PATCH 19/22] replaced print with napari info --- src/napari_clusters_plotter/_new_plotter_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 9a80b172..7472768e 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -810,8 +810,8 @@ def _on_highlighted_changed(self, boolean_object_selected: bool): if not np.any(boolean_object_selected): return if np.count_nonzero(boolean_object_selected) > 1: - print( - "Focus only works for single object selection, not focusing." + napari.utils.notifications.show_info( + "Multiple objects selected - only single objects can be highlighted." ) return features = self._get_features() From 02f7b10e06e85199c727970d0a086762cbe8c348 Mon Sep 17 00:00:00 2001 From: Johannes Soltwedel <38459088+jo-mueller@users.noreply.github.com> Date: Thu, 14 Aug 2025 12:37:54 +0200 Subject: [PATCH 20/22] shortened code --- src/napari_clusters_plotter/_new_plotter_widget.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index 7472768e..d4936349 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -815,10 +815,7 @@ def _on_highlighted_changed(self, boolean_object_selected: bool): ) return features = self._get_features() - features_sub = features.iloc[ - np.argwhere(boolean_object_selected).flatten() - ] - layer = features_sub["layer"].values[0] + layer = features[boolean_object_selected]["layer"].values[0] boolean_object_selected_in_layer = boolean_object_selected[ features["layer"] == layer ] @@ -882,7 +879,7 @@ def _focus_object(layer, boolean_object_selected): transformed_center = _apply_affine_transform( center, n_dims, affine_net ) - _set_viewer_camera(viewer, transformed_center) + # Set the selected data in the layer (only displays if single layer is selected) layer.selected_data = set( np.argwhere(boolean_object_selected).flatten() @@ -895,7 +892,6 @@ def _focus_object(layer, boolean_object_selected): transformed_center = _apply_affine_transform( center, n_dims, affine_net ) - _set_viewer_camera(viewer, transformed_center) # 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): @@ -904,7 +900,6 @@ def _focus_object(layer, boolean_object_selected): transformed_center = _apply_affine_transform( center, n_dims, affine_net ) - _set_viewer_camera(viewer, transformed_center) elif isinstance(layer, napari.layers.Shapes): selected_shape = layer.data[ np.nonzero(boolean_object_selected)[0][0] @@ -914,7 +909,6 @@ def _focus_object(layer, boolean_object_selected): transformed_center = _apply_affine_transform( center, n_dims, affine_net ) - _set_viewer_camera(viewer, transformed_center) layer.selected_data = set( np.argwhere(boolean_object_selected).flatten() ) @@ -925,7 +919,8 @@ def _focus_object(layer, boolean_object_selected): transformed_center = _apply_affine_transform( center, n_dims, affine_net ) - _set_viewer_camera(viewer, transformed_center) + + _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 From a88247a5eeaa2169a68c38fadc7ef8aa20c78a06 Mon Sep 17 00:00:00 2001 From: Johannes Soltwedel <38459088+jo-mueller@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:15:23 +0200 Subject: [PATCH 21/22] fixed test --- .../_tests/test_plotter.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/napari_clusters_plotter/_tests/test_plotter.py b/src/napari_clusters_plotter/_tests/test_plotter.py index 8cddef8c..cc7ed720 100644 --- a/src/napari_clusters_plotter/_tests/test_plotter.py +++ b/src/napari_clusters_plotter/_tests/test_plotter.py @@ -801,16 +801,27 @@ def test_focus_object_on_highlighted_multi_selected_points_layers( boolean_object_selected = np.zeros( len(layer.data) + len(layer2.data), dtype=bool ) - boolean_object_selected[len(layer.data) + 3] = True + 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) - np.testing.assert_allclose( - viewer.camera.center, layer2.data[3][-3:] + translate, rtol=1e-5 + 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 - np.testing.assert_allclose( - viewer.dims.current_step[0], layer2.data[3][0], rtol=1e-5 + 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) \ No newline at end of file From b470a8449ab3f6482128a2231136c3e5a541ca7d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:32:52 +0000 Subject: [PATCH 22/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../_dim_reduction_and_clustering.py | 4 +--- .../_new_plotter_widget.py | 6 ++--- .../_tests/test_plotter.py | 22 ++++++++++++++----- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/napari_clusters_plotter/_dim_reduction_and_clustering.py b/src/napari_clusters_plotter/_dim_reduction_and_clustering.py index 483e28e4..527dc473 100644 --- a/src/napari_clusters_plotter/_dim_reduction_and_clustering.py +++ b/src/napari_clusters_plotter/_dim_reduction_and_clustering.py @@ -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.name - ] + 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 782b307e..10486d2f 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -244,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.name - ].index + layer_indices = features[features["layer"] == layer.name].index # store latest cluster indeces in the features table layer.features["MANUAL_CLUSTER_ID"] = pd.Series( @@ -953,7 +951,7 @@ def _focus_object(layer, boolean_object_selected): transformed_center = _apply_affine_transform( center, n_dims, affine_net ) - + _set_viewer_camera(viewer, transformed_center) diff --git a/src/napari_clusters_plotter/_tests/test_plotter.py b/src/napari_clusters_plotter/_tests/test_plotter.py index 05f6517c..d48df6bb 100644 --- a/src/napari_clusters_plotter/_tests/test_plotter.py +++ b/src/napari_clusters_plotter/_tests/test_plotter.py @@ -801,8 +801,12 @@ def test_focus_object_on_highlighted_multi_selected_points_layers( 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] + 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 @@ -810,10 +814,12 @@ def test_focus_object_on_highlighted_multi_selected_points_layers( # 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] + 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 + 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( @@ -821,10 +827,14 @@ def test_focus_object_on_highlighted_multi_selected_points_layers( ) -if __name__ == '__main__': +if __name__ == "__main__": import napari - test_focus_object_on_highlighted_multi_selected_points_layers(napari.Viewer) + test_focus_object_on_highlighted_multi_selected_points_layers( + napari.Viewer + ) + + @pytest.mark.parametrize( "create_sample_layers", [