Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
d5e5a96
renamed tracks.graph to tracks.solution_graph, and graph_full looks a…
TeunHuijben Jun 9, 2026
d006c2e
SolutionTracks is gone :o
TeunHuijben Jun 9, 2026
db7c383
soft delete|add actions on view/_root + td changes
TeunHuijben Jun 11, 2026
c9b8c89
regionprops and edgeannotator act on graph_full, trackannotator on gr…
TeunHuijben Jun 11, 2026
a2c8122
properly test soft delete roundtrip
TeunHuijben Jun 11, 2026
48b537a
stale warning
TeunHuijben Jun 11, 2026
3d91f1b
Merge branch 'main' into persistent-graph
TeunHuijben Jun 11, 2026
5ed7dad
get_positions read wrong graph
TeunHuijben Jun 11, 2026
bacd96b
Merge branch 'main' into persistent-graph
TeunHuijben Jun 12, 2026
88ad00c
Merge branch 'main' into persistent-graph
cmalinmayor Jun 18, 2026
6319dac
[pre-commit.ci] pre-commit autoupdate
pre-commit-ci[bot] Jun 22, 2026
cccac04
Provide explicit node_name_map in test for subgroup export/import
cmalinmayor Jun 18, 2026
8c5ebe8
Refactor geff export to work for internal save as well
cmalinmayor Jun 24, 2026
58cfb33
Add write_to_geff tests
cmalinmayor Jun 24, 2026
99c9ddd
add write_to_geff test with segmentation + removed stale if-statement
TeunHuijben Jun 25, 2026
c490b2b
add solutiontracks benchmark
TeunHuijben Jun 26, 2026
7f3d82e
speed up assign_lineage_ids and assign_tracklet_ids
TeunHuijben Jun 26, 2026
0e5b65f
Merge branch 'main' into persistent-graph
TeunHuijben Jun 29, 2026
576a739
replace solutiontracks in benchmark
TeunHuijben Jun 30, 2026
15425a2
replace graph_solution with graph_full + removed in_degree and out_de…
TeunHuijben Jun 30, 2026
9942055
update tracksdata pin
TeunHuijben Jun 30, 2026
c4bf231
make sure that tracks always has track_annotator and tracklet_key
TeunHuijben Jun 30, 2026
84eac14
remove from_tracks
TeunHuijben Jun 30, 2026
249df29
rename is_solution in conftest to prefill_track_ids, as every Tracks …
TeunHuijben Jun 30, 2026
baf0125
give export_to_csv to option to export either full or solution graph
TeunHuijben Jun 30, 2026
3279124
make TracksBuilder.build easier
TeunHuijben Jun 30, 2026
f9fa4a6
load_v1_tracks no longer makes distinction between Tracks and Solutio…
TeunHuijben Jun 30, 2026
e70d946
make graph_full the MAIN graph and graph_solution merely a view of it…
TeunHuijben Jun 30, 2026
0d368da
all attribute I/O on graph_full, topology/track_id stuff on graph_sol…
TeunHuijben Jul 1, 2026
7c90f5b
since tracklet_id column now always exist, check if it contains any -…
TeunHuijben Jul 1, 2026
412dd60
fix: revive an edd didn't use provided attrs
TeunHuijben Jul 1, 2026
0a8287b
failsave for not having a TrackAnnotator
TeunHuijben Jul 1, 2026
86588bd
tracks.get_track_neighbors should not permanently sort the tracklet_ids
TeunHuijben Jul 1, 2026
5e4ad28
note about tracksdata
TeunHuijben Jul 1, 2026
0703f41
TrackAnnotator didn't use the defined self.graph
TeunHuijben Jul 1, 2026
b0f4429
warn that graph annotators compute on every possible candidate edge (…
TeunHuijben Jul 1, 2026
d9c9735
update tracksdata to v0.1.0rc6
TeunHuijben Jul 1, 2026
588e57a
revive node (AddNode) didnt assign provided attrs to revived node, si…
TeunHuijben Jul 1, 2026
efb1034
fix add node/edge revive regarding attrs
TeunHuijben Jul 1, 2026
fca98a0
tracklet_id_to_nodes inconsistent after recompute
TeunHuijben Jul 1, 2026
d571762
warm up windows cache for fair benchmark
TeunHuijben Jul 1, 2026
902a0ee
ruff fixes
TeunHuijben Jul 1, 2026
66aa352
docstring updates
TeunHuijben Jul 1, 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
19 changes: 10 additions & 9 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ An abstract base class for components that compute and update features on a grap
|-----------|---------|--------------|-------------------|---------------|
| **RegionpropsAnnotator** | Extracts node features from segmentation using scikit-image's `regionprops` | `segmentation` must not be `None` | `pos`, `area`, `ellipse_axis_radii`, `circularity`, `perimeter` | [📚 API](../reference/funtracks/annotators/#funtracks.annotators.RegionpropsAnnotator) |
| **EdgeAnnotator** | Computes edge features based on segmentation overlap between consecutive time frames | `segmentation` must not be `None` | `iou` (Intersection over Union) | [📚 API](../reference/funtracks/annotators/#funtracks.annotators.EdgeAnnotator) |
| **TrackAnnotator** | Computes tracklet and lineage IDs for SolutionTracks | Must be used with `SolutionTracks` (binary tree structure) | `tracklet_id`, `lineage_id` | [📚 API](../reference/funtracks/annotators/#funtracks.annotators.TrackAnnotator) |
| **TrackAnnotator** | Computes tracklet and lineage IDs | Requires `tracks.features.tracklet_key` to be set (i.e. a tracking solution with a binary-tree structure) | `tracklet_id`, `lineage_id` | [📚 API](../reference/funtracks/annotators/#funtracks.annotators.TrackAnnotator) |

### 5. AnnotatorRegistry

Expand Down Expand Up @@ -149,7 +149,8 @@ classDiagram
}

class Tracks {
+graph: td.graph.GraphView
+graph_full: td.graph.BaseGraph
+graph_solution: td.graph.GraphView
+segmentation: ndarray|None
+features: FeatureDict
+annotators: AnnotatorRegistry
Expand Down Expand Up @@ -199,10 +200,10 @@ provided:

```python
# Uses default: tracklet_key="tracklet_id"
tracks = SolutionTracks(graph=graph)
tracks = Tracks(graph=graph, tracklet_attr="tracklet_id")

# Uses custom attribute name
tracks = SolutionTracks(graph=graph, tracklet_attr="my_track_col")
tracks = Tracks(graph=graph, tracklet_attr="my_track_col")
```

Custom attribute names are also supported through `FeatureDict`:
Expand All @@ -213,7 +214,7 @@ fd = FeatureDict(
tracklet_key="my_track_col",
lineage_key="my_lineage",
)
tracks = SolutionTracks(graph=graph, features=fd)
tracks = Tracks(graph=graph, features=fd)
```

When a `FeatureDict` is provided, the `tracklet_attr`/`pos_attr`/`time_attr` arguments
Expand All @@ -229,7 +230,7 @@ TrackAnnotator(tracks, tracklet_key="my_track", lineage_key="my_lineage")
RegionpropsAnnotator(tracks, pos_key="coordinates")
```

When constructing `Tracks` or `SolutionTracks` directly, you have full control over
When constructing `Tracks` directly, you have full control over
which attribute names are used.

**Through the import path** (`tracks_from_df`, `import_from_geff`), computed features
Expand Down Expand Up @@ -286,11 +287,11 @@ tracks = tracks_from_df(df, segmentation=seg)

**Scenario 2: Creating tracks from raw segmentation**
```python
from funtracks.utils import create_empty_graphview_graph
from funtracks.utils import create_empty_graph
from funtracks.data_model import Tracks

# Create empty graph and add nodes
graph = create_empty_graphview_graph()
graph = create_empty_graph()
graph.add_node(index=1, attrs={"t": 0})
tracks = Tracks(graph, segmentation=seg)
# Auto-detection: pos, area don't exist → compute them from segmentation
Expand Down Expand Up @@ -353,7 +354,7 @@ tracks.disable_features(["area"])
def compute(self, feature_keys=None):
# Compute feature values in bulk
if "custom" in self.features:
for node in self.tracks.graph.node_ids():
for node in self.tracks.graph_solution.node_ids():
value = self._compute_custom(node)
self.tracks[node]["custom"] = value

Expand Down
2 changes: 1 addition & 1 deletion docs/import-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ graph LR
Validate["validate<br/><small>check graph structure</small>"]
ConstructGraph["construct_graph<br/><small>build tracksdata graph</small>"]
HandleSeg["handle_segmentation<br/><small>load & relabel if needed</small>"]
CreateTracks[Create SolutionTracks]
CreateTracks[Create Tracks]
EnableFeatures["enable_features<br/><small>register & compute</small>"]

ValidateMap --> LoadSource
Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ dependencies =[
"dask>=2025.5.0",
"pandas>=2.3.3",
"zarr>=2.18,<4",
"tracksdata[spatial]==0.1.0rc4",
"tracksdata[spatial]>=0.1.0rc6",
"tqdm>=4.66.1",
# zarr 2.x's util.py imports cbuffer_sizes/cbuffer_metainfo from
# numcodecs.blosc, which numcodecs >= 0.16 removed. Pin numcodecs per
Expand Down Expand Up @@ -107,8 +107,6 @@ unfixable = [
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["D"] # no docstrings in tests
"src/funtracks/data_model/tracks.py" = ["D"] # Remove this when refactoring tracks
"src/funtracks/data_model/solution_tracks.py" = ["D"] # Remove this when refactoring tracks
"__init__.py" = ["F401"] # unused imports allowed in __init__.py

# https://docs.astral.sh/ruff/formatter/
Expand Down
77 changes: 50 additions & 27 deletions src/funtracks/actions/add_delete_edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,35 +45,51 @@ def _apply(self) -> None:
Raises:
ValueError if an endpoint of the edge does not exist
"""
# Check that both endpoints exist before computing edge attributes
# Check that both endpoints exist in the solution before adding the edge
for node in self.edge:
if not self.tracks.graph.has_node(node):
if not self.tracks.graph_solution.has_node(node):
raise ValueError(
f"Cannot add edge {self.edge}: endpoint {node} not in graph yet"
f"Cannot add edge {self.edge}: endpoint {node} not in solution yet"
)

if self.tracks.graph.has_edge(*self.edge):
raise ValueError(f"Edge {self.edge} already exists in the graph")

attrs = dict(self.attributes)

# Fill in missing edge attributes with schema defaults (includes
# solution and any other registered edge attrs).
schemas = self.tracks.graph._edge_attr_schemas()
for attr in self.tracks.graph.edge_attr_keys():
if attr not in attrs:
# An edge added to a Tracks graph is by definition part of the
# solution, so default `solution` to True rather than the schema
# default, which can be wrong (e.g. Float64/0.0) on graphs loaded
# from geff. An explicit caller-provided value still wins.
attrs[attr] = True if attr == "solution" else schemas[attr].default_value

# Create edge attributes for this specific edge
self.tracks.graph.add_edge(
source_id=self.edge[0],
target_id=self.edge[1],
attrs=attrs,
)
if self.tracks.graph_solution.has_edge(*self.edge):
raise ValueError(f"Edge {self.edge} already exists in the solution")

if self.tracks.graph_full.has_edge(*self.edge):
# Revive a soft-deleted edge (already present in the full graph as a
# candidate): flip solution=True, apply any caller-provided attributes (so
# revive matches the add-new branch), and re-surface it in the solution view.
edge_id = self.tracks.graph_full.edge_id(self.edge[0], self.edge[1])
# Values are wrapped in single-element lists because update_edge_attrs
# reads a bare list value (e.g. a vector feature) as one-value-per-edge.
revive_attrs = {k: [v] for k, v in self.attributes.items() if k != "solution"}
revive_attrs["solution"] = [True]
self.tracks.graph_full.update_edge_attrs(
attrs=revive_attrs, edge_ids=[edge_id]
)
self.tracks.graph_solution.add_edge_to_view(self.edge[0], self.edge[1])
else:
attrs = dict(self.attributes)

# Fill in missing edge attributes with schema defaults (includes
# solution and any other registered edge attrs).
schemas = self.tracks.graph_solution._edge_attr_schemas()
for attr in self.tracks.graph_solution.edge_attr_keys():
if attr not in attrs:
# An edge added to a Tracks graph is by definition part of the
# solution, so default `solution` to True rather than the schema
# default, which can be wrong (e.g. Float64/0.0) on graphs loaded
# from geff. An explicit caller-provided value still wins.
attrs[attr] = (
True if attr == "solution" else schemas[attr].default_value
)

# Create edge attributes for this specific edge
self.tracks.graph_solution.add_edge(
source_id=self.edge[0],
target_id=self.edge[1],
attrs=attrs,
)

# Notify annotators to recompute features (will overwrite computed ones)
self.tracks.notify_annotators(self)
Expand All @@ -93,7 +109,7 @@ def __init__(self, tracks: Tracks, edge: Edge):
"""
super().__init__(tracks)
self.edge = edge
if not self.tracks.graph.has_edge(*self.edge):
if not self.tracks.graph_solution.has_edge(*self.edge):
raise ValueError(f"Edge {self.edge} not in the graph, and cannot be removed")

# Save all edge feature values from the features dict
Expand All @@ -110,5 +126,12 @@ def inverse(self) -> BasicAction:
return AddEdge(self.tracks, self.edge, attributes=self.attributes)

def _apply(self) -> None:
self.tracks.graph.remove_edge(*self.edge)
"""Soft-delete the edge: flag solution=False in the full graph and remove it
from the solution view only. The edge is preserved in graph_full (as a
candidate) so the delete is reversible."""
edge_id = self.tracks.graph_full.edge_id(self.edge[0], self.edge[1])
self.tracks.graph_full.update_edge_attrs(
attrs={"solution": False}, edge_ids=[edge_id]
)
self.tracks.graph_solution.remove_edge_from_view(self.edge[0], self.edge[1])
self.tracks.notify_annotators(self)
66 changes: 50 additions & 16 deletions src/funtracks/actions/add_delete_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
if TYPE_CHECKING:
from typing import Any

from funtracks.data_model.solution_tracks import SolutionTracks
from funtracks.data_model.tracks import Node
from funtracks.data_model.tracks import Node, Tracks


class AddNode(BasicAction):
Expand All @@ -22,7 +21,7 @@ class AddNode(BasicAction):

def __init__(
self,
tracks: SolutionTracks,
tracks: Tracks,
node: Node,
attributes: dict[str, Any],
):
Expand All @@ -40,7 +39,7 @@ def __init__(
ValueError: If neither position nor a mask feature is in attributes.
"""
super().__init__(tracks)
self.tracks: SolutionTracks # Narrow type from base class
self.tracks: Tracks # Narrow type from base class
self.node = int(node)

# Get keys from tracks features
Expand Down Expand Up @@ -76,14 +75,34 @@ def inverse(self) -> BasicAction:
return DeleteNode(self.tracks, self.node)

def _apply(self) -> None:
"""Add the node with all attributes from self.attributes."""
attrs = dict(self.attributes)
# A node added to a Tracks graph is by definition part of the solution,
# so default `solution` to True rather than the column schema default,
# which can be wrong on graphs loaded from geff. An explicit
# caller-provided value still wins.
attrs.setdefault("solution", True)
self.tracks.graph.add_node(attrs=attrs, index=self.node, validate_keys=False)
"""Add the node, or revive a soft-deleted one.

If the node still exists in the full graph (it was soft-deleted, so its
topology was preserved), revive it: flip solution=True and re-surface it in the
solution view. Otherwise add a genuinely new node.
"""
if self.tracks.graph_full.has_node(self.node):
# Revive: same node id, topology preserved in graph_full. Flip it back into
# the solution, apply any caller-provided attributes (so revive matches the
# add-new branch), and re-surface it in the view in place (incident edges
# are revived separately by AddEdge).
# Values are wrapped in single-element lists because update_node_attrs
# reads a bare list value (pos, bbox, mask) as one-value-per-node.
revive_attrs = {k: [v] for k, v in self.attributes.items() if k != "solution"}
revive_attrs["solution"] = [True]
self.tracks.graph_full.update_node_attrs(
attrs=revive_attrs, node_ids=[self.node]
)
self.tracks.graph_solution.add_node_to_view(self.node)
else:
# Genuinely new node — default `solution` to True rather than the
# column schema default, which can be wrong (e.g. Float64/0.0) on
# graphs loaded from geff. An explicit caller-provided value still wins.
attrs = dict(self.attributes)
attrs.setdefault("solution", True)
self.tracks.graph_solution.add_node(
attrs=attrs, index=self.node, validate_keys=False
)

# Always notify annotators - they will check their own preconditions
self.tracks.notify_annotators(self)
Expand All @@ -93,15 +112,24 @@ class DeleteNode(BasicAction):
"""Action of deleting an existing node.

Saves all node feature values so the action can be inverted.

Low-level action — not meant to be used directly. It soft-deletes only the
node itself (incident edges are dropped from the view by
``remove_node_from_view`` but keep ``solution=True`` in ``graph_full``).
Managing the incident edges' solution flags is the responsibility of the
enclosing user action (``UserDeleteNode``), which soft-deletes each incident
edge with its own ``DeleteEdge`` first. Applying a bare ``DeleteNode`` to a
node that still has in-solution edges therefore leaves ``graph_full``'s edge
flags inconsistent with ``graph_solution`` — always go through the user action.
"""

def __init__(
self,
tracks: SolutionTracks,
tracks: Tracks,
node: Node,
):
super().__init__(tracks)
self.tracks: SolutionTracks # Narrow type from base class
self.tracks: Tracks # Narrow type from base class
self.node = int(node)

# Save all node feature values from the features dict
Expand All @@ -120,6 +148,12 @@ def inverse(self) -> BasicAction:
return AddNode(self.tracks, self.node, self.attributes)

def _apply(self) -> None:
"""Remove the node from the graph."""
self.tracks.graph.remove_node(self.node)
"""Soft-delete the node: flag solution=False in the full graph and remove it
from the solution view only. The node (and its topology) is preserved in
graph_full so the delete is reversible and the node remains a candidate.
"""
self.tracks.graph_full.update_node_attrs(
attrs={"solution": False}, node_ids=[self.node]
)
self.tracks.graph_solution.remove_node_from_view(self.node)
self.tracks.notify_annotators(self)
6 changes: 3 additions & 3 deletions src/funtracks/actions/update_segmentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ def _apply(self) -> None:

if value == 0:
# val=0 means deleting (part of) the mask
mask_old = self.tracks.graph.nodes[self.node][self.mask_key]
mask_old = self.tracks.graph_full.nodes[self.node][self.mask_key]
mask_subtracted = mask_old.__isub__(mask_new)
self.tracks.update_mask(self.node, mask_subtracted, mask_key=self.mask_key)

elif self.tracks.graph.has_node(value):
elif self.tracks.graph_full.has_node(value):
# if node already exists:
mask_old = self.tracks.graph.nodes[value][self.mask_key]
mask_old = self.tracks.graph_full.nodes[value][self.mask_key]
mask_combined = mask_old.__or__(mask_new)
self.tracks.update_mask(value, mask_combined, mask_key=self.mask_key)

Expand Down
6 changes: 3 additions & 3 deletions src/funtracks/actions/update_track_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from ._base import BasicAction

if TYPE_CHECKING:
from funtracks.data_model import SolutionTracks
from funtracks.data_model import Tracks
from funtracks.data_model.tracks import Node


Expand All @@ -31,13 +31,13 @@ class UpdateTrackIDs(BasicAction):

def __init__(
self,
tracks: SolutionTracks,
tracks: Tracks,
start_node: Node,
tracklet_id: int | None = None,
lineage_id: int | None = None,
):
super().__init__(tracks)
self.tracks: SolutionTracks # Narrow type from base class
self.tracks: Tracks # Narrow type from base class
self.start_node = start_node

# Capture old tracklet ID
Expand Down
Loading
Loading