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
+
+
+
+ -
+
+
+