diff --git a/docs/scatterplot.ipynb b/docs/scatterplot.ipynb index fbd5364..07a5978 100644 --- a/docs/scatterplot.ipynb +++ b/docs/scatterplot.ipynb @@ -18,7 +18,7 @@ { "data": { "text/plain": [ - "'0.7.11'" + "'0.11.2'" ] }, "execution_count": 1, @@ -29,7 +29,6 @@ "source": [ "import pandas as pd\n", "import numpy as np\n", - "import stackview\n", "import pandas as pd\n", "from skimage.measure import regionprops_table\n", "from skimage.io import imread\n", @@ -37,6 +36,7 @@ "from skimage.measure import label\n", "import matplotlib.pyplot as plt\n", "\n", + "import stackview\n", "stackview.__version__" ] }, @@ -230,12 +230,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "90f8e79f06174f038601ab84bbba8f64", + "model_id": "b7a2b1ced0a24c678c748b2ba9a038cf", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "HBox(children=(VBox(children=(VBox(children=(HBox(children=(Label(value='Axes '), Dropdown(index=2, layout=Lay…" + "HBox(children=(VBox(children=(VBox(children=(HBox(children=(Dropdown(description='X-axis', index=2, layout=Lay…" ] }, "execution_count": 4, @@ -247,6 +247,40 @@ "stackview.scatterplot(df, 'area', 'feret_diameter_max', \"selection\", figsize=(5,4))" ] }, + { + "cell_type": "markdown", + "id": "7315ae2c", + "metadata": {}, + "source": [ + "You can also display a histogram if you select twice the same measurement:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "0cd953cd", + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "b255de5c0f184c6d9960ab48948b65e4", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "HBox(children=(VBox(children=(VBox(children=(HBox(children=(Dropdown(description='X-axis', index=2, layout=Lay…" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "stackview.scatterplot(df, 'area', 'area', \"selection\", figsize=(5,4))" + ] + }, { "cell_type": "markdown", "id": "70165553-2c82-40d4-8db4-5287786b1db2", @@ -257,7 +291,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "id": "137e5269-f5f7-4f38-b562-8eccf752b092", "metadata": {}, "outputs": [ @@ -267,7 +301,7 @@ "
| \n",
- " | \n",
"\n", "\n", @@ -292,7 +326,7 @@ " [0, 0, 0, ..., 0, 0, 0]], dtype=uint32)" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -316,22 +350,22 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "7b2bbd63-3255-4ada-94a6-b77207c8efaf", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "98ab4d4f5a1f41329a2d7d039cf57d52", + "model_id": "eae81136e1bd47d49a7138317b4a8b8f", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "VBox(children=(HBox(children=(VBox(children=(HBox(children=(VBox(children=(ImageWidget(height=406, width=409),…" + "VBox(children=(HBox(children=(HBox(children=(VBox(children=(VBox(children=(HBox(children=(VBox(children=(Image…" ] }, - "execution_count": 6, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -356,7 +390,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "id": "0aa32ebb-7539-48e1-a8c4-be0deb255d05", "metadata": {}, "outputs": [ @@ -377,7 +411,7 @@ "Name: selection, Length: 64, dtype: float64" ] }, - "execution_count": 7, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -404,12 +438,12 @@ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "6364820096204b41969d4d12eeabd489", + "model_id": "5b6634c67e684f0da09bd949c4952c39", "version_major": 2, "version_minor": 0 }, "text/plain": [ - "HBox(children=(HBox(children=(VBox(children=(VBox(children=(HBox(children=(Label(value='Axes '), Dropdown(layo…" + "HBox(children=(HBox(children=(VBox(children=(VBox(children=(HBox(children=(Dropdown(description='X-axis', layo…" ] }, "execution_count": 10, @@ -429,7 +463,7 @@ " widget1.update()\n", " \n", "widget1 = stackview.scatterplot(df, column_x=\"centroid-0\", column_y=\"centroid-1\", selection_changed_callback=update2, markersize=50)\n", - "widget2 = stackview.scatterplot(df, column_x=\"area\", column_y=\"aspect_ratio\", selection_changed_callback=update1)\n", + "widget2 = stackview.scatterplot(df, column_x=\"area\", column_y=\"area\", selection_changed_callback=update1)\n", "\n", "# Arrange the widgets side by side using HBox\n", "HBox([widget1, widget2])\n" @@ -438,7 +472,7 @@ { "cell_type": "code", "execution_count": null, - "id": "e54890a5-e89c-4861-bf5b-8aa1c5f7f697", + "id": "f3332216", "metadata": {}, "outputs": [], "source": [] @@ -446,7 +480,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "allrounder_env", "language": "python", "name": "python3" }, @@ -460,7 +494,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.9" + "version": "3.12.2" } }, "nbformat": 4, diff --git a/stackview/_clusterplot.py b/stackview/_clusterplot.py index 885b06c..5afcded 100644 --- a/stackview/_clusterplot.py +++ b/stackview/_clusterplot.py @@ -75,6 +75,6 @@ def update(selection, label_image, selected_image, widget): return grid([[ image_display, scatterplot, - + ]]) diff --git a/stackview/_scatterplot.py b/stackview/_scatterplot.py index 8f30b62..1889927 100644 --- a/stackview/_scatterplot.py +++ b/stackview/_scatterplot.py @@ -26,40 +26,59 @@ def scatterplot(df, column_x: str = "x", column_y: str = "y", column_selection: An ipywidgets widget """ - from ipywidgets import VBox, HBox, Layout - from ._utilities import _no_resize + from ipywidgets import VBox, HBox, Layout, Output, Dropdown, Label + from IPython.display import display + + if column_x == column_y: + plotter = HistogramPlotter(df, column_x, column_selection, figsize, selection_changed_callback, bins=50) + else: + plotter = ScatterPlotter(df, column_x, column_y, column_selection, figsize, selection_changed_callback, markersize) - plotter = ScatterPlotter(df, column_x, column_y, column_selection, figsize, selection_changed_callback=selection_changed_callback, markersize=markersize) small_layout = Layout(width='auto', padding='0px', margin='0px', align_items='center', justify_content='center') - import ipywidgets - x_pulldown = ipywidgets.Dropdown( - options=list(df.columns), - value=column_x, - layout=small_layout - ) - y_pulldown = ipywidgets.Dropdown( - options=list(df.columns), - value=column_y, - layout=small_layout - ) + x_pulldown = Dropdown(options=list(df.columns), value=column_x, layout=small_layout) + y_pulldown = Dropdown(options=list(df.columns), value=column_y, layout=small_layout) + + plot_output = Output() + with plot_output: + display(plotter.widget) def on_change(event): - """Executed when the user changes the pulldown selection.""" if event['type'] == 'change' and event['name'] == 'value': - plotter.set_data(df, x_pulldown.value, y_pulldown.value) - plotter.update() + x_col = x_pulldown.value + y_col = y_pulldown.value + + nonlocal plotter + + is_histogram_now = (x_col == y_col) + is_histogram_current = (plotter.column_x == plotter.column_y) + + if is_histogram_now != is_histogram_current: + if is_histogram_now: + new_plotter = HistogramPlotter(df, x_col, column_selection, figsize, selection_changed_callback, bins=50) + else: + new_plotter = ScatterPlotter(df, x_col, y_col, column_selection, figsize, selection_changed_callback, markersize) + plotter = new_plotter + + with plot_output: + plot_output.clear_output(wait=True) + display(new_plotter.widget) + else: + if is_histogram_now: + plotter.set_data(df, x_col) + else: + plotter.set_data(df, x_col, y_col) + plotter.update() x_pulldown.observe(on_change) y_pulldown.observe(on_change) - result = _no_resize(VBox([ - HBox([ipywidgets.Label("Axes "), x_pulldown, y_pulldown], layout=small_layout), - plotter.widget - ])) + result = VBox([ + HBox([Label("Axes "), x_pulldown, y_pulldown], layout=small_layout), + plot_output + ]) result.update = plotter.update - return result @@ -90,21 +109,17 @@ def __init__(self, df, column_x, column_y, column_selection, figsize, selection_ from matplotlib._pylab_helpers import Gcf from IPython import get_ipython - # switch to interactive mode if we are in a Jupyter notebook ipython = get_ipython() if ipython is not None: ipython.run_line_magic("matplotlib", "ipympl") plt.ion() - # store variables self.set_data(df, column_x, column_y) self.selection_column = column_selection self.selection_changed_callback = selection_changed_callback self.markersize = markersize - # create figure self.fig = plt.figure(figsize=figsize) - #self.fig.tight_layout(pad=0, h_pad=0, w_pad=0) plt.subplots_adjust(left=0.15, right=1, top=1, bottom=0.1) self.ax = None @@ -116,23 +131,18 @@ def __init__(self, df, column_x, column_y, column_selection, figsize, selection_ self.fig.canvas.footer_visible = False self.fig.canvas.resizable = False - # prevent immediate display of the canvas manager = Gcf.get_active() Gcf.figs.pop(manager.num, None) self.selector = None - #self.selector = Selector(self.fig, self.ax, self.plotted_points, callback=self.set_selection) self.update() - # show selection if defined if column_selection in df.columns: self.selector.set_selection(df[column_selection]) self.update() self.widget = self.fig.canvas - - def set_data(self, df, column_x, column_y): self.dataframe = df self.column_x = column_x @@ -154,47 +164,205 @@ def update(self): self.selector = Selector(self.fig, self.ax, self.plotted_points, callback=self.set_selection) if self.selection_column in self.dataframe.columns: self.selector.set_selection(self.dataframe[self.selection_column]) - self.selector.update() + +class HistogramPlotter: + + def __init__(self, df, column, column_selection, figsize, selection_changed_callback, bins=15): + """ + An interactive histogram plotter for a single column of a pandas DataFrame. + Designed to be used in combination with ipywidgets for dynamic display. + + Parameters + ---------- + df : pandas.DataFrame + The dataframe containing the data to plot + column : str + The column of the dataframe to plot as a histogram + column_selection : str + The name of the column storing selection status (currently unused for histograms) + figsize : tuple + The size of the figure (width, height) + selection_changed_callback : function or None + Callback function triggered when selection changes (not used in histogram) + bins : int + Number of bins to use for the histogram + + Attributes + ---------- + widget : matplotlib.backend_bases.FigureCanvasBase + The canvas widget to be displayed in a Jupyter notebook + """ + import matplotlib.pyplot as plt + from matplotlib._pylab_helpers import Gcf + from IPython import get_ipython + + ipython = get_ipython() + if ipython is not None: + ipython.run_line_magic("matplotlib", "ipympl") + plt.ion() + + self.dataframe = df + self.column = column + self.column_x = column + self.column_y = column + self.selection_column = column_selection + self.selection_changed_callback = selection_changed_callback + self.bins = bins + + self.fig = plt.figure(figsize=figsize) + plt.subplots_adjust(left=0.15, right=1, top=1, bottom=0.1) + self.ax = self.fig.gca() + + self.update() + + self.selector = Selector(self.fig, self.ax, self.ax.patches, callback=self.set_selection, mode="hist") + self.widget = self.fig.canvas + + self.fig.canvas.toolbar_visible = False + self.fig.canvas.header_visible = False + self.fig.canvas.footer_visible = False + self.fig.canvas.resizable = False + + manager = Gcf.get_active() + Gcf.figs.pop(manager.num, None) + + self.widget = self.fig.canvas + + def set_data(self, df, column): + self.dataframe = df + self.column = column + self.column_x = column + self.column_y = column + + def set_selection(self, selection): + import numpy as np + + # Get the bin edges used by the current histogram + bin_counts, bin_edges = np.histogram(self.dataframe[self.column], bins=self.bins) + + # Create a boolean selection mask over the dataframe + selected = np.zeros(len(self.dataframe), dtype=bool) + + for i, selected_bin in enumerate(selection): + if selected_bin: + if i == len(bin_edges) - 2: + # Include right edge for last bin + in_bin = (self.dataframe[self.column] >= bin_edges[i]) & (self.dataframe[self.column] <= bin_edges[i + 1]) + else: + in_bin = (self.dataframe[self.column] >= bin_edges[i]) & (self.dataframe[self.column] < bin_edges[i + 1]) + selected |= in_bin + + self.dataframe[self.selection_column] = selected + if self.selection_changed_callback is not None: + self.selection_changed_callback(selected) + + + def update(self): + self.fig.clf() + self.ax = self.fig.gca() + counts, bins, patches = self.ax.hist(self.dataframe[self.column], bins=self.bins, color='steelblue') + self.ax.set_xlabel(self.column) + self.ax.set_ylabel("Frequency") + self.fig.canvas.draw_idle() + + if hasattr(self, "selector"): + self.selector = Selector(self.fig, self.ax, self.ax.patches, callback=self.set_selection, mode="hist") # modified from https://matplotlib.org/3.1.1/gallery/widgets/lasso_selector_demo_sgskip.html class Selector: - def __init__(self, parent, ax, collection, callback): + def __init__(self, parent, ax, collection, callback, mode= "scatter"): + """ + Interactive Lasso-based point selector for matplotlib scatter plots. + Highlights selected points and invokes a callback with the selection mask. + + Parameters + ---------- + parent : matplotlib.figure.Figure + The matplotlib figure containing the plot + ax : matplotlib.axes.Axes + The axes to attach the lasso selector to + collection : matplotlib.collections.PathCollection + The scatter plot collection from which points are selected + callback : function + A function that receives a boolean mask of selected points + + Attributes + ---------- + selected_indices : list + Indices of the currently selected points + face_colors : ndarray + Array of RGBA colors used to visualize selected/unselected points + """ from matplotlib.widgets import LassoSelector + from matplotlib.path import Path + self.parent = parent self.ax = ax self.canvas = ax.figure.canvas - self.offsets = collection.get_offsets() - self.num_points = len(self.offsets) self.collection = collection - - self.lasso = LassoSelector(ax, onselect=self.on_select, props=dict(color='magenta')) - self.selected_indices = [] + self.mode = mode self.callback = callback + self.lasso = LassoSelector(ax, onselect=self.on_select, props=dict(color='magenta')) + + if mode == "scatter": + self.offsets = collection.get_offsets() + self.num_points = len(self.offsets) - self.face_colors = collection.get_facecolors() - if len(self.face_colors) == 0: - raise ValueError('Collection must have a face color') - elif len(self.face_colors) == 1: - self.face_colors = np.tile(self.face_colors, (self.num_points, 1)) + self.face_colors = collection.get_facecolors() + if len(self.face_colors) == 0: + raise ValueError('Collection must have a face color') + elif len(self.face_colors) == 1: + self.face_colors = np.tile(self.face_colors, (self.num_points, 1)) + elif mode == "hist": + self.offsets = None + self.num_points = len(collection) def on_select(self, verts): from matplotlib.path import Path path = Path(verts) - selection = path.contains_points(self.offsets) - self.callback(selection) + + if self.mode == "scatter": + selection = path.contains_points(self.offsets) + self.callback(selection) + elif self.mode == "hist": + selection = np.zeros(len(self.collection), dtype=bool) + for i, patch in enumerate(self.collection): + x = patch.get_x() + y = patch.get_y() + width = patch.get_width() + height = patch.get_height() + + # Define key points inside the bar (corners + center) + test_points = [ + (x + width * 0.5, y + height * 0.5), # center + (x, y), # bottom-left + (x + width, y), # bottom-right + (x, y + height), # top-left + (x + width, y + height) # top-right + ] + + if any(path.contains_point(pt) for pt in test_points): + selection[i] = True + + self.callback(selection) + self.set_selection(selection) def set_selection(self, selection): - from ._colormaps import _labels_lut - self.selected_indices = np.nonzero(selection) - labels_lut = _labels_lut() + blue = [31 / 255, 119 / 255, 180 / 255] # #1f77b4 + orange = [255 / 255, 127 / 255, 14 / 255] # #ff7f0e + + if self.mode == "scatter": + self.selected_indices = np.nonzero(selection) + self.face_colors[:, :3] = blue # reset all to blue + self.face_colors[self.selected_indices, :3] = orange # selected to orange + self.collection.set_facecolors(self.face_colors) - for i in range(3): - self.face_colors[:, i] = labels_lut[1,i] - self.face_colors[self.selected_indices, i] = labels_lut[2,i] + elif self.mode == "hist": + for i, bar in enumerate(self.collection): + bar.set_facecolor(orange if selection[i] else blue) - self.collection.set_facecolors(self.face_colors) self.update() def update(self): |