Skip to content

Commit 358a478

Browse files
Add geff tracking format (#221)
* Add geff tracking format export * Update ultrack/core/export/exporter.py Co-authored-by: Jordão Bragantini <jordao.bragantini@czbiohub.org> * Apply suggestions from code review Co-authored-by: Jordão Bragantini <jordao.bragantini@czbiohub.org> * Remove redudant geff tests --------- Co-authored-by: Jordão Bragantini <jordao.bragantini@czbiohub.org>
1 parent 852db2b commit 358a478

8 files changed

Lines changed: 190 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dependencies = [
5050
"websocket >=0.2.1",
5151
"websockets >=12.0",
5252
"zarr >=2.15.0,<3.0.0",
53+
"geff>=0.2.1,<0.3",
5354
]
5455

5556
[project.optional-dependencies]

ultrack/core/_test/test_tracker.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import functools
12
from pathlib import Path
23
from typing import Tuple
4+
from unittest.mock import Mock, patch
35

46
import networkx as nx
57
import numpy as np
8+
import pandas as pd
69
import pytest
710
import torch as th
811
import torch.nn.functional as F
@@ -173,6 +176,15 @@ def test_outputs(
173176

174177
assert nx.utils.graphs_equal(nx_tracker, nx_original)
175178

179+
# test to_geff
180+
with patch("ultrack.core.export.geff.geff.write_nx") as mock_write_nx:
181+
tracker.to_geff("test.geff")
182+
mock_write_nx.assert_called_once()
183+
184+
# Test with overwrite=True
185+
tracker.to_geff("test.geff", overwrite=True)
186+
assert mock_write_nx.call_count == 2
187+
176188

177189
@pytest.mark.parametrize(
178190
"config_content,timelapse_mock_data,mock_flow_field",

ultrack/core/export/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from ultrack.core.export.ctc import to_ctc
2+
from ultrack.core.export.exporter import export_tracks_by_extension
3+
from ultrack.core.export.geff import to_geff
24
from ultrack.core.export.networkx import to_networkx, tracks_layer_to_networkx
35
from ultrack.core.export.trackmate import to_trackmate, tracks_layer_to_trackmate
46
from ultrack.core.export.tracks_layer import to_tracks_layer

ultrack/core/export/_test/test_exporter.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from pathlib import Path
22

3+
import pytest
4+
35
from ultrack import MainConfig, export_tracks_by_extension
46

57

68
def test_exporter(tracked_database_mock_data: MainConfig, tmp_path: Path) -> None:
7-
file_ext_list = [".xml", ".csv", ".zarr", ".dot", ".json"]
9+
file_ext_list = [".xml", ".csv", ".zarr", ".dot", ".json", ".geff"]
810
last_modified_time = {}
911
for file_ext in file_ext_list:
1012
tmp_file = tmp_path / f"tracks{file_ext}"
@@ -40,3 +42,28 @@ def test_exporter(tracked_database_mock_data: MainConfig, tmp_path: Path) -> Non
4042
assert (tmp_path / f"tracks{file_ext}").stat().st_size > 0
4143

4244
assert last_modified_time[str(tmp_file)] != tmp_file.stat().st_mtime
45+
46+
47+
def test_geff_zarr_extension_specific(
48+
tracked_database_mock_data: MainConfig, tmp_path: Path
49+
) -> None:
50+
"""Test specific functionality of .geff.zarr extension in exporter."""
51+
geff_file = tmp_path / "tracks.geff"
52+
53+
# Test that .geff.zarr extension calls the to_geff function
54+
export_tracks_by_extension(tracked_database_mock_data, geff_file)
55+
56+
# Check that file exists and has content
57+
assert geff_file.exists()
58+
assert geff_file.stat().st_size > 0
59+
60+
# Test overwrite behavior
61+
with pytest.raises(FileExistsError, match="already exists"):
62+
export_tracks_by_extension(
63+
tracked_database_mock_data, geff_file, overwrite=False
64+
)
65+
66+
# Test that overwrite=True works
67+
export_tracks_by_extension(tracked_database_mock_data, geff_file, overwrite=True)
68+
assert geff_file.exists()
69+
assert geff_file.stat().st_size > 0
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from pathlib import Path
2+
from unittest.mock import MagicMock, Mock, patch
3+
4+
import networkx as nx
5+
import pytest
6+
7+
from ultrack import MainConfig
8+
from ultrack.core.export.geff import to_geff
9+
10+
11+
def test_to_geff_basic_functionality(
12+
tracked_database_mock_data: MainConfig, tmp_path: Path
13+
):
14+
"""Test basic functionality of to_geff function and call chain."""
15+
output_file = tmp_path / "test_tracks.geff.zarr"
16+
17+
# Mock both functions to verify the call chain
18+
with patch("ultrack.core.export.geff.to_networkx") as mock_to_networkx, patch(
19+
"ultrack.core.export.geff.geff.write_nx"
20+
) as mock_write_nx:
21+
22+
# Set up the mock to return a simple graph
23+
mock_graph = nx.Graph()
24+
mock_to_networkx.return_value = mock_graph
25+
26+
to_geff(tracked_database_mock_data, output_file)
27+
28+
# Verify that to_networkx was called with the config
29+
mock_to_networkx.assert_called_once_with(tracked_database_mock_data)
30+
31+
# Verify that geff.write_nx was called with the graph and filename
32+
mock_write_nx.assert_called_once_with(mock_graph, output_file)
33+
34+
35+
def test_to_geff_file_overwrite_false(
36+
tracked_database_mock_data: MainConfig, tmp_path: Path
37+
):
38+
"""Test that FileExistsError is raised when file exists and overwrite=False."""
39+
output_file = tmp_path / "test_tracks.geff.zarr"
40+
41+
# Create a file that already exists
42+
output_file.touch()
43+
44+
# Test that FileExistsError is raised when overwrite=False
45+
with pytest.raises(FileExistsError, match="already exists"):
46+
to_geff(tracked_database_mock_data, output_file, overwrite=False)
47+
48+
49+
def test_to_geff_file_overwrite_true(
50+
tracked_database_mock_data: MainConfig, tmp_path: Path
51+
):
52+
"""Test that function works when file exists and overwrite=True."""
53+
output_file = tmp_path / "test_tracks.geff.zarr"
54+
55+
# Create a file that already exists
56+
output_file.touch()
57+
58+
# Mock the geff.write_nx function
59+
with patch("ultrack.core.export.geff.geff.write_nx") as mock_write_nx:
60+
# This should not raise an error
61+
to_geff(tracked_database_mock_data, output_file, overwrite=True)
62+
63+
# Verify that geff.write_nx was called
64+
mock_write_nx.assert_called_once()
65+
66+
67+
def test_geff_correctness(tracked_database_mock_data: MainConfig, tmp_path: Path):
68+
"""Test that the geff file is correct."""
69+
output_file = tmp_path / "test_tracks.geff.zarr"
70+
to_geff(tracked_database_mock_data, output_file)
71+
72+
# Read the geff file
73+
import geff
74+
75+
geff_nx = geff.read_nx(output_file)
76+
77+
from ultrack.core.export import to_networkx
78+
79+
ultrack_nx = to_networkx(tracked_database_mock_data)
80+
81+
assert nx.is_isomorphic(geff_nx, ultrack_nx)

ultrack/core/export/exporter.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import json
2+
import logging
23
from pathlib import Path
34
from typing import Union
45

56
import networkx as nx
67

78
from ultrack.config import MainConfig
8-
from ultrack.core.export import (
9-
to_networkx,
10-
to_trackmate,
11-
to_tracks_layer,
12-
tracks_to_zarr,
13-
)
9+
from ultrack.core.export.geff import to_geff
10+
from ultrack.core.export.networkx import to_networkx
11+
from ultrack.core.export.trackmate import to_trackmate
12+
from ultrack.core.export.tracks_layer import to_tracks_layer
13+
from ultrack.core.export.zarr import tracks_to_zarr
14+
15+
LOG = logging.getLogger(__name__)
1416

1517

1618
def export_tracks_by_extension(
@@ -19,11 +21,12 @@ def export_tracks_by_extension(
1921
"""
2022
Export tracks to a file given the file extension.
2123
22-
Supported file extensions are .xml, .csv, .zarr, .dot, and .json.
24+
Supported file extensions are .xml, .csv, .zarr, .parquet, .dot, .json, and .geff
2325
- `.xml` exports to a TrackMate compatible XML file.
2426
- `.csv` exports to a CSV file.
2527
- `.parquet` exports to a Parquet file.
2628
- `.zarr` exports the tracks to dense segments in a `zarr` array format.
29+
- `.geff` exports the tracks to a `zarr` format using the geff standard.
2730
- `.dot` exports to a Graphviz DOT file.
2831
- `.json` exports to a networkx JSON file.
2932
@@ -46,13 +49,16 @@ def export_tracks_by_extension(
4649
Export tracks to a `zarr` array.
4750
to_networkx :
4851
Export tracks to a networkx graph.
52+
to_geff :
53+
Export tracks to a geff file.
4954
"""
50-
if Path(filename).exists() and not overwrite:
55+
filename = Path(filename)
56+
if filename.exists() and not overwrite:
5157
raise FileExistsError(
5258
f"File {filename} already exists. Set `overwrite=True` to overwrite the file"
5359
)
5460

55-
file_ext = Path(filename).suffix
61+
file_ext = filename.suffix
5662
if file_ext.lower() == ".xml":
5763
to_trackmate(config, filename, overwrite=True)
5864
elif file_ext.lower() == ".csv":
@@ -61,6 +67,8 @@ def export_tracks_by_extension(
6167
elif file_ext.lower() == ".zarr":
6268
df, _ = to_tracks_layer(config)
6369
tracks_to_zarr(config, df, filename, overwrite=True)
70+
elif file_ext.lower() == ".geff":
71+
to_geff(config, filename, overwrite=overwrite)
6472
elif file_ext.lower() == ".parquet":
6573
df, _ = to_tracks_layer(config)
6674
df.to_parquet(filename)
@@ -75,5 +83,5 @@ def export_tracks_by_extension(
7583
else:
7684
raise ValueError(
7785
f"Unknown file extension: {file_ext}. "
78-
"Supported extensions are .xml, .csv, .zarr, .parquet, .dot, and .json."
86+
"Supported extensions are .xml, .csv, .zarr, .geff, .parquet, .dot, and .json."
7987
)

ultrack/core/export/geff.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from pathlib import Path
2+
from typing import Union
3+
4+
import geff
5+
import networkx as nx
6+
7+
from ultrack.config import MainConfig
8+
from ultrack.core.export.networkx import to_networkx
9+
10+
11+
def to_geff(
12+
config: MainConfig,
13+
filename: Union[str, Path],
14+
overwrite: bool = False,
15+
) -> None:
16+
"""
17+
Export tracks to a geff (Graph Exchange File Format) file.
18+
19+
Parametersmnist
20+
----------
21+
config : MainConfig
22+
The configuration object.
23+
filename : str or Path
24+
The name of the file to save the tracks to.
25+
overwrite : bool, optional
26+
Whether to overwrite the file if it already exists, by default False.
27+
28+
Raises
29+
------
30+
FileExistsError
31+
If the file already exists and overwrite is False.
32+
"""
33+
if Path(filename).exists() and not overwrite:
34+
raise FileExistsError(
35+
f"File {filename} already exists. Set `overwrite=True` to overwrite the file"
36+
)
37+
38+
# Get the networkx graph from the configuration
39+
graph = to_networkx(config)
40+
41+
# Write the graph to geff format
42+
geff.write_nx(graph, filename)

ultrack/core/tracker.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ultrack.config import MainConfig
1212
from ultrack.core.export import (
1313
to_ctc,
14+
to_geff,
1415
to_tracks_layer,
1516
tracks_layer_to_networkx,
1617
tracks_layer_to_trackmate,
@@ -154,6 +155,11 @@ def to_ctc(self, *args, **kwargs) -> None:
154155
self._assert_solved()
155156
to_ctc(config=self.config, *args, **kwargs)
156157

158+
@functools.wraps(to_geff)
159+
def to_geff(self, filename: str, overwrite: bool = False) -> None:
160+
self._assert_solved()
161+
to_geff(self.config, filename, overwrite=overwrite)
162+
157163
@functools.wraps(to_tracks_layer)
158164
def to_tracks_layer(self, *args, **kwargs) -> Tuple[pd.DataFrame, Dict]:
159165
self._assert_solved()

0 commit comments

Comments
 (0)