diff --git a/pyproject.toml b/pyproject.toml index a19175c6..624113ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/src/funtracks/actions/add_delete_node.py b/src/funtracks/actions/add_delete_node.py index ed3df040..9ec1aff5 100644 --- a/src/funtracks/actions/add_delete_node.py +++ b/src/funtracks/actions/add_delete_node.py @@ -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] diff --git a/src/funtracks/candidate_graph/compute_graph.py b/src/funtracks/candidate_graph/compute_graph.py index 390a57e1..615adae5 100644 --- a/src/funtracks/candidate_graph/compute_graph.py +++ b/src/funtracks/candidate_graph/compute_graph.py @@ -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 @@ -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()) @@ -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. @@ -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( diff --git a/src/funtracks/candidate_graph/utils.py b/src/funtracks/candidate_graph/utils.py index f1fee38c..e000f5a1 100644 --- a/src/funtracks/candidate_graph/utils.py +++ b/src/funtracks/candidate_graph/utils.py @@ -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 @@ -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 @@ -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]] = {} @@ -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. @@ -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 @@ -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]] = {} diff --git a/src/funtracks/data_model/tracks.py b/src/funtracks/data_model/tracks.py index 821d6b54..03abb572 100644 --- a/src/funtracks/data_model/tracks.py +++ b/src/funtracks/data_model/tracks.py @@ -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 @@ -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, @@ -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 diff --git a/src/funtracks/import_export/_tracks_builder.py b/src/funtracks/import_export/_tracks_builder.py index b6cfc2db..1b090540 100644 --- a/src/funtracks/import_export/_tracks_builder.py +++ b/src/funtracks/import_export/_tracks_builder.py @@ -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. @@ -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 @@ -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"]] @@ -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. @@ -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 @@ -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( diff --git a/src/funtracks/import_export/csv/_import.py b/src/funtracks/import_export/csv/_import.py index 7aeae87c..68d9e691 100644 --- a/src/funtracks/import_export/csv/_import.py +++ b/src/funtracks/import_export/csv/_import.py @@ -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. @@ -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 @@ -218,4 +220,5 @@ def tracks_from_df( segmentation, scale=scale, node_name_map=builder.node_name_map, + backend=backend, ) diff --git a/src/funtracks/import_export/geff/_import.py b/src/funtracks/import_export/geff/_import.py index 04e388cb..027dfb5f 100644 --- a/src/funtracks/import_export/geff/_import.py +++ b/src/funtracks/import_export/geff/_import.py @@ -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. @@ -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 @@ -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. @@ -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 @@ -533,4 +536,5 @@ def import_from_geff( scale=scale, node_name_map=builder.node_name_map, database=database, + backend=backend, ) diff --git a/src/funtracks/utils/tracksdata_utils.py b/src/funtracks/utils/tracksdata_utils.py index e063e437..e256afee 100644 --- a/src/funtracks/utils/tracksdata_utils.py +++ b/src/funtracks/utils/tracksdata_utils.py @@ -68,6 +68,25 @@ def to_polars_dtype(dtype_or_value: str | Any) -> pl.DataType: raise ValueError(f"Unsupported type: {type(dtype_or_value)}") +def _new_empty_backend( + backend: str = "memory", database: str | None = None +) -> td.graph.BaseGraph: + """Construct an empty tracksdata base graph on the requested backend. + + Args: + backend: "memory" for an in-memory ``IndexedRXGraph``, or "sql" for a + SQLite-backed ``SQLGraph`` at ``database``. + database: SQLite path (sql backend only). A unique temp file if None. + """ + if backend == "memory": + return td.graph.IndexedRXGraph() + if backend == "sql": + if database is None: + database = f"{tempfile.gettempdir()}/funtracks_{uuid.uuid4().hex[:8]}.db" + return td.graph.SQLGraph(drivername="sqlite", database=database, overwrite=True) + raise ValueError(f"Unknown backend {backend!r}; expected 'sql' or 'memory'.") + + def create_empty_graph( node_attributes: list[str] | None = None, edge_attributes: list[str] | None = None, @@ -76,6 +95,7 @@ def create_empty_graph( database: str | None = None, position_attrs: list[str] | None = None, ndim: int = 3, + backend: str = "memory", ) -> td.graph.BaseGraph: """ Create an empty tracksdata base graph with standard node and edge attributes. @@ -100,6 +120,10 @@ def create_empty_graph( ndim : int Number of dimensions including time, so 2D+T dataset has ndim = 3. Defaults to 3 (2D+time). + backend : str + Which tracksdata backend to build: "sql" for a database-backed ``SQLGraph`` + (SQLite at ``database``) or "memory" for an in-memory ``IndexedRXGraph``. + Defaults to "memory". Returns ------- @@ -110,12 +134,6 @@ def create_empty_graph( if position_attrs is None: position_attrs = ["pos"] - # Generate unique database path if not specified - if database is None: - temp_dir = tempfile.gettempdir() - unique_id = uuid.uuid4().hex[:8] - database = f"{temp_dir}/funtracks_test_{unique_id}.db" - if node_default_values is not None: assert len(node_default_values) == len(node_attributes or []), ( "Length of node_default_values must match length of node_attributes" @@ -130,14 +148,8 @@ def create_empty_graph( else: edge_default_values = [0.0] * len(edge_attributes or []) - # Initialize an empty graph - # kwargs = { - # "drivername": "sqlite", - # "database": database, - # "overwrite": True, - # } - # graph_td = td.graph.SQLGraph(**kwargs) - graph_td = td.graph.IndexedRXGraph() + # Initialize an empty graph on the requested backend. + graph_td = _new_empty_backend(backend, database) # Add standard node and edge attributes if "pos" in (node_attributes or []) or any( @@ -446,28 +458,25 @@ def add_masks_and_bboxes_to_graph( return graph -def td_relabel_nodes(graph, mapping: dict[int, int]) -> td.graph.IndexedRXGraph: +def td_relabel_nodes( + graph, mapping: dict[int, int], backend: str | None = None +) -> td.graph.BaseGraph: """Relabel nodes in a tracksdata graph according to a mapping. Args: graph: A tracksdata graph mapping: Dictionary mapping old node IDs to new node IDs + backend: Backend for the new graph ("memory" or "sql"). If None, matches the + input graph's backend so relabeling never silently changes it. Returns: A new tracksdata graph with relabeled nodes """ - # For IndexedRXGraph or SQLGraph old_graph = graph - - # database = f"{tempfile.gettempdir()}/funtracks_{uuid.uuid4().hex[:8]}.db" - # kwargs = { - # "drivername": "sqlite", - # "database": database, - # "overwrite": True, - # } - # new_graph = td.graph.SQLGraph(**kwargs) - new_graph = td.graph.IndexedRXGraph() + if backend is None: + backend = "sql" if isinstance(graph, td.graph.SQLGraph) else "memory" + new_graph = _new_empty_backend(backend) # Copy attribute key registrations with defaults and dtypes node_schemas = graph._node_attr_schemas() @@ -510,25 +519,21 @@ def td_relabel_nodes(graph, mapping: dict[int, int]) -> td.graph.IndexedRXGraph: return new_graph -def convert_graph_nx_to_td(graph_nx: nx.DiGraph) -> td.graph.BaseGraph: +def convert_graph_nx_to_td( + graph_nx: nx.DiGraph, backend: str = "memory" +) -> td.graph.BaseGraph: """Convert a NetworkX DiGraph to a tracksdata graph. Args: graph_nx: The NetworkX DiGraph to convert. + backend: Backend for the new graph ("memory" or "sql"). Returns: A tracksdata graph representing the same graph. """ - # Initialize an empty tracksdata graph - # database = f"{tempfile.gettempdir()}/funtracks_{uuid.uuid4().hex[:8]}.db" - # kwargs = { - # "drivername": "sqlite", - # "database": database, - # "overwrite": True, - # } - # graph_td = td.graph.SQLGraph(**kwargs) - graph_td = td.graph.IndexedRXGraph() + # Initialize an empty tracksdata graph on the requested backend + graph_td = _new_empty_backend(backend) # Get all nodes and edges with attributes all_nodes = list(graph_nx.nodes(data=True)) diff --git a/tests/candidate_graph/test_compute_graph.py b/tests/candidate_graph/test_compute_graph.py index 9dcb7a03..21fa0fca 100644 --- a/tests/candidate_graph/test_compute_graph.py +++ b/tests/candidate_graph/test_compute_graph.py @@ -1,5 +1,6 @@ import numpy as np import pytest +import tracksdata as td from funtracks.candidate_graph import ( compute_graph_from_points_list, @@ -7,7 +8,7 @@ ) -def test_graph_from_segmentation_2d(get_tracks): +def test_graph_from_segmentation_2d(get_tracks, backend): tracks = get_tracks(ndim=3, with_seg=True) segmentation_2d = np.asarray(tracks.segmentation) @@ -15,8 +16,13 @@ def test_graph_from_segmentation_2d(get_tracks): segmentation=segmentation_2d, max_edge_distance=100, iou=True, + backend=backend, ) + # graph is built on the requested backend + expected_cls = td.graph.SQLGraph if backend == "sql" else td.graph.IndexedRXGraph + assert isinstance(cand_graph, expected_cls) + # Same node IDs as the segmentation labels assert set(cand_graph.node_ids()) == set(tracks.graph_solution.node_ids()) @@ -55,6 +61,7 @@ def test_graph_from_segmentation_2d(get_tracks): cand_graph = compute_graph_from_seg( segmentation=segmentation_2d, max_edge_distance=15, + backend=backend, ) assert set(cand_graph.node_ids()) == set(tracks.graph_solution.node_ids()) assert sorted(cand_graph.edge_list()) == [[1, 3]] @@ -177,7 +184,7 @@ def test_graph_from_segmentation_t_start_zero_matches_default(get_tracks): ) -def test_graph_from_points_list(): +def test_graph_from_points_list(backend): points_list = np.array( [ # t, z, y, x @@ -188,7 +195,11 @@ def test_graph_from_points_list(): [2, 1, 1, 1], ] ) - cand_graph = compute_graph_from_points_list(points_list, max_edge_distance=3) + cand_graph = compute_graph_from_points_list( + points_list, max_edge_distance=3, backend=backend + ) + expected_cls = td.graph.SQLGraph if backend == "sql" else td.graph.IndexedRXGraph + assert isinstance(cand_graph, expected_cls) assert cand_graph.num_edges() == 3 assert len(list(cand_graph.predecessors(3))) == 0 diff --git a/tests/conftest.py b/tests/conftest.py index 78768e1d..5dcd1cdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -125,6 +125,17 @@ def make_3d_cube_mask(start_corner=(0, 0, 0), width=4) -> Mask: ) +@pytest.fixture(params=["memory", "sql"]) +def backend(request) -> str: + """Run graph-based tests on both backends. + + Every graph fixture below depends on this, so tests that use a graph fixture + automatically run once per backend. Select one with `pytest -k memory` / + `pytest -k sql`. + """ + return request.param + + def _make_graph( *, ndim: int = 3, @@ -134,6 +145,7 @@ def _make_graph( with_iou: bool = False, with_masks: bool = False, database: str | None = None, + backend: str = "memory", ) -> td.graph.BaseGraph: """Generate a test graph with configurable features. @@ -145,6 +157,7 @@ def _make_graph( with_iou: Include iou edge attribute (requires with_area=True) with_masks: Include mask and bbox node attributes database: Database path for SQLGraph (if None, uses default) + backend: Graph backend ("memory" or "sql") Returns: A graph with the requested features @@ -172,6 +185,11 @@ def _make_graph( node_attributes.append(td.DEFAULT_ATTR_KEYS.BBOX) node_default_values.append(0.0) + # Use an in-memory SQLite DB for tests: a per-test .db file is very slow on + # Windows CI (file create/fsync/delete), and tests don't need on-disk persistence. + if backend == "sql": + database = ":memory:" + graph = create_empty_graph( node_attributes=node_attributes, node_default_values=node_default_values, @@ -179,6 +197,7 @@ def _make_graph( database=database, position_attrs=["pos"] if with_pos else None, ndim=ndim, + backend=backend, ) # Base node data (always has time) @@ -288,28 +307,30 @@ def _make_graph( @pytest.fixture -def graph_clean(tmp_path) -> td.graph.BaseGraph: +def graph_clean(tmp_path, backend) -> td.graph.BaseGraph: """Base graph with only time - no positions or computed features.""" db_path = str(tmp_path / "graph_clean.db") - return _make_graph(ndim=3, database=db_path) + return _make_graph(ndim=3, database=db_path, backend=backend) @pytest.fixture -def graph_2d_with_position(tmp_path) -> td.graph.BaseGraph: +def graph_2d_with_position(tmp_path, backend) -> td.graph.BaseGraph: """Graph with 2D positions - for Tracks without segmentation.""" db_path = str(tmp_path / "graph_2d_position.db") - return _make_graph(ndim=3, with_pos=True, database=db_path) + return _make_graph(ndim=3, with_pos=True, database=db_path, backend=backend) @pytest.fixture -def graph_2d_with_track_id(tmp_path) -> td.graph.BaseGraph: +def graph_2d_with_track_id(tmp_path, backend) -> td.graph.BaseGraph: """Graph with 2D positions and track_id - for Tracks without segmentation.""" db_path = str(tmp_path / "graph_2d_track_id.db") - return _make_graph(ndim=3, with_pos=True, with_track_id=True, database=db_path) + return _make_graph( + ndim=3, with_pos=True, with_track_id=True, database=db_path, backend=backend + ) @pytest.fixture -def graph_2d_with_segmentation(tmp_path) -> td.graph.BaseGraph: +def graph_2d_with_segmentation(tmp_path, backend) -> td.graph.BaseGraph: """Graph with segmentation (masks/bboxes) and all computed features.""" db_path = str(tmp_path / "graph_2d_segmentation.db") return _make_graph( @@ -320,25 +341,28 @@ def graph_2d_with_segmentation(tmp_path) -> td.graph.BaseGraph: with_iou=True, with_masks=True, database=db_path, + backend=backend, ) @pytest.fixture -def graph_3d_with_position(tmp_path) -> td.graph.BaseGraph: +def graph_3d_with_position(tmp_path, backend) -> td.graph.BaseGraph: """Graph with 3D positions - for Tracks without segmentation.""" db_path = str(tmp_path / "graph_3d_position.db") - return _make_graph(ndim=4, with_pos=True, database=db_path) + return _make_graph(ndim=4, with_pos=True, database=db_path, backend=backend) @pytest.fixture -def graph_3d_with_track_id(tmp_path) -> td.graph.BaseGraph: +def graph_3d_with_track_id(tmp_path, backend) -> td.graph.BaseGraph: """Graph with 3D positions and track_id - for Tracks without segmentation.""" db_path = str(tmp_path / "graph_3d_track_id.db") - return _make_graph(ndim=4, with_pos=True, with_track_id=True, database=db_path) + return _make_graph( + ndim=4, with_pos=True, with_track_id=True, database=db_path, backend=backend + ) @pytest.fixture -def graph_3d_with_segmentation(tmp_path) -> td.graph.BaseGraph: +def graph_3d_with_segmentation(tmp_path, backend) -> td.graph.BaseGraph: """Graph with segmentation (masks/bboxes) and all computed features.""" db_path = str(tmp_path / "graph_3d_segmentation.db") return _make_graph( @@ -349,6 +373,7 @@ def graph_3d_with_segmentation(tmp_path) -> td.graph.BaseGraph: with_iou=True, with_masks=True, database=db_path, + backend=backend, ) @@ -431,9 +456,9 @@ def _make_tracks( @pytest.fixture -def graph_2d_list(tmp_path) -> td.graph.BaseGraph: - db_path = str(tmp_path / "graph_2d_list.db") - graph = create_empty_graph(database=db_path) +def graph_2d_list(tmp_path, backend) -> td.graph.BaseGraph: + db_path = ":memory:" if backend == "sql" else str(tmp_path / "graph_2d_list.db") + graph = create_empty_graph(database=db_path, backend=backend) nodes = [ { @@ -472,7 +497,7 @@ def sphere(center, radius, shape): @pytest.fixture -def get_graph(tmp_path) -> Callable[..., td.graph.BaseGraph]: +def get_graph(tmp_path, backend) -> Callable[..., td.graph.BaseGraph]: """Factory fixture to create a graph with configurable features. Args: @@ -507,6 +532,7 @@ def _get_graph( with_iou=with_seg, with_masks=with_seg, database=db_path, + backend=backend, ) return _get_graph diff --git a/tests/data_model/test_tracks.py b/tests/data_model/test_tracks.py index 80188c99..7a350600 100644 --- a/tests/data_model/test_tracks.py +++ b/tests/data_model/test_tracks.py @@ -36,7 +36,9 @@ def test_create_tracks(graph_3d_with_segmentation: td.graph.BaseGraph): assert isinstance(tracks.features[pos_key], dict) assert tracks.get_positions([1]).tolist() == [[50, 50, 50]] assert tracks.get_time(1) == 0 - with pytest.raises(KeyError): + # Missing NODE id (not a missing attr key): tracksdata's single-node getitem path + # still diverges by backend (KeyError in-memory, ValueError on SQL) + with pytest.raises((KeyError, ValueError)): tracks.get_position(0) # create track with graph and seg @@ -357,7 +359,9 @@ def test_update_mask_syncs_bbox(graph_2d_with_segmentation): stored_mask = tracks.graph_solution.nodes[1][td.DEFAULT_ATTR_KEYS.MASK] stored_bbox = tracks.graph_solution.nodes[1][td.DEFAULT_ATTR_KEYS.BBOX] - assert stored_mask is new_mask + # Value equality, not identity: the SQL backend materializes a fresh Mask on + # read rather than returning the same object (unlike the in-memory backend). + assert stored_mask == new_mask assert np.array_equal(stored_bbox, new_mask.bbox) diff --git a/tests/import_export/test_csv_import.py b/tests/import_export/test_csv_import.py index 4fc1997a..e1aced90 100644 --- a/tests/import_export/test_csv_import.py +++ b/tests/import_export/test_csv_import.py @@ -1,6 +1,7 @@ import numpy as np import pandas as pd import pytest +import tracksdata as td from funtracks.data_model import Tracks from funtracks.import_export import tracks_from_df @@ -38,11 +39,13 @@ def df_3d(): class TestDataFrameImportBasic: """Test basic DataFrame import.""" - def test_import_2d(self, simple_df_2d): + def test_import_2d(self, simple_df_2d, backend): """Test importing 2D DataFrame.""" - tracks = tracks_from_df(simple_df_2d) + tracks = tracks_from_df(simple_df_2d, backend=backend) assert isinstance(tracks, Tracks) + expected_cls = td.graph.SQLGraph if backend == "sql" else td.graph.IndexedRXGraph + assert isinstance(tracks.graph_full, expected_cls) assert tracks.graph_solution.num_nodes() == 4 assert tracks.graph_solution.num_edges() == 3 assert tracks.ndim == 3 diff --git a/tests/import_export/test_import_from_geff.py b/tests/import_export/test_import_from_geff.py index 12bbeac1..bd5555f9 100644 --- a/tests/import_export/test_import_from_geff.py +++ b/tests/import_export/test_import_from_geff.py @@ -2,6 +2,7 @@ import numpy as np import pytest import tifffile +import tracksdata as td import zarr from geff.testing.data import create_mock_geff @@ -296,7 +297,9 @@ def test_segmentation_axes_mismatch(valid_geff, tmp_path): import_from_geff(store, name_map, segmentation_path=seg_path) -def test_tracks_with_segmentation(valid_geff, invalid_geff, valid_segmentation, tmp_path): +def test_tracks_with_segmentation( + valid_geff, invalid_geff, valid_segmentation, tmp_path, backend +): """Test relabeling of the segmentation from seg_id to node_id.""" store, _ = valid_geff @@ -317,7 +320,10 @@ def test_tracks_with_segmentation(valid_geff, invalid_geff, valid_segmentation, name_map_with_features, segmentation_path=valid_segmentation_path, scale=scale, + backend=backend, ) + expected_cls = td.graph.SQLGraph if backend == "sql" else td.graph.IndexedRXGraph + assert isinstance(tracks.graph_full, expected_cls) assert hasattr(tracks, "segmentation") assert tracks.segmentation.shape == valid_segmentation.shape # Get last node by ID (don't rely on iteration order)