Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
28cf382
Add separate branches in save/load for Tracks and MotileRun
cmalinmayor Jun 24, 2026
3e98bdd
Separate logic for export and internal save to geff
cmalinmayor Jun 24, 2026
3d153ea
Add docstrings for tracks_saved and tracks_loaded signals
cmalinmayor Jun 24, 2026
5281f77
Merge branch 'main' into deprecate-motile-run
cmalinmayor Jul 29, 2026
de7c1b2
Remove pinned funtracks dep
cmalinmayor Jul 29, 2026
aeb1200
Upgrade funtracks lower bound
cmalinmayor Jul 29, 2026
6f97901
Merge branch 'main' into deprecate-motile-run
TeunHuijben Jul 29, 2026
295b7d0
update docstrings
TeunHuijben Jul 29, 2026
8d040cd
disconnect TracksViewer when the same Tracks object is viewed in mult…
TeunHuijben Jul 29, 2026
ef0491a
Add separate dialog for saving internal format geff
cmalinmayor Jul 29, 2026
434cc0b
Emit SolutionTracks from TracksList
cmalinmayor Jul 30, 2026
f89619c
Ignore deprecation warnings until next major funtracks release
cmalinmayor Jul 30, 2026
bfa71e3
Clean up loading code
cmalinmayor Jul 30, 2026
bb6b4e1
Unify signal paths to always point to the .geff
cmalinmayor Jul 30, 2026
5ed3e5f
Explicitly save and load the timestamps from run attrs
cmalinmayor Jul 30, 2026
810bc3e
Update motile run save to not create timestamped dir
cmalinmayor Jul 30, 2026
6a20327
Use widget insted of pop-up dialog for internal save location
cmalinmayor Jul 30, 2026
a5d5b21
Suppress warnings about extra files in zarr
cmalinmayor Jul 30, 2026
40baf12
Make agnostic to zarr version
cmalinmayor Jul 30, 2026
dbdd5f1
Fix bug where tracklet id isn't computed before emitting
cmalinmayor Jul 30, 2026
99607d7
Remove unnecessary mkdir command
cmalinmayor Jul 31, 2026
fa1d280
More edge case geff fixes
cmalinmayor Aug 3, 2026
b839498
Update docs to reflect import/export vs save/load
cmalinmayor Aug 18, 2026
fc677d0
Merge branch 'main' into deprecate-motile-run
cmalinmayor Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ classifiers = [

dependencies =[
"napari>=0.6.2,<0.8.0",
"funtracks>=2,<3",
"funtracks>=2.0.3,<3",
"appdirs>=1,<2",
"numpy>=2,<3",
"magicgui>=0.10.1",
Expand Down
93 changes: 64 additions & 29 deletions src/motile_tracker/data_views/views_coordinator/tracks_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from fonticon_fa6 import FA6S
from funtracks.data_model import Tracks
from funtracks.import_export import import_from_geff, write_to_geff
from napari._qt.qt_resources import QColoredSVGIcon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import (
Expand Down Expand Up @@ -74,6 +75,16 @@ class TracksList(QGroupBox):
view_tracks = Signal(Tracks, str)
request_colormap = Signal()

tracks_saved = Signal(object, Path)
"""Emitted after tracks are saved to disk. Arguments: (tracks, path).
Dependent applications can connect to this signal to save additional
data (e.g. solver parameters) alongside the tracks."""

tracks_loaded = Signal(object, Path)
"""Emitted after tracks are loaded from disk. Arguments: (tracks, path).
Dependent applications can connect to this signal to load additional
data (e.g. solver parameters) from the same location."""

def __init__(self):
super().__init__(title="Results List")

Expand All @@ -95,7 +106,12 @@ def __init__(self):
load_menu = QHBoxLayout()
self.dropdown_menu = QComboBox()
self.dropdown_menu.addItems(
["Motile Run", "External tracks from CSV", "External tracks from geff"]
[
"Tracks (geff)",
"Motile Run",
"External tracks from CSV",
"External tracks from geff",
]
)

load_button = QPushButton("Load")
Expand All @@ -116,6 +132,8 @@ def _load_tracks(self, import_type: str):
name = dialog.name
if tracks is not None:
self.add_tracks(tracks, name, select=True)
if dialog.source_path is not None:
self.tracks_loaded.emit(tracks, dialog.source_path)

def _selection_changed(self):
selected = self.tracks_list.selectedItems()
Expand All @@ -124,14 +142,12 @@ def _selection_changed(self):
self.view_tracks.emit(tracks_button.tracks, tracks_button.name.text())

def add_tracks(self, tracks: Tracks, name: str, select=True):
"""Add a run to the list and optionally select it. Will make a new
row in the list UI representing the given run.
"""Add tracks to the list and optionally select them. Will make a new
row in the list UI representing the given tracks.

Accepts any Tracks object. Plain Tracks/SolutionTracks are wrapped in
a MotileRun (with solver_params=None) so the list internally always
holds MotileRun and save_tracks can rely on tracks.save().
Accepts any Tracks object directly (SolutionTracks, MotileRun, etc.).

Note: selecting the run will also emit the selection changed event on
Note: selecting the tracks will also emit the selection changed event on
the list.

Args:
Expand All @@ -140,18 +156,6 @@ def add_tracks(self, tracks: Tracks, name: str, select=True):
select (bool, optional): Whether or not to select the new tracks item in the
list (and thus display it in the tracks viewer). Defaults to True.
"""
if not isinstance(tracks, MotileRun):
tracks = MotileRun(
graph=tracks.graph,
run_name=name,
solver_params=None,
pos_attr=tracks.features.position_key,
time_attr=tracks.features.time_key,
scale=tracks.scale,
ndim=tracks.ndim,
_features=tracks.features,
_segmentation=tracks.segmentation,
)
item = QListWidgetItem(self.tracks_list)
tracks_row = TracksButton(tracks, name)
self.tracks_list.setItemWidget(item, tracks_row)
Expand Down Expand Up @@ -188,14 +192,26 @@ def save_tracks(self, item: QListWidgetItem):
"""Saves a tracks object from the list. You must pass the list item that
represents the tracks, not the tracks object itself.

For MotileRun objects, delegates to MotileRun.save() which creates a
timestamped subdirectory and saves solver params alongside the tracks.
For plain Tracks/SolutionTracks, saves directly to the chosen path
using write_to_geff with overwrite enabled.

After saving, emits the tracks_saved signal so that downstream code
can save additional data into the same directory.

Args:
item (QListWidgetItem): The list item to save. This list item
contains the TracksButton that represents a set of tracks.
"""
tracks: Tracks = self.tracks_list.itemWidget(item).tracks
if self.save_dialog.exec_():
directory = Path(self.save_dialog.selectedFiles()[0])
tracks.save(directory)
if isinstance(tracks, MotileRun):
directory = tracks.save(directory)
else:
write_to_geff(tracks, directory, overwrite=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure overwrite=True is good here? What if directory is a normal folder, and during loading we found a geff in there. Does this mean we only overwite the geff, or the entire directory? I think we have to be very careful, since with the new loading strategy, directory can be a lot of things (any folder containing a geff, a .zarr with a geff inside, the .geff store itself, etc.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah I actually had a local unpushed commit where I made a separate dialog for the "internal" format. But I agree that this is confusing, partially because the internal GEFF save will hopefully be replaced with the persistent sql graph soon..... 🙃 I'll clean it up a bit and clarify the two paths with docstirngs and docs

self.tracks_saved.emit(tracks, directory)

def remove_tracks(self, item: QListWidgetItem):
"""Remove a tracks object from the list. You must pass the list item that
Expand All @@ -209,27 +225,46 @@ def remove_tracks(self, item: QListWidgetItem):
self.tracks_list.takeItem(row)

def load_tracks(self):
"""Call the function to load tracks from disk for a Motile Run or for externally
generated tracks (CSV file), depending on the choice in the dropdown menu.
"""Call the function to load tracks from disk, depending on the choice
in the dropdown menu.
"""

if self.dropdown_menu.currentText() == "Motile Run":
selection = self.dropdown_menu.currentText()
if selection == "Tracks (geff)":
self.load_internal_tracks()
elif selection == "Motile Run":
self.load_motile_run()
elif self.dropdown_menu.currentText() == "External tracks from CSV":
elif selection == "External tracks from CSV":
self._load_tracks(import_type="csv")
elif self.dropdown_menu.currentText() == "External tracks from geff":
elif selection == "External tracks from geff":
self._load_tracks("geff")

def load_motile_run(self):
"""Load a set of tracks from disk. The user selects the directory created
by calling save_tracks.
def load_internal_tracks(self):
"""Load tracks saved in internal format. The user selects the GEFF
store directly (the path written by :func:`write_to_geff`).

After loading, emits the tracks_loaded signal so that downstream code
can load additional data from the same directory.
"""
if self.file_dialog.exec_():
directory = Path(self.file_dialog.selectedFiles()[0])
name = directory.stem
try:
tracks = import_from_geff(directory)
self.add_tracks(tracks, name, select=True)
self.tracks_loaded.emit(tracks, directory)
except (ValueError, FileNotFoundError) as e:
warn(f"Could not load tracks from {directory}: {e}", stacklevel=2)

def load_motile_run(self):
"""Load a MotileRun from disk. The user selects the directory created
by MotileRun.save().
"""
if self.file_dialog.exec_():
directory = Path(self.file_dialog.selectedFiles()[0])
name = directory.stem
try:
tracks = MotileRun.load(directory)
self.add_tracks(tracks, name, select=True)
self.tracks_loaded.emit(tracks, directory)
except (ValueError, FileNotFoundError) as e:
warn(f"Could not load tracks from {directory}: {e}", stacklevel=2)
4 changes: 4 additions & 0 deletions src/motile_tracker/import_export/menus/import_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def __init__(self, import_type: str = "csv") -> None:
self.seg = None
self.df = None
self.incl_z = False
self.source_path: Path | None = None
self.setWindowTitle(f"Import external tracks from {import_type.upper()}")
self.name = f"Tracks from {import_type.upper()}"

Expand Down Expand Up @@ -379,6 +380,7 @@ def _finish(self) -> None:
except Exception as e: # noqa: BLE001
QMessageBox.critical(self, "Error", f"Failed to load tracks: {e}")
return
self.source_path = store_path
self.accept()
else:
if self.df is not None:
Expand Down Expand Up @@ -407,4 +409,6 @@ def _finish(self) -> None:
except Exception as e: # noqa: BLE001
QMessageBox.critical(self, "Error", f"Failed to load tracks: {e}")
return
csv_text = self.import_widget.csv_path_line.text().strip()
self.source_path = Path(csv_text) if csv_text else None
self.accept()
Loading
Loading