diff --git a/pyproject.toml b/pyproject.toml index 124bd892..ffc3b404 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "websocket >=0.2.1", "websockets >=12.0", "zarr >=2.15.0,<3.0.0", + "geff>=0.2.1,<0.3", ] [project.optional-dependencies] diff --git a/ultrack/cli/_test/test_trackmate_cli.py b/ultrack/cli/_test/test_trackmate_cli.py new file mode 100644 index 00000000..4d327de2 --- /dev/null +++ b/ultrack/cli/_test/test_trackmate_cli.py @@ -0,0 +1,171 @@ +import types +import sys +from typing import Dict + +import numpy as np +import pytest +from click.testing import CliRunner + + +@pytest.fixture(autouse=True) +def _stub_external_modules(monkeypatch): + """Stub heavy optional dependencies that are not required for CLI parsing. + + The `trackmate` CLI relies on *napari* and *zarr*. Importing these heavy + libraries is unnecessary for the unit-tests and might fail on CI where they + are not installed. Instead we register lightweight stub modules **before** + the CLI implementation is imported so the import succeeds without the real + dependencies. + """ + # ------------------------------------------------------------------ + # stub napari (nested modules: napari.plugins, napari.viewer) + # ------------------------------------------------------------------ + napari_mod = types.ModuleType("napari") + + # sub-module: napari.plugins + plugins_mod = types.ModuleType("napari.plugins") + + def _initialize_plugins(): # dummy implementation + return None + + plugins_mod._initialize_plugins = _initialize_plugins + napari_mod.plugins = plugins_mod + + # sub-module: napari.viewer with a minimal ViewerModel replacement + viewer_mod = types.ModuleType("napari.viewer") + + class _DummyLayer: # minimal stand-in for napari Layer + def __init__(self, data): + self.data = data + self.multiscale = False + self.name = "layer" + + class _DummyViewerModel: + """Very small subset of napari.viewer.ViewerModel API""" + def __init__(self): + self.layers = {"layer": _DummyLayer(np.zeros((1, 1)))} + + def open(self, *args, **kwargs): # noqa: D401 – no-op + return None + + viewer_mod.ViewerModel = _DummyViewerModel + napari_mod.viewer = viewer_mod + + # Register stub hierarchy before importing the CLI module + sys.modules.update( + { + "napari": napari_mod, + "napari.plugins": plugins_mod, + "napari.viewer": viewer_mod, + } + ) + + # ------------------------------------------------------------------ + # stub zarr – only the ``open`` function is used in _is_zarr_directory + # ------------------------------------------------------------------ + zarr_mod = types.ModuleType("zarr") + + def _fake_open(path, mode="r", *args, **kwargs): + return None # pretend success + + zarr_mod.open = _fake_open + sys.modules["zarr"] = zarr_mod + + +@pytest.fixture +def _import_trackmate(): + """Import the CLI module (with stubs already installed).""" + import importlib + + module = importlib.import_module("ultrack.cli.trackmate") + module = importlib.reload(module) # make sure we load fresh each time + return module + + +def _patch_pipeline(monkeypatch, trackmate_module): + """Patch the heavy computational functions with lightweight fakes. + + Returns a dict capturing the call arguments so the tests can inspect them. + """ + call_info: Dict[str, Dict] = {} + + # Replace _get_data so the test runs without files or napari + def _fake_get_data(paths, data_type, sigma, reader_plugin): + call_info["get_data"] = { + "paths": paths, + "data_type": data_type, + "sigma": sigma, + "reader_plugin": reader_plugin, + } + return np.zeros((1, 1)), np.zeros((1, 1)) # foreground, contours + + monkeypatch.setattr(trackmate_module, "_get_data", _fake_get_data) + + # Replace heavy steps + def _fake_segment(fg, ct, config, overwrite): + call_info["segment"] = { + "foreground_shape": fg.shape, + "contours_shape": ct.shape, + "config": config, + "overwrite": overwrite, + } + + def _fake_link(config, overwrite): + call_info["link"] = {"config": config, "overwrite": overwrite} + + def _fake_solve(config, overwrite): + call_info["solve"] = {"config": config, "overwrite": overwrite} + + monkeypatch.setattr(trackmate_module, "segment", _fake_segment) + monkeypatch.setattr(trackmate_module, "link", _fake_link) + monkeypatch.setattr(trackmate_module, "solve", _fake_solve) + + # Stub exporters (avoid IO) + monkeypatch.setattr(trackmate_module, "to_geff", lambda *a, **kw: None) + monkeypatch.setattr(trackmate_module, "to_trackmate", lambda *a, **kw: None) + + return call_info + + +# ----------------------------------------------------------------------------- +# TESTS +# ----------------------------------------------------------------------------- + + +def test_trackmate_cli_default(tmp_path, monkeypatch, _import_trackmate): + """CLI runs end-to-end with default parameters (no overrides).""" + trackmate = _import_trackmate + call_info = _patch_pipeline(monkeypatch, trackmate) + + runner = CliRunner() + output_path = tmp_path / "out.geff" + + result = runner.invoke( + trackmate.trackmate_cli, + [str(tmp_path), "-o", str(output_path), "--overwrite"], + ) + + assert result.exit_code == 0, result.output + assert {"segment", "link", "solve"}.issubset(call_info) + assert call_info["segment"]["overwrite"] is True + + +def test_trackmate_cli_parameter_override(tmp_path, monkeypatch, _import_trackmate): + """Override parsing: ensure CLI arguments update the config.""" + trackmate = _import_trackmate + call_info = _patch_pipeline(monkeypatch, trackmate) + + runner = CliRunner() + output_path = tmp_path / "result.xml" + + override_str = "segmentation.threshold=0.75" + + result = runner.invoke( + trackmate.trackmate_cli, + [str(tmp_path), "-o", str(output_path), "--overwrite", override_str], + ) + + assert result.exit_code == 0, result.output + + seg_cfg = call_info["segment"]["config"].segmentation_config + assert pytest.approx(seg_cfg.threshold) == 0.75 \ No newline at end of file diff --git a/ultrack/cli/main.py b/ultrack/cli/main.py index 496c7bad..1286b8a0 100644 --- a/ultrack/cli/main.py +++ b/ultrack/cli/main.py @@ -14,6 +14,7 @@ from ultrack.cli.segment import segmentation_cli from ultrack.cli.server import server_cli from ultrack.cli.solve import solve_cli +from ultrack.cli.trackmate import trackmate_cli from ultrack.cli.view import view_cli @@ -36,4 +37,5 @@ def main(): main.add_command(segmentation_cli) main.add_command(solve_cli) main.add_command(server_cli) +main.add_command(trackmate_cli) main.add_command(view_cli) diff --git a/ultrack/cli/trackmate.py b/ultrack/cli/trackmate.py new file mode 100644 index 00000000..868c3bf4 --- /dev/null +++ b/ultrack/cli/trackmate.py @@ -0,0 +1,299 @@ +from pathlib import Path +from typing import Optional, Sequence +import pprint +import glob +import os +import re + +import click +import numpy as np +from napari.plugins import _initialize_plugins +from napari.viewer import ViewerModel +import zarr + +from ultrack import segment, link, solve +from ultrack.utils.array import array_apply +from ultrack.cli.utils import ( + napari_reader_option, + overwrite_option, +) +from ultrack.config import MainConfig +from ultrack.core.export.geff import to_geff +from ultrack.core.export.trackmate import to_trackmate +from ultrack.utils.edge import labels_to_contours +from ultrack.imgproc.segmentation import detect_foreground +from ultrack.imgproc.intensity import robust_invert + + +def _get_layer_data(viewer: ViewerModel, key: str | int): + """Get layer data from napari viewer.""" + layer = viewer.layers[key] + if layer.multiscale: + return layer.data[0] + else: + return layer.data + + +def _apply_config_overrides(config: MainConfig, overrides: Sequence[str]) -> MainConfig: + """Apply parameter overrides to config. + + Parameters + ---------- + config : MainConfig + Base configuration to modify + overrides : Sequence[str] + Parameter overrides in format "section.parameter=value" + + Returns + ------- + MainConfig + Modified configuration + """ + for override in overrides: + if "=" not in override: + raise ValueError(f"Invalid override format: {override}. Expected format: 'section.parameter=value'") + + param_path, value_str = override.split("=", 1) + sections = param_path.split(".") + + if len(sections) != 2: + raise ValueError(f"Invalid parameter path: {param_path}. Expected format: 'section.parameter'") + + section_name, param_name = sections + + # Get the appropriate config section + if section_name == "segmentation": + config_section = config.segmentation_config + elif section_name == "linking": + config_section = config.linking_config + elif section_name == "tracking": + config_section = config.tracking_config + elif section_name == "data": + config_section = config.data_config + else: + raise ValueError(f"Unknown config section: {section_name}. Valid sections: segmentation, linking, tracking, data") + + # Check if parameter exists + if not hasattr(config_section, param_name): + raise ValueError(f"Parameter '{param_name}' not found in section '{section_name}'") + + # Get the parameter's current type and convert the string value + current_value = getattr(config_section, param_name) + if current_value is None: + # If current value is None, try to infer type from string + try: + # Try int first + new_value = int(value_str) + except ValueError: + try: + # Try float + new_value = float(value_str) + except ValueError: + # Try boolean + if value_str.lower() in ('true', 'false'): + new_value = value_str.lower() == 'true' + else: + # Keep as string + new_value = value_str + else: + # Convert to the same type as current value + current_type = type(current_value) + if current_type == bool: + new_value = value_str.lower() == 'true' + elif current_type == int: + new_value = int(value_str) + elif current_type == float: + new_value = float(value_str) + else: + new_value = value_str + + # Set the new value + setattr(config_section, param_name, new_value) + + return config + + +def _preprocess_data(data, data_type: str, sigma: float, voxel_size: tuple[float, ...] | None = None): + """Preprocess data based on type. + + Parameters + ---------- + data : ArrayLike + Input data array + data_type : str + Type of data: 'labels' or 'raw' + sigma : Optional[float] + Sigma parameter for smoothing + + Returns + ------- + Tuple[ArrayLike, ArrayLike] + Foreground and contours arrays + """ + if voxel_size is not None: + sigma = len(data.shape) * sigma + + if data_type == "labels": + print("Converting labels to foreground and contours...") + foreground, contours = array_apply( + data, + func=labels_to_contours, + sigma=None, + overwrite=True, + ) + return foreground, contours + + elif data_type == "raw": + print("Detecting foreground and generating contours from raw image...") + foreground = array_apply( + data, + func=detect_foreground, + sigma=sigma, + remove_hist_mode=False, + min_foreground=0.0, + channel_axis=None, + ) + contours = array_apply( + data, + func=robust_invert, + sigma=1.0, + lower_quantile=None, + upper_quantile=0.9999, + ) + + return foreground, contours + + else: + raise ValueError(f"Unknown data type: {data_type}. Expected 'labels' or 'raw'") + + +def _is_zarr_directory(path: Path) -> bool: + """Check if a path is a valid zarr directory.""" + try: + zarr.open(path, mode="r") + return True + except Exception: + return False + +def _get_data(paths: Sequence[Path], data_type: str, sigma: float, reader_plugin: str) -> tuple[np.ndarray, np.ndarray]: + # Initialize napari plugins and load data + _initialize_plugins() + viewer = ViewerModel() + + stack = False + if reader_plugin == "napari": + if len(paths) > 1: + stack = True + elif len(paths) == 1: + if Path(paths[0]).is_dir(): + if not _is_zarr_directory(paths[0]): + # Create a glob pattern to get all files + all_files = glob.glob(os.path.join(paths[0], '*')) + + # Define regex pattern for image files + pattern = re.compile(r'.*\.(tif|tiff|png|jpg|jpeg|zarr)$', re.IGNORECASE) + + # Filter and sort files + matched_files = sorted(f for f in all_files if pattern.match(f)) + paths = [Path(f) for f in matched_files] + stack = True + + viewer.open(path=paths, plugin=reader_plugin, stack=stack) + + # Get the first layer's data (assuming single data source) + if len(viewer.layers) == 0: + raise ValueError("No data layers found in the provided paths.") + + # Use the first layer as the main data + main_data = _get_layer_data(viewer, viewer.layers[0].name) + + # Preprocess data based on type + foreground, contours = _preprocess_data(main_data, data_type, sigma) + + del viewer + + return foreground, contours + + +@click.command("trackmate", hidden=True, context_settings=dict(ignore_unknown_options=True)) +@click.argument('path', nargs=1, type=click.Path(path_type=Path)) +@click.option( + "--output-path", + "-o", + type=Path, + help="Path to save the output.", +) +@napari_reader_option() +# @click.option( +# "--data-type", +# "-dt", +# required=True, +# type=click.Choice(['labels', 'raw']), +# help="Type of input data: 'labels' for label maps/segmentations or 'raw' for raw images.", +# ) +@click.option( + "--sigma", + "-s", + type=float, + default=15.0, + help="Sigma parameter for smoothing (labels) or contour detection (raw images).", +) +@click.argument('args', nargs=-1, type=click.UNPROCESSED) +@overwrite_option() +def trackmate_cli( + path: Path, + output_path: Path, + reader_plugin: str, + #data_type: str, + sigma: float, + args: Sequence[str], + overwrite: bool, +) -> None: + """Perform complete tracking pipeline (segmentation, linking, and solving) with default config. + + Accepts a directory containing data files (TIFF directory with all TIFFs to stack them, + single TIFF, zarr, etc.) and preprocesses the data based on the specified data type. + """ + + data_type = "raw" + + # Create default config + config = MainConfig() + + # Apply parameter overrides + config = _apply_config_overrides(config, args) + pprint.pprint(config) + + # Get data + paths = [path] + foreground, contours = _get_data(paths, data_type, sigma, reader_plugin) + + # Run segmentation + print("Running segmentation...") + segment( + foreground, + contours, + config, + overwrite=overwrite, + ) + + # Run linking + print("Running linking...") + link( + config, + overwrite=overwrite, + ) + + # Run solving + print("Running solving...") + solve(config, overwrite=overwrite) + + print("Trackmate pipeline completed successfully!") + + try: + to_geff(config, output_path, overwrite=overwrite) + except Exception as e: + print(f"Error exporting to GEFF: {e}, fallback to saving as TrackMate XML") + to_trackmate(config, output_path.with_suffix(".xml"), overwrite=overwrite) + + diff --git a/ultrack/core/_test/test_tracker.py b/ultrack/core/_test/test_tracker.py index fc52b115..2dafb6fb 100644 --- a/ultrack/core/_test/test_tracker.py +++ b/ultrack/core/_test/test_tracker.py @@ -1,8 +1,11 @@ +import functools from pathlib import Path from typing import Tuple +from unittest.mock import Mock, patch import networkx as nx import numpy as np +import pandas as pd import pytest import torch as th import torch.nn.functional as F @@ -173,6 +176,15 @@ def test_outputs( assert nx.utils.graphs_equal(nx_tracker, nx_original) + # test to_geff + with patch("ultrack.core.export.geff.geff.write_nx") as mock_write_nx: + tracker.to_geff("test.geff") + mock_write_nx.assert_called_once() + + # Test with overwrite=True + tracker.to_geff("test.geff", overwrite=True) + assert mock_write_nx.call_count == 2 + @pytest.mark.parametrize( "config_content,timelapse_mock_data,mock_flow_field", diff --git a/ultrack/core/export/__init__.py b/ultrack/core/export/__init__.py index f0de33a2..e1cb0404 100644 --- a/ultrack/core/export/__init__.py +++ b/ultrack/core/export/__init__.py @@ -1,4 +1,6 @@ from ultrack.core.export.ctc import to_ctc +from ultrack.core.export.exporter import export_tracks_by_extension +from ultrack.core.export.geff import to_geff from ultrack.core.export.networkx import to_networkx, tracks_layer_to_networkx from ultrack.core.export.trackmate import to_trackmate, tracks_layer_to_trackmate from ultrack.core.export.tracks_layer import to_tracks_layer diff --git a/ultrack/core/export/_test/test_exporter.py b/ultrack/core/export/_test/test_exporter.py index 8595b906..121c5ec4 100644 --- a/ultrack/core/export/_test/test_exporter.py +++ b/ultrack/core/export/_test/test_exporter.py @@ -1,10 +1,12 @@ from pathlib import Path +import pytest + from ultrack import MainConfig, export_tracks_by_extension def test_exporter(tracked_database_mock_data: MainConfig, tmp_path: Path) -> None: - file_ext_list = [".xml", ".csv", ".zarr", ".dot", ".json"] + file_ext_list = [".xml", ".csv", ".zarr", ".dot", ".json", ".geff"] last_modified_time = {} for file_ext in file_ext_list: tmp_file = tmp_path / f"tracks{file_ext}" @@ -40,3 +42,28 @@ def test_exporter(tracked_database_mock_data: MainConfig, tmp_path: Path) -> Non assert (tmp_path / f"tracks{file_ext}").stat().st_size > 0 assert last_modified_time[str(tmp_file)] != tmp_file.stat().st_mtime + + +def test_geff_zarr_extension_specific( + tracked_database_mock_data: MainConfig, tmp_path: Path +) -> None: + """Test specific functionality of .geff.zarr extension in exporter.""" + geff_file = tmp_path / "tracks.geff" + + # Test that .geff.zarr extension calls the to_geff function + export_tracks_by_extension(tracked_database_mock_data, geff_file) + + # Check that file exists and has content + assert geff_file.exists() + assert geff_file.stat().st_size > 0 + + # Test overwrite behavior + with pytest.raises(FileExistsError, match="already exists"): + export_tracks_by_extension( + tracked_database_mock_data, geff_file, overwrite=False + ) + + # Test that overwrite=True works + export_tracks_by_extension(tracked_database_mock_data, geff_file, overwrite=True) + assert geff_file.exists() + assert geff_file.stat().st_size > 0 diff --git a/ultrack/core/export/_test/test_geff.py b/ultrack/core/export/_test/test_geff.py new file mode 100644 index 00000000..21c1812e --- /dev/null +++ b/ultrack/core/export/_test/test_geff.py @@ -0,0 +1,81 @@ +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import networkx as nx +import pytest + +from ultrack import MainConfig +from ultrack.core.export.geff import to_geff + + +def test_to_geff_basic_functionality( + tracked_database_mock_data: MainConfig, tmp_path: Path +): + """Test basic functionality of to_geff function and call chain.""" + output_file = tmp_path / "test_tracks.geff.zarr" + + # Mock both functions to verify the call chain + with patch("ultrack.core.export.geff.to_networkx") as mock_to_networkx, patch( + "ultrack.core.export.geff.geff.write_nx" + ) as mock_write_nx: + + # Set up the mock to return a simple graph + mock_graph = nx.Graph() + mock_to_networkx.return_value = mock_graph + + to_geff(tracked_database_mock_data, output_file) + + # Verify that to_networkx was called with the config + mock_to_networkx.assert_called_once_with(tracked_database_mock_data) + + # Verify that geff.write_nx was called with the graph and filename + mock_write_nx.assert_called_once_with(mock_graph, output_file) + + +def test_to_geff_file_overwrite_false( + tracked_database_mock_data: MainConfig, tmp_path: Path +): + """Test that FileExistsError is raised when file exists and overwrite=False.""" + output_file = tmp_path / "test_tracks.geff.zarr" + + # Create a file that already exists + output_file.touch() + + # Test that FileExistsError is raised when overwrite=False + with pytest.raises(FileExistsError, match="already exists"): + to_geff(tracked_database_mock_data, output_file, overwrite=False) + + +def test_to_geff_file_overwrite_true( + tracked_database_mock_data: MainConfig, tmp_path: Path +): + """Test that function works when file exists and overwrite=True.""" + output_file = tmp_path / "test_tracks.geff.zarr" + + # Create a file that already exists + output_file.touch() + + # Mock the geff.write_nx function + with patch("ultrack.core.export.geff.geff.write_nx") as mock_write_nx: + # This should not raise an error + to_geff(tracked_database_mock_data, output_file, overwrite=True) + + # Verify that geff.write_nx was called + mock_write_nx.assert_called_once() + + +def test_geff_correctness(tracked_database_mock_data: MainConfig, tmp_path: Path): + """Test that the geff file is correct.""" + output_file = tmp_path / "test_tracks.geff.zarr" + to_geff(tracked_database_mock_data, output_file) + + # Read the geff file + import geff + + geff_nx = geff.read_nx(output_file) + + from ultrack.core.export import to_networkx + + ultrack_nx = to_networkx(tracked_database_mock_data) + + assert nx.is_isomorphic(geff_nx, ultrack_nx) diff --git a/ultrack/core/export/exporter.py b/ultrack/core/export/exporter.py index b4e5f98a..d2d6dcbf 100644 --- a/ultrack/core/export/exporter.py +++ b/ultrack/core/export/exporter.py @@ -1,16 +1,18 @@ import json +import logging from pathlib import Path from typing import Union import networkx as nx from ultrack.config import MainConfig -from ultrack.core.export import ( - to_networkx, - to_trackmate, - to_tracks_layer, - tracks_to_zarr, -) +from ultrack.core.export.geff import to_geff +from ultrack.core.export.networkx import to_networkx +from ultrack.core.export.trackmate import to_trackmate +from ultrack.core.export.tracks_layer import to_tracks_layer +from ultrack.core.export.zarr import tracks_to_zarr + +LOG = logging.getLogger(__name__) def export_tracks_by_extension( @@ -19,11 +21,12 @@ def export_tracks_by_extension( """ Export tracks to a file given the file extension. - Supported file extensions are .xml, .csv, .zarr, .dot, and .json. + Supported file extensions are .xml, .csv, .zarr, .parquet, .dot, .json, and .geff - `.xml` exports to a TrackMate compatible XML file. - `.csv` exports to a CSV file. - `.parquet` exports to a Parquet file. - `.zarr` exports the tracks to dense segments in a `zarr` array format. + - `.geff` exports the tracks to a `zarr` format using the geff standard. - `.dot` exports to a Graphviz DOT file. - `.json` exports to a networkx JSON file. @@ -46,13 +49,16 @@ def export_tracks_by_extension( Export tracks to a `zarr` array. to_networkx : Export tracks to a networkx graph. + to_geff : + Export tracks to a geff file. """ - if Path(filename).exists() and not overwrite: + filename = Path(filename) + if filename.exists() and not overwrite: raise FileExistsError( f"File {filename} already exists. Set `overwrite=True` to overwrite the file" ) - file_ext = Path(filename).suffix + file_ext = filename.suffix if file_ext.lower() == ".xml": to_trackmate(config, filename, overwrite=True) elif file_ext.lower() == ".csv": @@ -61,6 +67,8 @@ def export_tracks_by_extension( elif file_ext.lower() == ".zarr": df, _ = to_tracks_layer(config) tracks_to_zarr(config, df, filename, overwrite=True) + elif file_ext.lower() == ".geff": + to_geff(config, filename, overwrite=overwrite) elif file_ext.lower() == ".parquet": df, _ = to_tracks_layer(config) df.to_parquet(filename) @@ -75,5 +83,5 @@ def export_tracks_by_extension( else: raise ValueError( f"Unknown file extension: {file_ext}. " - "Supported extensions are .xml, .csv, .zarr, .parquet, .dot, and .json." + "Supported extensions are .xml, .csv, .zarr, .geff, .parquet, .dot, and .json." ) diff --git a/ultrack/core/export/geff.py b/ultrack/core/export/geff.py new file mode 100644 index 00000000..acce186f --- /dev/null +++ b/ultrack/core/export/geff.py @@ -0,0 +1,42 @@ +from pathlib import Path +from typing import Union + +import geff +import networkx as nx + +from ultrack.config import MainConfig +from ultrack.core.export.networkx import to_networkx + + +def to_geff( + config: MainConfig, + filename: Union[str, Path], + overwrite: bool = False, +) -> None: + """ + Export tracks to a geff (Graph Exchange File Format) file. + + Parametersmnist + ---------- + config : MainConfig + The configuration object. + filename : str or Path + The name of the file to save the tracks to. + overwrite : bool, optional + Whether to overwrite the file if it already exists, by default False. + + Raises + ------ + FileExistsError + If the file already exists and overwrite is False. + """ + if Path(filename).exists() and not overwrite: + raise FileExistsError( + f"File {filename} already exists. Set `overwrite=True` to overwrite the file" + ) + + # Get the networkx graph from the configuration + graph = to_networkx(config) + + # Write the graph to geff format + geff.write_nx(graph, filename) diff --git a/ultrack/core/tracker.py b/ultrack/core/tracker.py index ec177236..a00f9d7d 100644 --- a/ultrack/core/tracker.py +++ b/ultrack/core/tracker.py @@ -11,6 +11,7 @@ from ultrack.config import MainConfig from ultrack.core.export import ( to_ctc, + to_geff, to_tracks_layer, tracks_layer_to_networkx, tracks_layer_to_trackmate, @@ -154,6 +155,11 @@ def to_ctc(self, *args, **kwargs) -> None: self._assert_solved() to_ctc(config=self.config, *args, **kwargs) + @functools.wraps(to_geff) + def to_geff(self, filename: str, overwrite: bool = False) -> None: + self._assert_solved() + to_geff(self.config, filename, overwrite=overwrite) + @functools.wraps(to_tracks_layer) def to_tracks_layer(self, *args, **kwargs) -> Tuple[pd.DataFrame, Dict]: self._assert_solved()