Skip to content
Merged

SQL #270

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a8d841e
small changes for SQL compatibility (must effort is spread over 4 td …
TeunHuijben Jul 31, 2026
698ae09
Merge branch 'main' into sql
TeunHuijben Aug 10, 2026
c186199
incorporate latest tracksdata changes
TeunHuijben Aug 10, 2026
bfe2533
tracks.delete_feature should act on graph_full - needs tracksdata fix
TeunHuijben Aug 10, 2026
fe1328b
tracksdata now propagates delete_node/edge_attr down to all views
TeunHuijben Aug 10, 2026
62aa2fc
necessary changes in tests_old
TeunHuijben Aug 10, 2026
60ba046
newer td commit
TeunHuijben Aug 10, 2026
50ec96f
Merge branch 'main' into sql
cmalinmayor Aug 18, 2026
549bb06
Merge branch 'main' into sql
TeunHuijben Aug 18, 2026
92ef804
Merge branch 'main' into sql
TeunHuijben Aug 20, 2026
85f947c
Merge branch 'main' into sql
TeunHuijben Aug 20, 2026
ccfe3c1
Merge branch 'main' into sql
TeunHuijben Aug 31, 2026
851f20d
IndexedRXGraph is default + test both backends
TeunHuijben Aug 31, 2026
149b9d7
for windows ci, use the inmemory sql graph, because on disc is very slow
TeunHuijben Aug 31, 2026
f35b5ae
add backend option to all functions that return a graph + test for so…
TeunHuijben Aug 31, 2026
955980f
upgrade tracksdata to v0.1.0rc9
TeunHuijben Sep 1, 2026
f5775ba
Remove identical test files from test_old
cmalinmayor Sep 1, 2026
343de17
Move solution tracks deprecation tests to main tests
cmalinmayor Sep 1, 2026
8a58c81
remove test old benchmarks
cmalinmayor Sep 1, 2026
7ce0245
Explicitly test backward compatibility in main tests
cmalinmayor Sep 1, 2026
8601eac
Remove tests_old - all deprecated API is now tested in main tests
cmalinmayor Sep 1, 2026
7e4594e
[pre-commit.ci] pre-commit autoupdate (#279)
pre-commit-ci[bot] Sep 1, 2026
0c967d7
Merge branch 'main' into sql
TeunHuijben Sep 1, 2026
dd7c345
latest tracksdata (merged td main into CMM's graph-views branch)
TeunHuijben Sep 1, 2026
6d90bd5
newer graph-views td branch
TeunHuijben Sep 3, 2026
99d10ce
Merge branch 'main' into sql
TeunHuijben Sep 9, 2026
91853f2
pin td rc10 (with graph-views merged)
TeunHuijben Sep 10, 2026
e9f9d69
tracks() uses graph.filter().subgraph(mode=LIVE) to have root and vie…
TeunHuijben Sep 10, 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 @@ -38,7 +38,7 @@ dependencies =[
"dask>=2025.5.0",
"pandas>=2.3.3",
"zarr>=2.18,<4",
"tracksdata>=0.1.0rc9",
"tracksdata>=0.1.0rc10",
"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
10 changes: 9 additions & 1 deletion src/funtracks/actions/add_delete_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,15 @@ def _apply(self) -> None:
# 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"}
# The time key is excluded: a soft-deleted node keeps its time in
# graph_full (revive never moves it in time), and the SQL backend makes
# time immutable (node ids are time-derived), so updating it errors.
time_key = self.tracks.features.time_key
revive_attrs = {
k: [v]
for k, v in self.attributes.items()
if k not in ("solution", time_key)
}
revive_attrs["solution"] = [True]
self.tracks.graph_full.update_node_attrs(
attrs=revive_attrs, node_ids=[self.node]
Expand Down
10 changes: 8 additions & 2 deletions src/funtracks/candidate_graph/compute_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def compute_graph_from_seg(
iou: bool = False,
scale: list[float] | None = None,
t_start: int = 0,
backend: str = "memory",
) -> td.graph.BaseGraph:
"""Construct a candidate graph from a segmentation array. Nodes are placed at the
centroid of each segmentation and edges are added for all nodes in adjacent frames
Expand All @@ -35,13 +36,14 @@ def compute_graph_from_seg(
segmentation. Frame i will get t = t_start + i. Useful when the
segmentation is a slice of a larger array and nodes need absolute
time values. Defaults to 0.
backend (str, optional): Graph backend, "memory" or "sql". Defaults to "memory".

Returns:
td.graph.BaseGraph: A candidate graph that can be passed to the motile solver
"""
# add nodes (including mask and bbox in the same bulk_add_nodes call)
cand_graph, node_frame_dict = nodes_from_segmentation(
segmentation, scale=scale, t_start=t_start
segmentation, scale=scale, t_start=t_start, backend=backend
)
logger.info("Candidate nodes: %d", cand_graph.num_nodes())

Expand Down Expand Up @@ -75,6 +77,7 @@ def compute_graph_from_points_list(
points_list: np.ndarray,
max_edge_distance: float,
scale: list[float] | None = None,
backend: str = "memory",
) -> td.graph.BaseGraph:
"""Construct a candidate graph from a points list.

Expand All @@ -88,12 +91,15 @@ def compute_graph_from_points_list(
dimension. Only needed if the provided points are in "voxel" coordinates
instead of world coordinates. Defaults to None, which implies the data is
isotropic.
backend (str, optional): Graph backend, "memory" or "sql". Defaults to "memory".

Returns:
td.graph.BaseGraph: A candidate graph that can be passed to the motile solver.
"""
# add nodes
cand_graph, node_frame_dict = nodes_from_points_list(points_list, scale=scale)
cand_graph, node_frame_dict = nodes_from_points_list(
points_list, scale=scale, backend=backend
)
logger.info("Candidate nodes: %d", cand_graph.num_nodes())
# add edges
add_cand_edges(
Expand Down
6 changes: 6 additions & 0 deletions src/funtracks/candidate_graph/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def nodes_from_segmentation(
scale: list[float] | None = None,
mask: bool = True,
t_start: int = 0,
backend: str = "memory",
) -> tuple[td.graph.BaseGraph, dict[int, list[Any]]]:
"""Extract candidate nodes from a segmentation. Returns a tracksdata graph
with only nodes, and also a dictionary from frames to node_ids for
Expand Down Expand Up @@ -48,6 +49,7 @@ def nodes_from_segmentation(
segmentation. Frame i will get t = t_start + i. Useful when the
segmentation is a slice of a larger array and nodes need absolute
time values. Defaults to 0.
backend (str, optional): Graph backend, "memory" or "sql". Defaults to "memory".

Returns:
tuple[td.graph.BaseGraph, dict[int, list[Any]]]: A candidate graph with only
Expand All @@ -70,6 +72,7 @@ def nodes_from_segmentation(
node_attributes=node_attributes,
position_attrs=["pos"],
ndim=segmentation.ndim,
backend=backend,
)

node_frame_dict: dict[int, list[Any]] = {}
Expand Down Expand Up @@ -115,6 +118,7 @@ def nodes_from_segmentation(
def nodes_from_points_list(
points_list: np.ndarray,
scale: list[float] | None = None,
backend: str = "memory",
) -> tuple[td.graph.BaseGraph, dict[int, list[Any]]]:
"""Extract candidate nodes from a list of points. Uses the index of the
point in the list as its unique id.
Expand All @@ -128,6 +132,7 @@ def nodes_from_points_list(
dimension (including time). Only needed if the provided points are in
"voxel" coordinates instead of world coordinates. Defaults to None, which
implies the data is isotropic.
backend (str, optional): Graph backend, "memory" or "sql". Defaults to "memory".

Returns:
tuple[td.graph.BaseGraph, dict[int, list[Any]]]: A candidate graph with only
Expand All @@ -147,6 +152,7 @@ def nodes_from_points_list(
node_attributes=["pos"],
position_attrs=["pos"],
ndim=ndim,
backend=backend,
)

node_frame_dict: dict[int, list[Any]] = {}
Expand Down
38 changes: 19 additions & 19 deletions src/funtracks/data_model/tracks.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,14 @@ def __init__(
if "solution" not in graph.edge_attr_keys():
graph.add_edge_attr_key("solution", default_value=True, dtype=pl.Boolean)
self.graph_full = graph
# ViewMode.LIVE: the root pushes its attribute writes (and new attr keys)
# back into this view. funtracks writes values/schema on graph_full and reads
# them via graph_solution, so the view must stay live. Since tracksdata rc10,
# views default to WRITE_THROUGH (no root->view propagation), so this is required.
self.graph_solution = graph.filter(
td.NodeAttr("solution") == True, # noqa: E712
td.EdgeAttr("solution") == True, # noqa: E712
).subgraph()
).subgraph(mode=td.graph.ViewMode.LIVE)
if _segmentation is not None:
# Reuse provided segmentation instance (internal use only)
self.segmentation = _segmentation
Expand Down Expand Up @@ -953,31 +957,28 @@ def add_feature(self, key: str, feature: Feature) -> None:

# Perform custom graph operations when a feature is added.
#
# Schema (attr-key) registration is done on graph_solution (the view), NOT on
# graph_full, even though annotators write the VALUES to graph_full. This relies
# on a tracksdata invariant: adding an attr key to a view propagates up to its
# root, so the column ends up on both. The reverse does NOT hold today — adding
# a key directly to the root is not propagated down into an existing view — so
# registering on graph_full would leave graph_solution without the column.
# If tracksdata ever makes view attr-key additions local, revisit this.
# Schema (attr-key) registration is done on graph_full (the root), per the
# accessor policy (attribute I/O → graph_full). tracksdata propagates a root
# attr-key addition down into its live views, so graph_solution gets the column
# too. Annotators write the VALUES to graph_full as well.
ft = feature["feature_type"]
if "node" in ft and key not in self.graph_solution.node_attr_keys():
if "node" in ft and key not in self.graph_full.node_attr_keys():
# "mask" value_type maps to pl.Object via to_polars_dtype
dtype = to_polars_dtype(feature["value_type"])
num_values = feature.get("num_values")
if num_values is not None and num_values > 1:
dtype = pl.Array(dtype, num_values)
self.graph_solution.add_node_attr_key(
self.graph_full.add_node_attr_key(
key,
default_value=feature["default_value"],
dtype=dtype,
)
if "edge" in ft and key not in self.graph_solution.edge_attr_keys():
if "edge" in ft and key not in self.graph_full.edge_attr_keys():
dtype = to_polars_dtype(feature["value_type"])
num_values = feature.get("num_values")
if num_values is not None and num_values > 1:
dtype = pl.Array(dtype, num_values)
self.graph_solution.add_edge_attr_key(
self.graph_full.add_edge_attr_key(
key,
default_value=feature["default_value"],
dtype=dtype,
Expand Down Expand Up @@ -1012,13 +1013,12 @@ def delete_feature(self, key: str) -> None:
else:
return

# Perform custom graph operations when a feature is deleted. Schema ops go
# through graph_solution (the view) and propagate to the root — same tracksdata
# invariant as add_feature (see the note there).
if "node" in feature_type and key in self.graph_solution.node_attr_keys():
self.graph_solution.remove_node_attr_key(key)
if "edge" in feature_type and key in self.graph_solution.edge_attr_keys():
self.graph_solution.remove_edge_attr_key(key)
# Schema removal goes through graph_full (the root), mirroring add_feature;
# tracksdata propagates the removal down into live views.
if "node" in feature_type and key in self.graph_full.node_attr_keys():
self.graph_full.remove_node_attr_key(key)
if "edge" in feature_type and key in self.graph_full.edge_attr_keys():
self.graph_full.remove_edge_attr_key(key)

# ========== Track ID management (solution view) ==========
# These operate on the solution view via the TrackAnnotator, which every Tracks
Expand Down
7 changes: 6 additions & 1 deletion src/funtracks/import_export/_tracks_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ def construct_graph(
self,
node_name_map: dict[str, str | list[str]] | None = None,
database: str | None = None,
backend: str = "memory",
) -> td.graph.BaseGraph:
"""Construct Tracksdata graph from validated InMemoryGeff data.

Expand All @@ -496,6 +497,7 @@ def construct_graph(
attribute dtype.
database: Optional path to a SQLite database file for backing storage.
If None (default), an in-memory/temp graph is used.
backend: Graph backend, "memory" or "sql". Defaults to "memory".

Returns:
Tracksdata base graph with standard keys
Expand Down Expand Up @@ -558,6 +560,7 @@ def construct_graph(
node_default_values=node_default_values,
database=database,
ndim=self.ndim,
backend=backend,
)

node_ids = [int(i) for i in self.in_memory_geff["node_ids"]]
Expand Down Expand Up @@ -764,6 +767,7 @@ def build(
scale: list[float] | None = None,
node_name_map: dict[str, str | list[str]] | None = None,
database: str | None = None,
backend: str = "memory",
) -> Tracks:
"""Orchestrate the full construction process.

Expand All @@ -774,6 +778,7 @@ def build(
node_name_map: Optional node_name_map to override self.node_name_map
database: Optional path to a SQLite database file for backing storage.
If None (default), an in-memory/temp graph is used.
backend: Graph backend, "memory" or "sql". Defaults to "memory".

Returns:
Fully constructed Tracks object
Expand Down Expand Up @@ -845,7 +850,7 @@ def build(
self.relabel_zero_based_node_ids(has_segmentation=segmentation is not None)

# 4. Construct graph
graph = self.construct_graph(node_name_map, database=database)
graph = self.construct_graph(node_name_map, database=database, backend=backend)

# 5. Handle segmentation
segmentation_array, scale, graph = self.handle_segmentation(
Expand Down
3 changes: 3 additions & 0 deletions src/funtracks/import_export/csv/_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ def tracks_from_df(
segmentation: np.ndarray | None = None,
scale: list[float] | None = None,
node_name_map: dict[str, str | list[str]] | None = None,
backend: str = "memory",
) -> Tracks:
"""Import tracks from pandas DataFrame.

Expand All @@ -193,6 +194,7 @@ def tracks_from_df(
- Values are column names from the DataFrame (e.g., "t", "Area")
- For multi-value features like position, use a list: {"pos": ["y", "x"]}
If None, column names are auto-inferred using fuzzy matching.
backend: Graph backend, "memory" or "sql". Defaults to "memory".

Returns:
Tracks: a solution tracks object
Expand All @@ -218,4 +220,5 @@ def tracks_from_df(
segmentation,
scale=scale,
node_name_map=builder.node_name_map,
backend=backend,
)
6 changes: 5 additions & 1 deletion src/funtracks/import_export/geff/_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ def construct_graph(
self,
node_name_map: dict[str, str | list[str]] | None = None,
database: str | None = None,
backend: str = "memory",
) -> td.graph.BaseGraph:
"""Construct graph and prepare embedded segmentation data.

Expand All @@ -391,7 +392,7 @@ def construct_graph(
the segmentation and create the
:class:`~funtracks.annotators.RegionpropsAnnotator` naturally.
"""
graph = super().construct_graph(node_name_map, database=database)
graph = super().construct_graph(node_name_map, database=database, backend=backend)

mask_key = td.DEFAULT_ATTR_KEYS.MASK
bbox_key = td.DEFAULT_ATTR_KEYS.BBOX
Expand Down Expand Up @@ -459,6 +460,7 @@ def import_from_geff(
scale: list[float] | None = None,
edge_name_map: dict[str, str | list[str]] | None = None,
database: str | None = None,
backend: str = "memory",
) -> Tracks:
"""Import tracks from GEFF format.

Expand All @@ -485,6 +487,7 @@ def import_from_geff(
edge property names. Example: {"iou": "overlap"}
database: Optional path to a SQLite database file for backing storage.
If None (default), an in-memory/temp graph is used.
backend: Graph backend, "memory" or "sql". Defaults to "memory".

Returns:
Tracks object
Expand Down Expand Up @@ -533,4 +536,5 @@ def import_from_geff(
scale=scale,
node_name_map=builder.node_name_map,
database=database,
backend=backend,
)
Loading
Loading