diff --git a/CHANGELOG b/CHANGELOG index 1319c71..6cb1c7e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,5 @@ +2.24.0 + - feat: add contour-only plotting mode (#210) 2.23.1 - fix: QuickView plotting failed for all-NaN coordinate data (#223) - build: remove pin scipy==1.11.4 diff --git a/dcscope/gui/analysis/ana_plot.py b/dcscope/gui/analysis/ana_plot.py index 4294e1f..cdef704 100644 --- a/dcscope/gui/analysis/ana_plot.py +++ b/dcscope/gui/analysis/ana_plot.py @@ -41,6 +41,7 @@ def __init__(self, *args, **kwargs): self.comboBox_division.addItem("One plot per dataset", "each") self.comboBox_division.addItem("Scatter plots and joint contour plot", "multiscatter+contour") + self.comboBox_division.addItem("Only contour plots", "onlycontours") self.comboBox_division.setCurrentIndex(2) # signals diff --git a/dcscope/gui/pipeline_plot.py b/dcscope/gui/pipeline_plot.py index cac4eb4..317f9e6 100644 --- a/dcscope/gui/pipeline_plot.py +++ b/dcscope/gui/pipeline_plot.py @@ -224,6 +224,19 @@ def update_content_plot(self, plot_state, slot_states, dslist): colspan=1) pp.redraw(dslist, slot_states, plot_state_contour) + elif lay["division"] == "onlycontours": + # contour plots + plot_state_contour = copy.deepcopy(plot_state) + plot_state_contour["scatter"]["enabled"] = False + pp = PipelinePlotItem(parent=linner) + self.plot_items.append(pp) + linner.addItem(item=pp, + row=None, + col=None, + rowspan=1, + colspan=1) + pp.redraw(dslist, slot_states, plot_state_contour) + # colorbar colorbar_kwds = {} diff --git a/dcscope/pipeline/core.py b/dcscope/pipeline/core.py index 52e7b2e..699efd5 100644 --- a/dcscope/pipeline/core.py +++ b/dcscope/pipeline/core.py @@ -545,6 +545,8 @@ def get_plot_col_row_count(self, plot_id, pipeline_state=None): num_plots = 1 elif div == "multiscatter+contour": num_plots = num_scat + 1 + elif div == "onlycontours": + num_plots = 1 else: raise ValueError(f"Unrecognized division: '{div}'") diff --git a/dcscope/pipeline/plot.py b/dcscope/pipeline/plot.py index bd75727..2a561d8 100644 --- a/dcscope/pipeline/plot.py +++ b/dcscope/pipeline/plot.py @@ -60,7 +60,7 @@ "identifier": str, "layout": { "column count": int, - "division": ["each", "merge", "multiscatter+contour"], + "division": ["each", "merge", "multiscatter+contour", "onlycontours"], "label plots": bool, "name": str, "size x": float, diff --git a/tests/test_gui_plotting.py b/tests/test_gui_plotting.py index 627d0f1..fd4e854 100644 --- a/tests/test_gui_plotting.py +++ b/tests/test_gui_plotting.py @@ -314,3 +314,147 @@ def test_changing_lut_identifier_in_analysis_view_plots(qtbot): qtbot.mouseClick(pv.pushButton_apply, QtCore.Qt.MouseButton.LeftButton) assert pv.comboBox_lut.currentData() == "HE-3D-FEM-22" + + +def test_zoomin_contours(qtbot): + """Test that zooming in on contours works correctly""" + mw = DCscope() + qtbot.addWidget(mw) + + # Add test dataset and create plot + slot_id = mw.add_dataslot(paths=[datapath / "calibration_beads_47.rtdc"]) + plot_id = mw.add_plot() + + # Activate slot-plot pair + pe = mw.block_matrix.get_widget(filt_plot_id=plot_id, slot_id=slot_id[0]) + qtbot.mouseClick(pe, QtCore.Qt.MouseButton.LeftButton) + + # Get range before zoom-in + mw.add_plot_window(plot_id) + plot_widget = mw.subwindows_plots[plot_id].widget() + view_range_before = plot_widget.plot_items[-1].getViewBox().viewRange() + x_range_before = view_range_before[0] + y_range_before = view_range_before[1] + + # Switch to plot tab + mw.widget_ana_view.tabWidget.setCurrentWidget(mw.widget_ana_view.tab_plot) + pv = mw.widget_ana_view.widget_plot + + # Enable contour zoom-in and apply + pv.checkBox_zoomin.setChecked(True) + qtbot.mouseClick(pv.pushButton_apply, QtCore.Qt.MouseButton.LeftButton) + + # Get range after zoom-in + plot_widget = mw.subwindows_plots[plot_id].widget() + view_range_after = plot_widget.plot_items[-1].getViewBox().viewRange() + x_range_after = view_range_after[0] + y_range_after = view_range_after[1] + + # Verify zoom-in reduced both X and Y ranges + assert x_range_after[1] < x_range_before[1], "x-max should decrease" + assert y_range_after[1] < y_range_before[1], "y-max should decrease" + + +def test_only_contours_division(qtbot): + """Test that 'onlycontours' division mode works correctly""" + mw = DCscope() + qtbot.addWidget(mw) + + # Add multiple datasets + path = datapath / "calibration_beads_47.rtdc" + mw.add_dataslot(paths=[path, path]) # Add same dataset twice for testing + + # Add a plot + plot_id = mw.add_plot() + + # Activate analysis view + pe = mw.block_matrix.get_widget(filt_plot_id=plot_id) + qtbot.mouseClick(pe.toolButton_modify, QtCore.Qt.MouseButton.LeftButton) + + # Switch to plot tab + mw.widget_ana_view.tabWidget.setCurrentWidget(mw.widget_ana_view.tab_plot) + pv = mw.widget_ana_view.widget_plot + + # Get the initial plot state + plot_state = mw.pipeline.get_plot(plot_id).__getstate__() + + # Verify there is only one plot + assert len(mw.pipeline.plot_ids) == 1, "Should have exactly one plot" + + # Verify initial division mode + assert plot_state["layout"]["division"] == "multiscatter+contour" + + # Set division to "onlycontours" + idx = pv.comboBox_division.findData("onlycontours") + pv.comboBox_division.setCurrentIndex(idx) + + # Apply changes + qtbot.mouseClick(pv.pushButton_apply, QtCore.Qt.MouseButton.LeftButton) + + # Get the plot widget + pw = mw.block_matrix.get_widget(filt_plot_id=plot_id) + + # Activate plots for contour view + qtbot.mouseClick(pw.toolButton_toggle, QtCore.Qt.MouseButton.LeftButton) + + # Get the plot state + plot_state = mw.pipeline.get_plot(plot_id).__getstate__() + + # Verify division mode + assert plot_state["layout"]["division"] == "onlycontours" + + +def test_contour_plot_with_invalid_percentiles(qtbot): + """Test contour plot with edge case percentiles (e.g., 100% KDE)""" + mw = DCscope() + qtbot.addWidget(mw) + + # Add a dataset + path = datapath / "calibration_beads_47.rtdc" + slot_id = mw.add_dataslot(paths=[path])[0] + + # Add a plot + plot_id = mw.add_plot() + + # Activate the slot-plot pair to show data + pe = mw.block_matrix.get_widget(slot_id, plot_id) + qtbot.mouseClick(pe, QtCore.Qt.MouseButton.LeftButton) + + # Activate analysis view + pe = mw.block_matrix.get_widget(filt_plot_id=plot_id) + qtbot.mouseClick(pe.toolButton_modify, QtCore.Qt.MouseButton.LeftButton) + + # Switch to plot tab + mw.widget_ana_view.tabWidget.setCurrentWidget(mw.widget_ana_view.tab_plot) + pv = mw.widget_ana_view.widget_plot + + # Enable contours + pv.groupBox_contour.setChecked(True) + + # Set contour percentiles to extreme values (edge cases) + # 100% percentile is at the maximum KDE value + pv.doubleSpinBox_perc_1.setValue(100.0) # Maximum percentile + pv.doubleSpinBox_perc_2.setValue(100.0) # Near maximum + + # Apply changes + qtbot.mouseClick(pv.pushButton_apply, QtCore.Qt.MouseButton.LeftButton) + + # Verify the plot state was updated with the new percentiles + plot_state = mw.pipeline.get_plot(plot_id).__getstate__() + con = plot_state["contour"] + + # Check that percentiles were set + assert con["percentiles"][0] == 100.0 + assert con["percentiles"][1] == 100.0 + assert con["enabled"] is True + + # Open the plot window to verify rendering works + mw.add_plot_window(plot_id) + + # Get the plot widget + plot_widget = mw.subwindows_plots[plot_id].widget() + + # Check that plot items were created + assert plot_widget is not None + if plot_widget.plot_items: + assert len(plot_widget.plot_items) > 0