Skip to content

Commit 6d17332

Browse files
authored
Merge pull request #95 from funkelab/91-implement-import-dialog-for-geff-graph
91 implement import dialog for geff graph
2 parents e9e1f06 + ef62347 commit 6d17332

File tree

10 files changed

+956
-2
lines changed

10 files changed

+956
-2
lines changed

finn/track_data_views/views_coordinator/tracks_list.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
from finn.track_import_export.menus.import_external_tracks_dialog import (
2929
ImportTracksDialog,
3030
)
31+
from finn.track_import_export.menus.import_from_geff.geff_import_dialog import (
32+
ImportGeffDialog,
33+
)
3134

3235

3336
class TrackListWidget(QWidget):
@@ -110,7 +113,9 @@ def __init__(self):
110113

111114
load_menu = QHBoxLayout()
112115
self.dropdown_menu = QComboBox()
113-
self.dropdown_menu.addItems(["Motile Run", "External tracks from CSV"])
116+
self.dropdown_menu.addItems(
117+
["Motile Run", "External tracks from CSV", "External tracks from geff"]
118+
)
114119

115120
load_button = QPushButton("Load")
116121
load_button.clicked.connect(self.load_tracks)
@@ -123,6 +128,14 @@ def __init__(self):
123128
layout.addLayout(load_menu)
124129
self.setLayout(layout)
125130

131+
def _load_tracks_from_geff(self):
132+
dialog = ImportGeffDialog()
133+
if dialog.exec_() == QDialog.Accepted:
134+
tracks = dialog.tracks
135+
name = dialog.name
136+
if tracks is not None:
137+
self.add_tracks(tracks, name, select=True)
138+
126139
def _load_external_tracks(self):
127140
dialog = ImportTracksDialog()
128141
if dialog.exec_() == QDialog.Accepted:
@@ -251,6 +264,8 @@ def load_tracks(self):
251264
self.load_motile_run()
252265
elif self.dropdown_menu.currentText() == "External tracks from CSV":
253266
self._load_external_tracks()
267+
elif self.dropdown_menu.currentText() == "External tracks from geff":
268+
self._load_tracks_from_geff()
254269

255270
def load_motile_run(self):
256271
"""Load a set of tracks from disk. The user selects the directory created

finn/track_import_export/menus/import_from_geff/__init__.py

Whitespace-only changes.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
from pathlib import Path
2+
3+
from funtracks.import_export.import_from_geff import import_from_geff
4+
from qtpy.QtCore import Qt
5+
from qtpy.QtWidgets import (
6+
QApplication,
7+
QDialog,
8+
QHBoxLayout,
9+
QMessageBox,
10+
QPushButton,
11+
QScrollArea,
12+
QSizePolicy,
13+
QVBoxLayout,
14+
QWidget,
15+
)
16+
17+
from finn.track_import_export.menus.import_from_geff.geff_import_widget import (
18+
ImportGeffWidget,
19+
)
20+
from finn.track_import_export.menus.import_from_geff.geff_prop_map_widget import (
21+
StandardFieldMapWidget,
22+
)
23+
from finn.track_import_export.menus.import_from_geff.geff_scale_widget import ScaleWidget
24+
from finn.track_import_export.menus.import_from_geff.geff_segmentation_widgets import (
25+
SegmentationWidget,
26+
)
27+
28+
29+
class ImportGeffDialog(QDialog):
30+
"""Dialgo for importing external tracks from a geff file"""
31+
32+
def __init__(self):
33+
super().__init__()
34+
self.setWindowTitle("Import external tracks from geff")
35+
self.name = "Tracks from Geff"
36+
37+
# cancel and finish buttons
38+
self.button_layout = QHBoxLayout()
39+
self.cancel_button = QPushButton("Cancel")
40+
self.finish_button = QPushButton("Finish")
41+
self.finish_button.setEnabled(False)
42+
self.button_layout.addWidget(self.cancel_button)
43+
self.button_layout.addWidget(self.finish_button)
44+
45+
# Connect button signals
46+
self.cancel_button.clicked.connect(self._cancel)
47+
self.finish_button.clicked.connect(self._finish)
48+
self.cancel_button.setDefault(False)
49+
self.cancel_button.setAutoDefault(False)
50+
self.finish_button.setDefault(False)
51+
self.finish_button.setAutoDefault(False)
52+
53+
# Initialize widgets and connect to update signals
54+
self.geff_widget = ImportGeffWidget()
55+
self.geff_widget.update_buttons.connect(self._update_segmentation_widget)
56+
self.segmentation_widget = SegmentationWidget(root=self.geff_widget.root)
57+
self.segmentation_widget.none_radio.toggled.connect(
58+
self._toggle_scale_widget_and_seg_id
59+
)
60+
self.segmentation_widget.segmentation_widget.seg_path_updated.connect(
61+
self._update_finish_button
62+
)
63+
self.prop_map_widget = StandardFieldMapWidget()
64+
self.geff_widget.update_buttons.connect(self._update_field_map_widget)
65+
self.scale_widget = ScaleWidget()
66+
67+
self.content_widget = QWidget()
68+
main_layout = QVBoxLayout(self.content_widget)
69+
main_layout.addWidget(self.geff_widget)
70+
main_layout.addWidget(self.segmentation_widget)
71+
main_layout.addWidget(self.prop_map_widget)
72+
main_layout.addWidget(self.scale_widget)
73+
main_layout.addLayout(self.button_layout)
74+
self.content_widget.setLayout(main_layout)
75+
self.content_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
76+
77+
# Create scroll area
78+
self.scroll_area = QScrollArea()
79+
self.scroll_area.setWidget(self.content_widget)
80+
self.scroll_area.setWidgetResizable(True)
81+
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
82+
self.scroll_area.setMinimumWidth(500)
83+
self.scroll_area.setSizePolicy(
84+
QSizePolicy.Preferred, QSizePolicy.MinimumExpanding
85+
)
86+
self.content_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
87+
88+
dialog_layout = QVBoxLayout()
89+
dialog_layout.addWidget(self.scroll_area)
90+
self.setLayout(dialog_layout)
91+
self.setSizePolicy(self.sizePolicy().horizontalPolicy(), QSizePolicy.Minimum)
92+
93+
def _resize_dialog(self):
94+
"""Dynamic widget resizing depending on the visible contents"""
95+
96+
self.content_widget.adjustSize()
97+
self.content_widget.updateGeometry()
98+
99+
content_hint = self.content_widget.sizeHint()
100+
screen_geometry = QApplication.primaryScreen().availableGeometry()
101+
102+
max_height = int(screen_geometry.height() * 0.85)
103+
new_height = min(content_hint.height(), max_height)
104+
new_width = max(content_hint.width(), 500)
105+
106+
self.resize(new_width, new_height)
107+
108+
# Center horizontally, but upwards if too tall
109+
screen_center = screen_geometry.center()
110+
x = screen_center.x() - self.width() // 2
111+
112+
if new_height < screen_geometry.height():
113+
y = screen_center.y() - new_height // 2
114+
else:
115+
y = screen_geometry.top() + 50
116+
117+
self.move(x, y)
118+
119+
def _update_segmentation_widget(self) -> None:
120+
"""Refresh the segmentation widget based on the geff root group."""
121+
122+
if self.geff_widget.root is not None:
123+
self.segmentation_widget.update_root(self.geff_widget.root)
124+
else:
125+
self.segmentation_widget.setVisible(False)
126+
self._update_finish_button()
127+
self._resize_dialog()
128+
129+
def _update_field_map_widget(self) -> None:
130+
"""Prefill the field map widget with the geff metadata and graph attributes."""
131+
132+
if self.geff_widget.root is not None:
133+
self.prop_map_widget.update_mapping(
134+
self.geff_widget.root, self.segmentation_widget.include_seg()
135+
)
136+
137+
self.scale_widget._prefill_from_metadata(
138+
dict(self.geff_widget.root.attrs.get("geff", {}))
139+
)
140+
self.scale_widget.setVisible(self.segmentation_widget.include_seg())
141+
else:
142+
self.prop_map_widget.setVisible(False)
143+
self.scale_widget.setVisible(False)
144+
145+
self._update_finish_button()
146+
self._resize_dialog()
147+
148+
def _update_finish_button(self):
149+
"""Update the finish button status depending on whether a segmentation is required
150+
and whether a valid geff root is present."""
151+
152+
include_seg = self.segmentation_widget.include_seg()
153+
has_seg = self.segmentation_widget.get_segmentation() is not None
154+
valid_seg = not (include_seg and not has_seg)
155+
self.finish_button.setEnabled(self.geff_widget.root is not None and valid_seg)
156+
157+
def _toggle_scale_widget_and_seg_id(self, checked: bool) -> None:
158+
"""Toggle visibility of the scale widget based on the 'None' radio button state,
159+
and update the visibility of the 'seg_id' combobox in the prop map widget."""
160+
161+
self.scale_widget.setVisible(not checked)
162+
163+
# Also remove the seg_id from the fields widget
164+
if len(self.prop_map_widget.mapping_widgets) > 0:
165+
self.prop_map_widget.mapping_widgets["seg_id"].setVisible(not checked)
166+
self.prop_map_widget.mapping_labels["seg_id"].setVisible(not checked)
167+
self.prop_map_widget.optional_features["area"]["recompute"].setEnabled(
168+
not checked
169+
)
170+
171+
self._update_finish_button()
172+
self._resize_dialog()
173+
174+
def _cancel(self) -> None:
175+
"""Close the dialog without loading tracks."""
176+
self.reject()
177+
178+
def _finish(self) -> None:
179+
"""Tries to read the geff file and optional segmentation image and apply the
180+
attribute to column mapping to construct a Tracks object"""
181+
182+
if self.geff_widget.root is not None:
183+
store_path = Path(self.geff_widget.root.store.path) # e.g. /.../my_store.zarr
184+
group_path = Path(self.geff_widget.root.path) # e.g. 'tracks'
185+
geff_dir = store_path / group_path
186+
187+
self.name = self.geff_widget.dir_name
188+
scale = self.scale_widget.get_scale()
189+
190+
segmentation = self.segmentation_widget.get_segmentation()
191+
name_map = self.prop_map_widget.get_name_map()
192+
name_map = {k: (None if v == "None" else v) for k, v in name_map.items()}
193+
extra_features = self.prop_map_widget.get_optional_props()
194+
195+
try:
196+
self.tracks = import_from_geff(
197+
geff_dir,
198+
name_map,
199+
segmentation_path=segmentation,
200+
extra_features=extra_features,
201+
scale=scale,
202+
)
203+
except (ValueError, OSError, FileNotFoundError, AssertionError) as e:
204+
QMessageBox.critical(self, "Error", f"Failed to load tracks: {e}")
205+
return
206+
self.accept()
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import zarr
2+
from qtpy.QtWidgets import (
3+
QLayout,
4+
)
5+
6+
7+
def find_geff_group(group: zarr.Group) -> zarr.Group | None:
8+
"""Recursively search for a Zarr group with 'geff' in its .zattrs.
9+
Args:
10+
group (zarr.Group): The Zarr group to search within.
11+
Returns:
12+
zarr.Group | None: The first group found with 'geff' in its .zattrs, or None if
13+
not found.
14+
"""
15+
16+
if "geff" in group.attrs:
17+
return group
18+
19+
for key in group.group_keys():
20+
subgroup = group[key]
21+
if isinstance(subgroup, zarr.Group):
22+
found = find_geff_group(subgroup)
23+
if found:
24+
return found
25+
return None
26+
27+
28+
def clear_layout(layout: QLayout) -> None:
29+
"""Recursively clear all widgets and layouts in a QLayout.
30+
Args:
31+
layout (QLayout): The layout to clear.
32+
"""
33+
while layout.count():
34+
item = layout.takeAt(0)
35+
widget = item.widget()
36+
if widget is not None:
37+
widget.setParent(None)
38+
# If the item is a layout itself, clear it recursively
39+
elif item.layout() is not None:
40+
clear_layout(item.layout())
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import os
2+
from pathlib import Path
3+
4+
import zarr
5+
from psygnal import Signal
6+
from qtpy.QtCore import Qt
7+
from qtpy.QtWidgets import (
8+
QFileDialog,
9+
QGroupBox,
10+
QHBoxLayout,
11+
QLineEdit,
12+
QMessageBox,
13+
QPushButton,
14+
QVBoxLayout,
15+
QWidget,
16+
)
17+
from zarr.storage import FSStore
18+
19+
from finn.track_import_export.menus.import_from_geff.geff_import_utils import (
20+
find_geff_group,
21+
)
22+
23+
24+
class ImportGeffWidget(QWidget):
25+
"""QWidget for selecting geff directory"""
26+
27+
update_buttons = Signal()
28+
29+
def __init__(self):
30+
super().__init__()
31+
32+
self.root = None
33+
self.dir_name = None
34+
35+
# QlineEdit for geff file path and browse button
36+
self.geff_path_line = QLineEdit(self)
37+
self.geff_path_line.setFocus()
38+
self.geff_path_line.setFocusPolicy(Qt.StrongFocus)
39+
self.geff_path_line.returnPressed.connect(self._on_line_editing_finished)
40+
self.geff_browse_button = QPushButton("Browse", self)
41+
self.geff_browse_button.setAutoDefault(0)
42+
self.geff_browse_button.clicked.connect(self._browse_geff)
43+
44+
browse_layout = QHBoxLayout()
45+
browse_layout.addWidget(self.geff_path_line)
46+
browse_layout.addWidget(self.geff_browse_button)
47+
48+
box = QGroupBox("Path to geff zarr directory")
49+
box_layout = QVBoxLayout()
50+
box_layout.addLayout(browse_layout)
51+
box.setLayout(box_layout)
52+
main_layout = QVBoxLayout()
53+
main_layout.addWidget(box)
54+
self.setLayout(main_layout)
55+
56+
def _on_line_editing_finished(self) -> None:
57+
"""Load the geff group when the user presses Enter in the path line"""
58+
59+
folder_path = self.geff_path_line.text().strip()
60+
if not folder_path:
61+
self.root = None
62+
self.update_buttons.emit() # to remove any widgets and disable finish button
63+
else:
64+
# try to load, will raise an error if no zarr group can be found
65+
self._load_geff(Path(folder_path))
66+
67+
def _browse_geff(self) -> None:
68+
"""Open File dialog to select geff folder"""
69+
70+
folder = QFileDialog.getExistingDirectory(self, "Select Geff Zarr directory")
71+
if folder:
72+
folder_path = Path(folder)
73+
self.geff_path_line.setText(str(folder_path))
74+
self._load_geff(folder_path)
75+
76+
def _load_geff(self, folder_path: Path) -> None:
77+
"""Load the graph and display the geffFieldMapWidget"""
78+
79+
self.root = None
80+
if not os.path.exists(folder_path):
81+
QMessageBox.critical(self, "Error", f"Path does not exist: {folder_path}")
82+
self.update_buttons.emit()
83+
return
84+
try:
85+
store = FSStore(folder_path)
86+
root = zarr.group(store=store)
87+
except (KeyError, ValueError, OSError) as e:
88+
QMessageBox.critical(self, "Error", f"Could not open zarr store: {e}")
89+
self.update_buttons.emit()
90+
return
91+
92+
geff_group = find_geff_group(root)
93+
if geff_group is None:
94+
QMessageBox.critical(
95+
self, "Error", "No geff group found in the selected directory."
96+
)
97+
self.update_buttons.emit()
98+
return
99+
100+
self.root = geff_group
101+
self.dir_name = os.path.basename(folder_path)
102+
self.update_buttons.emit()

0 commit comments

Comments
 (0)