diff --git a/pyproject.toml b/pyproject.toml index ab084335..7ff1e0ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "napari-skimage-regionprops>=0.3.1", "scikit-image", "scipy", - "biaplotter>=0.2.0", + "biaplotter>=0.3.1", "imagecodecs" ] diff --git a/src/napari_clusters_plotter/_new_plotter_widget.py b/src/napari_clusters_plotter/_new_plotter_widget.py index fd6fc580..8e95b749 100644 --- a/src/napari_clusters_plotter/_new_plotter_widget.py +++ b/src/napari_clusters_plotter/_new_plotter_widget.py @@ -4,8 +4,11 @@ import napari import numpy as np import pandas as pd +from biaplotter.artists import Histogram2D, Scatter +from biaplotter.colormap import BiaColormap from biaplotter.plotter import CanvasWidget -from matplotlib.pyplot import cm as plt_colormaps +from matplotlib.cm import viridis +from matplotlib.colors import LinearSegmentedColormap from nap_plot_tools.cmap import ( cat10_mod_cmap, cat10_mod_cmap_first_transparent, @@ -39,6 +42,7 @@ class PlotterWidget(BaseWidget): def __init__(self, napari_viewer): super().__init__(napari_viewer) self._setup_ui(napari_viewer) + self.layers_being_unselected = [] self._on_update_layer_selection(None) self._setup_callbacks() @@ -49,10 +53,20 @@ def __init__(self, napari_viewer): self.colormap_reference = { (True, "HISTOGRAM2D"): cat10_mod_cmap_first_transparent, (True, "SCATTER"): cat10_mod_cmap, - (False, "HISTOGRAM2D"): plt_colormaps.magma, - (False, "SCATTER"): plt_colormaps.magma, + (False, "HISTOGRAM2D"): self._napari_to_mpl_cmap( + self.overlay_colormap_plot + ), + (False, "SCATTER"): self._napari_to_mpl_cmap( + self.overlay_colormap_plot + ), } + def _napari_to_mpl_cmap(self, colormap_name): + return LinearSegmentedColormap.from_list( + ALL_COLORMAPS[colormap_name].name, + ALL_COLORMAPS[colormap_name].colors, + ) + def _setup_ui(self, napari_viewer): """ Helper function to set up the UI of the widget. @@ -72,6 +86,9 @@ def _setup_ui(self, napari_viewer): self.layout.setAlignment(Qt.AlignTop) self.plotting_widget = CanvasWidget(napari_viewer, self) + self.plotting_widget.artists["HISTOGRAM2D"]._histogram_colormap = ( + BiaColormap(viridis) + ) # Start histogram colormap with viridis self.plotting_widget.active_artist = "SCATTER" # Add plot and options as widgets @@ -82,16 +99,27 @@ def _setup_ui(self, napari_viewer): self.hue: QComboBox = self.control_widget.hue_box self.control_widget.plot_type_box.addItems(["SCATTER", "HISTOGRAM2D"]) - - self.control_widget.cmap_box.addItems(list(ALL_COLORMAPS.keys())) - self.control_widget.cmap_box.setCurrentIndex( + # Fill overlay colormap box with all available colormaps + self.control_widget.overlay_cmap_box.addItems( + list(ALL_COLORMAPS.keys()) + ) + self.control_widget.overlay_cmap_box.setCurrentIndex( np.argwhere(np.array(list(ALL_COLORMAPS.keys())) == "magma")[0][0] ) + # Fill histogram colormap box with all available colormaps + self.control_widget.histogram_cmap_box.addItems( + list(ALL_COLORMAPS.keys()) + ) + self.control_widget.histogram_cmap_box.setCurrentIndex( + np.argwhere(np.array(list(ALL_COLORMAPS.keys())) == "viridis")[0][ + 0 + ] + ) # Setting Visibility Defaults - self.control_widget.manual_bins_container.setVisible(False) + self.control_widget.cmap_container.setVisible(False) self.control_widget.bins_settings_container.setVisible(False) - self.control_widget.log_scale_container.setVisible(False) + self.control_widget.additional_options_container.setVisible(False) def _setup_callbacks(self): """ @@ -101,29 +129,21 @@ def _setup_callbacks(self): # Connect all necessary functions to the replot connections_to_replot = [ ( - self.control_widget.plot_type_box.currentIndexChanged, + self.control_widget.log_scale_checkbutton.toggled, self.plot_needs_update.emit, ), ( - self.control_widget.set_bins_button.clicked, + self.control_widget.histogram_cmap_box.currentTextChanged, self.plot_needs_update.emit, ), ( - self.control_widget.auto_bins_checkbox.stateChanged, - self.plot_needs_update.emit, - ), - ( - self.control_widget.log_scale_checkbutton.stateChanged, + self.control_widget.n_bins_box.valueChanged, self.plot_needs_update.emit, ), ( self.control_widget.non_selected_checkbutton.stateChanged, self.plot_needs_update.emit, ), - ( - self.control_widget.cmap_box.currentIndexChanged, - self.plot_needs_update.emit, - ), ] for signal, callback in connections_to_replot: @@ -147,11 +167,20 @@ def _setup_callbacks(self): # connect data selection in plot to layer coloring update for selector in self.plotting_widget.selectors.values(): selector.selection_applied_signal.connect(self._on_finish_draw) + self.plotting_widget.show_color_overlay_signal.connect( + self._on_show_plot_overlay + ) # connect scatter/histogram switch self.control_widget.plot_type_box.currentTextChanged.connect( self._on_plot_type_changed ) + self.control_widget.overlay_cmap_box.currentTextChanged.connect( + self._on_overlay_colormap_changed + ) + self.control_widget.auto_bins_checkbox.toggled.connect( + self._on_bin_auto_toggled + ) def _on_finish_draw(self, color_indices: np.ndarray): """ @@ -179,6 +208,30 @@ def _on_finish_draw(self, color_indices: np.ndarray): self.plot_needs_update.emit() + def _handle_advanced_options_widget_visibility(self): + """ + Control visibility of overlay colormap box and log scale checkbox + based on the selected hue axis and active artist. + """ + active_artist = self.plotting_widget.active_artist + # Control visibility of overlay colormap box and log scale checkbox + if self.hue_axis in self.categorical_columns: + self.control_widget.overlay_cmap_box.setEnabled(False) + self.control_widget.log_scale_checkbutton.setEnabled(False) + if isinstance(active_artist, Histogram2D): + # Enable if histogram to allow log scale of histogram itself + self.control_widget.log_scale_checkbutton.setEnabled(True) + else: + self.control_widget.overlay_cmap_box.setEnabled(True) + self.control_widget.log_scale_checkbutton.setEnabled(True) + + if isinstance(active_artist, Histogram2D): + self.control_widget.cmap_container.setVisible(True) + self.control_widget.bins_settings_container.setVisible(True) + else: + self.control_widget.cmap_container.setVisible(False) + self.control_widget.bins_settings_container.setVisible(False) + def _replot(self): """ Replot the data with the current settings. @@ -193,25 +246,59 @@ def _replot(self): x_data = features[self.x_axis].values y_data = features[self.y_axis].values - # select appropriate colormap for usecase - cmap = self.colormap_reference[ + # select appropriate overlay colormap for usecase + overlay_cmap = self.colormap_reference[ (self.hue_axis in self.categorical_columns, self.plotting_type) ] - self.plotting_widget.active_artist.overlay_colormap = cmap + self._handle_advanced_options_widget_visibility() - # set the data and color indices in the active artist active_artist = self.plotting_widget.active_artist + color_norm = "log" if self.log_scale else "linear" + # First set the data related properties in the active artist active_artist.data = np.stack([x_data, y_data], axis=1) + if isinstance(active_artist, Histogram2D): + active_artist.histogram_colormap = self._napari_to_mpl_cmap( + self.histogram_colormap_plot + ) + if self.automatic_bins: + number_bins = int( + np.max( + [ + self._estimate_number_bins(x_data), + self._estimate_number_bins(y_data), + ] + ) + ) + # Block signal to avoid replotting while setting value + self.control_widget.n_bins_box.blockSignals(True) + self.bin_number = number_bins + self.control_widget.n_bins_box.blockSignals(False) + active_artist.bins = self.bin_number + active_artist.histogram_color_normalization_method = color_norm + + # Then set color_indices and colormap properties in the active artist + active_artist.overlay_colormap = overlay_cmap active_artist.color_indices = features[self.hue_axis].to_numpy() - self._color_layer_by_value() + # Force overlay to be visible if non-categorical hue axis is selected + if self.hue_axis not in self.categorical_columns: + self.plotting_widget.show_color_overlay = True - # this makes sure that previously drawn clusters are preserved - # when a layer is re-selected or different features are plotted - # if "MANUAL_CLUSTER_ID" in features.columns: - # self.plotting_widget.active_artist.color_indices = features[ - # "MANUAL_CLUSTER_ID" - # ].to_numpy() + # If color_indices are all zeros (no selection) and the hue axis + # is categorical, apply default colors + if ( + np.all(active_artist.color_indices == 0) + and self.hue_axis in self.categorical_columns + ): + self._update_layer_colors(use_color_indices=False) + + # Otherwise, color layer by value (optionally applying log scale) + else: + if isinstance(active_artist, Histogram2D): + active_artist.overlay_color_normalization_method = color_norm + elif isinstance(active_artist, Scatter): + active_artist.color_normalization_method = color_norm + self._update_layer_colors(use_color_indices=True) def _on_frame_changed(self, event: napari.utils.events.Event): """ @@ -248,18 +335,30 @@ def _on_plot_type_changed(self): ) self._replot() - def _checkbox_status_changed(self): + def _on_overlay_colormap_changed(self): + colormap_name = self.overlay_colormap_plot + # Dynamically update the colormap_reference dictionary + self.colormap_reference[(False, "HISTOGRAM2D")] = ( + self._napari_to_mpl_cmap(colormap_name) + ) + self.colormap_reference[(False, "SCATTER")] = self._napari_to_mpl_cmap( + colormap_name + ) self._replot() - def _bin_number_set(self): + def _on_histogram_colormap_changed(self): self._replot() - def _bin_auto(self): - self.control_widget.manual_bins_container.setVisible( - not self.control_widget.auto_bins_checkbox.isChecked() - ) - if self.control_widget.auto_bins_checkbox.isChecked(): - self._replot() + def _checkbox_status_changed(self): + self._replot() + + def _on_bin_auto_toggled(self, state: bool): + """ + Called when the automatic bin checkbox is toggled. + Enables or disables the bin number box accordingly. + """ + self.control_widget.n_bins_box.setEnabled(not state) + self._replot() # Connecting the widgets to actual object variables: # using getters and setters for flexibility @@ -283,6 +382,10 @@ def automatic_bins(self, val: bool): def bin_number(self): return self.control_widget.n_bins_box.value() + @bin_number.setter + def bin_number(self, val: int): + self.control_widget.n_bins_box.setValue(val) + @property def hide_non_selected(self): return self.control_widget.non_selected_checkbutton.isChecked() @@ -292,8 +395,12 @@ def hide_non_selected(self, val: bool): self.control_widget.non_selected_checkbutton.setChecked(val) @property - def colormap_plot(self): - return self.control_widget.cmap_box.currentText() + def overlay_colormap_plot(self): + return self.control_widget.overlay_cmap_box.currentText() + + @property + def histogram_colormap_plot(self): + return self.control_widget.histogram_cmap_box.currentText() @property def plotting_type(self): @@ -338,6 +445,27 @@ def hue_axis(self, column: str): ) self.control_widget.hue_box.setCurrentText(column) + def _estimate_number_bins(self, data) -> int: + """ + Estimates number of bins according Freedman–Diaconis rule + + Parameters + ---------- + data: Numpy array + + Returns + ------- + Estimated number of bins + """ + from scipy.stats import iqr + + est_a = (np.max(data) - np.min(data)) / ( + 2 * iqr(data) / np.cbrt(len(data)) + ) + if np.isnan(est_a): + return 256 + return int(est_a) + def _on_update_layer_selection( self, event: napari.utils.events.Event ) -> None: @@ -369,6 +497,9 @@ def _on_update_layer_selection( ).astype("category") self.layers = list(self.viewer.layers.selection) + if event is not None and len(event.removed) > 0: + # remove the layers that are not in the selection anymore + self.layers_being_unselected = list(event.removed) self._update_feature_selection(None) for layer in self.layers: @@ -465,84 +596,129 @@ def _set_categorical_column_styles(self, selector, categorical_columns): index, "Categorical Column", Qt.ToolTipRole ) - def _color_layer_by_value(self): + def _on_show_plot_overlay(self, state: bool) -> None: + """ + Called when the plot overlay is hidden or shown. """ - Color the selected layer according to the color indices. + self._update_layer_colors(use_color_indices=state) + + def _generate_default_colors(self, layer): """ + Generate default colors for a given layer based on its type. + + Parameters + ---------- + layer : napari.layers.Layer + The layer for which to generate default colors. + + Returns + ------- + np.ndarray + An array of default colors (Nx4). + """ + if isinstance(layer, napari.layers.Labels): + # Use CyclicLabelColormap with N colors + from napari.utils.colormaps.colormap_utils import label_colormap + + n_labels = ( + np.unique(layer.data).size - 1 + ) # unique labels (minus background: 0) + return np.asarray( + label_colormap(n_labels).dict()["colors"] + ) # rgba + else: + # Default to white for other layer types + return np.array([[1, 1, 1, 1]]) + + def _update_layer_colors(self, use_color_indices: bool = False) -> None: + """ + Update colors for the selected layers based on the context. + + Parameters + ---------- + use_color_indices : bool, optional + If True, apply colors based on the active artist's color indices + (unless show_color_overlay is False). + If False, apply default colors to the layers. + Defaults to False. + """ + if self.n_selected_layers == 0: + return + + # Disable coloring based on color_indices if overlay toggle unchecked + if not self.plotting_widget.show_color_overlay: + use_color_indices = False features = self._get_features() active_artist = self.plotting_widget.active_artist - rgba_colors = active_artist.color_indices_to_rgba( - active_artist.color_indices - ) for selected_layer in self.viewer.layers.selection: - layer_indices = features[ - features["layer"] == selected_layer.name - ].index - _apply_layer_color(selected_layer, rgba_colors[layer_indices]) + if use_color_indices: + # Apply colors based on color indices + rgba_colors = active_artist.color_indices_to_rgba( + active_artist.color_indices + ) + layer_indices = features[ + features["layer"] == selected_layer.name + ].index + self._set_layer_color( + selected_layer, rgba_colors[layer_indices] + ) - # store latest cluster indeces in the features table - if self.hue_axis == "MANUAL_CLUSTER_ID": - selected_layer.features["MANUAL_CLUSTER_ID"] = pd.Series( - active_artist.color_indices[layer_indices] - ).astype("category") + # Update MANUAL_CLUSTER_ID if applicable + if self.hue_axis == "MANUAL_CLUSTER_ID": + selected_layer.features["MANUAL_CLUSTER_ID"] = pd.Series( + active_artist.color_indices[layer_indices] + ).astype("category") + else: + # Apply default colors + rgba_colors = self._generate_default_colors(selected_layer) + self._set_layer_color(selected_layer, rgba_colors) + + # Apply default colors to layers being unselected + for layer in self.layers_being_unselected: + if layer in self.viewer.layers: + rgba_colors = self._generate_default_colors(layer) + self._set_layer_color(layer, rgba_colors) + self.layers_being_unselected = [] + + def _set_layer_color(self, layer, colors): + """ + Set colors for a specific layer based on its type. + + Parameters + ---------- + layer : napari.layers.Layer + The layer to color. + + colors : np.ndarray + The color array (Nx4). + """ + if isinstance(layer, napari.layers.Points): + layer.face_color = colors + elif isinstance(layer, napari.layers.Vectors): + layer.edge_color = colors + elif isinstance(layer, napari.layers.Surface): + layer.vertex_colors = colors + elif isinstance(layer, napari.layers.Shapes): + layer.face_color = colors + elif isinstance(layer, napari.layers.Labels): + # Ensure the first color is transparent for the background + colors = np.insert(colors, 0, [0, 0, 0, 0], axis=0) + from napari.utils import DirectLabelColormap + + color_dict = dict(zip(np.unique(layer.data), colors)) + layer.colormap = DirectLabelColormap(color_dict=color_dict) + layer.refresh() def _reset(self): """ Reset the selection in the current plotting widget. """ - if self.n_selected_layers == 0: return self.plotting_widget.active_artist.color_indices = np.zeros( len(self._get_features()) ) - self._color_layer_by_value() - - -def _apply_layer_color(layer, colors): - """ - Apply colors to the layer based on the layer type. - - Parameters - ---------- - layer : napari.layers.Layer - The layer to color. - - colors : np.ndarray - The color array (Nx4). - """ - from napari.utils import DirectLabelColormap - - if isinstance(layer, napari.layers.Points): - layer.face_color = colors - - elif isinstance(layer, napari.layers.Vectors): - layer.edge_color = colors - - elif isinstance(layer, napari.layers.Surface): - layer.vertex_colors = colors - - elif isinstance(layer, napari.layers.Shapes): - layer.face_color = colors - - elif isinstance(layer, napari.layers.Tracks): - layer._track_colors = colors - layer.events.color_by() - - elif isinstance(layer, napari.layers.Labels): - - colors = np.insert(colors, 0, [0, 0, 0, 0], axis=0) - color_dict = dict(zip(np.unique(layer.data), colors)) - - # Insert default colors for labels that are not in the color_dict - # Relevant for non-sequential label images - if max(color_dict.keys()) > len(colors): - for i in range(1, max(color_dict.keys()) - 1): - color_dict[i] = [0, 0, 0, 0] - # Add a color for the background at the first index - layer.colormap = DirectLabelColormap(color_dict=color_dict) - - layer.refresh() + self._update_layer_colors(use_color_indices=False) diff --git a/src/napari_clusters_plotter/_tests/test_plotter.py b/src/napari_clusters_plotter/_tests/test_plotter.py index f92f193b..e2316dc6 100644 --- a/src/napari_clusters_plotter/_tests/test_plotter.py +++ b/src/napari_clusters_plotter/_tests/test_plotter.py @@ -187,8 +187,8 @@ def create_multi_shapes_layers(n_samples: int = 100): shapes1, shapes2 = [], [] for i in range(len(points1.data)): - # create a random shape around the point, whereas the shape consists of the coordinates - # of the four corner of the rectangle + # create a random shape around the point, whereas the shape + # consists of the coordinates of the four corner of the rectangle y, x = points1.data[i, 2], points1.data[i, 3] w, h = np.random.randint(1, 5), np.random.randint(1, 5) @@ -203,8 +203,8 @@ def create_multi_shapes_layers(n_samples: int = 100): shapes1.append(shape1) for i in range(len(points2.data)): - # create a random shape around the point, whereas the shape consists of the coordinates - # of the four corner of the rectangle + # create a random shape around the point, whereas the shape consists + # of the coordinates of the four corner of the rectangle y, x = points2.data[i, 2], points2.data[i, 3] w, h = np.random.randint(1, 5), np.random.randint(1, 5) @@ -480,4 +480,10 @@ def test_histogram_support(make_napari_viewer, create_sample_layers): assert "MANUAL_CLUSTER_ID" in layer.features.columns assert "MANUAL_CLUSTER_ID" in layer2.features.columns + # trigger manual bin size + plotter_widget.automatic_bins = False + plotter_widget.bin_number = 10 + plotter_widget.control_widget.histogram_cmap_box.setCurrentText("viridis") + plotter_widget.control_widget.overlay_cmap_box.setCurrentText("viridis") + plotter_widget.plotting_type = "SCATTER" diff --git a/src/napari_clusters_plotter/plotter_inputs.ui b/src/napari_clusters_plotter/plotter_inputs.ui index dd85399c..265e6ac8 100644 --- a/src/napari_clusters_plotter/plotter_inputs.ui +++ b/src/napari_clusters_plotter/plotter_inputs.ui @@ -55,6 +55,9 @@ true + + <html><head/><body><p>Feature used to color data</p><p>By default, <span style=" font-family:'Consolas','Courier New','monospace'; font-size:14px; color:#ce9178;">MANUAL_CLUSTER_ID</span>, which displays manual selections.</p></body></html> + @@ -81,114 +84,126 @@ Advanced Options - - + + - false + true - - - - - Hide non-selected clusters + + + + + true + + + - - - - - Colormap + Colors in Log Scale - - + + + + <html><head/><body><p>If checked, display histogram colors and overlay colors in log scale.</p><p>To enale this, select a HISTOGRAM2D in the <span style=" font-weight:600;">Plotting Type</span> Combobox.</p></body></html> + - - - - - - - false - - - - - - Log scale + + + + + + + true + + + - - + + - + Plotting Type - - - - - - - false + + + + + + + <html><head/><body><p>Continuous colormap to display a feature as colors over plotted data.</p><p>Use the eye button in the plotter controls to hide/show the overlay colors.</p><p>To enale this, select a non-categorical feature in the <span style=" font-weight:600;">Hue</span> Combobox.</p></body></html> + + + + - Auto - - - true + Overlay Colormap - + + + + + + + true + + + - Number of bins + Number of Bins - + true - + - false + true - - 1 + + Auto - - 1000 - - - 10 + + true - + false - - Set + + 1 + + + 10000 + + + 20 @@ -198,20 +213,43 @@ - - - + + + - + - Plotting type + Histogram Colormap - - - true + + + <html><head/><body><p>Continuous colormap to display the histogram data (not the colors overlay).</p></body></html> + + + + + + + + + + false + + + + + + Hide non-selected clusters + + + + + + +