Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 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
8 changes: 6 additions & 2 deletions 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 Expand Up @@ -109,7 +109,11 @@ addopts = "--ignore=tests/benchmarks"
# that pointing pytest at the tests/benchmarks directory discovers them.
python_files = ["test_*.py", "bench_*.py"]
filterwarnings = [
"error::DeprecationWarning:funtracks",
# Temporarily disabled: motile_tracker still converts to the deprecated
# SolutionTracks when emitting tracks, because the views/actions require
# track IDs. Re-add when we refactor to operate on Tracks directly, ahead
# of the next major funtracks release.
# "error::DeprecationWarning:funtracks",
]

[tool.setuptools_scm]
Expand Down
335 changes: 280 additions & 55 deletions src/motile_tracker/data_views/views_coordinator/tracks_list.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ def get_instance(cls, viewer=None):
):
if viewer is None:
raise ValueError("Make a viewer first please!")
# The outgoing instance is about to become unreachable, but psygnal
# connections keep it subscribed to its tracks object. Unsubscribe it,
# or a tracks object shown in successive viewers ends up notifying
# every TracksViewer ever built (see _disconnect_tracks).
if hasattr(cls, "_instance"):
cls._instance._disconnect_tracks()
cls._instance = TracksViewer(viewer)
return cls._instance

Expand All @@ -77,6 +83,7 @@ def __init__(
self.table_widget_present = False

def _clear_if_current():
self._disconnect_tracks()
if hasattr(TracksViewer, "_instance") and TracksViewer._instance is self:
del TracksViewer._instance

Expand Down Expand Up @@ -247,6 +254,20 @@ def _refresh(self, node: str | None = None, refresh_view: bool = False) -> None:
# know about their selection ('all' vs 'lineage'), but TracksViewer does)
self.update_selection(update_counts=True)

def _disconnect_tracks(self) -> None:
"""Stop listening to the currently displayed tracks object.

The connections below live on the Tracks object, not on this TracksViewer,
so they outlive both the viewer and the singleton reference unless they are
explicitly removed. Because one Tracks object can be handed to more than one
viewer over a session, leaving them in place means an edit notifies every
TracksViewer that ever displayed those tracks.
"""
tracks = getattr(self, "tracks", None)
if tracks is not None:
tracks.refresh.disconnect(self._refresh)
tracks.action_applied.disconnect(self._on_action_applied)

def update_tracks(self, tracks: SolutionTracks, name: str) -> None:
"""Stop viewing a previous set of tracks and replace it with a new one.
Will create new segmentation and tracks layers and add them to the viewer.
Expand All @@ -257,9 +278,7 @@ def update_tracks(self, tracks: SolutionTracks, name: str) -> None:
"""
self.selected_nodes.reset()

if self.tracks is not None:
self.tracks.refresh.disconnect(self._refresh)
self.tracks.action_applied.disconnect(self._on_action_applied)
self._disconnect_tracks()

self.tracks = tracks
self.selected_nodes.deleted_items.clear() # Reset deleted nodes when switching tracks
Expand Down
75 changes: 75 additions & 0 deletions src/motile_tracker/import_export/geff_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Helpers for writing geff stores that hold extra, non-geff files.

Saving tracks writes a geff store, and dependent applications add their own
files inside it by listening to TracksList.tracks_saved. That is a supported
arrangement — a geff is a zarr directory, and rewriting the geff leaves
everything else alone — but it makes both zarr and geff chatty.
"""

from __future__ import annotations

import warnings
from pathlib import Path

from funtracks.data_model import Tracks
from funtracks.import_export import write_to_geff


def is_geff(directory: Path) -> bool:
"""Whether the given directory is itself a geff store.

A geff keeps its graph in `nodes`/`edges` groups at the top level, so their
presence distinguishes a directory that is a geff from one that merely
contains one, or is empty.

Note that geff's own `check_for_geff` cannot be used here: it reports
whether a geff exists at or under a store, and so returns True for an old
run directory containing tracks.geff, for a v1 run directory, and even for
an empty one.
"""
return (directory / "nodes").exists() and (directory / "edges").exists()


def write_geff_over(tracks: Tracks, path: Path) -> None:
"""Write tracks to a geff store, replacing any geff already there.

Saved tracks are a geff store that dependents may also keep their own files
in: tracks_saved listeners write extra data (e.g. solver params) inside the
store, and it survives because writing a geff only replaces geff-controlled
groups. Zarr walks the directory on the way and warns once per file it does
not recognise, and geff warns that it found non-geff members. Both are
expected for any store with extras in it and say nothing the caller can act
on, so they are silenced.

The filters match on message rather than category: zarr only grew a
dedicated ZarrUserWarning class in 3.x, and this package supports 2.x,
where importing it fails outright.

`overwrite` is only passed when there really is a geff to replace, because
geff deletes the old graph by removing its `nodes`/`edges` groups outright
and raises KeyError if they are absent. An empty directory needs more than
that: geff's `check_for_geff` reports one as an existing geff, so writing
without `overwrite` raises FileExistsError while writing with it raises
KeyError. Removing it first leaves geff to create the store itself. Only
an empty directory is removed, never one holding a caller's own files.

Args:
tracks (Tracks): The tracks to write.
path (Path): The geff store to write them to. Created if it does not
exist; any geff already there is replaced.
"""
if path.is_dir() and not any(path.iterdir()):
path.rmdir()

with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message="Object at .* is not recognized as a component of a Zarr hierarchy",
category=UserWarning,
)
warnings.filterwarnings(
"ignore",
message="Found non-geff members in zarr.*",
category=UserWarning,
)
write_to_geff(tracks, path, overwrite=is_geff(path))
8 changes: 8 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,11 @@ def _finish(self) -> None:
except Exception as e: # noqa: BLE001
QMessageBox.critical(self, "Error", f"Failed to load tracks: {e}")
return
# Report the geff group we actually read, not the container it
# was found in: a listener uses this path to find data saved
# alongside the tracks, and the container may hold several
# groups.
self.source_path = geff_dir
self.accept()
else:
if self.df is not None:
Expand Down Expand Up @@ -407,4 +413,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()
123 changes: 102 additions & 21 deletions src/motile_tracker/motile/backend/motile_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
import numpy as np
import tracksdata as td
from funtracks.data_model import SolutionTracks
from funtracks.import_export import export_to_geff, import_from_geff, load_v1_tracks
from funtracks.import_export import import_from_geff, load_v1_tracks

from motile_tracker.import_export.geff_io import is_geff, write_geff_over

from .solver_params import SolverParams

Expand Down Expand Up @@ -103,29 +105,94 @@ def _unpack_id(_id: str) -> tuple[datetime, str]:
) from e
return time, run_name

def save(self, base_path: str | Path, save_segmentation: bool = False) -> Path:
"""Save the run in the provided directory. Creates a subdirectory from
the timestamp and run name and stores one file for each element of the
run in that subdirectory.
@classmethod
def _resolve_name_and_time(
cls, run_dir: Path, attrs: dict | None
) -> tuple[datetime | None, str]:
"""Determine the run name and run time for a run being loaded.

Runs used to be saved in a directory named by _make_id, so the name and
time could be recovered by unpacking the directory name. Newer runs
store both in the attrs file instead, which lets them be saved to a
directory the user named. Falls back through both, and finally to the
directory name with no time, so that a run directory is loadable
however it was named. A None time is replaced with the current time by
__init__, so the run still displays.

Args:
base_path (str | Path): The directory to save the run in.
run_dir (Path): The directory the run is being loaded from.
attrs (dict | None): The loaded attrs, or None if there is no
attrs file.

Returns:
(Path): The Path that the run was saved in. The last part of the
path is the directory that was created to store the run.
tuple[datetime | None, str]: The run time and run name.
"""
base_path = Path(base_path)
run_dir = base_path / self._make_id()
Path.mkdir(run_dir)
export_to_geff(self, run_dir, save_segmentation=save_segmentation)
if attrs is not None and attrs.get("run_name") is not None:
stamp = attrs.get("time")
time = datetime.fromisoformat(stamp) if stamp is not None else None
return time, attrs["run_name"]
try:
return cls._unpack_id(run_dir.stem)
except ValueError:
return None, run_dir.stem

def save(self, path: str | Path, save_segmentation: bool = False) -> Path:
"""Save the run as a geff store at the provided path.

The geff store is written at exactly `path` — no subdirectory is
created — and the rest of the run (solver params, attrs, input points,
gaps) is stored inside that store alongside the graph. A geff is a zarr
directory, and writing a geff only replaces geff-controlled groups, so
these files survive re-saving over the same store.

Args:
path (str | Path): The geff store to save the run to. Created if
it does not exist, and replaced if it does.
save_segmentation (bool): Ignored. Kept for backwards
compatibility; the segmentation is never written here.

Returns:
(Path): The Path that the run was saved to.
"""
run_dir = Path(path)
write_geff_over(self, run_dir)
self._save_params(run_dir)
self._save_attrs(run_dir)
if self.input_points is not None:
self._save_array(run_dir, IN_POINTS_FILENAME, self.input_points)
self._save_list(list_to_save=self.gaps, run_dir=run_dir, filename=GAPS_FILENAME)
return run_dir

@staticmethod
def geff_path(run_dir: Path | str) -> Path | None:
"""Return the geff store holding a saved run's graph.

Mirrors the layouts that :meth:`load` accepts. Runs saved by the
current version are themselves the geff store. Returns None for v1
runs, which stored the graph as graph.json rather than as a geff.

Args:
run_dir (Path | str): A directory created by MotileRun.save.
"""
run_dir = Path(run_dir)
if MotileRun._is_geff(run_dir):
return run_dir
tracks_path = run_dir / "tracks.geff"
if tracks_path.exists():
return tracks_path
if (run_dir / "graph.json").exists():
return None
return run_dir / "tracks"

@staticmethod
def _is_geff(directory: Path) -> bool:
"""Whether the given directory is itself a geff store.

Distinguishes a run saved as a geff from an older run directory that
merely contains one, which is exactly what load() needs.
"""
return is_geff(directory)

@classmethod
def load(cls, run_dir: Path | str, output_required: bool = True):
"""Load a run from disk into memory.
Expand All @@ -143,14 +210,17 @@ def load(cls, run_dir: Path | str, output_required: bool = True):
"""
if isinstance(run_dir, str):
run_dir = Path(run_dir)
time, run_name = cls._unpack_id(run_dir.stem)
params = cls._load_params(run_dir)
input_points = cls._load_array(run_dir, IN_POINTS_FILENAME, required=False)
attrs = cls._load_attrs(run_dir)
# Support old v1 ("graph.json" at run dir level), intermediate ("tracks" zarr),
# and new ("tracks.geff") save formats
time, run_name = cls._resolve_name_and_time(run_dir, attrs)
# Support the current format (the run dir is itself the geff store) as
# well as old v1 ("graph.json" at run dir level), intermediate
# ("tracks" zarr), and ("tracks.geff") save formats
tracks_path = run_dir / "tracks.geff"
if tracks_path.exists():
if cls._is_geff(run_dir):
tracks = import_from_geff(run_dir)
elif tracks_path.exists():
tracks = import_from_geff(tracks_path)
elif (run_dir / "graph.json").exists():
tracks = load_v1_tracks(run_dir, solution=True)
Expand Down Expand Up @@ -184,8 +254,8 @@ def load(cls, run_dir: Path | str, output_required: bool = True):
def _save_params(self, run_dir: Path):
"""Save the run parameters in the provided run directory. Currently
dumps the parameters dict into a json file. Skips writing if there are
no params (e.g. tracks imported from CSV/geff that never went through
the solver).
no params, which only happens for a run loaded from a directory that
had no params file (see _load_params).

Args:
run_dir (Path): A directory in which to save the parameters file.
Expand All @@ -199,8 +269,9 @@ def _save_params(self, run_dir: Path):
@staticmethod
def _load_params(run_dir: Path) -> SolverParams | None:
"""Load parameters from the parameters json file in the provided
directory. Returns None if the file is absent — runs imported from
CSV/geff are saved without solver params.
directory. Returns None if the file is absent, which is the case for
v1 run directories and for runs saved by versions that wrapped
imported (CSV/geff) tracks in a MotileRun with no solver params.

Args:
run_dir (Path): The directory in which to find the parameters file.
Expand Down Expand Up @@ -259,7 +330,15 @@ def _load_array(
return None

def _save_attrs(self, directory: Path):
"""Save the time_attr, pos_attr, scale, and segmentation_shape in a json file.
"""Save the run name, run time, time_attr, pos_attr, scale, and
segmentation_shape in a json file.

The run name and time are stored here rather than being recoverable
from the directory name alone (see _make_id), so that a run can be
saved to a directory the user named.

Note that "time" is when the run was solved, while "time_attr" is the
name of the graph's time column.

Args:
directory (Path): The directory in which to save the attributes
Expand All @@ -275,6 +354,8 @@ def _save_attrs(self, directory: Path):
"segmentation_shape": list(seg_shape) if seg_shape is not None else None,
"scale": scale,
"time_attr": self.features.time_key,
"run_name": self.run_name,
"time": self.time.isoformat(),
}
with open(out_path, "w") as f:
json.dump(attrs_dict, f)
Expand Down
5 changes: 3 additions & 2 deletions src/motile_tracker/motile/menus/run_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,9 @@ def emit_run(self) -> None:

def new_run(self, run: MotileRun) -> None:
"""Configure the run editor to copy the name and params of the given
run. Imported runs (CSV/geff) have no solver_params — leave the
editor at its current values rather than emitting None.
run. A run loaded from a directory with no params file has no
solver_params — leave the editor at its current values rather than
emitting None.
"""
self.run_name.setText(run.run_name)
if run.solver_params is not None:
Expand Down
5 changes: 3 additions & 2 deletions src/motile_tracker/motile/menus/run_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ def update_run(self, run: MotileRun):
run_name_view = f"{run.run_name} ({run_time})"
self.setTitle("Run Viewer: " + run_name_view)
self.solver_event_update()
# Imported runs (CSV/geff) have no solver_params — hide the params
# display rather than emit None into widgets that can't render it.
# A run loaded from a directory with no params file has no
# solver_params — hide the params display rather than emit None into
# widgets that can't render it.
if run.solver_params is None:
self.params_widget.hide()
else:
Expand Down
Loading
Loading