diff --git a/pyproject.toml b/pyproject.toml index a218e150..87fc19f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ dependencies =[ "napari>=0.6.2,<1", - "funtracks>=1.7.0,<2", + "funtracks>=1.8.0-a2,<2", "appdirs>=1,<2", "numpy>=2,<3", "magicgui>=0.10.1", diff --git a/src/motile_tracker/data_views/views_coordinator/groups.py b/src/motile_tracker/data_views/views_coordinator/groups.py index 20840122..8d66090e 100644 --- a/src/motile_tracker/data_views/views_coordinator/groups.py +++ b/src/motile_tracker/data_views/views_coordinator/groups.py @@ -484,4 +484,5 @@ def _show_export_dialog(self, item: QListWidgetItem) -> None: tracks=self.tracks_viewer.tracks, name=group_name, nodes_to_keep=nodes_to_keep, + colormap=self.tracks_viewer.colormap, ) diff --git a/src/motile_tracker/data_views/views_coordinator/tracks_list.py b/src/motile_tracker/data_views/views_coordinator/tracks_list.py index 0098515b..a9bf2695 100644 --- a/src/motile_tracker/data_views/views_coordinator/tracks_list.py +++ b/src/motile_tracker/data_views/views_coordinator/tracks_list.py @@ -92,9 +92,12 @@ class TracksList(QGroupBox): """ view_tracks = Signal(Tracks, str) + request_colormap = Signal() def __init__(self): super().__init__(title="Results List") + + self.colormap = None self.file_dialog = QFileDialog() self.file_dialog.setFileMode(QFileDialog.Directory) self.file_dialog.setOption(QFileDialog.ShowDirsOnly, True) @@ -178,11 +181,11 @@ def show_export_dialog(self, item: QListWidgetItem) -> None: widget: TracksButton = self.tracks_list.itemWidget(item) tracks: Tracks = widget.tracks name: str = widget.name.text() + self.request_colormap.emit() + colormap = self.colormap ExportDialog.show_export_dialog( - self, - tracks=tracks, - name=name, + self, tracks=tracks, name=name, colormap=colormap ) def save_tracks(self, item: QListWidgetItem): diff --git a/src/motile_tracker/data_views/views_coordinator/tracks_viewer.py b/src/motile_tracker/data_views/views_coordinator/tracks_viewer.py index 75891d04..43e2f9ce 100644 --- a/src/motile_tracker/data_views/views_coordinator/tracks_viewer.py +++ b/src/motile_tracker/data_views/views_coordinator/tracks_viewer.py @@ -79,6 +79,7 @@ def __init__( self.tracks_list = TracksList() self.tracks_list.view_tracks.connect(self.update_tracks) + self.tracks_list.request_colormap.connect(self.set_colormap_to_trackslist) self.selected_track = None self.track_id_color = [0, 0, 0, 0] self.force = False @@ -90,6 +91,10 @@ def __init__( self.viewer.dims.events.ndisplay.connect(self.update_selection) + def set_colormap_to_trackslist(self): + """Set the current colormap on the TracksList, so that it can be exported.""" + self.tracks_list.colormap = self.colormap + def set_keybinds(self): bind_keymap(self.viewer, KEYMAP, self) diff --git a/src/motile_tracker/import_export/menus/export_dialog.py b/src/motile_tracker/import_export/menus/export_dialog.py index 1d75d4e3..88676127 100644 --- a/src/motile_tracker/import_export/menus/export_dialog.py +++ b/src/motile_tracker/import_export/menus/export_dialog.py @@ -1,21 +1,76 @@ from pathlib import Path +import napari from funtracks.data_model import Tracks from funtracks.import_export import export_to_csv from funtracks.import_export.export_to_geff import export_to_geff from qtpy.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, QFileDialog, - QInputDialog, + QLabel, QMessageBox, + QVBoxLayout, ) +class ExportTypeDialog(QDialog): + def __init__(self, parent=None, label: str = ""): + super().__init__(parent) + self.setWindowTitle("Select Export Type") + + layout = QVBoxLayout(self) + + if label: + layout.addWidget(QLabel(label)) + + self.export_type_combo = QComboBox() + self.export_type_combo.addItems(["CSV", "geff"]) + layout.addWidget(self.export_type_combo) + + self.relabel_checkbox = QCheckBox( + "Export segmentation relabeled by Tracklet ID" + ) + layout.addWidget(self.relabel_checkbox) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + layout.addWidget(buttons) + + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + # Initial visibility + self._update_checkbox_visibility(self.export_type_combo.currentText()) + + # Update visibility when export type changes + self.export_type_combo.currentTextChanged.connect( + self._update_checkbox_visibility + ) + + def _update_checkbox_visibility(self, export_type: str): + self.relabel_checkbox.setVisible(export_type == "CSV") + + @property + def export_type(self) -> str: + return self.export_type_combo.currentText() + + @property + def relabel_by_tracklet_id(self) -> bool: + return self.relabel_checkbox.isChecked() + + class ExportDialog: """Handles exporting tracks to CSV or Geff.""" @staticmethod def show_export_dialog( - parent, tracks: Tracks, name: str, nodes_to_keep: set[int] | None = None + parent, + tracks: Tracks, + name: str, + colormap: napari.utils.Colormap, + nodes_to_keep: set[int] | None = None, ): """ Export tracks to CSV or Geff, with the option to export a subset of nodes only. @@ -41,31 +96,66 @@ def show_export_dialog( f"

Choose export format:

" ) - export_type, ok = QInputDialog.getItem( - parent, - "Select Export Type", - label, - ["CSV", "geff"], - 0, - False, - ) + dialog = ExportTypeDialog(parent, label) - if not ok: + if dialog.exec_() != QDialog.Accepted: return False + export_type = dialog.export_type + relabel_by_tracklet_id = dialog.relabel_by_tracklet_id + if export_type == "CSV": - file_dialog = QFileDialog(parent) - file_dialog.setFileMode(QFileDialog.AnyFile) - file_dialog.setAcceptMode(QFileDialog.AcceptSave) - file_dialog.setNameFilter("CSV files (*.csv)") - file_dialog.setDefaultSuffix("csv") - default_file = f"{name}_tracks.csv" - file_dialog.selectFile(str(Path.home() / default_file)) + # CSV file dialog + csv_dialog = QFileDialog(parent, "Save to CSV") # set title + csv_dialog.setFileMode(QFileDialog.AnyFile) + csv_dialog.setAcceptMode(QFileDialog.AcceptSave) + csv_dialog.setNameFilter("CSV files (*.csv)") + csv_dialog.setDefaultSuffix("csv") + default_csv_file = f"{name}_tracks.csv" + csv_dialog.selectFile(str(Path.home() / default_csv_file)) - if file_dialog.exec_(): - file_path = Path(file_dialog.selectedFiles()[0]) - export_to_csv(tracks, file_path, nodes_to_keep, use_display_names=True) - return True + if not csv_dialog.exec_(): + return False # User canceled + + file_path = Path(csv_dialog.selectedFiles()[0]) + seg_path = None + + # Optional segmentation dialog + if relabel_by_tracklet_id: + default_seg_path = file_path.with_suffix(".tif") + seg_dialog = QFileDialog( + parent, "Save segmentation to TIF" + ) # set title + seg_dialog.setFileMode(QFileDialog.AnyFile) + seg_dialog.setAcceptMode(QFileDialog.AcceptSave) + seg_dialog.setNameFilter("TIF files (*.tif)") + seg_dialog.setDefaultSuffix("tif") + seg_dialog.selectFile(str(default_seg_path)) + + if not seg_dialog.exec_(): + return False # User canceled + + seg_path = Path(seg_dialog.selectedFiles()[0]) + + # Construct color_dict from colormap + nodes = list(tracks.graph.nodes()) + track_ids = [tracks.get_track_id(node) for node in nodes] + colors = [colormap.map(tid) for tid in track_ids] + color_dict = { + **dict(zip(nodes, colors, strict=True)), + None: [0, 0, 0, 0], + } + + export_to_csv( + tracks=tracks, + outfile=file_path, + color_dict=color_dict, + node_ids=nodes_to_keep, + use_display_names=True, + export_seg=relabel_by_tracklet_id, + seg_path=seg_path, + ) + return True elif export_type == "geff": file_dialog = QFileDialog(parent, "Save as geff file") diff --git a/tests/import_export/test_export_dialog.py b/tests/import_export/test_export_dialog.py index d8d76b45..fd0550b2 100644 --- a/tests/import_export/test_export_dialog.py +++ b/tests/import_export/test_export_dialog.py @@ -23,120 +23,229 @@ def fake_parent(qtbot): return parent -def test_export_dialog_cancel(monkeypatch, mock_tracks, fake_parent): - """Should return False if user cancels export type selection.""" - # Simulate QInputDialog returning 'ok=False' - monkeypatch.setattr( - "qtpy.QtWidgets.QInputDialog.getItem", lambda *a, **kw: ("CSV", False) - ) +@pytest.fixture +def mock_colormap(): + cmap = MagicMock() + cmap.map.side_effect = lambda tid: [tid, tid, tid, 255] + return cmap - result = ExportDialog.show_export_dialog( - fake_parent, mock_tracks, name="TestGroup", nodes_to_keep={1, 2} - ) + +def test_export_dialog_cancel(mock_tracks, fake_parent): + """Should return False if user cancels export type selection.""" + mock_dialog = MagicMock() + mock_dialog.exec_.return_value = 0 # QDialog.Rejected + with patch( + "motile_tracker.import_export.menus.export_dialog.ExportTypeDialog", + return_value=mock_dialog, + ): + result = ExportDialog.show_export_dialog( + fake_parent, + mock_tracks, + name="TestGroup", + nodes_to_keep={1, 2}, + colormap=mock_colormap, + ) assert result is False mock_tracks.export_tracks.assert_not_called() -def test_export_dialog_csv(monkeypatch, mock_tracks, fake_parent, tmp_path): - """Should call export_tracks when CSV is selected and confirmed.""" +def test_export_dialog_csv(mock_tracks, fake_parent, tmp_path, mock_colormap): + """Simulate CSV export with single file dialog.""" test_file = tmp_path / "test_export.csv" - # Mock the QInputDialog to simulate choosing CSV and clicking OK - monkeypatch.setattr( - "motile_tracker.import_export.menus.export_dialog.QInputDialog.getItem", - lambda *a, **kw: ("CSV", True), - ) + # --- Setup mock_tracks to have a graph and track IDs --- + mock_tracks.graph = MagicMock() + mock_tracks.graph.nodes.return_value = [1, 2] + mock_tracks.get_track_id.side_effect = lambda node: node # identity mapping - # Create a fake QFileDialog instance - mock_file_dialog_instance = MagicMock() - mock_file_dialog_instance.exec_.return_value = True - mock_file_dialog_instance.selectedFiles.return_value = [str(test_file)] + # --- Mock ExportTypeDialog to return CSV and relabel=False --- + mock_dialog = MagicMock() + mock_dialog.exec_.return_value = 1 # QDialog.Accepted + mock_dialog.export_type = "CSV" + mock_dialog.relabel_by_tracklet_id = False - # Create a fake class that returns that instance when constructed - mock_file_dialog_class = MagicMock(return_value=mock_file_dialog_instance) + # --- Mock QFileDialog for CSV --- + mock_file_dialog = MagicMock() + mock_file_dialog.exec_.return_value = True + mock_file_dialog.selectedFiles.return_value = [str(test_file)] - # Patch QFileDialog *in the same module where ExportDialog is defined - monkeypatch.setattr( - "motile_tracker.import_export.menus.export_dialog.QFileDialog", - mock_file_dialog_class, - ) + expected_color_dict = { + 1: [1, 1, 1, 255], + 2: [2, 2, 2, 255], + None: [0, 0, 0, 0], + } - # Run the dialog method - with patch( - "motile_tracker.import_export.menus.export_dialog.export_to_csv" - ) as mock_export_csv: + with ( + patch( + "motile_tracker.import_export.menus.export_dialog.ExportTypeDialog", + return_value=mock_dialog, + ), + patch( + "motile_tracker.import_export.menus.export_dialog.QFileDialog", + return_value=mock_file_dialog, + ), + patch( + "motile_tracker.import_export.menus.export_dialog.export_to_csv" + ) as mock_export_csv, + ): result = ExportDialog.show_export_dialog( - fake_parent, mock_tracks, name="MyGroup", nodes_to_keep={1, 2} + fake_parent, + mock_tracks, + name="MyGroup", + nodes_to_keep={1, 2}, + colormap=mock_colormap, # pass fixture ) - # Assertions + # --- Assertions --- assert result is True mock_export_csv.assert_called_once_with( - mock_tracks, test_file, {1, 2}, use_display_names=True + tracks=mock_tracks, + outfile=test_file, + color_dict=expected_color_dict, + node_ids={1, 2}, + use_display_names=True, + export_seg=False, + seg_path=None, ) - # Verify QFileDialog was instantiated once - mock_file_dialog_class.assert_called_once() +def test_export_dialog_csv_with_seg(mock_tracks, fake_parent, tmp_path, mock_colormap): + """CSV export with segmentation — both dialogs mocked.""" + csv_file = tmp_path / "test_tracks.csv" + tif_file = tmp_path / "test_tracks.tif" -def test_export_dialog_geff(monkeypatch, mock_tracks, fake_parent, tmp_path): - """Should call export_to_geff when geff is selected and confirmed.""" - test_file = tmp_path / "test_export.zarr" + # --- Setup mock_tracks to have a graph and track IDs --- + mock_tracks.graph = MagicMock() + mock_tracks.graph.nodes.return_value = [1, 2] + mock_tracks.get_track_id.side_effect = lambda node: node # identity mapping - # Mock user selecting 'geff' and confirming - monkeypatch.setattr( - "motile_tracker.import_export.menus.export_dialog.QInputDialog.getItem", - lambda *a, **kw: ("geff", True), - ) + # --- Mock ExportTypeDialog to return CSV and relabel=True --- + mock_dialog = MagicMock() + mock_dialog.exec_.return_value = 1 + mock_dialog.export_type = "CSV" + mock_dialog.relabel_by_tracklet_id = True - # Mock QFileDialog instance + class - mock_file_dialog_instance = MagicMock() - mock_file_dialog_instance.exec_.return_value = True - mock_file_dialog_instance.selectedFiles.return_value = [str(test_file)] - mock_file_dialog_class = MagicMock(return_value=mock_file_dialog_instance) + # --- Mock two QFileDialog instances --- + mock_csv_dialog = MagicMock() + mock_csv_dialog.exec_.return_value = True + mock_csv_dialog.selectedFiles.return_value = [str(csv_file)] + + mock_tif_dialog = MagicMock() + mock_tif_dialog.exec_.return_value = True + mock_tif_dialog.selectedFiles.return_value = [str(tif_file)] + + # Patch QFileDialog constructor to return first CSV, then TIF dialog + mock_file_dialog_class = MagicMock(side_effect=[mock_csv_dialog, mock_tif_dialog]) + + # Expected color dict + expected_color_dict = { + 1: [1, 1, 1, 255], + 2: [2, 2, 2, 255], + None: [0, 0, 0, 0], + } + + with ( + patch( + "motile_tracker.import_export.menus.export_dialog.ExportTypeDialog", + return_value=mock_dialog, + ), + patch( + "motile_tracker.import_export.menus.export_dialog.QFileDialog", + mock_file_dialog_class, + ), + patch( + "motile_tracker.import_export.menus.export_dialog.export_to_csv" + ) as mock_export_csv, + ): + result = ExportDialog.show_export_dialog( + fake_parent, + mock_tracks, + name="MyGroup", + nodes_to_keep={1, 2}, + colormap=mock_colormap, + ) - monkeypatch.setattr( - "motile_tracker.import_export.menus.export_dialog.QFileDialog", - mock_file_dialog_class, + # --- Assertions --- + assert result is True + mock_export_csv.assert_called_once_with( + tracks=mock_tracks, + outfile=csv_file, + color_dict=expected_color_dict, + node_ids={1, 2}, + use_display_names=True, + export_seg=True, + seg_path=tif_file, ) - with patch( - "motile_tracker.import_export.menus.export_dialog.export_to_geff" - ) as mock_export_geff: + # Ensure two dialogs were created + assert mock_file_dialog_class.call_count == 2 + + +def test_export_dialog_geff(mock_tracks, fake_parent, tmp_path): + """Should call export_to_geff when geff is selected and confirmed.""" + geff_file = tmp_path / "test_export.zarr" + + mock_dialog = MagicMock() + mock_dialog.exec_.return_value = 1 # QDialog.Accepted + mock_dialog.export_type = "geff" + mock_dialog.relabel_by_tracklet_id = False # irrelevant for geff + + mock_file_dialog = MagicMock() + mock_file_dialog.exec_.return_value = True + mock_file_dialog.selectedFiles.return_value = [str(geff_file)] + + with ( + patch( + "motile_tracker.import_export.menus.export_dialog.ExportTypeDialog", + return_value=mock_dialog, + ), + patch( + "motile_tracker.import_export.menus.export_dialog.QFileDialog", + return_value=mock_file_dialog, + ), + patch( + "motile_tracker.import_export.menus.export_dialog.export_to_geff" + ) as mock_export_geff, + ): result = ExportDialog.show_export_dialog( - fake_parent, mock_tracks, name="MyGroup", nodes_to_keep={1, 2} + fake_parent, + mock_tracks, + name="MyGroup", + nodes_to_keep={1, 2}, + colormap=mock_colormap, ) assert result is True mock_export_geff.assert_called_once_with( - mock_tracks, test_file, overwrite=True, node_ids={1, 2} + mock_tracks, geff_file, overwrite=True, node_ids={1, 2} ) - mock_file_dialog_class.assert_called_once() -def test_export_dialog_geff_error(monkeypatch, mock_tracks, fake_parent, tmp_path): +def test_export_dialog_geff_error(mock_tracks, fake_parent, tmp_path): """Should show a QMessageBox if export_to_geff raises ValueError.""" test_file = tmp_path / "error_case.zarr" - # Mock user choosing 'geff' and confirming - monkeypatch.setattr( - "motile_tracker.import_export.menus.export_dialog.QInputDialog.getItem", - lambda *a, **kw: ("geff", True), - ) - - # Mock QFileDialog instance + class - mock_file_dialog_instance = MagicMock() - mock_file_dialog_instance.exec_.return_value = True - mock_file_dialog_instance.selectedFiles.return_value = [str(test_file)] - mock_file_dialog_class = MagicMock(return_value=mock_file_dialog_instance) + # Mock ExportTypeDialog to return geff + mock_dialog = MagicMock() + mock_dialog.exec_.return_value = 1 # QDialog.Accepted + mock_dialog.export_type = "geff" + mock_dialog.relabel_by_tracklet_id = False - monkeypatch.setattr( - "motile_tracker.import_export.menus.export_dialog.QFileDialog", - mock_file_dialog_class, - ) + # Mock QFileDialog + mock_file_dialog = MagicMock() + mock_file_dialog.exec_.return_value = True + mock_file_dialog.selectedFiles.return_value = [str(test_file)] - # Patch export_to_geff to raise ValueError and QMessageBox.warning to intercept UI + # Patch export_to_geff to raise ValueError, and intercept QMessageBox.warning with ( + patch( + "motile_tracker.import_export.menus.export_dialog.ExportTypeDialog", + return_value=mock_dialog, + ), + patch( + "motile_tracker.import_export.menus.export_dialog.QFileDialog", + return_value=mock_file_dialog, + ), patch( "motile_tracker.import_export.menus.export_dialog.export_to_geff", side_effect=ValueError("Export Error"), @@ -146,9 +255,14 @@ def test_export_dialog_geff_error(monkeypatch, mock_tracks, fake_parent, tmp_pat ) as mock_warning, ): result = ExportDialog.show_export_dialog( - fake_parent, mock_tracks, name="ErrGroup", nodes_to_keep={3} + fake_parent, + mock_tracks, + name="ErrGroup", + nodes_to_keep={3}, + colormap=mock_colormap, ) assert result is False mock_warning.assert_called_once() - mock_file_dialog_class.assert_called_once() + # Ensure the file dialog was shown + mock_file_dialog.exec_.assert_called_once() diff --git a/tests/import_export/test_export_solution_to_csv.py b/tests/import_export/test_export_solution_to_csv.py index 5378ee18..0d3495dd 100644 --- a/tests/import_export/test_export_solution_to_csv.py +++ b/tests/import_export/test_export_solution_to_csv.py @@ -14,7 +14,7 @@ def test_export_solution_to_csv(graph_2d, graph_3d, tmp_path): header = ["t", "y", "x", "id", "parent_id", "track_id"] assert lines[0].strip().split(",") == header # Row format: t, y, x, id, parent_id, track_id - line1 = ["0", "50", "50", "1", "", "1"] + line1 = ["0", "50.0", "50.0", "1", "", "1"] assert lines[1].strip().split(",") == line1 tracks = SolutionTracks(graph_3d, ndim=4)